fix: optimistic archiving/closing and tab UX improvements
- Archive agents optimistically on close with rollback on error - Close tabs immediately instead of waiting for RPC response - Kill terminals in background with cache invalidation on failure - Bulk close fires RPC in background, closes all tabs upfront - Move new-tab button out of scroll area, rename to "New agent tab" - Support invalidateQueries option in applyArchivedAgentCloseResults - Fix workspace descriptor id/projectId types (string not number)
This commit is contained in:
@@ -139,4 +139,36 @@ describe("useArchiveAgent", () => {
|
|||||||
entries: [{ agent: { id: "agent-2" } }],
|
entries: [{ agent: { id: "agent-2" } }],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("can apply archived agent close results without invalidating cached lists", () => {
|
||||||
|
const queryClient = new QueryClient();
|
||||||
|
useSessionStore
|
||||||
|
.getState()
|
||||||
|
.initializeSession("server-a", {} as DaemonClient);
|
||||||
|
useSessionStore.getState().setAgents(
|
||||||
|
"server-a",
|
||||||
|
new Map([
|
||||||
|
[
|
||||||
|
"agent-1",
|
||||||
|
makeAgent(),
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
queryClient.setQueryData(["sidebarAgentsList", "server-a"], {
|
||||||
|
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||||
|
});
|
||||||
|
queryClient.setQueryData(["allAgents", "server-a"], {
|
||||||
|
entries: [{ agent: { id: "agent-1" } }, { agent: { id: "agent-2" } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
applyArchivedAgentCloseResults({
|
||||||
|
queryClient,
|
||||||
|
serverId: "server-a",
|
||||||
|
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||||
|
invalidateQueries: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(queryClient.getQueryState(["sidebarAgentsList", "server-a"])?.isInvalidated).toBe(false);
|
||||||
|
expect(queryClient.getQueryState(["allAgents", "server-a"])?.isInvalidated).toBe(false);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ interface AgentsListQueryData {
|
|||||||
entries?: Array<{ agent?: { id?: string | null } | null } | null>;
|
entries?: Array<{ agent?: { id?: string | null } | null } | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ArchivedAgentListCacheSnapshot {
|
||||||
|
sidebarAgentsList: AgentsListQueryData | undefined;
|
||||||
|
allAgents: AgentsListQueryData | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ArchiveAgentMutationContext {
|
||||||
|
agent: ReturnType<typeof getStoredAgentSnapshot>;
|
||||||
|
lists: ArchivedAgentListCacheSnapshot;
|
||||||
|
}
|
||||||
|
|
||||||
function toArchiveKey(input: ArchiveAgentInput): string {
|
function toArchiveKey(input: ArchiveAgentInput): string {
|
||||||
const serverId = input.serverId.trim();
|
const serverId = input.serverId.trim();
|
||||||
const agentId = input.agentId.trim();
|
const agentId = input.agentId.trim();
|
||||||
@@ -111,6 +121,70 @@ function removeAgentFromCachedLists(queryClient: QueryClient, input: ArchiveAgen
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
|
||||||
|
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreAgentSnapshot(
|
||||||
|
input: ArchiveAgentInput & { agent: ReturnType<typeof getStoredAgentSnapshot> },
|
||||||
|
): void {
|
||||||
|
const setAgents = useSessionStore.getState().setAgents;
|
||||||
|
setAgents(input.serverId, (prev) => {
|
||||||
|
const hasAgent = prev.has(input.agentId);
|
||||||
|
if (!input.agent) {
|
||||||
|
if (!hasAgent) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.delete(input.agentId);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
const current = prev.get(input.agentId);
|
||||||
|
if (current === input.agent) {
|
||||||
|
return prev;
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = new Map(prev);
|
||||||
|
next.set(input.agentId, input.agent);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getArchivedAgentListCacheSnapshot(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
serverId: string,
|
||||||
|
): ArchivedAgentListCacheSnapshot {
|
||||||
|
return {
|
||||||
|
sidebarAgentsList: queryClient.getQueryData<AgentsListQueryData | undefined>([
|
||||||
|
"sidebarAgentsList",
|
||||||
|
serverId,
|
||||||
|
]),
|
||||||
|
allAgents: queryClient.getQueryData<AgentsListQueryData | undefined>(["allAgents", serverId]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCachedListSnapshot(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
queryKey: readonly [string, string],
|
||||||
|
snapshot: AgentsListQueryData | undefined,
|
||||||
|
): void {
|
||||||
|
if (snapshot === undefined) {
|
||||||
|
queryClient.removeQueries({ queryKey, exact: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
queryClient.setQueryData(queryKey, snapshot);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreArchivedAgentListCacheSnapshot(
|
||||||
|
queryClient: QueryClient,
|
||||||
|
serverId: string,
|
||||||
|
snapshot: ArchivedAgentListCacheSnapshot,
|
||||||
|
): void {
|
||||||
|
restoreCachedListSnapshot(queryClient, ["sidebarAgentsList", serverId], snapshot.sidebarAgentsList);
|
||||||
|
restoreCachedListSnapshot(queryClient, ["allAgents", serverId], snapshot.allAgents);
|
||||||
|
}
|
||||||
|
|
||||||
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
|
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
|
||||||
const archivedAt = new Date(input.archivedAt);
|
const archivedAt = new Date(input.archivedAt);
|
||||||
if (Number.isNaN(archivedAt.getTime())) {
|
if (Number.isNaN(archivedAt.getTime())) {
|
||||||
@@ -139,6 +213,7 @@ interface ApplyArchivedAgentCloseResultsInput {
|
|||||||
queryClient: QueryClient;
|
queryClient: QueryClient;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
results: ArchivedAgentCloseResult[];
|
results: ArchivedAgentCloseResult[];
|
||||||
|
invalidateQueries?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applyArchivedAgentCloseResults(
|
export function applyArchivedAgentCloseResults(
|
||||||
@@ -160,12 +235,14 @@ export function applyArchivedAgentCloseResults(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
void input.queryClient.invalidateQueries({
|
if (input.invalidateQueries ?? true) {
|
||||||
queryKey: ["sidebarAgentsList", input.serverId],
|
void input.queryClient.invalidateQueries({
|
||||||
});
|
queryKey: ["sidebarAgentsList", input.serverId],
|
||||||
void input.queryClient.invalidateQueries({
|
});
|
||||||
queryKey: ["allAgents", input.serverId],
|
void input.queryClient.invalidateQueries({
|
||||||
});
|
queryKey: ["allAgents", input.serverId],
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
|
export function clearArchiveAgentPending(input: IsAgentArchivingInput): void {
|
||||||
@@ -195,26 +272,56 @@ export function useArchiveAgent() {
|
|||||||
return await client.archiveAgent(input.agentId);
|
return await client.archiveAgent(input.agentId);
|
||||||
},
|
},
|
||||||
onMutate: (input) => {
|
onMutate: (input) => {
|
||||||
|
const context: ArchiveAgentMutationContext = {
|
||||||
|
agent: getStoredAgentSnapshot(input),
|
||||||
|
lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId),
|
||||||
|
};
|
||||||
|
const archivedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
applyArchivedAgentCloseResults({
|
||||||
|
queryClient,
|
||||||
|
serverId: input.serverId,
|
||||||
|
results: [{ agentId: input.agentId, archivedAt }],
|
||||||
|
invalidateQueries: false,
|
||||||
|
});
|
||||||
setAgentArchiving({
|
setAgentArchiving({
|
||||||
queryClient,
|
queryClient,
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
agentId: input.agentId,
|
agentId: input.agentId,
|
||||||
isArchiving: true,
|
isArchiving: true,
|
||||||
});
|
});
|
||||||
|
return context;
|
||||||
},
|
},
|
||||||
onSuccess: (result, input) => {
|
onSuccess: (result, input) => {
|
||||||
applyArchivedAgentCloseResults({
|
markAgentArchivedInStore({
|
||||||
queryClient,
|
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
results: [{ agentId: input.agentId, archivedAt: result.archivedAt }],
|
agentId: input.agentId,
|
||||||
|
archivedAt: result.archivedAt,
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
onError: (_error, input, context) => {
|
||||||
|
if (!context) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
restoreAgentSnapshot({
|
||||||
|
serverId: input.serverId,
|
||||||
|
agentId: input.agentId,
|
||||||
|
agent: context.agent,
|
||||||
|
});
|
||||||
|
restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists);
|
||||||
|
},
|
||||||
onSettled: (_result, _error, input) => {
|
onSettled: (_result, _error, input) => {
|
||||||
clearArchiveAgentPending({
|
clearArchiveAgentPending({
|
||||||
queryClient,
|
queryClient,
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
agentId: input.agentId,
|
agentId: input.agentId,
|
||||||
});
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["sidebarAgentsList", input.serverId],
|
||||||
|
});
|
||||||
|
void queryClient.invalidateQueries({
|
||||||
|
queryKey: ["allAgents", input.serverId],
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -259,8 +259,8 @@ function getWorkspaceOrderScopeKey(serverId: string, projectKey: string): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
function toWorkspaceDescriptor(payload: {
|
function toWorkspaceDescriptor(payload: {
|
||||||
id: number;
|
id: string;
|
||||||
projectId: number;
|
projectId: string;
|
||||||
projectDisplayName: string;
|
projectDisplayName: string;
|
||||||
projectRootPath: string;
|
projectRootPath: string;
|
||||||
workspaceDirectory: string;
|
workspaceDirectory: string;
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ describe("workspace bulk close helpers", () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses one mixed closeItems RPC for agent and terminal tabs, then applies local cleanup", async () => {
|
it("closes all tabs immediately and fires one mixed closeItems RPC in the background", async () => {
|
||||||
const groups = classifyBulkClosableTabs([
|
const groups = classifyBulkClosableTabs([
|
||||||
makeAgentTab("a1"),
|
makeAgentTab("a1"),
|
||||||
makeTerminalTab("t1"),
|
makeTerminalTab("t1"),
|
||||||
@@ -91,7 +91,7 @@ describe("workspace bulk close helpers", () => {
|
|||||||
requestId: "req-1",
|
requestId: "req-1",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const result = await closeBulkWorkspaceTabs({
|
await closeBulkWorkspaceTabs({
|
||||||
groups,
|
groups,
|
||||||
client: { closeItems },
|
client: { closeItems },
|
||||||
closeTab: async (tabId, action) => {
|
closeTab: async (tabId, action) => {
|
||||||
@@ -109,23 +109,21 @@ describe("workspace bulk close helpers", () => {
|
|||||||
agentIds: ["a1"],
|
agentIds: ["a1"],
|
||||||
terminalIds: ["t1", "t2"],
|
terminalIds: ["t1", "t2"],
|
||||||
});
|
});
|
||||||
expect(result).toEqual({
|
expect(closedTabIds).toEqual([
|
||||||
agents: [{ agentId: "a1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
"agent_a1",
|
||||||
terminals: [
|
"terminal_t1",
|
||||||
{ terminalId: "t1", success: true },
|
"terminal_t2",
|
||||||
{ terminalId: "t2", success: false },
|
"file_/repo/README.md",
|
||||||
],
|
]);
|
||||||
requestId: "req-1",
|
|
||||||
});
|
|
||||||
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
|
||||||
expect(cleanupCalls).toEqual([
|
expect(cleanupCalls).toEqual([
|
||||||
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||||
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||||
|
{ tabId: "terminal_t2", target: { kind: "terminal", terminalId: "t2" } },
|
||||||
{ tabId: "file_/repo/README.md" },
|
{ tabId: "file_/repo/README.md" },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("still closes passive tabs when the mixed closeItems RPC fails", async () => {
|
it("still closes all tabs when the mixed closeItems RPC fails", async () => {
|
||||||
const groups = classifyBulkClosableTabs([
|
const groups = classifyBulkClosableTabs([
|
||||||
makeAgentTab("a1"),
|
makeAgentTab("a1"),
|
||||||
makeTerminalTab("t1"),
|
makeTerminalTab("t1"),
|
||||||
@@ -135,7 +133,7 @@ describe("workspace bulk close helpers", () => {
|
|||||||
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
|
const cleanupCalls: Array<{ tabId: string; target?: WorkspaceTabDescriptor["target"] }> = [];
|
||||||
const warn = vi.fn();
|
const warn = vi.fn();
|
||||||
|
|
||||||
const result = await closeBulkWorkspaceTabs({
|
await closeBulkWorkspaceTabs({
|
||||||
groups,
|
groups,
|
||||||
client: {
|
client: {
|
||||||
closeItems: async () => {
|
closeItems: async () => {
|
||||||
@@ -153,9 +151,14 @@ describe("workspace bulk close helpers", () => {
|
|||||||
logLabel: "others",
|
logLabel: "others",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await Promise.resolve();
|
||||||
|
|
||||||
expect(warn).toHaveBeenCalledTimes(1);
|
expect(warn).toHaveBeenCalledTimes(1);
|
||||||
expect(result).toBeNull();
|
expect(closedTabIds).toEqual(["agent_a1", "terminal_t1", "file_/repo/README.md"]);
|
||||||
expect(closedTabIds).toEqual(["file_/repo/README.md"]);
|
expect(cleanupCalls).toEqual([
|
||||||
expect(cleanupCalls).toEqual([{ tabId: "file_/repo/README.md" }]);
|
{ tabId: "agent_a1", target: { kind: "agent", agentId: "a1" } },
|
||||||
|
{ tabId: "terminal_t1", target: { kind: "terminal", terminalId: "t1" } },
|
||||||
|
{ tabId: "file_/repo/README.md" },
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -7,8 +7,6 @@ export type BulkClosableTabGroups = {
|
|||||||
otherTabs: Array<{ tabId: string }>;
|
otherTabs: Array<{ tabId: string }>;
|
||||||
};
|
};
|
||||||
|
|
||||||
type CloseItemsPayload = Awaited<ReturnType<DaemonClient["closeItems"]>>;
|
|
||||||
|
|
||||||
interface CloseWorkspaceTabWithCleanupInput {
|
interface CloseWorkspaceTabWithCleanupInput {
|
||||||
tabId: string;
|
tabId: string;
|
||||||
target?: WorkspaceTabDescriptor["target"];
|
target?: WorkspaceTabDescriptor["target"];
|
||||||
@@ -68,47 +66,27 @@ export function buildBulkCloseConfirmationMessage(input: BulkClosableTabGroups):
|
|||||||
return `This will archive ${agentTabs.length} agent(s).`;
|
return `This will archive ${agentTabs.length} agent(s).`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toSuccessfulAgentIds(payload: CloseItemsPayload | null): Set<string> {
|
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
|
||||||
return new Set(payload?.agents.map((agent) => agent.agentId) ?? []);
|
|
||||||
}
|
|
||||||
|
|
||||||
function toSuccessfulTerminalIds(payload: CloseItemsPayload | null): Set<string> {
|
|
||||||
return new Set(
|
|
||||||
payload?.terminals.filter((terminal) => terminal.success).map((terminal) => terminal.terminalId) ??
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function closeBulkWorkspaceTabs(
|
|
||||||
input: CloseBulkWorkspaceTabsInput,
|
|
||||||
): Promise<CloseItemsPayload | null> {
|
|
||||||
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
|
||||||
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
|
||||||
let payload: CloseItemsPayload | null = null;
|
|
||||||
|
|
||||||
if (hasDestructiveTabs && client) {
|
if (hasDestructiveTabs && client) {
|
||||||
try {
|
void client
|
||||||
payload = await client.closeItems({
|
.closeItems({
|
||||||
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
agentIds: groups.agentTabs.map((tab) => tab.agentId),
|
||||||
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
||||||
});
|
});
|
||||||
} catch (error) {
|
|
||||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, { error });
|
|
||||||
}
|
|
||||||
} else if (hasDestructiveTabs) {
|
} else if (hasDestructiveTabs) {
|
||||||
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
|
warn?.(`[WorkspaceScreen] Failed to bulk close tabs ${logLabel}`, {
|
||||||
error: new Error("Daemon client not available"),
|
error: new Error("Daemon client not available"),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const successfulAgentIds = toSuccessfulAgentIds(payload);
|
|
||||||
const successfulTerminalIds = toSuccessfulTerminalIds(payload);
|
|
||||||
|
|
||||||
for (const { tabId, agentId } of groups.agentTabs) {
|
for (const { tabId, agentId } of groups.agentTabs) {
|
||||||
if (!successfulAgentIds.has(agentId)) {
|
void closeTab(tabId, async () => {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await closeTab(tabId, async () => {
|
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
@@ -117,10 +95,7 @@ export async function closeBulkWorkspaceTabs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||||
if (!successfulTerminalIds.has(terminalId)) {
|
void closeTab(tabId, async () => {
|
||||||
continue;
|
|
||||||
}
|
|
||||||
await closeTab(tabId, async () => {
|
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "terminal", terminalId },
|
target: { kind: "terminal", terminalId },
|
||||||
@@ -129,10 +104,8 @@ export async function closeBulkWorkspaceTabs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const { tabId } of groups.otherTabs) {
|
for (const { tabId } of groups.otherTabs) {
|
||||||
await closeTab(tabId, async () => {
|
void closeTab(tabId, async () => {
|
||||||
closeWorkspaceTabWithCleanup({ tabId });
|
closeWorkspaceTabWithCleanup({ tabId });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return payload;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ import {
|
|||||||
ArrowRightToLine,
|
ArrowRightToLine,
|
||||||
Columns2,
|
Columns2,
|
||||||
Copy,
|
Copy,
|
||||||
Plus,
|
|
||||||
Rows2,
|
Rows2,
|
||||||
|
SquarePen,
|
||||||
SquareTerminal,
|
SquareTerminal,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
@@ -473,42 +473,36 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
</ScrollView>
|
||||||
|
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger
|
||||||
<Pressable
|
testID="workspace-new-agent-tab"
|
||||||
testID="workspace-new-tab"
|
onPress={() => onCreateDraftTab({ paneId })}
|
||||||
onPress={() => onCreateDraftTab({ paneId })}
|
accessibilityRole="button"
|
||||||
accessibilityRole="button"
|
accessibilityLabel="New agent tab"
|
||||||
accessibilityLabel="New tab"
|
style={({ hovered, pressed }) => [
|
||||||
style={styles.inlineNewTabButton}
|
styles.newTabActionButton,
|
||||||
>
|
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||||
{({ hovered, pressed }) => (
|
]}
|
||||||
<Plus
|
>
|
||||||
size={theme.iconSize.md}
|
<SquarePen size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
color={
|
|
||||||
hovered || pressed ? theme.colors.foreground : theme.colors.foregroundMuted
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Pressable>
|
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="center" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<View style={styles.newTabTooltipRow}>
|
<View style={styles.newTabTooltipRow}>
|
||||||
<Text style={styles.newTabTooltipText}>New tab</Text>
|
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||||
{newTabKeys ? (
|
{newTabKeys ? (
|
||||||
<Shortcut chord={newTabKeys} style={styles.newTabTooltipShortcut} />
|
<Shortcut chord={newTabKeys} style={styles.newTabTooltipShortcut} />
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</View>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</ScrollView>
|
|
||||||
<View style={styles.tabsActions} onLayout={handleTabsActionsLayout}>
|
|
||||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
testID="workspace-new-terminal"
|
testID="workspace-new-terminal"
|
||||||
onPress={() => onCreateTerminalTab({ paneId })}
|
onPress={() => onCreateTerminalTab({ paneId })}
|
||||||
accessibilityRole="button"
|
accessibilityRole="button"
|
||||||
accessibilityLabel="New terminal"
|
accessibilityLabel="New terminal tab"
|
||||||
style={({ hovered, pressed }) => [
|
style={({ hovered, pressed }) => [
|
||||||
styles.newTabActionButton,
|
styles.newTabActionButton,
|
||||||
(hovered || pressed) && styles.newTabActionButtonHovered,
|
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||||
@@ -518,7 +512,7 @@ export function WorkspaceDesktopTabsRow({
|
|||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom" align="center" offset={8}>
|
<TooltipContent side="bottom" align="center" offset={8}>
|
||||||
<View style={styles.newTabTooltipRow}>
|
<View style={styles.newTabTooltipRow}>
|
||||||
<Text style={styles.newTabTooltipText}>New terminal</Text>
|
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||||
{newTerminalKeys ? (
|
{newTerminalKeys ? (
|
||||||
<Shortcut chord={newTerminalKeys} style={styles.newTabTooltipShortcut} />
|
<Shortcut chord={newTerminalKeys} style={styles.newTabTooltipShortcut} />
|
||||||
) : null}
|
) : null}
|
||||||
@@ -819,13 +813,6 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
tabCloseButtonActive: {
|
tabCloseButtonActive: {
|
||||||
backgroundColor: theme.colors.surface3,
|
backgroundColor: theme.colors.surface3,
|
||||||
},
|
},
|
||||||
inlineNewTabButton: {
|
|
||||||
paddingHorizontal: theme.spacing[3],
|
|
||||||
height: "100%",
|
|
||||||
alignItems: "center",
|
|
||||||
justifyContent: "center",
|
|
||||||
borderRadius: 0,
|
|
||||||
},
|
|
||||||
newTabActionButton: {
|
newTabActionButton: {
|
||||||
width: 22,
|
width: 22,
|
||||||
height: 22,
|
height: 22,
|
||||||
|
|||||||
@@ -63,7 +63,6 @@ import type { WorkspaceTab, WorkspaceTabTarget } from "@/stores/workspace-tabs-s
|
|||||||
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
import { useKeyboardActionHandler } from "@/hooks/use-keyboard-action-handler";
|
||||||
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
import type { KeyboardActionDefinition } from "@/keyboard/keyboard-action-dispatcher";
|
||||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||||
import { decodeWorkspaceIdFromPathSegment } from "@/utils/host-routes";
|
|
||||||
import {
|
import {
|
||||||
normalizeWorkspaceTabTarget,
|
normalizeWorkspaceTabTarget,
|
||||||
workspaceTabTargetsEqual,
|
workspaceTabTargetsEqual,
|
||||||
@@ -79,7 +78,7 @@ import {
|
|||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
import { applyArchivedAgentCloseResults, useArchiveAgent } from "@/hooks/use-archive-agent";
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
import { buildProviderCommand } from "@/utils/provider-command-templates";
|
||||||
import { generateDraftId } from "@/stores/draft-keys";
|
import { generateDraftId } from "@/stores/draft-keys";
|
||||||
@@ -607,7 +606,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
|
|
||||||
const normalizedWorkspaceId =
|
const normalizedWorkspaceId =
|
||||||
resolveWorkspaceRouteId({
|
resolveWorkspaceRouteId({
|
||||||
routeWorkspaceId: decodeWorkspaceIdFromPathSegment(workspaceId),
|
routeWorkspaceId: workspaceId,
|
||||||
}) ?? "";
|
}) ?? "";
|
||||||
const sessionWorkspaces = useSessionStore(
|
const sessionWorkspaces = useSessionStore(
|
||||||
(state) => state.sessions[normalizedServerId]?.workspaces,
|
(state) => state.sessions[normalizedServerId]?.workspaces,
|
||||||
@@ -1198,6 +1197,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
if (!workspaceDirectory) {
|
if (!workspaceDirectory) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
createTerminalMutation.mutate(input);
|
createTerminalMutation.mutate(input);
|
||||||
},
|
},
|
||||||
[createTerminalMutation, workspaceDirectory],
|
[createTerminalMutation, workspaceDirectory],
|
||||||
@@ -1243,10 +1243,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await killTerminalAsync(terminalId);
|
|
||||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
|
||||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
|
||||||
|
|
||||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
||||||
if (!current) {
|
if (!current) {
|
||||||
return current;
|
return current;
|
||||||
@@ -1256,13 +1252,18 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||||
|
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||||
if (persistenceKey) {
|
if (persistenceKey) {
|
||||||
closeWorkspaceTabWithCleanup({
|
closeWorkspaceTabWithCleanup({
|
||||||
tabId,
|
tabId,
|
||||||
target: { kind: "terminal", terminalId },
|
target: { kind: "terminal", terminalId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void killTerminalAsync(terminalId).catch(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
@@ -1294,7 +1295,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await archiveAgent({ serverId: normalizedServerId, agentId });
|
|
||||||
setHoveredTabKey((current) => (current === tabId ? null : current));
|
setHoveredTabKey((current) => (current === tabId ? null : current));
|
||||||
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
|
||||||
if (persistenceKey) {
|
if (persistenceKey) {
|
||||||
@@ -1303,6 +1303,8 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
target: { kind: "agent", agentId },
|
target: { kind: "agent", agentId },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void archiveAgent({ serverId: normalizedServerId, agentId });
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey],
|
||||||
@@ -1464,7 +1466,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const closeItemsPayload = await closeBulkWorkspaceTabs({
|
await closeBulkWorkspaceTabs({
|
||||||
client,
|
client,
|
||||||
groups,
|
groups,
|
||||||
closeTab,
|
closeTab,
|
||||||
@@ -1480,31 +1482,6 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
if (closeItemsPayload) {
|
|
||||||
for (const terminal of closeItemsPayload.terminals) {
|
|
||||||
if (!terminal.success) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => {
|
|
||||||
if (!current) {
|
|
||||||
return current;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...current,
|
|
||||||
terminals: current.terminals.filter((entry) => entry.id !== terminal.terminalId),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (normalizedServerId) {
|
|
||||||
applyArchivedAgentCloseResults({
|
|
||||||
queryClient,
|
|
||||||
serverId: normalizedServerId,
|
|
||||||
results: closeItemsPayload.agents,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
const closedKeys = new Set(tabsToClose.map((tab) => tab.key));
|
||||||
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
setHoveredTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||||
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
|
||||||
@@ -1513,10 +1490,7 @@ function WorkspaceScreenContent({ serverId, workspaceId }: WorkspaceScreenProps)
|
|||||||
client,
|
client,
|
||||||
closeTab,
|
closeTab,
|
||||||
closeWorkspaceTabWithCleanup,
|
closeWorkspaceTabWithCleanup,
|
||||||
normalizedServerId,
|
|
||||||
persistenceKey,
|
persistenceKey,
|
||||||
queryClient,
|
|
||||||
terminalsQueryKey,
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -42,8 +42,8 @@ describe("normalizeWorkspaceDescriptor", () => {
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
const workspace = normalizeWorkspaceDescriptor({
|
const workspace = normalizeWorkspaceDescriptor({
|
||||||
id: 1,
|
id: "1",
|
||||||
projectId: 1,
|
projectId: "1",
|
||||||
projectDisplayName: "Project 1",
|
projectDisplayName: "Project 1",
|
||||||
projectRootPath: "/repo",
|
projectRootPath: "/repo",
|
||||||
workspaceDirectory: "/repo",
|
workspaceDirectory: "/repo",
|
||||||
@@ -71,8 +71,8 @@ describe("normalizeWorkspaceDescriptor", () => {
|
|||||||
|
|
||||||
it("defaults missing services to an empty array", () => {
|
it("defaults missing services to an empty array", () => {
|
||||||
const payload = {
|
const payload = {
|
||||||
id: 1,
|
id: "1",
|
||||||
projectId: 1,
|
projectId: "1",
|
||||||
projectDisplayName: "Project 1",
|
projectDisplayName: "Project 1",
|
||||||
projectRootPath: "/repo",
|
projectRootPath: "/repo",
|
||||||
workspaceDirectory: "/repo",
|
workspaceDirectory: "/repo",
|
||||||
@@ -82,6 +82,7 @@ describe("normalizeWorkspaceDescriptor", () => {
|
|||||||
status: "done",
|
status: "done",
|
||||||
activityAt: null,
|
activityAt: null,
|
||||||
diffStat: null,
|
diffStat: null,
|
||||||
|
services: [],
|
||||||
} as WorkspaceDescriptorPayload;
|
} as WorkspaceDescriptorPayload;
|
||||||
|
|
||||||
const workspace = normalizeWorkspaceDescriptor(payload);
|
const workspace = normalizeWorkspaceDescriptor(payload);
|
||||||
|
|||||||
Reference in New Issue
Block a user