Add slash commands for ending and restarting agents (#1034)

* Add slash commands for ending agents

* Stabilize slash command e2e submit

* Unslop client slash command draft setup

* Fix slash commands while agent is running

* Fix slash command submit race

* Reshape client slash command execution

* Fix slash command tab cleanup
This commit is contained in:
Mohamed Boudra
2026-05-16 17:03:59 +08:00
committed by GitHub
parent 667f441cc0
commit 4b02daed8a
19 changed files with 934 additions and 53 deletions

View File

@@ -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<void>,
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await openSessions(page);
await expectSessionRowArchived(page, title);
}
async function expectReplacementDraftMatchesPreviousSetup(page: Page): Promise<void> {
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<void> {
await submitMessage(page, REPLACEMENT_PROMPT);
}
async function waitForReplacementAgentId(page: Page, oldAgentId: string): Promise<string> {
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);
},
);
});
});

View File

@@ -29,6 +29,11 @@ export interface TerminalPerfDaemonClient {
featureValues?: Record<string, unknown>; featureValues?: Record<string, unknown>;
initialPrompt?: string; initialPrompt?: string;
}): Promise<{ id: string; status: 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<void>; sendAgentMessage(agentId: string, text: string): Promise<void>;
subscribeTerminal( subscribeTerminal(
terminalId: string, terminalId: string,

View File

@@ -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> = {}): 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",
});
});
});

View File

@@ -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<string, unknown> = {};
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,
};
}

View File

