diff --git a/packages/app/e2e/client-slash-commands.spec.ts b/packages/app/e2e/client-slash-commands.spec.ts new file mode 100644 index 000000000..8e70ceb08 --- /dev/null +++ b/packages/app/e2e/client-slash-commands.spec.ts @@ -0,0 +1,205 @@ +import { expect, test, type Page } from "./fixtures"; +import { buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { composerLocator, expectComposerVisible, submitMessage } from "./helpers/composer"; +import { connectTerminalClient, type TerminalPerfDaemonClient } from "./helpers/terminal-perf"; +import { createTempGitRepo } from "./helpers/workspace"; +import { + expectSessionRowArchived, + expectWorkspaceTabHidden, + expectWorkspaceTabVisible, + openSessions, +} from "./helpers/archive-tab"; + +interface SlashCommandScenario { + agent: { id: string }; + client: TerminalPerfDaemonClient; + cwd: string; + title: string; +} + +const REPLACEMENT_PROMPT = "Replacement prompt after slash clear."; + +function getServerId(): string { + const serverId = process.env.E2E_SERVER_ID; + if (!serverId) { + throw new Error("E2E_SERVER_ID is not set."); + } + return serverId; +} + +async function withOpenReadyMockAgent( + page: Page, + input: { + title: string; + model?: string; + modeId?: string; + }, + run: (scenario: SlashCommandScenario) => Promise, +): Promise { + const repo = await createTempGitRepo("client-slash-command-"); + const client = await connectTerminalClient(); + + try { + await openProject(client, repo.path); + const agent = await createReadyMockAgent(client, { + cwd: repo.path, + title: input.title, + model: input.model, + modeId: input.modeId, + }); + await openActiveAgentTab(page, { cwd: repo.path, agentId: agent.id }); + + await run({ agent, client, cwd: repo.path, title: input.title }); + } finally { + await client.close(); + await repo.cleanup(); + } +} + +async function openProject(client: TerminalPerfDaemonClient, cwd: string): Promise { + const opened = await client.openProject(cwd); + if (!opened.workspace) { + throw new Error(opened.error ?? `Failed to open project ${cwd}`); + } +} + +async function createReadyMockAgent( + client: TerminalPerfDaemonClient, + input: { + cwd: string; + title: string; + model?: string; + modeId?: string; + }, +): Promise<{ id: string }> { + const agent = await client.createAgent({ + provider: "mock", + cwd: input.cwd, + title: input.title, + modeId: input.modeId ?? "load-test", + model: input.model ?? "ten-second-stream", + initialPrompt: "Prepare a client slash command test agent.", + }); + return { id: agent.id }; +} + +async function openActiveAgentTab( + page: Page, + input: { cwd: string; agentId: string }, +): Promise { + const agentUrl = `${buildHostWorkspaceRoute( + getServerId(), + input.cwd, + )}?open=${encodeURIComponent(`agent:${input.agentId}`)}`; + await page.goto(agentUrl); + await page.waitForURL( + (url) => url.pathname.includes("/workspace/") && !url.searchParams.has("open"), + { timeout: 60_000 }, + ); + await expectWorkspaceTabVisible(page, input.agentId); + await expectComposerVisible(page); +} + +async function runClientSlashCommand(page: Page, command: "/quit" | "/clear"): Promise { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill(command); + await expect(input).toHaveValue(command); + await input.press("Enter"); +} + +async function selectClientSlashCommand(page: Page, query: string, label: string): Promise { + const input = composerLocator(page); + await expect(input).toBeEditable({ timeout: 30_000 }); + await input.fill(query); + await expect(page.getByText(label, { exact: true }).first()).toBeVisible({ timeout: 30_000 }); + await input.press("Enter"); +} + +async function expectAgentArchivedInSessions(page: Page, title: string): Promise { + await openSessions(page); + await expectSessionRowArchived(page, title); +} + +async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise { + await expectComposerVisible(page); + await expect( + page.getByRole("button", { name: "Select model (Ten second stream)" }), + ).toBeVisible(); + await expect(page.getByRole("button", { name: "Select agent mode (load-test)" })).toBeVisible(); +} + +async function createAgentFromReplacementDraft(page: Page): Promise { + await submitMessage(page, REPLACEMENT_PROMPT); +} + +async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promise { + let newAgentId: string | null = null; + await expect + .poll( + async () => { + const ids = await page + .locator('[data-testid^="workspace-tab-agent_"]') + .evaluateAll((nodes) => + nodes.flatMap((node) => { + if (!(node instanceof HTMLElement)) { + return []; + } + const testId = node.getAttribute("data-testid") ?? ""; + if (!testId.startsWith("workspace-tab-agent_")) { + return []; + } + if (node.offsetParent === null) { + return []; + } + return [testId.slice("workspace-tab-agent_".length)]; + }), + ); + newAgentId = ids.find((id) => id !== oldAgentId) ?? null; + return newAgentId; + }, + { timeout: 30_000 }, + ) + .not.toBeNull(); + if (!newAgentId) { + throw new Error("Replacement agent was not created."); + } + return newAgentId; +} + +test.describe("Client slash commands", () => { + test("slash quit archives the active agent and removes its tab", async ({ page }) => { + await withOpenReadyMockAgent(page, { title: "Slash quit e2e" }, async ({ agent, title }) => { + await runClientSlashCommand(page, "/quit"); + await expectWorkspaceTabHidden(page, agent.id); + await expectAgentArchivedInSessions(page, title); + }); + }); + + test("slash quit selected from autocomplete archives immediately", async ({ page }) => { + await withOpenReadyMockAgent( + page, + { title: "Slash quit autocomplete e2e" }, + async ({ agent, title }) => { + await selectClientSlashCommand(page, "/qu", "/quit"); + await expectWorkspaceTabHidden(page, agent.id); + await expectAgentArchivedInSessions(page, title); + }, + ); + }); + + test("slash clear replaces the active agent with a matching draft", async ({ page }) => { + await withOpenReadyMockAgent( + page, + { title: "Slash clear e2e", model: "ten-second-stream", modeId: "load-test" }, + async ({ agent, title }) => { + await runClientSlashCommand(page, "/clear"); + await expectWorkspaceTabHidden(page, agent.id); + await expectReplacementDraftMatchesPreviousSetup(page); + await createAgentFromReplacementDraft(page); + await waitForReplacementAgentId(page, agent.id); + await expectAgentArchivedInSessions(page, title); + }, + ); + }); +}); diff --git a/packages/app/e2e/helpers/terminal-perf.ts b/packages/app/e2e/helpers/terminal-perf.ts index da6011292..676b0eb6d 100644 --- a/packages/app/e2e/helpers/terminal-perf.ts +++ b/packages/app/e2e/helpers/terminal-perf.ts @@ -29,6 +29,11 @@ export interface TerminalPerfDaemonClient { featureValues?: Record; initialPrompt?: string; }): Promise<{ id: string; status: string }>; + waitForAgentUpsert( + agentId: string, + predicate: (snapshot: { status: string }) => boolean, + timeout?: number, + ): Promise<{ status: string }>; sendAgentMessage(agentId: string, text: string): Promise; subscribeTerminal( terminalId: string, diff --git a/packages/app/src/client-slash-commands/index.test.ts b/packages/app/src/client-slash-commands/index.test.ts new file mode 100644 index 000000000..b22e0c737 --- /dev/null +++ b/packages/app/src/client-slash-commands/index.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, it } from "vitest"; +import { + CLIENT_SLASH_COMMANDS, + buildDraftAgentSetup, + resolveClientSlashCommand, +} from "@/client-slash-commands"; +import type { Agent } from "@/stores/session-store"; + +function createAgent(overrides: Partial = {}): Agent { + const now = new Date("2026-05-15T00:00:00.000Z"); + return { + serverId: "server-1", + id: "agent-1", + provider: "codex", + status: "idle", + createdAt: now, + updatedAt: now, + lastUserMessageAt: now, + lastActivityAt: now, + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: false, + supportsReasoningStream: false, + supportsToolInvocations: true, + }, + currentModeId: "mode-current", + availableModes: [], + pendingPermissions: [], + persistence: null, + runtimeInfo: { + provider: "codex", + sessionId: "session-1", + model: "runtime-model", + modeId: "runtime-mode", + thinkingOptionId: "runtime-thinking", + }, + title: "Agent", + cwd: "/repo", + model: "agent-model", + thinkingOptionId: "think-hard", + features: [ + { type: "toggle", id: "web-search", label: "Web search", value: true }, + { + type: "select", + id: "effort", + label: "Effort", + value: "high", + options: [{ id: "high", label: "High" }], + }, + ], + parentAgentId: null, + labels: {}, + ...overrides, + }; +} + +describe("resolveClientSlashCommand", () => { + it("declares the exact client commands that execute immediately", () => { + expect(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command.execution])).toEqual([ + ["quit", "immediate"], + ["exit", "immediate"], + ["q", "immediate"], + ["clear", "immediate"], + ["new", "immediate"], + ]); + }); + + it("resolves exact client commands after trimming", () => { + expect(resolveClientSlashCommand({ text: " /quit ", hasAttachments: false })).toMatchObject({ + kind: "archive-agent", + execution: "immediate", + }); + expect(resolveClientSlashCommand({ text: "/exit", hasAttachments: false })?.kind).toBe( + "archive-agent", + ); + expect(resolveClientSlashCommand({ text: "/q", hasAttachments: false })?.kind).toBe( + "archive-agent", + ); + expect(resolveClientSlashCommand({ text: "/clear", hasAttachments: false })?.kind).toBe( + "replace-agent-with-draft", + ); + expect(resolveClientSlashCommand({ text: "/new", hasAttachments: false })?.kind).toBe( + "replace-agent-with-draft", + ); + }); + + it("leaves provider commands, arguments, ordinary messages, and attachment submits alone", () => { + expect(resolveClientSlashCommand({ text: "/clear now", hasAttachments: false })).toBeNull(); + expect(resolveClientSlashCommand({ text: "/quit now", hasAttachments: false })).toBeNull(); + expect( + resolveClientSlashCommand({ text: "/provider-command", hasAttachments: false }), + ).toBeNull(); + expect(resolveClientSlashCommand({ text: "hello /quit", hasAttachments: false })).toBeNull(); + expect(resolveClientSlashCommand({ text: "/quit", hasAttachments: true })).toBeNull(); + }); +}); + +describe("buildDraftAgentSetup", () => { + it("builds draft setup from the active agent snapshot", () => { + expect(buildDraftAgentSetup(createAgent())).toEqual({ + provider: "codex", + cwd: "/repo", + modeId: "mode-current", + model: "agent-model", + thinkingOptionId: "think-hard", + featureValues: { + "web-search": true, + effort: "high", + }, + }); + }); + + it("falls back to runtime model setup when top-level fields are absent", () => { + expect( + buildDraftAgentSetup( + createAgent({ + currentModeId: null, + model: null, + thinkingOptionId: null, + }), + ), + ).toMatchObject({ + modeId: "runtime-mode", + model: "runtime-model", + thinkingOptionId: "runtime-thinking", + }); + }); +}); diff --git a/packages/app/src/client-slash-commands/index.ts b/packages/app/src/client-slash-commands/index.ts new file mode 100644 index 000000000..3ab8cdca1 --- /dev/null +++ b/packages/app/src/client-slash-commands/index.ts @@ -0,0 +1,90 @@ +import type { Agent } from "@/stores/session-store"; +import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; + +export type ClientSlashCommandKind = "archive-agent" | "replace-agent-with-draft"; +export type ClientSlashCommandExecution = "immediate" | "insert"; + +export interface ClientSlashCommand { + name: string; + description: string; + argumentHint: string; + kind: ClientSlashCommandKind; + execution: ClientSlashCommandExecution; +} + +export const CLIENT_SLASH_COMMANDS: readonly ClientSlashCommand[] = [ + { + name: "quit", + description: "Archive the current agent", + argumentHint: "", + kind: "archive-agent", + execution: "immediate", + }, + { + name: "exit", + description: "Archive the current agent", + argumentHint: "", + kind: "archive-agent", + execution: "immediate", + }, + { + name: "q", + description: "Archive the current agent", + argumentHint: "", + kind: "archive-agent", + execution: "immediate", + }, + { + name: "clear", + description: "Archive this agent and start a fresh draft", + argumentHint: "", + kind: "replace-agent-with-draft", + execution: "immediate", + }, + { + name: "new", + description: "Archive this agent and start a fresh draft", + argumentHint: "", + kind: "replace-agent-with-draft", + execution: "immediate", + }, +]; + +const COMMAND_BY_NAME = new Map(CLIENT_SLASH_COMMANDS.map((command) => [command.name, command])); + +export function resolveClientSlashCommand(input: { + text: string; + hasAttachments: boolean; +}): ClientSlashCommand | null { + if (input.hasAttachments) { + return null; + } + + const trimmed = input.text.trim(); + if (!trimmed.startsWith("/")) { + return null; + } + + const commandName = trimmed.slice(1); + if (!commandName || /\s/.test(commandName)) { + return null; + } + + return COMMAND_BY_NAME.get(commandName) ?? null; +} + +export function buildDraftAgentSetup(agent: Agent): WorkspaceDraftTabSetup { + const featureValues: Record = {}; + for (const feature of agent.features ?? []) { + featureValues[feature.id] = feature.value; + } + + return { + provider: agent.provider, + cwd: agent.cwd, + modeId: agent.currentModeId ?? agent.runtimeInfo?.modeId ?? null, + model: agent.model ?? agent.runtimeInfo?.model ?? null, + thinkingOptionId: agent.thinkingOptionId ?? agent.runtimeInfo?.thinkingOptionId ?? null, + featureValues, + }; +} diff --git a/packages/app/src/components/composer.tsx b/packages/app/src/components/composer.tsx index e381eec84..888214b77 100644 --- a/packages/app/src/components/composer.tsx +++ b/packages/app/src/components/composer.tsx @@ -99,6 +99,7 @@ import { useIsDictationReady } from "@/hooks/use-is-dictation-ready"; import { useGithubSearchQuery } from "@/git/use-github-search-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach"; +import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands"; type QueuedMessage = QueuedComposerMessage; @@ -607,6 +608,7 @@ interface ComposerProps { serverId: string; isPaneFocused: boolean; onSubmitMessage?: (payload: MessagePayload) => Promise; + onClientSlashCommand?: (command: ClientSlashCommand) => Promise; /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ hasExternalContent?: boolean; /** When true, the composer can submit even with no text or attachments. */ @@ -807,6 +809,7 @@ export function Composer({ serverId, isPaneFocused, onSubmitMessage, + onClientSlashCommand, hasExternalContent = false, allowEmptySubmit = false, submitButtonAccessibilityLabel, @@ -907,6 +910,41 @@ export function Composer({ `message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, ); + const runClientSlashCommand = useCallback( + (command: ClientSlashCommand): boolean => { + if (command.execution !== "immediate" || !onClientSlashCommand) { + return false; + } + + if (blurOnSubmit) { + messageInputRef.current?.blur(); + } + clearDraft("sent"); + setUserInput(""); + setSelectedAttachments([]); + resetSuppression(); + setSendError(null); + setIsProcessing(true); + void onClientSlashCommand(command) + .catch((error) => { + console.error("[Composer] Failed to run client slash command:", error); + setSendError(error instanceof Error ? error.message : String(error)); + }) + .finally(() => { + setIsProcessing(false); + }); + return true; + }, + [ + blurOnSubmit, + clearDraft, + onClientSlashCommand, + resetSuppression, + setSelectedAttachments, + setUserInput, + ], + ); + const autocomplete = useAgentAutocomplete({ userInput, cursorIndex, @@ -914,6 +952,8 @@ export function Composer({ serverId, agentId, draftConfig: commandDraftConfig, + canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0, + onClientSlashCommand: runClientSlashCommand, onAutocompleteApplied: () => { messageInputRef.current?.focus(); }, @@ -1109,16 +1149,27 @@ export function Composer({ const handleSubmit = useCallback( (payload: MessagePayload) => { + const outgoingAttachments = buildOutgoingAttachments(attachments); + const clientSlashCommand = resolveClientSlashCommand({ + text: payload.text, + hasAttachments: outgoingAttachments.length > 0, + }); + if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) { + return; + } + if (blurOnSubmit) { messageInputRef.current?.blur(); } - void sendMessageWithContent( - payload.text, - buildOutgoingAttachments(attachments), - payload.forceSend, - ); + void sendMessageWithContent(payload.text, outgoingAttachments, payload.forceSend); }, - [attachments, blurOnSubmit, buildOutgoingAttachments, sendMessageWithContent], + [ + attachments, + blurOnSubmit, + buildOutgoingAttachments, + runClientSlashCommand, + sendMessageWithContent, + ], ); const handlePickImage = useCallback(async () => { @@ -1280,18 +1331,25 @@ export function Composer({ const handleQueue = useCallback( (payload: MessagePayload) => { - queueMessage(payload.text, buildOutgoingAttachments(attachments)); + const outgoingAttachments = buildOutgoingAttachments(attachments); + const clientSlashCommand = resolveClientSlashCommand({ + text: payload.text, + hasAttachments: outgoingAttachments.length > 0, + }); + if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) { + return; + } + queueMessage(payload.text, outgoingAttachments); }, - [attachments, buildOutgoingAttachments, queueMessage], + [attachments, buildOutgoingAttachments, queueMessage, runClientSlashCommand], ); const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0; // Handle keyboard navigation for command autocomplete. const handleCommandKeyPress = useCallback( - (event: { key: string; preventDefault: () => void }) => { - return autocompleteOnKeyPressRef.current(event); - }, + (event: { key: string; preventDefault: () => void }) => + autocompleteOnKeyPressRef.current(event), [], ); diff --git a/packages/app/src/components/message-input.tsx b/packages/app/src/components/message-input.tsx index b99d526a2..e921e071e 100644 --- a/packages/app/src/components/message-input.tsx +++ b/packages/app/src/components/message-input.tsx @@ -1455,7 +1455,7 @@ export const MessageInput = forwardRef( const handleSendMessage = useCallback( () => sendMessageImpl({ - value, + value: valueRef.current, attachments, hasExternalContent, allowEmptySubmit, @@ -1466,7 +1466,6 @@ export const MessageInput = forwardRef( }), [ allowEmptySubmit, - value, attachments, cwd, onSubmit, @@ -1479,14 +1478,14 @@ export const MessageInput = forwardRef( const handleQueueMessage = useCallback( () => queueMessageImpl({ - value, + value: valueRef.current, attachments, cwd, onQueue, onChangeText, onMinimizeHeight: minimizeInputHeight, }), - [value, attachments, cwd, onQueue, onChangeText, minimizeInputHeight], + [attachments, cwd, onQueue, onChangeText, minimizeInputHeight], ); const handleDefaultSendAction = useCallback(() => { @@ -1629,6 +1628,7 @@ export const MessageInput = forwardRef( const handleInputChange = useCallback( (nextValue: string) => { + valueRef.current = nextValue; onChangeText(nextValue); }, [onChangeText], diff --git a/packages/app/src/hooks/use-agent-autocomplete.ts b/packages/app/src/hooks/use-agent-autocomplete.ts index 5c27c966f..f9ab93ee5 100644 --- a/packages/app/src/hooks/use-agent-autocomplete.ts +++ b/packages/app/src/hooks/use-agent-autocomplete.ts @@ -1,11 +1,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { keepPreviousData, useQuery } from "@tanstack/react-query"; import type { AutocompleteOption } from "@/components/ui/autocomplete"; -import { useAgentCommandsQuery, type DraftCommandConfig } from "./use-agent-commands-query"; +import { + useAgentCommandsQuery, + type AgentSlashCommand, + type DraftCommandConfig, +} from "./use-agent-commands-query"; import { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils"; import { useAutocomplete } from "./use-autocomplete"; import { useSessionStore } from "@/stores/session-store"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; +import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands"; import { applyFileMentionReplacement, findActiveFileMention, @@ -20,10 +25,13 @@ interface UseAgentAutocompleteInput { agentId: string; draftConfig?: DraftCommandConfig; onAutocompleteApplied?: () => void; + onClientSlashCommand?: (command: ClientSlashCommand) => void; + canExecuteClientSlashCommand?: boolean; } type AgentAutocompleteOption = - | (AutocompleteOption & { type: "command" }) + | (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand }) + | (AutocompleteOption & { type: "provider_command" }) | (AutocompleteOption & { type: "workspace_entry"; entryPath: string; @@ -47,6 +55,10 @@ interface DirectorySuggestionEntry { kind: "file" | "directory"; } +type AvailableCommand = + | { source: "client"; command: ClientSlashCommand } + | { source: "provider"; command: AgentSlashCommand }; + function normalizeDraftCommandConfig( draftConfig?: DraftCommandConfig, ): DraftCommandConfig | undefined { @@ -96,6 +108,28 @@ function mapDirectorySuggestionsToEntries(payload: { })); } +function mapCommandToOption(entry: AvailableCommand): AgentAutocompleteOption { + const command = entry.command; + const base = { + id: command.name, + label: `/${command.name}`, + detail: command.argumentHint || undefined, + description: command.description, + kind: "command" as const, + }; + if (entry.source === "client") { + return { + ...base, + type: "client_command", + command: entry.command, + }; + } + return { + ...base, + type: "provider_command", + }; +} + type AutocompleteMode = "command" | "file" | null; function resolveAutocompleteMode(args: { @@ -170,6 +204,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut agentId, draftConfig, onAutocompleteApplied, + onClientSlashCommand, + canExecuteClientSlashCommand, } = input; const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); @@ -277,16 +313,22 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut if (mode === "command") { const filterLower = commandFilterQuery.toLowerCase(); - const matches = commands.filter((cmd) => cmd.name.toLowerCase().includes(filterLower)); + const providerCommands = commands.map( + (command): AvailableCommand => ({ source: "provider", command }), + ); + const availableCommands: AvailableCommand[] = isDraftContext + ? providerCommands + : [ + ...CLIENT_SLASH_COMMANDS.map( + (command): AvailableCommand => ({ source: "client", command }), + ), + ...providerCommands, + ]; + const matches = availableCommands.filter((entry) => + entry.command.name.toLowerCase().includes(filterLower), + ); const orderedMatches = orderAutocompleteOptions(matches); - return orderedMatches.map((cmd) => ({ - type: "command" as const, - id: cmd.name, - label: `/${cmd.name}`, - detail: cmd.argumentHint || undefined, - description: cmd.description, - kind: "command", - })); + return orderedMatches.map(mapCommandToOption); } if (mode === "file" && activeFileMention) { @@ -302,12 +344,30 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut } return []; - }, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode]); + }, [ + activeFileMention, + commandFilterQuery, + commands, + fileSuggestionsQuery.data, + isDraftContext, + isVisible, + mode, + ]); const onSelectOption = useCallback( (option: AutocompleteOption) => { const selected = option as AgentAutocompleteOption; - if (selected.type === "command") { + if ( + selected.type === "client_command" && + selected.command.execution === "immediate" && + canExecuteClientSlashCommand && + onClientSlashCommand + ) { + onClientSlashCommand(selected.command); + return; + } + + if (selected.type === "client_command" || selected.type === "provider_command") { setUserInput(`/${selected.id} `); onAutocompleteApplied?.(); return; @@ -321,7 +381,13 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut setUserInput(nextInput); onAutocompleteApplied?.(); }, - [onAutocompleteApplied, setUserInput, userInput], + [ + canExecuteClientSlashCommand, + onAutocompleteApplied, + onClientSlashCommand, + setUserInput, + userInput, + ], ); const { selectedIndex, onKeyPress } = useAutocomplete({ diff --git a/packages/app/src/hooks/use-agent-commands-query.ts b/packages/app/src/hooks/use-agent-commands-query.ts index 7f45c615f..2018ecc6a 100644 --- a/packages/app/src/hooks/use-agent-commands-query.ts +++ b/packages/app/src/hooks/use-agent-commands-query.ts @@ -4,7 +4,7 @@ import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute -interface AgentSlashCommand { +export interface AgentSlashCommand { name: string; description: string; argumentHint: string; diff --git a/packages/app/src/hooks/use-agent-input-draft.ts b/packages/app/src/hooks/use-agent-input-draft.ts index 5962b548f..8e63f38ac 100644 --- a/packages/app/src/hooks/use-agent-input-draft.ts +++ b/packages/app/src/hooks/use-agent-input-draft.ts @@ -27,6 +27,7 @@ type AttachmentUpdater = interface AgentInputDraftComposerOptions { initialServerId: string | null; initialValues?: CreateAgentInitialValues; + initialFeatureValues?: Record; isVisible?: boolean; onlineServerIds?: string[]; lockedWorkingDir?: string; @@ -229,6 +230,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr modeId: formState.selectedMode, modelId: effectiveModelId, thinkingOptionId: effectiveThinkingOptionId, + initialFeatureValues: composerOptions?.initialFeatureValues, }); const commandDraftConfig = useMemo( diff --git a/packages/app/src/hooks/use-draft-agent-features.ts b/packages/app/src/hooks/use-draft-agent-features.ts index c0eae3c99..a76935829 100644 --- a/packages/app/src/hooks/use-draft-agent-features.ts +++ b/packages/app/src/hooks/use-draft-agent-features.ts @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { AgentProvider, AgentSessionConfig } from "@server/server/agent/agent-sdk-types"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; @@ -21,14 +21,19 @@ export function useDraftAgentFeatures(input: { modeId: string | null | undefined; modelId: string | null | undefined; thinkingOptionId: string | null | undefined; + initialFeatureValues?: Record; }) { - const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input; - const [localFeatureValues, setLocalFeatureValues] = useState>({}); + const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } = + input; + const [localFeatureValues, setLocalFeatureValues] = useState>( + () => initialFeatureValues ?? {}, + ); const client = useHostRuntimeClient(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const { preferences, updatePreferences } = useFormPreferences(); const normalizedCwd = cwd?.trim() || ""; const normalizedProvider = provider ?? null; + const previousProviderRef = useRef(normalizedProvider); const persistedFeatureValues = useMemo( () => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}), [preferences.providerPreferences, provider], @@ -88,15 +93,25 @@ export function useDraftAgentFeatures(input: { }, [availableFeatures, featureValues]); useEffect(() => { - setLocalFeatureValues({}); - }, [provider]); + const previousProvider = previousProviderRef.current; + previousProviderRef.current = normalizedProvider; + if (previousProvider === null) { + return; + } + if (previousProvider !== normalizedProvider) { + setLocalFeatureValues({}); + } + }, [normalizedProvider]); useEffect(() => { + if (availableFeaturesRaw === undefined) { + return; + } const next = pruneFeatureValues(localFeatureValues, availableFeatures); if (next !== localFeatureValues) { setLocalFeatureValues(next); } - }, [availableFeatures, localFeatureValues]); + }, [availableFeatures, availableFeaturesRaw, localFeatureValues]); const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined; const setFeatureValue = useCallback( diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index f1f61fa61..eb2726a30 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -48,9 +48,11 @@ import { deriveRouteBottomAnchorRequest, } from "@/screens/agent/agent-ready-screen-bottom-anchor"; import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { buildDraftStoreKey } from "@/stores/draft-keys"; +import { buildDraftStoreKey, generateDraftId } from "@/stores/draft-keys"; import { usePanelStore } from "@/stores/panel-store"; import { type Agent, useSessionStore } from "@/stores/session-store"; +import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store"; +import { buildWorkspaceTabPersistenceKey } from "@/stores/workspace-tabs-store"; import type { Theme } from "@/styles/theme"; import { SubagentsSection, useArchiveSubagent, useSubagentsForParent } from "@/subagents"; import type { PendingPermission } from "@/types/shared"; @@ -60,6 +62,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands"; interface ChatAgentStateShape { serverId: string | null; @@ -1257,7 +1260,11 @@ function ActiveAgentComposer({ const insets = useSafeAreaInsets(); const isCompact = useIsCompactFormFactor(); const paneContext = usePaneContext(); - const { workspaceId } = paneContext; + const { workspaceId, tabId, retargetCurrentTab } = paneContext; + const { archiveAgent } = useArchiveAgent(); + const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); + const hideWorkspaceAgent = useWorkspaceLayoutStore((state) => state.hideAgent); + const unpinWorkspaceAgent = useWorkspaceLayoutStore((state) => state.unpinAgent); const subagentRows = useSubagentsForParent({ serverId, parentAgentId: agentId, @@ -1305,6 +1312,44 @@ function ActiveAgentComposer({ [isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout], ); + const handleClientSlashCommand = useCallback( + async (command: ClientSlashCommand) => { + const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId); + if (!agent) { + throw new Error("Agent not found"); + } + + const workspaceKey = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); + if (workspaceKey) { + unpinWorkspaceAgent(workspaceKey, agentId); + hideWorkspaceAgent(workspaceKey, agentId); + } + + if (command.kind === "replace-agent-with-draft") { + retargetCurrentTab({ + kind: "draft", + draftId: generateDraftId(), + setup: buildDraftAgentSetup(agent), + }); + } else if (workspaceKey) { + closeWorkspaceTab(workspaceKey, tabId); + } + + await archiveAgent({ serverId, agentId }); + }, + [ + agentId, + archiveAgent, + closeWorkspaceTab, + hideWorkspaceAgent, + retargetCurrentTab, + serverId, + tabId, + unpinWorkspaceAgent, + workspaceId, + ], + ); + const inputAreaStyle = useMemo( () => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }], [insets.bottom], @@ -1336,6 +1381,7 @@ function ActiveAgentComposer({ onAddImages={onAddImages} onComposerHeightChange={onComposerHeightChange} onMessageSent={onMessageSent} + onClientSlashCommand={handleClientSlashCommand} /> ); diff --git a/packages/app/src/panels/draft-panel.tsx b/packages/app/src/panels/draft-panel.tsx index 5db7b5372..fd52edccc 100644 --- a/packages/app/src/panels/draft-panel.tsx +++ b/packages/app/src/panels/draft-panel.tsx @@ -56,6 +56,7 @@ function DraftPanel() { workspaceId={workspaceId} tabId={tabId} draftId={target.draftId} + initialSetup={target.setup} isPaneFocused={isInteractive} onOpenWorkspaceFile={handleOpenWorkspaceFile} onCreated={handleCreated} diff --git a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx index 2d1c10aa7..568187a66 100644 --- a/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx +++ b/packages/app/src/screens/workspace/workspace-draft-agent-tab.tsx @@ -10,6 +10,7 @@ import { AgentStreamView } from "@/components/agent-stream-view"; import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments"; import type { ImageAttachment } from "@/components/message-input"; import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; +import type { CreateAgentInitialValues } from "@/hooks/use-agent-form-state"; import { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; @@ -31,8 +32,10 @@ import { import type { UserMessageImageAttachment } from "@/types/stream"; import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; +import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store"; const EMPTY_PENDING_PERMISSIONS = new Map(); +const EMPTY_ONLINE_SERVER_IDS: string[] = []; const DRAFT_CAPABILITIES: AgentCapabilityFlags = { supportsStreaming: true, supportsSessionPersistence: false, @@ -271,11 +274,48 @@ function buildDraftAgentSnapshot(input: { }; } +function buildDraftInitialValues(input: { + workingDir: string | null; + initialSetup: WorkspaceDraftTabSetup | null; +}): CreateAgentInitialValues | undefined { + if (!input.workingDir) { + return undefined; + } + if (!input.initialSetup) { + return { workingDir: input.workingDir }; + } + return { + workingDir: input.workingDir, + provider: input.initialSetup.provider, + modeId: input.initialSetup.modeId, + model: input.initialSetup.model, + thinkingOptionId: input.initialSetup.thinkingOptionId, + }; +} + +function resolveDraftWorkingDirectory(input: { + workspaceDirectory: string | null; + initialSetup: WorkspaceDraftTabSetup | null; +}): string | null { + if (input.initialSetup) { + return input.initialSetup.cwd; + } + return input.workspaceDirectory; +} + +function resolveOnlineServerIds(input: { isConnected: boolean; serverId: string }): string[] { + if (!input.isConnected) { + return EMPTY_ONLINE_SERVER_IDS; + } + return [input.serverId]; +} + interface WorkspaceDraftAgentTabProps { serverId: string; workspaceId: string; tabId: string; draftId: string; + initialSetup?: WorkspaceDraftTabSetup; isPaneFocused: boolean; onCreated: (snapshot: AgentSnapshotPayload) => void; onOpenWorkspaceFile: (input: { filePath: string }) => void; @@ -287,6 +327,7 @@ export function WorkspaceDraftAgentTab({ workspaceId, tabId, draftId, + initialSetup = undefined, isPaneFocused, onCreated, onOpenWorkspaceFile, @@ -298,6 +339,16 @@ export function WorkspaceDraftAgentTab({ const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId); const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null; const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? null; + const draftSetup = initialSetup ?? null; + const draftWorkingDirectory = resolveDraftWorkingDirectory({ + workspaceDirectory, + initialSetup: draftSetup, + }); + const draftInitialValues = buildDraftInitialValues({ + workingDir: draftWorkingDirectory, + initialSetup: draftSetup, + }); + const onlineServerIds = resolveOnlineServerIds({ isConnected, serverId }); const addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null); const draftStoreKey = useMemo( () => @@ -312,10 +363,11 @@ export function WorkspaceDraftAgentTab({ draftKey: draftStoreKey, composer: { initialServerId: serverId, - initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined, + initialValues: draftInitialValues, + initialFeatureValues: draftSetup?.featureValues, isVisible: true, - onlineServerIds: isConnected ? [serverId] : [], - lockedWorkingDir: workspaceDirectory ?? undefined, + onlineServerIds, + lockedWorkingDir: draftWorkingDirectory ?? undefined, }, }); const composerState = draftInput.composerState; @@ -381,7 +433,7 @@ export function WorkspaceDraftAgentTab({ allowsEmptyAutoSubmit, composerState, autoSubmitConfig, - workspaceDirectory, + workspaceDirectory: draftWorkingDirectory, hasClient: Boolean(client), }), onBeforeSubmit: () => { @@ -396,7 +448,7 @@ export function WorkspaceDraftAgentTab({ attempt, serverId, tabId, - workspaceDirectory, + workspaceDirectory: draftWorkingDirectory, autoSubmitConfig, composerState, }), @@ -407,7 +459,7 @@ export function WorkspaceDraftAgentTab({ images, attachments, client, - workspaceDirectory, + workspaceDirectory: draftWorkingDirectory, workspaceExecutionAuthority, autoSubmitConfig, composerState, @@ -421,7 +473,7 @@ export function WorkspaceDraftAgentTab({ const isReadyForPendingAutoSubmit = Boolean( pendingAutoSubmit && draftInput.isHydrated && - workspaceDirectory && + draftWorkingDirectory && client && !isSubmitting && !composerState.isModelLoading, diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index a601e0ace..3f5f3ec95 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -75,6 +75,7 @@ export interface AgentRuntimeInfo { sessionId: string | null; model?: string | null; modeId?: string | null; + thinkingOptionId?: string | null; extra?: Record; } diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index c0d728408..630cc5220 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -1159,14 +1159,21 @@ export function retargetTabInLayout( }; } + const nextTabId = + currentTab?.target.kind === "draft" + ? input.tabId + : buildDeterministicWorkspaceTabId(input.target); + return { - // Preserve the existing tab id so draft->entity transitions keep the same - // React key during the first render. Reconciliation can canonicalize later. - tabId: input.tabId, + // Preserve draft-origin tab ids so draft->entity transitions keep the same + // React key during the first render. Non-draft retargets must take the new + // target identity immediately so local tab state cannot masquerade as the + // previous agent/terminal/file. + tabId: nextTabId, layout: { root: replaceTabInTree(layout.root, { tabId: input.tabId, - nextTabId: input.tabId, + nextTabId, target: input.target, }), focusedPaneId: layout.focusedPaneId, diff --git a/packages/app/src/stores/workspace-layout-store.test.ts b/packages/app/src/stores/workspace-layout-store.test.ts index d10dc4b2c..ded1ce2a8 100644 --- a/packages/app/src/stores/workspace-layout-store.test.ts +++ b/packages/app/src/stores/workspace-layout-store.test.ts @@ -589,6 +589,32 @@ describe("workspace-layout-store actions", () => { ]); }); + it("retargetTab gives a non-draft tab the new target identity", () => { + const workspaceKey = createWorkspaceKey(); + const store = workspaceLayoutStore.getState(); + + const agentTabId = store.openTabFocused(workspaceKey, { + kind: "agent", + agentId: "agent-retarget", + }); + const nextTabId = store.retargetTab(workspaceKey, agentTabId!, { + kind: "draft", + draftId: "draft-from-agent", + }); + const layout = workspaceLayoutStore.getState().layoutByWorkspace[workspaceKey]; + + expect(agentTabId).toBe("agent_agent-retarget"); + expect(nextTabId).toBe("draft-from-agent"); + expect(findPaneById(layout.root, "main")?.tabIds).toEqual(["draft-from-agent"]); + expect(collectAllTabs(layout.root)).toEqual([ + { + tabId: "draft-from-agent", + target: { kind: "draft", draftId: "draft-from-agent" }, + createdAt: expect.any(Number), + }, + ]); + }); + it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => { useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555"); const workspaceKey = createWorkspaceKey(); diff --git a/packages/app/src/stores/workspace-tabs-store.test.ts b/packages/app/src/stores/workspace-tabs-store.test.ts index 225361a1b..b9e0e7c3a 100644 --- a/packages/app/src/stores/workspace-tabs-store.test.ts +++ b/packages/app/src/stores/workspace-tabs-store.test.ts @@ -185,6 +185,96 @@ describe("workspace-tabs-store retargetTab", () => { ]); }); + it("keeps draft setup on a retargeted tab", () => { + const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); + expect(key).toBeTruthy(); + const workspaceKey = key as string; + + const tabId = useWorkspaceTabsStore.getState().ensureTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "agent", agentId: "agent-1" }, + }); + if (!tabId) { + throw new Error("Expected tab id"); + } + expect(tabId).toBe("agent_agent-1"); + + useWorkspaceTabsStore.getState().retargetTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + tabId, + target: { + kind: "draft", + draftId: "draft-replacement", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: { effort: "high" }, + }, + }, + }); + + expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({ + kind: "draft", + draftId: "draft-replacement", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: { effort: "high" }, + }, + }); + }); + + it("updates an existing draft tab when the setup changes", () => { + const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); + expect(key).toBeTruthy(); + const workspaceKey = key as string; + + const first = useWorkspaceTabsStore.getState().ensureTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { kind: "draft", draftId: "draft-1" }, + }); + const second = useWorkspaceTabsStore.getState().ensureTab({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + target: { + kind: "draft", + draftId: "draft-1", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: {}, + }, + }, + }); + + expect(second).toBe(first); + expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]).toHaveLength(1); + expect(useWorkspaceTabsStore.getState().uiTabsByWorkspace[workspaceKey]?.[0]?.target).toEqual({ + kind: "draft", + draftId: "draft-1", + setup: { + provider: "mock", + cwd: "/repo/worktree", + modeId: "load-test", + model: "ten-second-stream", + thinkingOptionId: null, + featureValues: {}, + }, + }); + }); + it("retargeting a background draft keeps the currently focused tab focused", () => { const draftTabId = "draft_background"; const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); diff --git a/packages/app/src/stores/workspace-tabs-store.ts b/packages/app/src/stores/workspace-tabs-store.ts index cde9e31f9..84a38ce58 100644 --- a/packages/app/src/stores/workspace-tabs-store.ts +++ b/packages/app/src/stores/workspace-tabs-store.ts @@ -1,14 +1,25 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; +import type { AgentProvider } from "@server/server/agent/agent-sdk-types"; import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { buildDeterministicWorkspaceTabId, + normalizeWorkspaceDraftTabSetup, normalizeWorkspaceTabTarget, workspaceTabTargetsEqual, } from "@/utils/workspace-tab-identity"; +export interface WorkspaceDraftTabSetup { + provider: AgentProvider; + cwd: string; + modeId: string | null; + model: string | null; + thinkingOptionId: string | null; + featureValues: Record; +} + export type WorkspaceTabTarget = - | { kind: "draft"; draftId: string } + | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup } | { kind: "agent"; agentId: string } | { kind: "terminal"; terminalId: string } | { kind: "browser"; browserId: string } @@ -140,7 +151,12 @@ function extractMigrationRawSources(persistedState: unknown): MigrationRawSource function coerceWorkspaceTabTarget(raw: Record): WorkspaceTabTarget | null { const kind = typeof raw.kind === "string" ? raw.kind : null; if (kind === "draft" && typeof raw.draftId === "string") { - return normalizeWorkspaceTabTarget({ kind: "draft", draftId: raw.draftId }); + const setup = normalizeWorkspaceDraftTabSetup(raw.setup); + return normalizeWorkspaceTabTarget({ + kind: "draft", + draftId: raw.draftId, + ...(setup ? { setup } : {}), + }); } if (kind === "agent" && typeof raw.agentId === "string") { return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); diff --git a/packages/app/src/utils/workspace-tab-identity.ts b/packages/app/src/utils/workspace-tab-identity.ts index 1a6e0f95a..fd08cb34e 100644 --- a/packages/app/src/utils/workspace-tab-identity.ts +++ b/packages/app/src/utils/workspace-tab-identity.ts @@ -1,5 +1,7 @@ import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; +type WorkspaceDraftTabSetup = NonNullable["setup"]>; + export function normalizeWorkspaceTabTarget( value: WorkspaceTabTarget | null | undefined, ): WorkspaceTabTarget | null { @@ -8,7 +10,11 @@ export function normalizeWorkspaceTabTarget( } if (value.kind === "draft") { const draftId = trimNonEmpty(value.draftId); - return draftId ? { kind: "draft", draftId } : null; + if (!draftId) { + return null; + } + const setup = normalizeWorkspaceDraftTabSetup(value.setup); + return setup ? { kind: "draft", draftId, setup } : { kind: "draft", draftId }; } if (value.kind === "agent") { const agentId = trimNonEmpty(value.agentId); @@ -33,6 +39,30 @@ export function normalizeWorkspaceTabTarget( return null; } +export function normalizeWorkspaceDraftTabSetup( + value: unknown, +): WorkspaceDraftTabSetup | undefined { + const record = isPlainRecord(value) ? value : null; + if (!record) { + return undefined; + } + const provider = trimNonEmpty(typeof record.provider === "string" ? record.provider : null); + const cwd = trimNonEmpty(typeof record.cwd === "string" ? record.cwd : null); + if (!provider || !cwd) { + return undefined; + } + return { + provider, + cwd, + modeId: trimOptionalString(typeof record.modeId === "string" ? record.modeId : null), + model: trimOptionalString(typeof record.model === "string" ? record.model : null), + thinkingOptionId: trimOptionalString( + typeof record.thinkingOptionId === "string" ? record.thinkingOptionId : null, + ), + featureValues: isPlainRecord(record.featureValues) ? { ...record.featureValues } : {}, + }; +} + export function workspaceTabTargetsEqual( left: WorkspaceTabTarget, right: WorkspaceTabTarget, @@ -41,7 +71,7 @@ export function workspaceTabTargetsEqual( return false; } if (left.kind === "draft" && right.kind === "draft") { - return left.draftId === right.draftId; + return left.draftId === right.draftId && workspaceDraftTabSetupsEqual(left.setup, right.setup); } if (left.kind === "agent" && right.kind === "agent") { return left.agentId === right.agentId; @@ -61,6 +91,39 @@ export function workspaceTabTargetsEqual( return false; } +function workspaceDraftTabSetupsEqual( + left: WorkspaceDraftTabSetup | undefined, + right: WorkspaceDraftTabSetup | undefined, +): boolean { + if (!left || !right) { + return left === right; + } + return ( + left.provider === right.provider && + left.cwd === right.cwd && + left.modeId === right.modeId && + left.model === right.model && + left.thinkingOptionId === right.thinkingOptionId && + recordsShallowEqual(left.featureValues, right.featureValues) + ); +} + +function recordsShallowEqual( + left: Record, + right: Record, +): boolean { + const leftKeys = Object.keys(left); + if (leftKeys.length !== Object.keys(right).length) { + return false; + } + for (const key of leftKeys) { + if (!Object.hasOwn(right, key) || !Object.is(left[key], right[key])) { + return false; + } + } + return true; +} + export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string { if (target.kind === "draft") { return target.draftId; @@ -87,3 +150,11 @@ function trimNonEmpty(value: string | null | undefined): string | null { const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : null; } + +function trimOptionalString(value: string | null | undefined): string | null { + return value == null ? null : trimNonEmpty(value); +} + +function isPlainRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +}