Detach subagents without archiving them (#1612)
* Add detach action for subagents * Translate detached subagent connection error * Address subagent detach review feedback * Fix subagent integration test import * Focus detached subagents as tabs
This commit is contained in:
@@ -1307,6 +1307,8 @@ export const ar: TranslationResources = {
|
||||
backdrop: "خلفية القائمة",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "فصل {{label}}",
|
||||
detachTooltip: "فصل الوكيل الفرعي",
|
||||
archiveAction: "أرشيف{{label}}",
|
||||
archiveTooltip: "أرشفة الوكيل الفرعي",
|
||||
},
|
||||
|
||||
@@ -1314,6 +1314,8 @@ export const en = {
|
||||
backdrop: "Menu backdrop",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Detach {{label}}",
|
||||
detachTooltip: "Detach subagent",
|
||||
archiveAction: "Archive {{label}}",
|
||||
archiveTooltip: "Archive subagent",
|
||||
},
|
||||
|
||||
@@ -1343,6 +1343,8 @@ export const es: TranslationResources = {
|
||||
backdrop: "Fondo del menú",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Separar {{label}}",
|
||||
detachTooltip: "Separar subagente",
|
||||
archiveAction: "Archivo{{label}}",
|
||||
archiveTooltip: "Subagente de archivo",
|
||||
},
|
||||
|
||||
@@ -1346,6 +1346,8 @@ export const fr: TranslationResources = {
|
||||
backdrop: "Toile de fond du menu",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Detacher {{label}}",
|
||||
detachTooltip: "Detacher le sous-agent",
|
||||
archiveAction: "Archiver{{label}}",
|
||||
archiveTooltip: "Sous-agent d'archivage",
|
||||
},
|
||||
|
||||
@@ -1335,6 +1335,8 @@ export const ru: TranslationResources = {
|
||||
backdrop: "Фон меню",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "Отсоединить {{label}}",
|
||||
detachTooltip: "Отсоединить субагент",
|
||||
archiveAction: "Архив{{label}}",
|
||||
archiveTooltip: "Архивный субагент",
|
||||
},
|
||||
|
||||
@@ -1290,6 +1290,8 @@ export const zhCN: TranslationResources = {
|
||||
backdrop: "菜单背景",
|
||||
},
|
||||
subagents: {
|
||||
detachAction: "分离 {{label}}",
|
||||
detachTooltip: "分离 subagent",
|
||||
archiveAction: "归档 {{label}}",
|
||||
archiveTooltip: "归档 subagent",
|
||||
},
|
||||
|
||||
@@ -77,7 +77,7 @@ 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 { useArchiveSubagent, useSubagentsForParent } from "@/subagents";
|
||||
import { useArchiveSubagent, useDetachSubagent, useSubagentsForParent } from "@/subagents";
|
||||
import { SubagentsTrack } from "@/subagents/track";
|
||||
import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -1442,6 +1442,9 @@ function ActiveAgentComposer({
|
||||
serverId,
|
||||
parentAgentId: agentId,
|
||||
});
|
||||
const canDetachSubagents = useSessionStore(
|
||||
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
|
||||
);
|
||||
const handleOpenSubagent = useCallback(
|
||||
(subagentId: string) => {
|
||||
navigateToAgent({ serverId, agentId: subagentId });
|
||||
@@ -1449,6 +1452,7 @@ function ActiveAgentComposer({
|
||||
[serverId],
|
||||
);
|
||||
const handleArchiveSubagent = useArchiveSubagent({ serverId });
|
||||
const handleDetachSubagent = useDetachSubagent({ serverId });
|
||||
const workspaceAttachmentScopeKey = useWorkspaceAttachmentScopeKey({
|
||||
serverId,
|
||||
cwd,
|
||||
@@ -1545,6 +1549,7 @@ function ActiveAgentComposer({
|
||||
rows={subagentRows}
|
||||
onOpenSubagent={handleOpenSubagent}
|
||||
onArchiveSubagent={handleArchiveSubagent}
|
||||
onDetachSubagent={canDetachSubagents ? handleDetachSubagent : undefined}
|
||||
/>
|
||||
<Composer
|
||||
agentId={agentId}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
deriveWorkspaceAgentVisibility,
|
||||
type WorkspaceAgentVisibility,
|
||||
} from "@/workspace-tabs/agent-visibility";
|
||||
import { selectSubagentsForParent } from "@/subagents";
|
||||
import { selectSubagentsForParent } from "@/subagents/select";
|
||||
import { buildWorkspaceTabPersistenceKey, useWorkspaceLayoutStore } from "./workspace-layout-store";
|
||||
import { useSessionStore, type Agent } from "./session-store";
|
||||
|
||||
@@ -167,4 +167,52 @@ describe("workspace subagents integration", () => {
|
||||
).map((row) => row.id),
|
||||
).toEqual(["child-agent"]);
|
||||
});
|
||||
|
||||
it("moves a detached child out of the parent section and back into normal workspace tabs", () => {
|
||||
const workspaceKey = buildWorkspaceTabPersistenceKey({
|
||||
serverId: SERVER_ID,
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
expect(workspaceKey).toBeTruthy();
|
||||
|
||||
const parent = makeAgent({
|
||||
id: "parent-agent",
|
||||
title: "Parent agent",
|
||||
});
|
||||
const child = makeAgent({
|
||||
id: "child-agent",
|
||||
parentAgentId: "parent-agent",
|
||||
title: "Child agent",
|
||||
});
|
||||
|
||||
initializeAgents([parent, child]);
|
||||
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
||||
|
||||
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
|
||||
expect(
|
||||
selectSubagentsForParent(
|
||||
useSessionStore.getState(),
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: "parent-agent",
|
||||
},
|
||||
new Set(),
|
||||
).map((row) => row.id),
|
||||
).toEqual(["child-agent"]);
|
||||
|
||||
appendAgent({ ...child, parentAgentId: null, labels: {} });
|
||||
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
|
||||
|
||||
expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_parent-agent", "agent_child-agent"]);
|
||||
expect(
|
||||
selectSubagentsForParent(
|
||||
useSessionStore.getState(),
|
||||
{
|
||||
serverId: SERVER_ID,
|
||||
parentAgentId: "parent-agent",
|
||||
},
|
||||
new Set(),
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -16,6 +16,7 @@ interface FakeArchiveSubagentEnv {
|
||||
deps: ArchiveSubagentDeps;
|
||||
recordedArchives: RecordedArchive[];
|
||||
recordedConfirmInputs: ConfirmDialogInput[];
|
||||
recordedErrors: unknown[];
|
||||
setSubagent(id: string, snapshot: ResolveArchiveSubagentDialogInput | undefined): void;
|
||||
}
|
||||
|
||||
@@ -31,10 +32,12 @@ function createFakeEnv(
|
||||
}
|
||||
const recordedArchives: RecordedArchive[] = [];
|
||||
const recordedConfirmInputs: ConfirmDialogInput[] = [];
|
||||
const recordedErrors: unknown[] = [];
|
||||
|
||||
return {
|
||||
recordedArchives,
|
||||
recordedConfirmInputs,
|
||||
recordedErrors,
|
||||
setSubagent(id, snapshot) {
|
||||
subagents.set(id, snapshot);
|
||||
},
|
||||
@@ -47,6 +50,9 @@ function createFakeEnv(
|
||||
archiveAgent: async (input) => {
|
||||
recordedArchives.push(input);
|
||||
},
|
||||
reportError: (error) => {
|
||||
recordedErrors.push(error);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -176,7 +182,7 @@ describe("requestArchiveSubagent", () => {
|
||||
expect(env.recordedArchives).toEqual([]);
|
||||
});
|
||||
|
||||
it("swallows archive errors so the caller never sees them", async () => {
|
||||
it("reports archive errors after the user confirms", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: true,
|
||||
initialSubagents: [
|
||||
@@ -186,12 +192,14 @@ describe("requestArchiveSubagent", () => {
|
||||
},
|
||||
],
|
||||
});
|
||||
const error = new Error("daemon offline");
|
||||
env.deps.archiveAgent = async () => {
|
||||
throw new Error("daemon offline");
|
||||
throw error;
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
|
||||
).resolves.toBeUndefined();
|
||||
expect(env.recordedErrors).toEqual([error]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -41,6 +41,7 @@ export interface ArchiveSubagentDeps {
|
||||
getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined;
|
||||
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
|
||||
archiveAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
|
||||
reportError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface RequestArchiveSubagentInput {
|
||||
@@ -62,5 +63,9 @@ export async function requestArchiveSubagent(
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
void deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId }).catch(() => {});
|
||||
try {
|
||||
await deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId });
|
||||
} catch (error) {
|
||||
deps.reportError(error);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
|
||||
import {
|
||||
requestDetachSubagent,
|
||||
resolveDetachSubagentDialog,
|
||||
type DetachSubagentDeps,
|
||||
type ResolveDetachSubagentDialogInput,
|
||||
} from "./detach-subagent";
|
||||
|
||||
interface RecordedDetach {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface RecordedOpen {
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
}
|
||||
|
||||
interface FakeDetachSubagentEnv {
|
||||
deps: DetachSubagentDeps;
|
||||
recordedDetaches: RecordedDetach[];
|
||||
recordedOpens: RecordedOpen[];
|
||||
recordedConfirmInputs: ConfirmDialogInput[];
|
||||
recordedErrors: unknown[];
|
||||
}
|
||||
|
||||
function createFakeEnv(
|
||||
options: {
|
||||
confirmResult?: boolean;
|
||||
initialSubagents?: Array<{ id: string; snapshot: ResolveDetachSubagentDialogInput }>;
|
||||
} = {},
|
||||
): FakeDetachSubagentEnv {
|
||||
const subagents = new Map<string, ResolveDetachSubagentDialogInput | undefined>();
|
||||
for (const entry of options.initialSubagents ?? []) {
|
||||
subagents.set(entry.id, entry.snapshot);
|
||||
}
|
||||
const recordedDetaches: RecordedDetach[] = [];
|
||||
const recordedOpens: RecordedOpen[] = [];
|
||||
const recordedConfirmInputs: ConfirmDialogInput[] = [];
|
||||
const recordedErrors: unknown[] = [];
|
||||
|
||||
return {
|
||||
recordedDetaches,
|
||||
recordedOpens,
|
||||
recordedConfirmInputs,
|
||||
recordedErrors,
|
||||
deps: {
|
||||
getSubagent: (id) => subagents.get(id),
|
||||
confirm: async (dialog) => {
|
||||
recordedConfirmInputs.push(dialog);
|
||||
return options.confirmResult ?? false;
|
||||
},
|
||||
detachAgent: async (input) => {
|
||||
recordedDetaches.push(input);
|
||||
},
|
||||
openDetachedAgent: (input) => {
|
||||
recordedOpens.push(input);
|
||||
},
|
||||
reportError: (error) => {
|
||||
recordedErrors.push(error);
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveDetachSubagentDialog", () => {
|
||||
it("uses non-destructive copy for named subagents", () => {
|
||||
expect(resolveDetachSubagentDialog({ title: "Review branch" })).toEqual({
|
||||
title: "Detach subagent?",
|
||||
message: "Review branch will leave this track and continue as a standalone agent.",
|
||||
confirmLabel: "Detach",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: false,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to this subagent when the title is not displayable", () => {
|
||||
expect(resolveDetachSubagentDialog({ title: "New Agent" })).toEqual({
|
||||
title: "Detach subagent?",
|
||||
message: "This subagent will leave this track and continue as a standalone agent.",
|
||||
confirmLabel: "Detach",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("requestDetachSubagent", () => {
|
||||
it("detaches the subagent with the server id when the user confirms", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: true,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
|
||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
||||
|
||||
expect(env.recordedDetaches).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
||||
});
|
||||
|
||||
it("opens the detached subagent after detach succeeds", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: true,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
|
||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
||||
|
||||
expect(env.recordedOpens).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
|
||||
});
|
||||
|
||||
it("does not detach the subagent when the user cancels", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: false,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
|
||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
||||
|
||||
expect(env.recordedDetaches).toEqual([]);
|
||||
expect(env.recordedOpens).toEqual([]);
|
||||
});
|
||||
|
||||
it("asks for confirmation using the resolved dialog for the subagent", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: false,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
|
||||
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
|
||||
|
||||
expect(env.recordedConfirmInputs).toEqual([
|
||||
resolveDetachSubagentDialog({ title: "Review branch" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports detach errors after the user confirms", async () => {
|
||||
const env = createFakeEnv({
|
||||
confirmResult: true,
|
||||
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
|
||||
});
|
||||
const error = new Error("daemon offline");
|
||||
env.deps.detachAgent = async () => {
|
||||
throw error;
|
||||
};
|
||||
|
||||
await expect(
|
||||
requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
|
||||
).resolves.toBeUndefined();
|
||||
expect(env.recordedErrors).toEqual([error]);
|
||||
expect(env.recordedOpens).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
|
||||
|
||||
export interface ResolveDetachSubagentDialogInput {
|
||||
title: Agent["title"] | null | undefined;
|
||||
}
|
||||
|
||||
function resolveSubagentLabel(title: Agent["title"] | null | undefined): string | null {
|
||||
if (typeof title !== "string") {
|
||||
return null;
|
||||
}
|
||||
const normalized = title.trim();
|
||||
if (!normalized) {
|
||||
return null;
|
||||
}
|
||||
if (normalized.toLowerCase() === "new agent") {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveDetachSubagentDialog(
|
||||
input: ResolveDetachSubagentDialogInput,
|
||||
): ConfirmDialogInput {
|
||||
const subagentLabel = resolveSubagentLabel(input.title) ?? "This subagent";
|
||||
|
||||
return {
|
||||
title: "Detach subagent?",
|
||||
message: `${subagentLabel} will leave this track and continue as a standalone agent.`,
|
||||
confirmLabel: "Detach",
|
||||
cancelLabel: "Cancel",
|
||||
destructive: false,
|
||||
};
|
||||
}
|
||||
|
||||
export interface DetachSubagentDeps {
|
||||
getSubagent: (subagentId: string) => ResolveDetachSubagentDialogInput | undefined;
|
||||
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
|
||||
detachAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
|
||||
openDetachedAgent: (input: { serverId: string; agentId: string }) => void;
|
||||
reportError: (error: unknown) => void;
|
||||
}
|
||||
|
||||
export interface RequestDetachSubagentInput {
|
||||
serverId: string;
|
||||
subagentId: string;
|
||||
}
|
||||
|
||||
export async function requestDetachSubagent(
|
||||
input: RequestDetachSubagentInput,
|
||||
deps: DetachSubagentDeps,
|
||||
): Promise<void> {
|
||||
const subagent = deps.getSubagent(input.subagentId);
|
||||
const confirmed = await deps.confirm(
|
||||
resolveDetachSubagentDialog({
|
||||
title: subagent?.title,
|
||||
}),
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deps.detachAgent({ serverId: input.serverId, agentId: input.subagentId });
|
||||
deps.openDetachedAgent({ serverId: input.serverId, agentId: input.subagentId });
|
||||
} catch (error) {
|
||||
deps.reportError(error);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
export type { SubagentRow } from "./select";
|
||||
export { selectSubagentsForParent, useSubagentsForParent } from "./select";
|
||||
export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent";
|
||||
export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent";
|
||||
export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy";
|
||||
export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
||||
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Archive, ChevronDown, ChevronRight } from "lucide-react-native";
|
||||
import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { getProviderIcon } from "@/components/provider-icons";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
@@ -18,6 +18,7 @@ import { buildSubagentRowPresentationData, formatHeaderLabel } from "./track-pre
|
||||
const ThemedArchive = withUnistyles(Archive);
|
||||
const ThemedChevronDown = withUnistyles(ChevronDown);
|
||||
const ThemedChevronRight = withUnistyles(ChevronRight);
|
||||
const ThemedUnlink = withUnistyles(Unlink);
|
||||
|
||||
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
||||
const foregroundMutedColorMapping = (theme: Theme) => ({
|
||||
@@ -28,6 +29,7 @@ export interface SubagentsTrackProps {
|
||||
rows: SubagentRow[];
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
|
||||
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
|
||||
@@ -43,6 +45,7 @@ export function SubagentsTrack({
|
||||
rows,
|
||||
onOpenSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackProps): ReactElement | null {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
@@ -103,6 +106,7 @@ export function SubagentsTrack({
|
||||
row={row}
|
||||
onOpenSubagent={onOpenSubagent}
|
||||
onArchiveSubagent={onArchiveSubagent}
|
||||
onDetachSubagent={onDetachSubagent}
|
||||
/>
|
||||
))}
|
||||
</ScrollView>
|
||||
@@ -117,12 +121,14 @@ interface SubagentsTrackRowProps {
|
||||
row: SubagentRow;
|
||||
onOpenSubagent: (id: string) => void;
|
||||
onArchiveSubagent: (id: string) => void;
|
||||
onDetachSubagent?: (id: string) => void;
|
||||
}
|
||||
|
||||
function SubagentsTrackRow({
|
||||
row,
|
||||
onOpenSubagent,
|
||||
onArchiveSubagent,
|
||||
onDetachSubagent,
|
||||
}: SubagentsTrackRowProps): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
const isCompact = useIsCompactFormFactor();
|
||||
@@ -136,10 +142,13 @@ function SubagentsTrackRow({
|
||||
const handleArchivePress = useCallback(() => {
|
||||
onArchiveSubagent(row.id);
|
||||
}, [onArchiveSubagent, row.id]);
|
||||
const handleDetachPress = useCallback(() => {
|
||||
onDetachSubagent?.(row.id);
|
||||
}, [onDetachSubagent, row.id]);
|
||||
const handlePointerEnter = useCallback(() => setHovered(true), []);
|
||||
const handlePointerLeave = useCallback(() => setHovered(false), []);
|
||||
const archiveAlwaysVisible = isNative || isCompact;
|
||||
const archiveVisible = archiveAlwaysVisible || hovered;
|
||||
const actionsAlwaysVisible = isNative || isCompact;
|
||||
const actionsVisible = actionsAlwaysVisible || hovered;
|
||||
|
||||
return (
|
||||
// Wrapper View handles hover so moving the pointer between the row and
|
||||
@@ -158,11 +167,12 @@ function SubagentsTrackRow({
|
||||
<Text style={styles.rowLabel} numberOfLines={1}>
|
||||
{displayLabel}
|
||||
</Text>
|
||||
<SubagentArchiveButton
|
||||
<SubagentRowActions
|
||||
rowId={row.id}
|
||||
displayLabel={displayLabel}
|
||||
visible={archiveVisible}
|
||||
onPress={handleArchivePress}
|
||||
visible={actionsVisible}
|
||||
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
||||
onArchivePress={handleArchivePress}
|
||||
/>
|
||||
</View>
|
||||
)}
|
||||
@@ -171,49 +181,93 @@ function SubagentsTrackRow({
|
||||
);
|
||||
}
|
||||
|
||||
function SubagentArchiveButton({
|
||||
function SubagentRowActions({
|
||||
rowId,
|
||||
displayLabel,
|
||||
visible,
|
||||
onPress,
|
||||
onDetachPress,
|
||||
onArchivePress,
|
||||
}: {
|
||||
rowId: string;
|
||||
displayLabel: string;
|
||||
visible: boolean;
|
||||
onPress: () => void;
|
||||
onDetachPress?: () => void;
|
||||
onArchivePress: () => void;
|
||||
}): ReactElement {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View
|
||||
style={visible ? styles.archiveSlotVisible : styles.archiveSlotHidden}
|
||||
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
|
||||
pointerEvents={visible ? "auto" : "none"}
|
||||
>
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
||||
testID={`subagents-track-archive-${rowId}`}
|
||||
onPress={onPress}
|
||||
style={styles.archiveButton}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => (
|
||||
<ThemedArchive
|
||||
size={14}
|
||||
uniProps={hovered || pressed ? foregroundColorMapping : foregroundMutedColorMapping}
|
||||
/>
|
||||
)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{t("subagents.archiveTooltip")}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onDetachPress ? (
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
|
||||
testID={`subagents-track-detach-${rowId}`}
|
||||
tooltipLabel={t("subagents.detachTooltip")}
|
||||
icon="detach"
|
||||
visible={visible}
|
||||
onPress={onDetachPress}
|
||||
/>
|
||||
) : null}
|
||||
<SubagentActionButton
|
||||
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
||||
testID={`subagents-track-archive-${rowId}`}
|
||||
tooltipLabel={t("subagents.archiveTooltip")}
|
||||
icon="archive"
|
||||
visible={visible}
|
||||
onPress={onArchivePress}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
type SubagentActionIcon = "archive" | "detach";
|
||||
|
||||
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
|
||||
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
|
||||
if (icon === "detach") {
|
||||
return <ThemedUnlink size={14} uniProps={uniProps} />;
|
||||
}
|
||||
return <ThemedArchive size={14} uniProps={uniProps} />;
|
||||
}
|
||||
|
||||
function SubagentActionButton({
|
||||
accessibilityLabel,
|
||||
testID,
|
||||
tooltipLabel,
|
||||
icon,
|
||||
visible,
|
||||
onPress,
|
||||
}: {
|
||||
accessibilityLabel: string;
|
||||
testID: string;
|
||||
tooltipLabel: string;
|
||||
icon: SubagentActionIcon;
|
||||
visible: boolean;
|
||||
onPress: () => void;
|
||||
}): ReactElement {
|
||||
return (
|
||||
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||
<TooltipTrigger asChild disabled={!visible}>
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={accessibilityLabel}
|
||||
testID={testID}
|
||||
onPress={onPress}
|
||||
style={styles.actionButton}
|
||||
hitSlop={8}
|
||||
>
|
||||
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
|
||||
</Pressable>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" align="center" offset={8}>
|
||||
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
outer: {
|
||||
width: "100%",
|
||||
@@ -288,13 +342,19 @@ const styles = StyleSheet.create((theme) => ({
|
||||
fontSize: theme.fontSize.sm,
|
||||
color: theme.colors.foreground,
|
||||
},
|
||||
archiveSlotVisible: {
|
||||
actionClusterVisible: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
opacity: 1,
|
||||
},
|
||||
archiveSlotHidden: {
|
||||
actionClusterHidden: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[1],
|
||||
opacity: 0,
|
||||
},
|
||||
archiveButton: {
|
||||
actionButton: {
|
||||
padding: theme.spacing[1],
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent";
|
||||
|
||||
export { resolveArchiveSubagentDialog, requestArchiveSubagent } from "./archive-subagent";
|
||||
@@ -18,6 +20,7 @@ export interface UseArchiveSubagentInput {
|
||||
export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void {
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
const { serverId } = input;
|
||||
const toast = useToast();
|
||||
|
||||
return useCallback(
|
||||
(subagentId: string) => {
|
||||
@@ -28,9 +31,12 @@ export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId:
|
||||
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
|
||||
confirm: confirmDialog,
|
||||
archiveAgent,
|
||||
reportError: (error) => {
|
||||
toast.error(toErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[archiveAgent, serverId],
|
||||
[archiveAgent, serverId, toast],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useCallback } from "react";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||
import { requestDetachSubagent, type ResolveDetachSubagentDialogInput } from "./detach-subagent";
|
||||
|
||||
export { resolveDetachSubagentDialog, requestDetachSubagent } from "./detach-subagent";
|
||||
export type {
|
||||
DetachSubagentDeps,
|
||||
RequestDetachSubagentInput,
|
||||
ResolveDetachSubagentDialogInput,
|
||||
} from "./detach-subagent";
|
||||
|
||||
export interface UseDetachSubagentInput {
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: string) => void {
|
||||
const { serverId } = input;
|
||||
const toast = useToast();
|
||||
|
||||
return useCallback(
|
||||
(subagentId: string) => {
|
||||
void requestDetachSubagent(
|
||||
{ serverId, subagentId },
|
||||
{
|
||||
getSubagent: (id): ResolveDetachSubagentDialogInput | undefined =>
|
||||
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
|
||||
confirm: confirmDialog,
|
||||
detachAgent: async ({ serverId: targetServerId, agentId }) => {
|
||||
const client = useSessionStore.getState().sessions[targetServerId]?.client;
|
||||
if (!client) {
|
||||
throw new Error(i18n.t("workspaceSetup.errors.hostDisconnected"));
|
||||
}
|
||||
await client.detachAgent(agentId);
|
||||
},
|
||||
openDetachedAgent: ({ serverId: targetServerId, agentId }) => {
|
||||
navigateToAgent({ serverId: targetServerId, agentId });
|
||||
},
|
||||
reportError: (error) => {
|
||||
toast.error(toErrorMessage(error));
|
||||
},
|
||||
},
|
||||
);
|
||||
},
|
||||
[serverId, toast],
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user