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:
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -29,6 +29,11 @@ export interface TerminalPerfDaemonClient {
|
||||
featureValues?: Record<string, unknown>;
|
||||
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<void>;
|
||||
subscribeTerminal(
|
||||
terminalId: string,
|
||||
|
||||
@@ -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",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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<void>;
|
||||
onClientSlashCommand?: (command: ClientSlashCommand) => Promise<void>;
|
||||
/** 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),
|
||||
[],
|
||||
);
|
||||
|
||||
|
||||
@@ -1455,7 +1455,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
const handleSendMessage = useCallback(
|
||||
() =>
|
||||
sendMessageImpl({
|
||||
value,
|
||||
value: valueRef.current,
|
||||
attachments,
|
||||
hasExternalContent,
|
||||
allowEmptySubmit,
|
||||
@@ -1466,7 +1466,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
}),
|
||||
[
|
||||
allowEmptySubmit,
|
||||
value,
|
||||
attachments,
|
||||
cwd,
|
||||
onSubmit,
|
||||
@@ -1479,14 +1478,14 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
|
||||
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<MessageInputRef, MessageInputProps>(
|
||||
|
||||
const handleInputChange = useCallback(
|
||||
(nextValue: string) => {
|
||||
valueRef.current = nextValue;
|
||||
onChangeText(nextValue);
|
||||
},
|
||||
[onChangeText],
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -27,6 +27,7 @@ type AttachmentUpdater =
|
||||
interface AgentInputDraftComposerOptions {
|
||||
initialServerId: string | null;
|
||||
initialValues?: CreateAgentInitialValues;
|
||||
initialFeatureValues?: Record<string, unknown>;
|
||||
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(
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}) {
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId } = input;
|
||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>({});
|
||||
const { serverId, provider, cwd, modeId, modelId, thinkingOptionId, initialFeatureValues } =
|
||||
input;
|
||||
const [localFeatureValues, setLocalFeatureValues] = useState<Record<string, unknown>>(
|
||||
() => initialFeatureValues ?? {},
|
||||
);
|
||||
const client = useHostRuntimeClient(serverId ?? "");
|
||||
const isConnected = useHostRuntimeIsConnected(serverId ?? "");
|
||||
const { preferences, updatePreferences } = useFormPreferences();
|
||||
const normalizedCwd = cwd?.trim() || "";
|
||||
const normalizedProvider = provider ?? null;
|
||||
const previousProviderRef = useRef<AgentProvider | null>(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(
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -56,6 +56,7 @@ function DraftPanel() {
|
||||
workspaceId={workspaceId}
|
||||
tabId={tabId}
|
||||
draftId={target.draftId}
|
||||
initialSetup={target.setup}
|
||||
isPaneFocused={isInteractive}
|
||||
onOpenWorkspaceFile={handleOpenWorkspaceFile}
|
||||
onCreated={handleCreated}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface AgentRuntimeInfo {
|
||||
sessionId: string | null;
|
||||
model?: string | null;
|
||||
modeId?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
extra?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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<string, unknown>;
|
||||
}
|
||||
|
||||
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<string, unknown>): 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 });
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import type { WorkspaceTabTarget } from "@/stores/workspace-tabs-store";
|
||||
|
||||
type WorkspaceDraftTabSetup = NonNullable<Extract<WorkspaceTabTarget, { kind: "draft" }>["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<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 {
|
||||
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<string, unknown> {
|
||||
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user