Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions apps/app/src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
168 changes: 168 additions & 0 deletions apps/app/src/react-app/domains/session/modals/workflow-graph-modal.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>;
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 (
<Dialog
open={props.open}
onOpenChange={(open) => {
if (!open) props.onClose();
}}
>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<GitBranch className="size-4 text-dls-secondary" />
{t("session.workflow_graph_title")}
</DialogTitle>
<DialogDescription>
{props.workspaceTitle
? t("session.workflow_graph_description_workspace", {
workspace: props.workspaceTitle,
})
: t("session.workflow_graph_description")}
</DialogDescription>
</DialogHeader>

<div className="max-h-[60vh] overflow-y-auto pr-1">
{roots.length === 0 ? (
<div className="rounded-lg border border-dls-border bg-dls-surface p-6 text-sm text-dls-secondary">
{t("session.workflow_graph_empty")}
</div>
) : (
<div className="space-y-3">
{roots.map((root) => (
<WorkflowGraphNode
key={root.id}
session={root}
tree={tree}
sessionStatusById={props.sessionStatusById}
depth={0}
onSelectSession={(sessionId) => {
props.onClose();
props.onSelectSession(sessionId);
}}
/>
))}
</div>
)}
</div>

<DialogFooter>
<DialogClose render={<Button variant="outline" type="button" />}>
{t("common.close")}
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

type WorkflowGraphNodeProps = {
session: SidebarSessionItem;
tree: SessionTreeState;
sessionStatusById?: Record<string, string>;
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 (
<div className="space-y-2">
<button
type="button"
onClick={() => onSelectSession(session.id)}
className={cn(
"flex w-full items-center justify-between gap-3 rounded-lg border px-3 py-2 text-left transition-colors",
subtreeActive
? "border-dls-accent/40 bg-dls-accent/5"
: "border-dls-border bg-dls-surface hover:bg-dls-hover",
)}
>
<div className="flex min-w-0 items-center gap-2">
<NodeStatusIcon status={status} active={subtreeActive} />
<span className="truncate text-sm font-medium text-dls-text">{title}</span>
</div>
<div className="flex shrink-0 items-center gap-2 text-xs text-dls-secondary">
{descendantCount > 0 ? (
<span>{t("session.workflow_graph_descendants", { count: descendantCount })}</span>
) : null}
<ChevronRight className="size-4 text-dls-secondary" />
</div>
</button>

{children.length > 0 ? (
<div className="ml-3 border-l border-dls-border pl-4">
<div className="space-y-2">
{children.map((child) => (
<WorkflowGraphNode
key={child.id}
session={child}
tree={tree}
sessionStatusById={sessionStatusById}
depth={depth + 1}
onSelectSession={onSelectSession}
/>
))}
</div>
</div>
) : null}
</div>
);
}

function NodeStatusIcon({ status, active }: { status: string; active: boolean }) {
if (status === "running" || status === "retry" || status === "busy") {
return <Loader2 className="size-4 shrink-0 animate-spin text-dls-accent" />;
}
if (active) {
return <Activity className="size-4 shrink-0 text-dls-accent" />;
}
if (status === "complete" || status === "done" || status === "ready") {
return <CheckCircle2 className="size-4 shrink-0 text-green-9" />;
}
return <div className="size-2 shrink-0 rounded-full bg-gray-7" aria-hidden />;
}
17 changes: 17 additions & 0 deletions apps/app/src/react-app/shell/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<PaletteItem[]>(
Expand Down
19 changes: 19 additions & 0 deletions apps/app/src/react-app/shell/session-route.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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<OpenTarget[]>([]);
const [settingsPaneTab, setSettingsPaneTab] = useState<SettingsPaneTab>("extensions");
// Model picker modal state (ported from settings-route; previously the
Expand Down Expand Up @@ -2955,8 +2957,25 @@ export function SessionRoute() {
// ignore event dispatch failures
}
}}
onOpenWorkflowGraph={
selectedWorkspaceId ? () => setWorkflowGraphOpen(true) : undefined
}
sessions={paletteSessionOptions}
/>
<WorkflowGraphModal
open={workflowGraphOpen}
workspaceTitle={selectedWorkspace ? workspaceLabel(selectedWorkspace) : null}
sessions={
workspaceSessionGroups.find((group) => group.workspace.id === selectedWorkspaceId)
?.sessions ?? []
}
onSelectSession={(sessionId) => {
if (selectedWorkspaceId) {
navigateToWorkspaceSession(selectedWorkspaceId, sessionId);
}
}}
onClose={() => setWorkflowGraphOpen(false)}
/>
<ModelPickerModal
open={modelPickerOpen}
options={allowedModelOptions}
Expand Down