Skip to content
Draft
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
32 changes: 32 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,7 @@ import { shouldSurfaceProviderAccountRateLimits } from "../lib/codexRateLimits";
import { BranchToolbar } from "./BranchToolbar";
import { resolveShortcutCommand } from "../keybindings";
import PlanSidebar from "./PlanSidebar";
import { WorkflowObservatoryDialog } from "./workflow/WorkflowObservatoryDialog";
import { SessionRail } from "./chat/SessionRail";
import { ComposerAsyncQuestionsPanel } from "./chat/ComposerAsyncQuestionsPanel";
import { persistExactAsyncQuestionAnswer } from "./chat/asyncQuestions";
Expand Down Expand Up @@ -3476,6 +3477,17 @@ export default function ChatView(props: ChatViewProps) {
const closePlanSidebar = useCallback(() => {
setPlanSidebarOpenForCurrentThread(false);
}, [setPlanSidebarOpenForCurrentThread]);

// The workflow panel is a read-only overlay. It keeps its own open state so
// it cannot change the plan sidebar layout, and it closes on a thread or
// environment switch so no stale projection stays on screen.
const [workflowObservatoryOpen, setWorkflowObservatoryOpen] = useState(false);
const openWorkflowObservatory = useCallback(() => {
setWorkflowObservatoryOpen(true);
}, []);
useEffect(() => {
setWorkflowObservatoryOpen(false);
}, [environmentId, routeThreadKey]);
const showSessionRail = useCallback(() => {
setSessionRailDocked(true);
}, [setSessionRailDocked]);
Expand Down Expand Up @@ -6569,6 +6581,7 @@ export default function ChatView(props: ChatViewProps) {
handleRuntimeModeChange={handleRuntimeModeChange}
handleInteractionModeChange={handleInteractionModeChange}
togglePlanSidebar={togglePlanSidebar}
{...(activeThread ? { onOpenWorkflowObservatory: openWorkflowObservatory } : {})}
onOpenGoalDialog={openThreadGoalDialog}
focusComposer={focusComposer}
scheduleComposerFocus={scheduleComposerFocus}
Expand Down Expand Up @@ -6670,6 +6683,25 @@ export default function ChatView(props: ChatViewProps) {
</RightPanelSheet>
) : null}

{activeThread ? (
<WorkflowObservatoryDialog
activePlan={activePlan}
activities={threadActivities}
environmentId={activeThread.environmentId}
latestTurn={activeLatestTurn}
modelLabel={activeThread.modelSelection?.model ?? null}
onOpenChange={setWorkflowObservatoryOpen}
open={workflowObservatoryOpen}
providerLabel={selectedProvider}
{...(activeLatestTurn?.turnId && activeLatestTurn.state !== "running"
? { subagentOptions: { terminalTurnIds: new Set([activeLatestTurn.turnId]) } }
: {})}
threadId={activeThread.id}
threadTitle={activeThread.title}
timestampFormat={timestampFormat}
/>
) : null}

{expandedImage && (
<ExpandedImageDialog preview={expandedImage} onClose={closeExpandedImage} />
)}
Expand Down
27 changes: 26 additions & 1 deletion apps/web/src/components/chat/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ import {
ImageIcon,
LoaderCircleIcon,
ListTodoIcon,
NetworkIcon,
FileIcon,
PencilIcon,
Trash2Icon,
Expand Down Expand Up @@ -181,12 +182,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
planSidebarOpen: boolean;
onToggleInteractionMode: () => void;
onTogglePlanSidebar: () => void;
onOpenWorkflowObservatory?: (() => void) | undefined;
}) {
const usesNativePermissionModes = props.provider === "claudeAgent" || props.provider === "grok";
const showStandaloneInteractionMode =
props.showInteractionModeToggle && !usesNativePermissionModes;

if (!showStandaloneInteractionMode && !props.showPlanToggle) {
if (!showStandaloneInteractionMode && !props.showPlanToggle && !props.onOpenWorkflowObservatory) {
return null;
}

Expand Down Expand Up @@ -240,6 +242,24 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop
</Button>
</>
) : null}

