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:
@@ -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 });
|
||||
|
||||
Reference in New Issue
Block a user