@@ -99,6 +99,7 @@ import { useIsDictationReady } from "@/hooks/use-is-dictation-ready";
import { useGithubSearchQuery } from "@/git/use-github-search-query"; import { useGithubSearchQuery } from "@/git/use-github-search-query";
import { useCheckoutStatusQuery } from "@/git/use-status-query"; import { useCheckoutStatusQuery } from "@/git/use-status-query";
import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach"; import { useComposerGithubAutoAttach } from "./use-composer-github-auto-attach";
import { resolveClientSlashCommand, type ClientSlashCommand } from "@/client-slash-commands";
type QueuedMessage = QueuedComposerMessage; type QueuedMessage = QueuedComposerMessage;
@@ -607,6 +608,7 @@ interface ComposerProps {
serverId: string; serverId: string;
isPaneFocused: boolean; isPaneFocused: boolean;
onSubmitMessage?: (payload: MessagePayload) => Promise<void>; onSubmitMessage?: (payload: MessagePayload) => Promise<void>;
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
/** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */ /** When true, the submit button is enabled even without text or images (e.g. external attachment selected). */
hasExternalContent?: boolean; hasExternalContent?: boolean;
/** When true, the composer can submit even with no text or attachments. */ /** When true, the composer can submit even with no text or attachments. */
@@ -807,6 +809,7 @@ export function Composer({
serverId, serverId,
isPaneFocused, isPaneFocused,
onSubmitMessage, onSubmitMessage,
onClientSlashCommand,
hasExternalContent = false, hasExternalContent = false,
allowEmptySubmit = false, allowEmptySubmit = false,
submitButtonAccessibilityLabel, submitButtonAccessibilityLabel,
@@ -907,6 +910,41 @@ export function Composer({
`message-input:${serverId}:${agentId}:${Math.random().toString(36).slice(2)}`, `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({ const autocomplete = useAgentAutocomplete({
userInput, userInput,
cursorIndex, cursorIndex,
@@ -914,6 +952,8 @@ export function Composer({
serverId, serverId,
agentId, agentId,
draftConfig: commandDraftConfig, draftConfig: commandDraftConfig,
canExecuteClientSlashCommand: buildOutgoingAttachments(attachments).length === 0,
onClientSlashCommand: runClientSlashCommand,
onAutocompleteApplied: () => { onAutocompleteApplied: () => {
messageInputRef.current?.focus(); messageInputRef.current?.focus();
}, },
@@ -1109,16 +1149,27 @@ export function Composer({
const handleSubmit = useCallback( const handleSubmit = useCallback(
(payload: MessagePayload) => { (payload: MessagePayload) => {
const outgoingAttachments = buildOutgoingAttachments(attachments);
const clientSlashCommand = resolveClientSlashCommand({
text: payload.text,
hasAttachments: outgoingAttachments.length > 0,
});
if (clientSlashCommand && runClientSlashCommand(clientSlashCommand)) {
return;
}
if (blurOnSubmit) { if (blurOnSubmit) {
messageInputRef.current?.blur(); messageInputRef.current?.blur();
} }
void sendMessageWithContent( void sendMessageWithContent(payload.text, outgoingAttachments, payload.forceSend);
payload.text,
buildOutgoingAttachments(attachments),
payload.forceSend,
);
}, },
[attachments, blurOnSubmit, buildOutgoingAttachments, sendMessageWithContent], [
attachments,
blurOnSubmit,
buildOutgoingAttachments,
runClientSlashCommand,
sendMessageWithContent,
],
); );
const handlePickImage = useCallback(async () => { const handlePickImage = useCallback(async () => {
@@ -1280,18 +1331,25 @@ export function Composer({
const handleQueue = useCallback( const handleQueue = useCallback(
(payload: MessagePayload) => { (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; const hasSendableContent = userInput.trim().length > 0 || selectedAttachments.length > 0;
// Handle keyboard navigation for command autocomplete. // Handle keyboard navigation for command autocomplete.
const handleCommandKeyPress = useCallback( const handleCommandKeyPress = useCallback(
(event: { key: string; preventDefault: () => void }) => { (event: { key: string; preventDefault: () => void }) =>
return autocompleteOnKeyPressRef.current(event); autocompleteOnKeyPressRef.current(event),
},
[], [],
); );

View File

@@ -1455,7 +1455,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleSendMessage = useCallback( const handleSendMessage = useCallback(
() => () =>
sendMessageImpl({ sendMessageImpl({
value, value: valueRef.current,
attachments, attachments,
hasExternalContent, hasExternalContent,
allowEmptySubmit, allowEmptySubmit,
@@ -1466,7 +1466,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
}), }),
[ [
allowEmptySubmit, allowEmptySubmit,
value,
attachments, attachments,
cwd, cwd,
onSubmit, onSubmit,
@@ -1479,14 +1478,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleQueueMessage = useCallback( const handleQueueMessage = useCallback(
() => () =>
queueMessageImpl({ queueMessageImpl({
value, value: valueRef.current,
attachments, attachments,
cwd, cwd,
onQueue, onQueue,
onChangeText, onChangeText,
onMinimizeHeight: minimizeInputHeight, onMinimizeHeight: minimizeInputHeight,
}), }),
[value, attachments, cwd, onQueue, onChangeText, minimizeInputHeight], [attachments, cwd, onQueue, onChangeText, minimizeInputHeight],
); );
const handleDefaultSendAction = useCallback(() => { const handleDefaultSendAction = useCallback(() => {
@@ -1629,6 +1628,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
const handleInputChange = useCallback( const handleInputChange = useCallback(
(nextValue: string) => { (nextValue: string) => {
valueRef.current = nextValue;
onChangeText(nextValue); onChangeText(nextValue);
}, },
[onChangeText], [onChangeText],

View File

@@ -1,11 +1,16 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { keepPreviousData, useQuery } from "@tanstack/react-query";
import type { AutocompleteOption } from "@/components/ui/autocomplete"; 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 { orderAutocompleteOptions } from "@/components/ui/autocomplete-utils";
import { useAutocomplete } from "./use-autocomplete"; import { useAutocomplete } from "./use-autocomplete";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { CLIENT_SLASH_COMMANDS, type ClientSlashCommand } from "@/client-slash-commands";
import { import {
applyFileMentionReplacement, applyFileMentionReplacement,
findActiveFileMention, findActiveFileMention,
@@ -20,10 +25,13 @@ interface UseAgentAutocompleteInput {
agentId: string; agentId: string;
draftConfig?: DraftCommandConfig; draftConfig?: DraftCommandConfig;
onAutocompleteApplied?: () => void; onAutocompleteApplied?: () => void;
onClientSlashCommand?: (command: ClientSlashCommand) => void;
canExecuteClientSlashCommand?: boolean;
} }
type AgentAutocompleteOption = type AgentAutocompleteOption =
| (AutocompleteOption & { type: "command" }) | (AutocompleteOption & { type: "client_command"; command: ClientSlashCommand })
| (AutocompleteOption & { type: "provider_command" })
| (AutocompleteOption & { | (AutocompleteOption & {
type: "workspace_entry"; type: "workspace_entry";
entryPath: string; entryPath: string;
@@ -47,6 +55,10 @@ interface DirectorySuggestionEntry {
kind: "file" | "directory"; kind: "file" | "directory";
} }
type AvailableCommand =
| { source: "client"; command: ClientSlashCommand }
| { source: "provider"; command: AgentSlashCommand };
function normalizeDraftCommandConfig( function normalizeDraftCommandConfig(
draftConfig?: DraftCommandConfig, draftConfig?: DraftCommandConfig,
): DraftCommandConfig | undefined { ): 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; type AutocompleteMode = "command" | "file" | null;
function resolveAutocompleteMode(args: { function resolveAutocompleteMode(args: {
@@ -170,6 +204,8 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
agentId, agentId,
draftConfig, draftConfig,
onAutocompleteApplied, onAutocompleteApplied,
onClientSlashCommand,
canExecuteClientSlashCommand,
} = input; } = input;
const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" "); const showCommandAutocomplete = userInput.startsWith("/") && !userInput.includes(" ");
@@ -277,16 +313,22 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
if (mode === "command") { if (mode === "command") {
const filterLower = commandFilterQuery.toLowerCase(); 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); const orderedMatches = orderAutocompleteOptions(matches);
return orderedMatches.map((cmd) => ({ return orderedMatches.map(mapCommandToOption);
type: "command" as const,
id: cmd.name,
label: `/${cmd.name}`,
detail: cmd.argumentHint || undefined,
description: cmd.description,
kind: "command",
}));
} }
if (mode === "file" && activeFileMention) { if (mode === "file" && activeFileMention) {
@@ -302,12 +344,30 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
} }
return []; return [];
}, [activeFileMention, commandFilterQuery, commands, fileSuggestionsQuery.data, isVisible, mode]); }, [
activeFileMention,
commandFilterQuery,
commands,
fileSuggestionsQuery.data,
isDraftContext,
isVisible,
mode,
]);
const onSelectOption = useCallback( const onSelectOption = useCallback(
(option: AutocompleteOption) => { (option: AutocompleteOption) => {
const selected = option as AgentAutocompleteOption; 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} `); setUserInput(`/${selected.id} `);
onAutocompleteApplied?.(); onAutocompleteApplied?.();
return; return;
@@ -321,7 +381,13 @@ export function useAgentAutocomplete(input: UseAgentAutocompleteInput): AgentAut
setUserInput(nextInput); setUserInput(nextInput);
onAutocompleteApplied?.(); onAutocompleteApplied?.();
}, },
[onAutocompleteApplied, setUserInput, userInput], [
canExecuteClientSlashCommand,
onAutocompleteApplied,
onClientSlashCommand,
setUserInput,
userInput,
],
); );
const { selectedIndex, onKeyPress } = useAutocomplete({ const { selectedIndex, onKeyPress } = useAutocomplete({

View File

@@ -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 const COMMANDS_STALE_TIME = 60_000; // Commands rarely change, cache for 1 minute
interface AgentSlashCommand { export interface AgentSlashCommand {
name: string; name: string;
description: string; description: string;
argumentHint: string; argumentHint: string;

View File

@@ -27,6 +27,7 @@ type AttachmentUpdater =
interface AgentInputDraftComposerOptions { interface AgentInputDraftComposerOptions {
initialServerId: string | null; initialServerId: string | null;
initialValues?: CreateAgentInitialValues; initialValues?: CreateAgentInitialValues;
initialFeatureValues?: Record<string, unknown>;
isVisible?: boolean; isVisible?: boolean;
onlineServerIds?: string[]; onlineServerIds?: string[];
lockedWorkingDir?: string; lockedWorkingDir?: string;
@@ -229,6 +230,7 @@ export function useAgentInputDraft(input: UseAgentInputDraftInput): AgentInputDr
modeId: formState.selectedMode, modeId: formState.selectedMode,
modelId: effectiveModelId, modelId: effectiveModelId,
thinkingOptionId: effectiveThinkingOptionId, thinkingOptionId: effectiveThinkingOptionId,
initialFeatureValues: composerOptions?.initialFeatureValues,
}); });
const commandDraftConfig = useMemo( const commandDraftConfig = useMemo(

View File

@@ -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 { useQuery } from "@tanstack/react-query";
import type { AgentProvider, AgentSessionConfig } from "@server/server/agent/agent-sdk-types"; import type { AgentProvider, AgentSessionConfig } from "@server/server/agent/agent-sdk-types";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
@@ -21,14 +21,19 @@ export function useDraftAgentFeatures(input: {
modeId: string | null | undefined; modeId: string | null | undefined;
modelId: string | null | undefined; modelId: string | null | undefined;
thinkingOptionId: string | null | undefined; thinkingOptionId: string | null | undefined;
initialFeatureValues?: Record<string, unknown>;
}) { }) {
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input; const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>({}); input;
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
() => initialFeatureValues ?? {},
);
const client = useHostRuntimeClient(serverId ?? ""); const client = useHostRuntimeClient(serverId ?? "");
const isConnected = useHostRuntimeIsConnected(serverId ?? ""); const isConnected = useHostRuntimeIsConnected(serverId ?? "");
const { preferences, updatePreferences } = useFormPreferences(); const { preferences, updatePreferences } = useFormPreferences();
const normalizedCwd = cwd?.trim() || ""; const normalizedCwd = cwd?.trim() || "";
const normalizedProvider = provider ?? null; const normalizedProvider = provider ?? null;
const previousProviderRef = useRef<AgentProvider | null>(normalizedProvider);
const persistedFeatureValues = useMemo( const persistedFeatureValues = useMemo(
() => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}), () => (provider ? (preferences.providerPreferences?.[provider]?.featureValues ?? {}) : {}),
[preferences.providerPreferences, provider], [preferences.providerPreferences, provider],
@@ -88,15 +93,25 @@ export function useDraftAgentFeatures(input: {
}, [availableFeatures, featureValues]); }, [availableFeatures, featureValues]);
useEffect(() => { useEffect(() => {
setLocalFeatureValues({}); const previousProvider = previousProviderRef.current;
}, [provider]); previousProviderRef.current = normalizedProvider;
if (previousProvider === null) {
return;
}
if (previousProvider !== normalizedProvider) {
setLocalFeatureValues({});
}
}, [normalizedProvider]);
useEffect(() => { useEffect(() => {
if (availableFeaturesRaw === undefined) {
return;
}
const next = pruneFeatureValues(localFeatureValues, availableFeatures); const next = pruneFeatureValues(localFeatureValues, availableFeatures);
if (next !== localFeatureValues) { if (next !== localFeatureValues) {
setLocalFeatureValues(next); setLocalFeatureValues(next);
} }
}, [availableFeatures, localFeatureValues]); }, [availableFeatures, availableFeaturesRaw, localFeatureValues]);
const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined; const effectiveFeatureValues = Object.keys(featureValues).length > 0 ? featureValues : undefined;
const setFeatureValue = useCallback( const setFeatureValue = useCallback(

View File

@@ -48,9 +48,11 @@ import {
deriveRouteBottomAnchorRequest, deriveRouteBottomAnchorRequest,
} from "@/screens/agent/agent-ready-screen-bottom-anchor"; } from "@/screens/agent/agent-ready-screen-bottom-anchor";
import { useCreateFlowStore } from "@/stores/create-flow-store"; 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 { usePanelStore } from "@/stores/panel-store";
import { type Agent, useSessionStore } from "@/stores/session-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 type { Theme } from "@/styles/theme";
import { SubagentsSection, useArchiveSubagent, useSubagentsForParent } from "@/subagents"; import { SubagentsSection, useArchiveSubagent, useSubagentsForParent } from "@/subagents";
import type { PendingPermission } from "@/types/shared"; import type { PendingPermission } from "@/types/shared";
@@ -60,6 +62,7 @@ import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agen
import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { navigateToAgent } from "@/utils/navigate-to-agent"; import { navigateToAgent } from "@/utils/navigate-to-agent";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { buildDraftAgentSetup, type ClientSlashCommand } from "@/client-slash-commands";
interface ChatAgentStateShape { interface ChatAgentStateShape {
serverId: string | null; serverId: string | null;
@@ -1257,7 +1260,11 @@ function ActiveAgentComposer({
const insets = useSafeAreaInsets(); const insets = useSafeAreaInsets();
const isCompact = useIsCompactFormFactor(); const isCompact = useIsCompactFormFactor();
const paneContext = usePaneContext(); 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({ const subagentRows = useSubagentsForParent({
serverId, serverId,
parentAgentId: agentId, parentAgentId: agentId,
@@ -1305,6 +1312,44 @@ function ActiveAgentComposer({
[isCompact, openFileExplorerForCheckout, serverId, setExplorerTabForCheckout], [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( const inputAreaStyle = useMemo(
() => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }], () => [styles.inputAreaWrapper, { paddingBottom: insets.bottom }],
[insets.bottom], [insets.bottom],
@@ -1336,6 +1381,7 @@ function ActiveAgentComposer({
onAddImages={onAddImages} onAddImages={onAddImages}
onComposerHeightChange={onComposerHeightChange} onComposerHeightChange={onComposerHeightChange}
onMessageSent={onMessageSent} onMessageSent={onMessageSent}
onClientSlashCommand={handleClientSlashCommand}
/> />
</View> </View>
); );

View File

@@ -56,6 +56,7 @@ function DraftPanel() {
workspaceId={workspaceId} workspaceId={workspaceId}
tabId={tabId} tabId={tabId}
draftId={target.draftId} draftId={target.draftId}
initialSetup={target.setup}
isPaneFocused={isInteractive} isPaneFocused={isInteractive}
onOpenWorkspaceFile={handleOpenWorkspaceFile} onOpenWorkspaceFile={handleOpenWorkspaceFile}
onCreated={handleCreated} onCreated={handleCreated}

View File

@@ -10,6 +10,7 @@ import { AgentStreamView } from "@/components/agent-stream-view";
import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments"; import { composerWorkspaceAttachment } from "@/attachments/composer-workspace-attachments";
import type { ImageAttachment } from "@/components/message-input"; import type { ImageAttachment } from "@/components/message-input";
import { useAgentInputDraft } from "@/hooks/use-agent-input-draft"; 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 { useDraftAgentCreateFlow } from "@/hooks/use-draft-agent-create-flow";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config"; import { buildWorkspaceDraftAgentConfig } from "@/screens/workspace/workspace-draft-agent-config";
@@ -31,8 +32,10 @@ import {
import type { UserMessageImageAttachment } from "@/types/stream"; import type { UserMessageImageAttachment } from "@/types/stream";
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout"; import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
import { isWeb } from "@/constants/platform"; import { isWeb } from "@/constants/platform";
import type { WorkspaceDraftTabSetup } from "@/stores/workspace-tabs-store";
const EMPTY_PENDING_PERMISSIONS = new Map(); const EMPTY_PENDING_PERMISSIONS = new Map();
const EMPTY_ONLINE_SERVER_IDS: string[] = [];
const DRAFT_CAPABILITIES: AgentCapabilityFlags = { const DRAFT_CAPABILITIES: AgentCapabilityFlags = {
supportsStreaming: true, supportsStreaming: true,
supportsSessionPersistence: false, 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 { interface WorkspaceDraftAgentTabProps {
serverId: string; serverId: string;
workspaceId: string; workspaceId: string;
tabId: string; tabId: string;
draftId: string; draftId: string;
initialSetup?: WorkspaceDraftTabSetup;
isPaneFocused: boolean; isPaneFocused: boolean;
onCreated: (snapshot: AgentSnapshotPayload) => void; onCreated: (snapshot: AgentSnapshotPayload) => void;
onOpenWorkspaceFile: (input: { filePath: string }) => void; onOpenWorkspaceFile: (input: { filePath: string }) => void;
@@ -287,6 +327,7 @@ export function WorkspaceDraftAgentTab({
workspaceId, workspaceId,
tabId, tabId,
draftId, draftId,
initialSetup = undefined,
isPaneFocused, isPaneFocused,
onCreated, onCreated,
onOpenWorkspaceFile, onOpenWorkspaceFile,
@@ -298,6 +339,16 @@ export function WorkspaceDraftAgentTab({
const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId); const workspaceAuthority = useWorkspaceExecutionAuthority(serverId, workspaceId);
const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null; const workspaceExecutionAuthority = workspaceAuthority?.ok ? workspaceAuthority.authority : null;
const workspaceDirectory = workspaceExecutionAuthority?.workspaceDirectory ?? 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 addImagesRef = useRef<((images: ImageAttachment[]) => void) | null>(null);
const draftStoreKey = useMemo( const draftStoreKey = useMemo(
() => () =>
@@ -312,10 +363,11 @@ export function WorkspaceDraftAgentTab({
draftKey: draftStoreKey, draftKey: draftStoreKey,
composer: { composer: {
initialServerId: serverId, initialServerId: serverId,
initialValues: workspaceDirectory ? { workingDir: workspaceDirectory } : undefined, initialValues: draftInitialValues,
initialFeatureValues: draftSetup?.featureValues,
isVisible: true, isVisible: true,
onlineServerIds: isConnected ? [serverId] : [], onlineServerIds,
lockedWorkingDir: workspaceDirectory ?? undefined, lockedWorkingDir: draftWorkingDirectory ?? undefined,
}, },
}); });
const composerState = draftInput.composerState; const composerState = draftInput.composerState;
@@ -381,7 +433,7 @@ export function WorkspaceDraftAgentTab({
allowsEmptyAutoSubmit, allowsEmptyAutoSubmit,
composerState, composerState,
autoSubmitConfig, autoSubmitConfig,
workspaceDirectory, workspaceDirectory: draftWorkingDirectory,
hasClient: Boolean(client), hasClient: Boolean(client),
}), }),
onBeforeSubmit: () => { onBeforeSubmit: () => {
@@ -396,7 +448,7 @@ export function WorkspaceDraftAgentTab({
attempt, attempt,
serverId, serverId,
tabId, tabId,
workspaceDirectory, workspaceDirectory: draftWorkingDirectory,
autoSubmitConfig, autoSubmitConfig,
composerState, composerState,
}), }),
@@ -407,7 +459,7 @@ export function WorkspaceDraftAgentTab({
images, images,
attachments, attachments,
client, client,
workspaceDirectory, workspaceDirectory: draftWorkingDirectory,
workspaceExecutionAuthority, workspaceExecutionAuthority,
autoSubmitConfig, autoSubmitConfig,
composerState, composerState,
@@ -421,7 +473,7 @@ export function WorkspaceDraftAgentTab({
const isReadyForPendingAutoSubmit = Boolean( const isReadyForPendingAutoSubmit = Boolean(
pendingAutoSubmit && pendingAutoSubmit &&
draftInput.isHydrated && draftInput.isHydrated &&
workspaceDirectory && draftWorkingDirectory &&
client && client &&
!isSubmitting && !isSubmitting &&
!composerState.isModelLoading, !composerState.isModelLoading,

View File

@@ -75,6 +75,7 @@ export interface AgentRuntimeInfo {
sessionId: string | null; sessionId: string | null;
model?: string | null; model?: string | null;
modeId?: string | null; modeId?: string | null;
thinkingOptionId?: string | null;
extra?: Record<string, unknown>; extra?: Record<string, unknown>;
} }

View File

@@ -1159,14 +1159,21 @@ export function retargetTabInLayout(
}; };
} }
const nextTabId =
currentTab?.target.kind === "draft"
? input.tabId
: buildDeterministicWorkspaceTabId(input.target);
return { return {
// Preserve the existing tab id so draft->entity transitions keep the same // Preserve draft-origin tab ids so draft->entity transitions keep the same
// React key during the first render. Reconciliation can canonicalize later. // React key during the first render. Non-draft retargets must take the new
tabId: input.tabId, // target identity immediately so local tab state cannot masquerade as the
// previous agent/terminal/file.
tabId: nextTabId,
layout: { layout: {
root: replaceTabInTree(layout.root, { root: replaceTabInTree(layout.root, {
tabId: input.tabId, tabId: input.tabId,
nextTabId: input.tabId, nextTabId,
target: input.target, target: input.target,
}), }),
focusedPaneId: layout.focusedPaneId, focusedPaneId: layout.focusedPaneId,

View File

@@ -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", () => { it("retargetTab closes a draft tab and focuses the existing canonical target tab", () => {
useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555"); useWorkspaceLayoutIds("55555555-5555-5555-5555-555555555555");
const workspaceKey = createWorkspaceKey(); const workspaceKey = createWorkspaceKey();

View File

@@ -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", () => { it("retargeting a background draft keeps the currently focused tab focused", () => {
const draftTabId = "draft_background"; const draftTabId = "draft_background";
const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID }); const key = buildWorkspaceTabPersistenceKey({ serverId: SERVER_ID, workspaceId: WORKSPACE_ID });

View File

@@ -1,14 +1,25 @@
import AsyncStorage from "@react-native-async-storage/async-storage"; import AsyncStorage from "@react-native-async-storage/async-storage";
import type { AgentProvider } from "@server/server/agent/agent-sdk-types";
import { create } from "zustand"; import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware"; import { createJSONStorage, persist } from "zustand/middleware";
import { import {
buildDeterministicWorkspaceTabId, buildDeterministicWorkspaceTabId,
normalizeWorkspaceDraftTabSetup,
normalizeWorkspaceTabTarget, normalizeWorkspaceTabTarget,
workspaceTabTargetsEqual, workspaceTabTargetsEqual,
} from "@/utils/workspace-tab-identity"; } from "@/utils/workspace-tab-identity";
export interface WorkspaceDraftTabSetup {
provider: AgentProvider;
cwd: string;
modeId: string | null;
model: string | null;
thinkingOptionId: string | null;
featureValues: Record<string, unknown>;
}
export type WorkspaceTabTarget = export type WorkspaceTabTarget =
| { kind: "draft"; draftId: string } | { kind: "draft"; draftId: string; setup?: WorkspaceDraftTabSetup }
| { kind: "agent"; agentId: string } | { kind: "agent"; agentId: string }
| { kind: "terminal"; terminalId: string } | { kind: "terminal"; terminalId: string }
| { kind: "browser"; browserId: string } | { kind: "browser"; browserId: string }
@@ -140,7 +151,12 @@ function extractMigrationRawSources(persistedState: unknown): MigrationRawSource
function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null { function coerceWorkspaceTabTarget(raw: Record<string, unknown>): WorkspaceTabTarget | null {
const kind = typeof raw.kind === "string" ? raw.kind : null; const kind = typeof raw.kind === "string" ? raw.kind : null;
if (kind === "draft" && typeof raw.draftId === "string") { 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") { if (kind === "agent" && typeof raw.agentId === "string") {
return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId }); return normalizeWorkspaceTabTarget({ kind: "agent", agentId: raw.agentId });

View File

@@ -1,5 +1,7 @@
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store"; import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["setup"]>;
export function normalizeWorkspaceTabTarget( export function normalizeWorkspaceTabTarget(
value: WorkspaceTabTarget | null | undefined, value: WorkspaceTabTarget | null | undefined,
): WorkspaceTabTarget | null { ): WorkspaceTabTarget | null {
@@ -8,7 +10,11 @@ export function normalizeWorkspaceTabTarget(
} }
if (value.kind === "draft") { if (value.kind === "draft") {
const draftId = trimNonEmpty(value.draftId); 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") { if (value.kind === "agent") {
const agentId = trimNonEmpty(value.agentId); const agentId = trimNonEmpty(value.agentId);
@@ -33,6 +39,30 @@ export function normalizeWorkspaceTabTarget(
return null; 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( export function workspaceTabTargetsEqual(
left: WorkspaceTabTarget, left: WorkspaceTabTarget,
right: WorkspaceTabTarget, right: WorkspaceTabTarget,
@@ -41,7 +71,7 @@ export function workspaceTabTargetsEqual(
return false; return false;
} }
if (left.kind === "draft" && right.kind === "draft") { 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") { if (left.kind === "agent" && right.kind === "agent") {
return left.agentId === right.agentId; return left.agentId === right.agentId;
@@ -61,6 +91,39 @@ export function workspaceTabTargetsEqual(
return false; 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<string, unknown>,
right: Record<string, unknown>,
): 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 { export function buildDeterministicWorkspaceTabId(target: WorkspaceTabTarget): string {
if (target.kind === "draft") { if (target.kind === "draft") {
return target.draftId; return target.draftId;
@@ -87,3 +150,11 @@ function trimNonEmpty(value: string | null | undefined): string | null {
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null; 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<string, unknown> {
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
}