diff --git a/apps/app/src/i18n/locales/en.ts b/apps/app/src/i18n/locales/en.ts index 8270425e57..b77e8d5caf 100644 --- a/apps/app/src/i18n/locales/en.ts +++ b/apps/app/src/i18n/locales/en.ts @@ -795,6 +795,8 @@ export default { "session.allow_for_session": "Allow for session", "session.allow_once": "Allow once", "session.cmd_current_workspace": "Current workspace", + "session.cmd_workflow_graph_detail": "Visualize parent and child sessions for the current workspace", + "session.cmd_workflow_graph_title": "View workflow graph", "session.cmd_new_session_detail": "Start a fresh task in the current workspace", "session.cmd_new_session_meta": "Create", "session.cmd_new_session_title": "Create new session", @@ -916,6 +918,12 @@ export default { "session.undo_label": "Revert", "session.undo_title": "Undo last message", "session.untitled": "Untitled", + "session.workflow_graph_descendants_one": "{count} subagent", + "session.workflow_graph_descendants_other": "{count} subagents", + "session.workflow_graph_description": "Tree view of the parent and child sessions spawned across this workspace.", + "session.workflow_graph_description_workspace": "Tree view of the parent and child sessions spawned in {workspace}.", + "session.workflow_graph_empty": "No sessions yet. Start a task to see its subagent tree here.", + "session.workflow_graph_title": "Workflow graph", "session.workspace_fallback": "Workspace", "settings.audit_actor_host": "host", "settings.audit_actor_remote": "remote", diff --git a/apps/app/src/react-app/domains/session/modals/workflow-graph-modal.tsx b/apps/app/src/react-app/domains/session/modals/workflow-graph-modal.tsx new file mode 100644 index 0000000000..d1d75a7298 --- /dev/null +++ b/apps/app/src/react-app/domains/session/modals/workflow-graph-modal.tsx @@ -0,0 +1,168 @@ +/** @jsxImportSource react */ +import { useMemo } from "react"; +import { Activity, CheckCircle2, ChevronRight, GitBranch, Loader2 } from "lucide-react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { cn } from "@/lib/utils"; +import { getDisplaySessionTitle } from "../../../../app/lib/session-title"; +import type { SidebarSessionItem } from "../../../../app/types"; +import { t } from "../../../../i18n"; +import { + buildSessionTreeState, + getRootSessions, + type SessionTreeState, +} from "../sidebar/utils"; + +export type WorkflowGraphModalProps = { + open: boolean; + workspaceTitle: string | null; + sessions: SidebarSessionItem[]; + sessionStatusById?: Record; + onSelectSession: (sessionId: string) => void; + onClose: () => void; +}; + +export function WorkflowGraphModal(props: WorkflowGraphModalProps) { + const tree = useMemo( + () => buildSessionTreeState(props.sessions, props.sessionStatusById), + [props.sessions, props.sessionStatusById], + ); + const roots = useMemo(() => getRootSessions(props.sessions), [props.sessions]); + + return ( + { + if (!open) props.onClose(); + }} + > + + + + + {t("session.workflow_graph_title")} + + + {props.workspaceTitle + ? t("session.workflow_graph_description_workspace", { + workspace: props.workspaceTitle, + }) + : t("session.workflow_graph_description")} + + + +
+ {roots.length === 0 ? ( +
+ {t("session.workflow_graph_empty")} +
+ ) : ( +
+ {roots.map((root) => ( + { + props.onClose(); + props.onSelectSession(sessionId); + }} + /> + ))} +
+ )} +
+ + + }> + {t("common.close")} + + +
+
+ ); +} + +type WorkflowGraphNodeProps = { + session: SidebarSessionItem; + tree: SessionTreeState; + sessionStatusById?: Record; + depth: number; + onSelectSession: (sessionId: string) => void; +}; + +function WorkflowGraphNode(props: WorkflowGraphNodeProps) { + const { session, tree, sessionStatusById, depth, onSelectSession } = props; + const children = tree.childrenByParent.get(session.id) ?? []; + const status = sessionStatusById?.[session.id] ?? "idle"; + const subtreeActive = tree.activeIds.has(session.id); + const descendantCount = tree.descendantCountBySessionId.get(session.id) ?? 0; + const title = getDisplaySessionTitle(session.title); + + return ( +
+ + + {children.length > 0 ? ( +
+
+ {children.map((child) => ( + + ))} +
+
+ ) : null} +
+ ); +} + +function NodeStatusIcon({ status, active }: { status: string; active: boolean }) { + if (status === "running" || status === "retry" || status === "busy") { + return ; + } + if (active) { + return ; + } + if (status === "complete" || status === "done" || status === "ready") { + return ; + } + return
; +} diff --git a/apps/app/src/react-app/shell/command-palette.tsx b/apps/app/src/react-app/shell/command-palette.tsx index 02ca695312..bd56add21f 100644 --- a/apps/app/src/react-app/shell/command-palette.tsx +++ b/apps/app/src/react-app/shell/command-palette.tsx @@ -85,6 +85,8 @@ export type CommandPaletteProps = { onOpenSettings: (route?: string) => void; /** Optional — open a URL in the user's browser. Falls back to window.open. */ onOpenUrl?: (url: string) => void; + /** Optional — opens the workflow graph modal for the current workspace. */ + onOpenWorkflowGraph?: () => void; /** Optional: current session servers/artifacts exposed through Cmd/Ctrl+K. */ accessibleTargets?: AccessibleTargetOption[]; onOpenAccessibleTarget?: (target: AccessibleTargetOption) => void; @@ -233,6 +235,21 @@ export function CommandPalette(props: CommandPaletteProps) { props.onOpenSettings("/settings/updates"); }, }, + ...(props.onOpenWorkflowGraph + ? [ + { + id: "view-workflow-graph", + title: t("session.cmd_workflow_graph_title"), + detail: t("session.cmd_workflow_graph_detail"), + meta: t("session.cmd_settings_meta"), + action: () => { + const handler = props.onOpenWorkflowGraph; + props.onClose(); + handler?.(); + }, + } satisfies PaletteItem, + ] + : []), ], [accessibleTargetCount, props]); const sessionItems = useMemo( diff --git a/apps/app/src/react-app/shell/session-route.tsx b/apps/app/src/react-app/shell/session-route.tsx index 1ee3b589d5..1d6b2e6d7d 100644 --- a/apps/app/src/react-app/shell/session-route.tsx +++ b/apps/app/src/react-app/shell/session-route.tsx @@ -104,6 +104,7 @@ import { } from "../domains/workspace/remote-workspace-diagnostics"; import { useShareWorkspaceState } from "../domains/workspace/share-workspace-state"; import { ModelPickerModal } from "../domains/session/modals/model-picker-modal"; +import { WorkflowGraphModal } from "../domains/session/modals/workflow-graph-modal"; import { CommandPalette, type AccessibleTargetOption, type SessionOption as PaletteSessionOption } from "./command-palette"; import { getDisplaySessionTitle } from "../../app/lib/session-title"; import { useBootState } from "./boot-state"; @@ -523,6 +524,7 @@ export function SessionRoute() { const [renameWorkspaceTitle, setRenameWorkspaceTitle] = useState(""); const [renameWorkspaceBusy, setRenameWorkspaceBusy] = useState(false); const [commandPaletteOpen, setCommandPaletteOpen] = useState(false); + const [workflowGraphOpen, setWorkflowGraphOpen] = useState(false); const [paletteAccessibleTargets, setPaletteAccessibleTargets] = useState([]); const [settingsPaneTab, setSettingsPaneTab] = useState("extensions"); // Model picker modal state (ported from settings-route; previously the @@ -2955,8 +2957,25 @@ export function SessionRoute() { // ignore event dispatch failures } }} + onOpenWorkflowGraph={ + selectedWorkspaceId ? () => setWorkflowGraphOpen(true) : undefined + } sessions={paletteSessionOptions} /> + group.workspace.id === selectedWorkspaceId) + ?.sessions ?? [] + } + onSelectSession={(sessionId) => { + if (selectedWorkspaceId) { + navigateToWorkspaceSession(selectedWorkspaceId, sessionId); + } + }} + onClose={() => setWorkflowGraphOpen(false)} + />