{props.onOpenWorkflowObservatory ? (
<>
<Separator orientation="vertical" className="mx-0.5 hidden h-4 sm:block" />
<Button
variant="ghost"
className="shrink-0 whitespace-nowrap px-2 text-muted-foreground/70 hover:text-foreground/80 sm:px-3"
size="sm"
type="button"
data-testid="composer-open-workflow-observatory"
onClick={props.onOpenWorkflowObservatory}
title="Show the recorded workflow for this thread"
>
<NetworkIcon />
<span className="sr-only sm:not-sr-only">Workflow</span>
</Button>
</>
) : null}
</>
);
});
Expand Down Expand Up @@ -829,6 +849,8 @@ export interface ChatComposerProps extends ComposerInteractionCallbacks {
handleRuntimeModeChange: (mode: RuntimeMode) => void;
handleInteractionModeChange: (mode: ProviderInteractionMode) => void;
togglePlanSidebar: () => void;
/** Opens the read-only workflow panel. Absent for draft threads. */
onOpenWorkflowObservatory?: (() => void) | undefined;
onOpenGoalDialog: () => void;
onOpenSubagentDetail?: (workEntry: WorkLogEntry, trigger: HTMLButtonElement) => void;

Expand Down Expand Up @@ -920,6 +942,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
handleRuntimeModeChange,
handleInteractionModeChange,
togglePlanSidebar,
onOpenWorkflowObservatory,
onOpenGoalDialog,
onOpenSubagentDetail,
focusComposer,
Expand Down Expand Up @@ -3445,6 +3468,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onToggleInteractionMode={cycleComposerInteractionMode}
onNativePermissionModeChange={handleClaudePermissionModeChange}
onTogglePlanSidebar={togglePlanSidebar}
onOpenWorkflowObservatory={onOpenWorkflowObservatory}
onRuntimeModeChange={handleRuntimeModeChange}
onOpenGoal={onOpenGoalDialog}
/>
Expand Down Expand Up @@ -3477,6 +3501,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
planSidebarOpen={planSidebarOpen}
onToggleInteractionMode={cycleComposerInteractionMode}
onTogglePlanSidebar={togglePlanSidebar}
onOpenWorkflowObservatory={onOpenWorkflowObservatory}
/>
{goalControlsSupported && interactionMode !== "plan" ? (
<>
Expand Down
21 changes: 21 additions & 0 deletions apps/web/src/components/chat/CompactComposerControlsMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
ListTodoIcon,
LockIcon,
LockOpenIcon,
NetworkIcon,
PenLineIcon,
ShieldCheckIcon,
TargetIcon,
Expand Down Expand Up @@ -98,6 +99,8 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
onToggleInteractionMode: () => void;
onNativePermissionModeChange: (mode: ClaudePermissionMode) => void;
onTogglePlanSidebar: () => void;
/** Opens the read-only workflow panel. Absent for draft threads. */
onOpenWorkflowObservatory?: (() => void) | undefined;
onRuntimeModeChange: (mode: RuntimeMode) => void;
onOpenGoal?: () => void;
}) {
Expand Down Expand Up @@ -232,6 +235,24 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls
</MenuItem>
</>
) : null}
{props.onOpenWorkflowObservatory ? (
<>
{hasTraits ||
props.showInteractionModeToggle ||
showAccessControls ||
props.showGoalControl ||
props.showPlanSidebar ? (
<MenuDivider />
) : null}
<MenuItem
data-testid="compact-open-workflow-observatory"
onClick={props.onOpenWorkflowObservatory}
>
<NetworkIcon className="size-4 shrink-0" />
Show workflow
</MenuItem>
</>
) : null}
</MenuPopup>
</Menu>
);
Expand Down
176 changes: 176 additions & 0 deletions apps/web/src/components/workflow/WorkflowGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { memo, useEffect, useId, useMemo, useState } from "react";

import { deriveWorkflowGraphLayout } from "../../workflowGraph";
import type { WorkflowNode, WorkflowNodeStatus } from "../../workflowProjection";

const STATUS_COLOR: Readonly<Record<WorkflowNodeStatus, string>> = {
queued: "#f59e0b",
running: "#60a5fa",
waiting: "#a78bfa",
completed: "#34d399",
failed: "#f87171",
interrupted: "#fb923c",
unknown: "#94a3b8",
};

const STATUS_LABEL: Readonly<Record<WorkflowNodeStatus, string>> = {
queued: "Queued",
running: "Running",
waiting: "Waiting",
completed: "Completed",
failed: "Failed",
interrupted: "Interrupted",
unknown: "Not reported",
};

