mirror of
https://github.com/getpaseo/paseo.git
synced 2026-08-15 04:42:45 +00:00
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:
205
packages/app/e2e/client-slash-commands.spec.ts
Normal file
205
packages/app/e2e/client-slash-commands.spec.ts
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
|
|||||||
130
packages/app/src/client-slash-commands/index.test.ts
Normal file
130
packages/app/src/client-slash-commands/index.test.ts
Normal 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",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
90
packages/app/src/client-slash-commands/index.ts
Normal file
90
packages/app/src/client-slash-commands/index.ts
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
},
|
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user