Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion apps/mobile/src/components/ProviderIcon.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { useColorScheme } from "react-native";
import { Path, Svg } from "react-native-svg";

import { providerIconKind, providerIconPalette } from "./providerIconKind";

type ProviderIconProps = {
readonly provider: string | null | undefined;
readonly size?: number;
Expand All @@ -9,8 +11,9 @@ type ProviderIconProps = {
export function ProviderIcon(props: ProviderIconProps) {
const isDarkMode = useColorScheme() === "dark";
const size = props.size ?? 16;
const iconKind = providerIconKind(props.provider);

if (props.provider === "claudeAgent") {
if (iconKind === "claude") {
return (
<Svg width={size} height={size} viewBox="0 0 256 257" fill="none">
<Path
Expand All @@ -21,6 +24,26 @@ export function ProviderIcon(props: ProviderIconProps) {
);
}

if (iconKind === "opencode" || iconKind === "opencode2") {
const palette = providerIconPalette(iconKind, isDarkMode);
const detailFill = palette === "dark" ? "#4B4646" : "#CFCECD";
const frameFill = palette === "dark" ? "#F1ECEC" : "#211E1E";
return (
<Svg width={size} height={size} viewBox="0 0 32 40" fill="none">
{iconKind === "opencode2" ? (
<>
<Path fill="#2E6CE9" d="M24 32H8V8H24V32Z" />
<Path fill="#82C4FF" d="M24 11H8V8H24V11Z" />
<Path fill="#0A2055" d="M24 32H8V29H24V32Z" />
</>
) : (
<Path fill={detailFill} d="M24 32H8V16H24V32Z" />
)}
<Path fill={frameFill} d="M24 8H8V32H24V8ZM32 40H0V0H32V40Z" />
</Svg>
);
}

return (
<Svg width={size} height={size} viewBox="0 0 256 260" fill="none">
<Path
Expand Down
25 changes: 25 additions & 0 deletions apps/mobile/src/components/providerIconKind.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { assert, describe, it } from "@effect/vitest";

import { providerIconKind, providerIconPalette } from "./providerIconKind";

describe("providerIconKind", () => {
it("keeps both OpenCode generations distinct", () => {
assert.strictEqual(providerIconKind("opencode"), "opencode");
assert.strictEqual(providerIconKind("opencode2"), "opencode2");
});

it("preserves existing provider fallbacks", () => {
assert.strictEqual(providerIconKind("claudeAgent"), "claude");
assert.strictEqual(providerIconKind("codex"), "openai");
assert.strictEqual(providerIconKind(undefined), "openai");
});
});

describe("providerIconPalette", () => {
it("keeps both OpenCode outer frames contrasted with the application theme", () => {
assert.strictEqual(providerIconPalette("opencode", false), "light");
assert.strictEqual(providerIconPalette("opencode", true), "dark");
assert.strictEqual(providerIconPalette("opencode2", false), "light");
assert.strictEqual(providerIconPalette("opencode2", true), "dark");
});
});
21 changes: 21 additions & 0 deletions apps/mobile/src/components/providerIconKind.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
export type ProviderIconKind = "claude" | "opencode" | "opencode2" | "openai";

export function providerIconKind(provider: string | null | undefined): ProviderIconKind {
switch (provider) {
case "claudeAgent":
return "claude";
case "opencode":
return "opencode";
case "opencode2":
return "opencode2";
default:
return "openai";
}
}

export function providerIconPalette(
_kind: ProviderIconKind,
isDarkMode: boolean,
): "light" | "dark" {
return isDarkMode ? "dark" : "light";
}
5 changes: 3 additions & 2 deletions apps/mobile/src/features/threads/PendingUserInputCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import type { PendingUserInput, PendingUserInputDraftAnswer } from "../../lib/th
export interface PendingUserInputCardProps {
readonly pendingUserInput: PendingUserInput;
readonly drafts: Record<string, PendingUserInputDraftAnswer>;
readonly answers: Record<string, string> | null;
readonly answers: Record<string, string | string[]> | null;
readonly respondingUserInputId: RuntimeRequestId | null;
readonly onSelectOption: (requestId: RuntimeRequestId, questionId: string, label: string) => void;
readonly onChangeCustomAnswer: (
Expand Down Expand Up @@ -48,7 +48,8 @@ export function PendingUserInputCard(props: PendingUserInputCardProps) {
<View className="flex-row flex-wrap gap-2.5">
{question.options.map((option) => {
const selected =
draft?.selectedOptionLabel === option.label && !draft.customAnswer?.trim().length;
draft?.selectedOptionLabels?.includes(option.label) === true &&
!draft.customAnswer?.trim().length;
return (
<Pressable
key={option.label}
Expand Down
32 changes: 32 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.logic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vite-plus/test";

import { collapsedComposerActions } from "./ThreadComposer.logic";

describe("collapsed composer actions", () => {
it("keeps Send primary and exposes Stop beside a settled draft", () => {
expect(
collapsedComposerActions({
canStopThread: true,
hasContent: true,
activeThreadBusy: false,
}),
).toEqual({ showStopPrimary: false, showStopSecondary: true });
});

it("uses Stop as the primary action when the draft is empty or busy", () => {
expect(
collapsedComposerActions({
canStopThread: true,
hasContent: false,
activeThreadBusy: false,
}),
).toEqual({ showStopPrimary: true, showStopSecondary: false });
expect(
collapsedComposerActions({
canStopThread: true,
hasContent: true,
activeThreadBusy: true,
}),
).toEqual({ showStopPrimary: true, showStopSecondary: false });
});
});
20 changes: 20 additions & 0 deletions apps/mobile/src/features/threads/ThreadComposer.logic.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export interface CollapsedComposerActionsInput {
readonly canStopThread: boolean;
readonly hasContent: boolean;
readonly activeThreadBusy: boolean;
}

export interface CollapsedComposerActions {
readonly showStopPrimary: boolean;
readonly showStopSecondary: boolean;
}

export function collapsedComposerActions(
input: CollapsedComposerActionsInput,
): CollapsedComposerActions {
const showStopPrimary = input.canStopThread && (!input.hasContent || input.activeThreadBusy);
return {
showStopPrimary,
showStopSecondary: input.canStopThread && input.hasContent && !input.activeThreadBusy,
};
}
50 changes: 37 additions & 13 deletions apps/mobile/src/features/threads/ThreadComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ import {
} from "../../lib/providerOptions";
import { useComposerPathSearch } from "../../state/use-composer-path-search";
import { ComposerCommandPopover, type ComposerCommandItem } from "./ComposerCommandPopover";
import { collapsedComposerActions } from "./ThreadComposer.logic";

/**
* Height of the collapsed composer (pill + vertical padding, excluding safe-area inset).
Expand Down Expand Up @@ -306,7 +307,12 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
setIsFocused(false);
onExpandedChange?.(false);
}, [onExpandedChange]);
const showStopAction = props.canStopThread;
const { showStopPrimary: showStopPrimaryAction, showStopSecondary: showStopSecondaryAction } =
collapsedComposerActions({
canStopThread: props.canStopThread,
hasContent,
activeThreadBusy: props.activeThreadBusy,
});

const sendLabel =
props.connectionState !== "connected" || props.activeThreadBusy || props.queueCount > 0
Expand Down Expand Up @@ -690,6 +696,33 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
}
}

let collapsedPrimaryAction: ReactNode = (
<ControlPill icon="arrow.up" variant="primary" disabled={!canSend} onPress={handleSend} />
);
if (showStopSecondaryAction) {
collapsedPrimaryAction = (
<View className="flex-row items-center gap-1">
<ControlPill
icon="stop.fill"
accessibilityLabel="Stop"
variant="danger"
onPress={props.onStopThread}
/>
<ControlPill
icon="arrow.up"
accessibilityLabel="Send"
variant="primary"
disabled={!canSend}
onPress={handleSend}
/>
</View>
);
} else if (showStopPrimaryAction) {
collapsedPrimaryAction = (
<ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
);
}

return (
<Animated.View
className="px-4"
Expand Down Expand Up @@ -820,16 +853,7 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
) : null}
{!isExpanded ? (
<Animated.View entering={FadeIn.duration(180)} exiting={FadeOut.duration(100)}>
{showStopAction ? (
<ControlPill icon="stop.fill" variant="danger" onPress={props.onStopThread} />
) : (
<ControlPill
icon="arrow.up"
variant="primary"
disabled={!canSend}
onPress={handleSend}
/>
)}
{collapsedPrimaryAction}
</Animated.View>
) : null}
</ComposerSurface>
Expand Down Expand Up @@ -870,15 +894,15 @@ export const ThreadComposer = memo(function ThreadComposer(props: ThreadComposer
label={configurationLabel}
/>
</ControlPillMenu>
{showStopAction ? (
{props.canStopThread && (
<ComposerToolbarButton
accessibilityLabel="Stop"
icon="stop.fill"
variant="danger"
onPress={props.onStopThread}
showChevron={false}
/>
) : null}
)}
</ComposerToolbarScroller>
<ComposerToolbarButton
accessibilityLabel={sendLabel}
Expand Down
2 changes: 1 addition & 1 deletion apps/mobile/src/features/threads/ThreadDetailScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ export interface ThreadDetailScreenProps {
readonly respondingApprovalId: RuntimeRequestId | null;
readonly activePendingUserInput: PendingUserInput | null;
readonly activePendingUserInputDrafts: Record<string, PendingUserInputDraftAnswer>;
readonly activePendingUserInputAnswers: Record<string, string> | null;
readonly activePendingUserInputAnswers: Record<string, string | string[]> | null;
readonly respondingUserInputId: RuntimeRequestId | null;
readonly draftMessage: string;
readonly draftAttachments: ReadonlyArray<DraftComposerImageAttachment>;
Expand Down
13 changes: 9 additions & 4 deletions apps/mobile/src/features/threads/ThreadRouteScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -461,17 +461,22 @@ function ThreadRouteContent(
void navigation.navigate("Connections");
}, [navigation]);
const handleStopThread = useCallback(() => {
if (!selectedThread || composer.interruptibleRunId === null) {
if (!selectedThread || !composer.canInterruptThread) {
return;
}
return interruptThreadTurn({
environmentId: selectedThread.environmentId,
input: {
threadId: selectedThread.id,
runId: composer.interruptibleRunId,
...(composer.interruptibleRunId === null ? {} : { runId: composer.interruptibleRunId }),
},
});
}, [composer.interruptibleRunId, interruptThreadTurn, selectedThread]);
}, [
composer.canInterruptThread,
composer.interruptibleRunId,
interruptThreadTurn,
selectedThread,
]);

const handleOpenTerminal = useCallback(
(nextTerminalId?: string | null) => {
Expand Down Expand Up @@ -762,7 +767,7 @@ function ThreadRouteContent(
connectionStateLabel={routeConnectionState}
threadSyncStatus={selectedThreadDetailState.status}
activeThreadBusy={composer.activeThreadBusy}
canStopThread={composer.interruptibleRunId !== null}
canStopThread={composer.canInterruptThread}
environmentId={selectedThread.environmentId}
projectWorkspaceRoot={selectedThreadProject?.workspaceRoot ?? null}
threadCwd={selectedThreadCwd}
Expand Down
50 changes: 50 additions & 0 deletions apps/mobile/src/lib/threadActivity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@ import * as DateTime from "effect/DateTime";
import { describe, expect, it } from "vite-plus/test";

import {
buildPendingUserInputAnswers,
buildThreadFeed,
deriveThreadFeedPresentation,
setPendingUserInputCustomAnswer,
threadFeedRunIsUnsettled,
togglePendingUserInputOptionSelection,
type ThreadFeedActivity,
type ThreadFeedEntry,
} from "./threadActivity";
Expand All @@ -21,6 +24,53 @@ const threadId = ThreadId.make("thread-1");
const sourceThreadId = ThreadId.make("thread-source");
const runId = RunId.make("run-1");

const multiSelectQuestion = {
id: "areas",
header: "Areas",
question: "Which areas should this change cover?",
options: [
{ label: "Server", description: "Server" },
{ label: "Mobile", description: "Mobile" },
],
multiSelect: true,
} as const;

describe("pending user input", () => {
it("toggles and submits multiple selected options", () => {
const first = togglePendingUserInputOptionSelection(multiSelectQuestion, undefined, "Server");
const second = togglePendingUserInputOptionSelection(multiSelectQuestion, first, "Mobile");

expect(buildPendingUserInputAnswers([multiSelectQuestion], { areas: second })).toEqual({
areas: ["Server", "Mobile"],
});
expect(togglePendingUserInputOptionSelection(multiSelectQuestion, second, "Server")).toEqual({
customAnswer: "",
selectedOptionLabels: ["Mobile"],
});
});

it("normalizes option labels before toggling a selected value", () => {
const selected = togglePendingUserInputOptionSelection(
multiSelectQuestion,
undefined,
" Server ",
);

expect(
togglePendingUserInputOptionSelection(multiSelectQuestion, selected, " Server "),
).toEqual({ customAnswer: "" });
});

it("clears selected options while a custom answer is active", () => {
expect(
setPendingUserInputCustomAnswer(
{ selectedOptionLabels: ["Server", "Mobile"] },
"No preference",
),
).toEqual({ customAnswer: "No preference" });
});
});

function base(id: string, updatedAt: string, ordinal: number) {
const timestamp = DateTime.makeUnsafe(updatedAt);
return {
Expand Down
Loading
Loading