/**
* Draws the workflow nodes and the edges the projection reports.
*
* An edge means that the thread recorded the agent. It is not a claim that one
* agent started another: no current Cafe provider reports that relationship.
*/
export const WorkflowGraph = memo(function WorkflowGraph({
nodes,
}: {
readonly nodes: readonly WorkflowNode[];
}) {
const layout = useMemo(() => deriveWorkflowGraphLayout(nodes), [nodes]);
const markerId = useId().replaceAll(":", "");
const [selectedId, setSelectedId] = useState<string | null>(() => nodes[0]?.id ?? null);

useEffect(() => {
if (selectedId && nodes.some((node) => node.id === selectedId)) return;
setSelectedId(nodes[0]?.id ?? null);
}, [nodes, selectedId]);

const selected = nodes.find((node) => node.id === selectedId) ?? null;
const selectedParent = selected?.parentId
? (nodes.find((node) => node.id === selected.parentId) ?? null)
: null;

return (
<div className="space-y-2" data-testid="workflow-graph">
<div className="overflow-auto rounded-lg border border-border/50 bg-background/35">
<div
className="relative text-muted-foreground"
style={{ height: layout.height, width: layout.width }}
>
<svg
aria-hidden="true"
className="absolute inset-0"
height={layout.height}
width={layout.width}
>
<defs>
<marker
id={markerId}
markerHeight="6"
markerWidth="7"
orient="auto"
refX="6"
refY="3"
>
<path d="M 0 0 L 6 3 L 0 6 z" fill="currentColor" />
</marker>
</defs>
{layout.edges.map((edge) => (
<path
d={edge.path}
fill="none"
key={edge.id}
markerEnd={`url(#${markerId})`}
stroke="currentColor"
strokeOpacity="0.45"
strokeWidth="1.5"
/>
))}
</svg>

{layout.nodes.map((entry) => {
const active = entry.node.id === selectedId;
const color = STATUS_COLOR[entry.node.status];
return (
<button
aria-pressed={active}
className="absolute overflow-hidden rounded-lg border bg-card/95 px-2.5 py-2 text-left shadow-md focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid={`workflow-graph-node-${entry.node.id}`}
key={entry.node.id}
onClick={() => setSelectedId(entry.node.id)}
style={{
borderColor: active ? color : `color-mix(in srgb, ${color} 45%, transparent)`,
height: entry.height,
left: entry.x,
top: entry.y,
width: entry.width,
}}
type="button"
>
<span className="flex items-center gap-1.5">
<span
aria-hidden="true"
className="size-2 shrink-0 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="truncate text-[11px] font-medium text-foreground/90">
{entry.node.title}
</span>
</span>
<span className="mt-1 block truncate text-[9px] text-muted-foreground">
{entry.node.kind === "thread" ? "Thread" : "Agent"} ·{" "}
{STATUS_LABEL[entry.node.status]}
</span>
<span className="mt-1 block truncate text-[9px] text-muted-foreground/70">
{entry.node.detail ?? entry.node.objective ?? "Detail not reported"}
</span>
</button>
);
})}
</div>
</div>

{layout.unknownParentCount > 0 ? (
<p className="text-[10px] text-amber-300/80" data-testid="workflow-graph-unknown-parents">
{layout.unknownParentCount} relationship
{layout.unknownParentCount === 1 ? " is" : "s are"} unknown or cyclic. Cafe does not draw
them.
</p>
) : null}
{layout.duplicateNodeIdCount > 0 ? (
<p className="text-[10px] text-amber-300/80" data-testid="workflow-graph-duplicates">
{layout.duplicateNodeIdCount} repeated node identifier
{layout.duplicateNodeIdCount === 1 ? " was" : "s were"} dropped.
</p>
) : null}

{selected ? (
<section
aria-label="Selected workflow node"
className="rounded-lg border border-border/45 bg-background/35 p-2"
data-testid="workflow-graph-selection"
>
<h3 className="text-[11px] font-medium text-foreground/90">{selected.title}</h3>
<p className="mt-1 text-[10px] text-muted-foreground">
{selected.objective ?? selected.detail ?? "Objective not reported"}
</p>
<p className="mt-1 text-[10px] text-muted-foreground/70">
Recorded by:{" "}
{selected.parentId === null
? "This node is the thread."
: (selectedParent?.title ?? "The source reported a thread that is not available.")}
</p>
</section>
) : null}

<ol aria-label="Workflow nodes and reported relationships" className="sr-only">
{nodes.map((node) => (
<li key={`accessible:${node.id}`}>
{node.title}, {node.kind}, status {STATUS_LABEL[node.status]},{" "}
{node.parentId
? `recorded by ${nodes.find((candidate) => candidate.id === node.parentId)?.title ?? "an unavailable thread"}`
: "no parent reported"}
</li>
))}
</ol>
</div>
);
});
Loading
Loading