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

* Add slash commands for ending agents

* Stabilize slash command e2e submit

* Unslop client slash command draft setup

* Fix slash commands while agent is running

* Fix slash command submit race

* Reshape client slash command execution

* Fix slash command tab cleanup
This commit is contained in:
Mohamed Boudra
2026-05-16 17:03:59 +08:00
committed by GitHub
parent 667f441cc0
commit 4b02daed8a
19 changed files with 934 additions and 53 deletions
+1
View File
@@ -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 });