diff --git a/src/__tests__/integration/core/run-agent.test.ts b/src/__tests__/integration/core/run-agent.test.ts index 45782207..0b04d055 100644 --- a/src/__tests__/integration/core/run-agent.test.ts +++ b/src/__tests__/integration/core/run-agent.test.ts @@ -1573,6 +1573,7 @@ describe('AgentRunService.run', () => { it('allows a final answer even when a recorded plan still has unfinished items', async () => { let stage = 0; + const events: AgentRunEvent[] = []; const fakeLlm: LlmAdapter = { async chat(): Promise { stage += 1; @@ -1615,6 +1616,7 @@ describe('AgentRunService.run', () => { tools: [updatePlanTool], maxSteps: 2, logger: silentLogger, + onEvent: (event) => events.push(event), }); expect(result.outcome).toBe('done'); @@ -1626,5 +1628,15 @@ describe('AgentRunService.run', () => { message.content.includes('you recorded a plan and it still has unfinished items'), ), ).toBe(false); + expect(events).toContainEqual({ + type: 'plan.updated', + step: 1, + explanation: 'Tracking the implementation steps.', + items: [ + { step: 'Inspect current implementation', status: 'completed' }, + { step: 'Implement the next bounded change', status: 'in_progress' }, + { step: 'Verify with tests', status: 'pending' }, + ], + }); }); }); diff --git a/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts b/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts index fe133fda..7c970dfc 100644 --- a/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts +++ b/src/__tests__/unit/cli-v2/control-plane-session-store.test.ts @@ -498,6 +498,56 @@ describe('ControlPlaneSessionStore', () => { store.dispose(); }); + it('tracks active plan updates until the run finishes', async () => { + const fixture = createClientFixture(); + const store = new ControlPlaneSessionStore({ client: fixture.client }); + await store.start(); + + fixture.sessionEvents?.onData?.({ + type: 'session.event', + sessionId: 'session-1', + timestamp: new Date().toISOString(), + activities: [ + { + source: 'agent-loop', + type: 'plan.updated', + runId: 'run-1', + step: 1, + timestamp: new Date().toISOString(), + explanation: 'Tracking current work.', + items: [ + { step: 'Inspect', status: 'completed' }, + { step: 'Implement', status: 'in_progress' }, + ], + }, + ], + } as ControlPlaneSessionEventEnvelope); + + expect(store.getSnapshot().activePlan?.items).toEqual([ + { step: 'Inspect', status: 'completed' }, + { step: 'Implement', status: 'in_progress' }, + ]); + + fixture.sessionEvents?.onData?.({ + type: 'session.event', + sessionId: 'session-1', + timestamp: new Date().toISOString(), + activities: [ + { + source: 'agent-loop', + type: 'loop.finished', + runId: 'run-1', + outcome: 'done', + summary: 'Done.', + timestamp: new Date().toISOString(), + }, + ], + } as ControlPlaneSessionEventEnvelope); + + expect(store.getSnapshot().activePlan).toBeUndefined(); + store.dispose(); + }); + it('keeps the final run outcome visible after loop completion', async () => { const fixture = createClientFixture(); const store = new ControlPlaneSessionStore({ client: fixture.client }); diff --git a/src/__tests__/unit/client-shared/session-activity-service.test.ts b/src/__tests__/unit/client-shared/session-activity-service.test.ts index f64ba2f5..e5aeeaf9 100644 --- a/src/__tests__/unit/client-shared/session-activity-service.test.ts +++ b/src/__tests__/unit/client-shared/session-activity-service.test.ts @@ -59,6 +59,28 @@ describe('ClientSharedSessionActivityService', () => { expect(effects).toEqual(['finished:Run finished: done', 'workspace changed']); }); + it('applies plan update effects without changing live status', () => { + const effects: string[] = []; + + ClientSharedSessionActivityService.applyActivity({ + type: 'plan.updated', + runId: 'run-1', + source: 'agent-loop', + step: 1, + timestamp: new Date().toISOString(), + explanation: 'Tracking current work.', + items: [ + { step: 'Inspect', status: 'completed' }, + { step: 'Implement', status: 'in_progress' }, + ], + } as ControlPlaneSessionActivity, { + onPlanUpdated: (plan) => effects.push(plan.items[1]?.step ?? ''), + onLiveStatus: () => effects.push('live status changed'), + }); + + expect(effects).toEqual(['Implement']); + }); + it('uses derived tool labels when the API provides them', () => { expect(ClientSharedSessionActivityService.formatToolLabel({ type: 'tool.approval_requested', diff --git a/src/__tests__/unit/web-v2/agent-plan-panel.test.tsx b/src/__tests__/unit/web-v2/agent-plan-panel.test.tsx new file mode 100644 index 00000000..21b177e9 --- /dev/null +++ b/src/__tests__/unit/web-v2/agent-plan-panel.test.tsx @@ -0,0 +1,70 @@ +/** @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { AgentPlanPanel } from '../../../web-v2/components/conversation/AgentPlanPanel.js'; +import type { ClientSharedSessionPlan } from '../../../client-shared/services/session-activities/index.js'; + +describe('AgentPlanPanel', () => { + afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + }); + + it('renders the current plan summary and items', () => { + const plan = createPlan(); + + render( + , + ); + + expect(screen.getByText('Plan')).toBeTruthy(); + expect(screen.getAllByText('Implement plan UI')).toHaveLength(2); + expect(screen.getByText('Inspect current path')).toBeTruthy(); + expect(screen.getByText('Verify behavior')).toBeTruthy(); + expect(screen.getByText('Plan').closest('details')?.open).toBe(true); + }); + + it('defaults collapsed on mobile and can be expanded', () => { + mockMobileViewport(); + + render(); + + const details = screen.getByText('Plan').closest('details'); + expect(details?.open).toBe(false); + + fireEvent.click(screen.getByText('Plan')); + + expect(details?.open).toBe(true); + }); +}); + +function createPlan(): ClientSharedSessionPlan { + return { + source: 'agent-loop', + type: 'plan.updated', + runId: 'run-1', + step: 1, + timestamp: new Date().toISOString(), + explanation: 'Tracking current work.', + items: [ + { step: 'Inspect current path', status: 'completed' }, + { step: 'Implement plan UI', status: 'in_progress' }, + { step: 'Verify behavior', status: 'pending' }, + ], + }; +} + +function mockMobileViewport(): void { + vi.stubGlobal('matchMedia', vi.fn((query) => ({ + matches: query === '(max-width: 38rem)', + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + }))); +} diff --git a/src/cli-v2/App.tsx b/src/cli-v2/App.tsx index 84984f52..5eb1dede 100644 --- a/src/cli-v2/App.tsx +++ b/src/cli-v2/App.tsx @@ -4,6 +4,7 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { Box, Text } from 'ink'; import { ApprovalPanel } from './components/ApprovalPanel.js'; +import { AgentPlanPanel } from './components/AgentPlanPanel.js'; import { CommandResultPanel } from './components/CommandResultPanel.js'; import { ConversationPanel } from './components/ConversationPanel.js'; import { ModelPickerPanel } from './components/ModelPickerPanel.js'; @@ -118,6 +119,7 @@ export function App({ cancelling={snapshot.cancelling} onCancel={cancelRun} /> + {pickers.model.query !== undefined ? ( ; + +type AgentPlanPanelProps = { + plan?: ClientSharedSessionPlan; +}; + +export function AgentPlanPanel({ plan }: AgentPlanPanelProps) { + if (!plan) { + return null; + } + + return ( + + Plan + {plan.explanation ? {plan.explanation} : null} + {plan.items.map((item) => ( + + {statusGlyphs[item.status]} {item.step} + + ))} + + ); +} diff --git a/src/cli-v2/state/control-plane-session-store.ts b/src/cli-v2/state/control-plane-session-store.ts index e808195b..29c4ae89 100644 --- a/src/cli-v2/state/control-plane-session-store.ts +++ b/src/cli-v2/state/control-plane-session-store.ts @@ -1,5 +1,6 @@ import type { ControlPlaneProxyClient } from '@/client-shared/api/proxy.js'; import { ClientSharedSessionActivityService } from '@/client-shared/services/session-activities/index.js'; +import type { ClientSharedSessionPlan } from '@/client-shared/services/session-activities/index.js'; import { ClientSharedSessionMessageService } from '@/client-shared/services/session-messages/index.js'; import { SessionActivityService, @@ -65,6 +66,7 @@ export type ControlPlaneSessionStoreSnapshot = { cancelling: boolean; streamConnected: boolean; liveStatus?: string; + activePlan?: ClientSharedSessionPlan; latestUpdate?: ControlPlaneSessionLatestUpdate; slashCommandCatalog?: ControlPlaneSlashCommandCatalog; commandResults: ControlPlaneSlashCommandResult[]; @@ -92,6 +94,8 @@ const INITIAL_SNAPSHOT: ControlPlaneSessionStoreSnapshot = { * This is the non-React counterpart to web-v2's focused session hooks: it loads * the selected workspace/session, subscribes to live updates, keeps transient * conversation messages coherent, and exposes terminal intent methods. + * Shared activity policy stays in client-shared; this store owns only cli-v2 + * state mutation and terminal workflow coordination. */ export class ControlPlaneSessionStore { private readonly api: ControlPlaneSessionApiService; @@ -192,6 +196,7 @@ export class ControlPlaneSessionStore { runtimeContext: undefined, pendingApproval: null, liveStatus: undefined, + activePlan: undefined, latestUpdate: undefined, error: undefined, loading: true, @@ -243,6 +248,7 @@ export class ControlPlaneSessionStore { submitting: true, running: true, error: undefined, + activePlan: undefined, liveStatus: current.streamConnected ? 'Heddle is working...' : 'Heddle is working... reconnecting live stream if needed.', @@ -487,6 +493,7 @@ export class ControlPlaneSessionStore { private async continueSession(workspaceId: string, sessionId: string): Promise { this.setSnapshot({ running: true, + activePlan: undefined, liveStatus: 'Heddle is continuing from the current transcript...', }); await this.api.continueSession(workspaceId, sessionId); @@ -500,6 +507,7 @@ export class ControlPlaneSessionStore { await this.api.sendPromptAsync({ workspaceId, sessionId, prompt }); this.setSnapshot({ running: true, + activePlan: undefined, liveStatus: this.snapshotValue.streamConnected ? 'Heddle is working...' : 'Heddle is working... reconnecting live stream if needed.', @@ -567,6 +575,12 @@ export class ControlPlaneSessionStore { onPendingApprovalChanged: () => { void this.refreshPendingApproval(event.sessionId); }, + onPlanUpdated: (plan) => { + this.setSnapshot({ activePlan: plan }); + }, + onPlanCleared: () => { + this.setSnapshot({ activePlan: undefined }); + }, onLiveStatus: (statusActivity, liveStatus) => { const latestUpdate = SessionActivityService.resolveLatestUpdate(statusActivity); if (liveStatus === undefined && latestUpdate === undefined) { @@ -683,6 +697,7 @@ export class ControlPlaneSessionStore { this.setSnapshot({ submitting: false, liveStatus: undefined, + activePlan: undefined, latestUpdate: { label: 'Run finished', tone: 'success', diff --git a/src/client-shared/services/session-activities/README.md b/src/client-shared/services/session-activities/README.md new file mode 100644 index 00000000..ff85cd70 --- /dev/null +++ b/src/client-shared/services/session-activities/README.md @@ -0,0 +1,29 @@ +# Session Activity Client Effects + +This service owns frontend-neutral effects derived from control-plane session +activities. It is the shared interpretation layer used by web-v2 and cli-v2 +after they receive API-provided live events. + +## Owns + +- Mapping each API activity type to shared client effects. +- Shared live status copy for activity progress. +- Shared derived labels for tool-related activities. +- Active plan lifetime at the client edge: `plan.updated` sets the visible plan, + `loop.started` and `loop.finished` clear it. + +## Does Not Own + +- Activity facts or schemas. Those belong to `src/core/live` and flow through + tRPC-derived client types. +- Plan parsing or validation. That belongs to the core agent planning/tool + modules. +- React state, Ink state, layout, or rendering. +- Control-plane transport or subscription setup. + +## Boundary + +Do not add duplicated activity switchboards in web-v2 or cli-v2. Add a shared +effect here when both clients need to react to the same control-plane activity. +Client code should provide state setters and render the resulting state in its +own UI language. diff --git a/src/client-shared/services/session-activities/index.ts b/src/client-shared/services/session-activities/index.ts index 0405196e..8717d4c6 100644 --- a/src/client-shared/services/session-activities/index.ts +++ b/src/client-shared/services/session-activities/index.ts @@ -1,4 +1,5 @@ export { ClientSharedSessionActivityService, type ClientSharedSessionActivity, + type ClientSharedSessionPlan, } from './session-activity-service.js'; diff --git a/src/client-shared/services/session-activities/session-activity-service.ts b/src/client-shared/services/session-activities/session-activity-service.ts index 38f77e8e..b22bbf80 100644 --- a/src/client-shared/services/session-activities/session-activity-service.ts +++ b/src/client-shared/services/session-activities/session-activity-service.ts @@ -13,6 +13,7 @@ type SessionActivityStatusHandlers = { type ActivityOf = Extract; type PendingApprovalActivity = ActivityOf<'tool.approval_requested'> | ActivityOf<'tool.approval_resolved'>; type WorkspaceChangedActivity = ActivityOf<'loop.finished'> | ActivityOf<'tool.completed'>; +export type ClientSharedSessionPlan = ActivityOf<'plan.updated'>; type SessionActivityEffectHandlers = { [ActivityType in ClientSharedSessionActivity['type']]?: ( activity: Extract, @@ -24,13 +25,20 @@ export type ClientSharedSessionActivityEffects = { onAssistantStream?: (activity: ActivityOf<'assistant.stream'>, liveStatus: string | undefined) => void; onRunStarted?: (activity: ActivityOf<'loop.started'>, liveStatus: string | undefined) => void; onRunFinished?: (activity: ActivityOf<'loop.finished'>, liveStatus: string | undefined) => void; + onPlanUpdated?: (activity: ClientSharedSessionPlan) => void; + onPlanCleared?: () => void; onLiveStatus?: (activity: ClientSharedSessionActivity, liveStatus: string | undefined) => void; onPendingApprovalChanged?: (activity: PendingApprovalActivity) => void; onWorkspaceChanged?: (activity: WorkspaceChangedActivity) => void; }; /** - * Applies control-plane session activity effects shared by frontend clients. + * Owns client-side effects derived from API-provided session activities. + * + * Core/live owns the activity vocabulary and facts. This service owns only the + * frontend-neutral consequences shared by web-v2 and cli-v2, including active + * plan lifetime: a plan is visible after `plan.updated` and cleared when a new + * run starts or the current run finishes. */ export class ClientSharedSessionActivityService { private static readonly effectHandlers: SessionActivityEffectHandlers = { @@ -38,9 +46,11 @@ export class ClientSharedSessionActivityService { effects.onAssistantStream?.(activity, activity.done ? undefined : 'Receiving assistant response...'); }, 'loop.started': (activity, effects) => { + effects.onPlanCleared?.(); effects.onRunStarted?.(activity, ClientSharedSessionActivityService.formatLiveStatus(activity)); }, 'loop.finished': (activity, effects) => { + effects.onPlanCleared?.(); effects.onRunFinished?.(activity, ClientSharedSessionActivityService.formatLiveStatus(activity)); effects.onWorkspaceChanged?.(activity); }, @@ -51,6 +61,9 @@ export class ClientSharedSessionActivityService { ClientSharedSessionActivityService.applyLiveStatus(activity, effects); effects.onWorkspaceChanged?.(activity); }, + 'plan.updated': (activity, effects) => { + effects.onPlanUpdated?.(activity); + }, 'tool.approval_requested': (activity, effects) => { effects.onPendingApprovalChanged?.(activity); ClientSharedSessionActivityService.applyLiveStatus(activity, effects); diff --git a/src/core/agent/planning/README.md b/src/core/agent/planning/README.md new file mode 100644 index 00000000..35e87eed --- /dev/null +++ b/src/core/agent/planning/README.md @@ -0,0 +1,27 @@ +# Agent Planning + +This module owns the agent loop's interpretation of successful `update_plan` +tool output. + +## Owns + +- Parsing `update_plan` tool output into `AgentPlanState`. +- Preserving the canonical plan item shape from the tool contract: `step` plus + `pending`, `in_progress`, or `completed` status. +- Rejecting malformed plan output by returning no active plan instead of + inventing fallback plan data. + +## Does Not Own + +- The `update_plan` tool input schema or validation. That belongs to + `src/core/tools/toolkits/internal/update-plan.ts`. +- Live event transport. That belongs to `src/core/live` and the runtime host + callback path. +- Web, TUI, or CLI rendering. +- Client-side visibility lifetime after events are received. + +## Boundary + +Other modules should not reparse `update_plan` payloads. The agent tool turn +updates `context.state.activePlan` from this parser, then emits the live +`plan.updated` activity from that parsed state. diff --git a/src/core/agent/tools/tool-turn-service.ts b/src/core/agent/tools/tool-turn-service.ts index 9bbfb4b1..c62ca5bf 100644 --- a/src/core/agent/tools/tool-turn-service.ts +++ b/src/core/agent/tools/tool-turn-service.ts @@ -119,6 +119,13 @@ export class AgentToolTurnService { AgentMemoryCheckpointTracker.trackToolResult({ context, effectiveCall, result }); if (effectiveCall.tool === 'update_plan') { context.state.activePlan = AgentPlanStateParser.parse({ output: result.output }); + if (context.state.activePlan) { + context.live.activity({ + type: HeddleEventType.planUpdated, + step: context.state.step, + ...context.state.activePlan, + }); + } } } diff --git a/src/core/event-types.ts b/src/core/event-types.ts index 9ab297b2..a6cc7f36 100644 --- a/src/core/event-types.ts +++ b/src/core/event-types.ts @@ -14,6 +14,7 @@ export const HeddleEventType = { toolFallback: 'tool.fallback', toolCalling: 'tool.calling', toolCompleted: 'tool.completed', + planUpdated: 'plan.updated', memoryCandidateRecorded: 'memory.candidate_recorded', memoryCheckpointSkipped: 'memory.checkpoint_skipped', memoryMaintenanceStarted: 'memory.maintenance_started', diff --git a/src/core/live/README.md b/src/core/live/README.md index d95ab606..e30de0b3 100644 --- a/src/core/live/README.md +++ b/src/core/live/README.md @@ -13,3 +13,15 @@ Do not add parallel callback lanes or mapper layers for assistant streaming, tool progress, run lifecycle, or compaction progress. Add structured fields to the activity type that owns the behavior, then let interfaces decide how to present those fields. + +## Plan Activity + +Agent planning uses the same live activity lane. The agent domain owns the +`update_plan` tool contract and parses successful tool output into active run +state. `src/core/live` owns the user-facing `plan.updated` activity shape that +hosts and control-plane clients can render. + +Do not create a second plan subscription, preview endpoint, or UI-only plan +schema. If the plan facts need to change, update the core plan state and the +`ConversationPlanUpdatedActivity` contract here, then let transports carry that +activity unchanged. diff --git a/src/core/live/index.ts b/src/core/live/index.ts index 3c78945f..f84777d7 100644 --- a/src/core/live/index.ts +++ b/src/core/live/index.ts @@ -14,6 +14,7 @@ export type { ConversationCompactionStatus, ConversationLoopFinishedActivity, ConversationLoopStartedActivity, + ConversationPlanUpdatedActivity, ConversationToolApprovalRequestedActivity, ConversationToolApprovalResolvedActivity, ConversationToolFallbackActivity, diff --git a/src/core/live/types.ts b/src/core/live/types.ts index cdf48026..582597b4 100644 --- a/src/core/live/types.ts +++ b/src/core/live/types.ts @@ -1,5 +1,6 @@ import type { LlmProvider, LlmUsage } from '@/core/llm/types.js'; import { HeddleEventType } from '@/core/event-types.js'; +import type { AgentPlanState } from '@/core/agent/planning/index.js'; import type { StopReason, ToolCall, ToolResult } from '@/core/types.js'; export type ConversationActivityCorrelation = { @@ -110,6 +111,14 @@ export type ConversationToolCompletedActivity = { timestamp: string; }; +export type ConversationPlanUpdatedActivity = AgentPlanState & { + source: 'agent-loop'; + type: typeof HeddleEventType.planUpdated; + runId: string; + step: number; + timestamp: string; +}; + export type ConversationLoopFinishedActivity = { source: 'agent-loop'; type: typeof HeddleEventType.loopFinished; @@ -128,6 +137,7 @@ export type ConversationAgentLoopActivity = | ConversationToolFallbackActivity | ConversationToolCallingActivity | ConversationToolCompletedActivity + | ConversationPlanUpdatedActivity | ConversationLoopFinishedActivity; export type ConversationCompactionRunningActivity = { diff --git a/src/core/runtime/loop/types.ts b/src/core/runtime/loop/types.ts index 9107f84b..f166ad0f 100644 --- a/src/core/runtime/loop/types.ts +++ b/src/core/runtime/loop/types.ts @@ -10,6 +10,7 @@ import type { ConversationToolFallbackActivity, ConversationToolCallingActivity, ConversationToolCompletedActivity, + ConversationPlanUpdatedActivity, } from '@/core/live/index.js'; import type { ChatMessage, LlmAdapter, LlmProvider, LlmUsage, ReasoningEffort } from '@/core/llm/types.js'; import type { RunResult, StopReason, ToolCall, ToolDefinition, TraceEvent } from '@/core/types.js'; @@ -54,6 +55,7 @@ export type AgentLoopEvent = | ConversationToolFallbackActivity | ConversationToolCallingActivity | ConversationToolCompletedActivity + | ConversationPlanUpdatedActivity | { type: typeof HeddleEventType.trace; runId: string; diff --git a/src/web-v2/components/conversation/AgentPlanPanel.tsx b/src/web-v2/components/conversation/AgentPlanPanel.tsx new file mode 100644 index 00000000..089cf261 --- /dev/null +++ b/src/web-v2/components/conversation/AgentPlanPanel.tsx @@ -0,0 +1,54 @@ +import { useEffect, useState } from 'react'; +import { CheckCircle2, ChevronDown, Circle, Loader2 } from 'lucide-react'; +import type { ClientSharedSessionPlan } from '@/client-shared/services/session-activities'; + +type AgentPlanPanelProps = { + plan: ClientSharedSessionPlan; +}; + +const statusIcons = { + pending: Circle, + in_progress: Loader2, + completed: CheckCircle2, +} satisfies Record; + +const mobilePlanQuery = '(max-width: 38rem)'; + +export function AgentPlanPanel({ plan }: AgentPlanPanelProps) { + const [open, setOpen] = useState(() => shouldDefaultOpen()); + const activeStep = plan.items.find((item) => item.status === 'in_progress') ?? plan.items.find((item) => item.status !== 'completed') ?? plan.items.at(-1); + + useEffect(() => { + setOpen(shouldDefaultOpen()); + }, [plan.runId]); + + return ( +
setOpen(event.currentTarget.open)} + > + + + {plan.explanation ?

{plan.explanation}

: null} +
    + {plan.items.map((item) => { + const StatusIcon = statusIcons[item.status]; + return ( +
  1. +
  2. + ); + })} +
+
+ ); +} + +function shouldDefaultOpen(): boolean { + return typeof window === 'undefined' || !window.matchMedia?.(mobilePlanQuery).matches; +} diff --git a/src/web-v2/components/conversation/ConversationThread.tsx b/src/web-v2/components/conversation/ConversationThread.tsx index a9ee26bc..b76539a5 100644 --- a/src/web-v2/components/conversation/ConversationThread.tsx +++ b/src/web-v2/components/conversation/ConversationThread.tsx @@ -6,6 +6,8 @@ import type { ControlPlaneSessionDetail, } from '@web/hooks/sessions/useControlPlaneSessionDetail'; import { useConversationAutoScroll } from '@web/hooks/conversation/useConversationAutoScroll'; +import type { ClientSharedSessionPlan } from '@/client-shared/services/session-activities'; +import { AgentPlanPanel } from './AgentPlanPanel'; import { ApprovalPanel } from './ApprovalPanel'; import { ConversationComposer } from './ConversationComposer'; import { ConversationMessage } from './ConversationMessage'; @@ -19,6 +21,7 @@ interface ConversationThreadProps { running: boolean; cancelling: boolean; liveStatus?: string; + activePlan?: ClientSharedSessionPlan; pendingApproval: ControlPlanePendingApproval; approvalResolving: boolean; approvalError?: string; @@ -43,6 +46,7 @@ export function ConversationThread({ running, cancelling, liveStatus, + activePlan, pendingApproval, approvalResolving, approvalError, @@ -123,6 +127,11 @@ export function ConversationThread({ /> ) : null} + {activePlan ? ( +
+ +
+ ) : null}
['pendingApproval']; approvalResolving: boolean; @@ -45,6 +47,7 @@ export function useControlPlaneSessionDetail({ sessionId, }: UseControlPlaneSessionDetailArgs): ControlPlaneSessionDetailState { const [liveStatus, setLiveStatus] = useState(); + const [activePlan, setActivePlan] = useState(); const loader = useControlPlaneSessionLoader({ workspaceId, sessionId }); const runControl = useControlPlaneSessionRunControl({ workspaceId, @@ -63,6 +66,7 @@ export function useControlPlaneSessionDetail({ setSession: loader.setSession, setRunning: runControl.setRunning, setLiveStatus, + setActivePlan, }); const promptSubmit = useControlPlaneSessionPromptSubmit({ workspaceId, @@ -87,6 +91,7 @@ export function useControlPlaneSessionDetail({ cancelling: runControl.cancelling, error: loader.error, liveStatus, + activePlan, cancelError: runControl.cancelError, pendingApproval: approval.pendingApproval, approvalResolving: approval.approvalResolving, @@ -105,6 +110,7 @@ export function useControlPlaneSessionDetail({ approval.approvalResolving, approval.pendingApproval, approval.resolvePendingApproval, + activePlan, liveStatus, loader.error, loader.loading, diff --git a/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts b/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts index d209bf49..2d22c30b 100644 --- a/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts +++ b/src/web-v2/hooks/sessions/useControlPlaneSessionEvents.ts @@ -6,6 +6,7 @@ import { type ControlPlaneSessionEventEnvelope, } from '@web/api/client'; import { ClientSharedSessionActivityService } from '@/client-shared/services/session-activities'; +import type { ClientSharedSessionPlan } from '@/client-shared/services/session-activities'; import { ClientSharedSessionMessageService } from '@/client-shared/services/session-messages'; import type { RefreshControlPlaneSession } from './useControlPlaneSessionLoader'; @@ -17,14 +18,16 @@ type UseControlPlaneSessionEventsArgs = { setSession: Dispatch>; setRunning: Dispatch>; setLiveStatus: Dispatch>; + setActivePlan: Dispatch>; }; export type ControlPlaneSessionEventsState = { streamConnected: boolean; }; -// Subscribes to the selected session's live event stream and applies only the -// web-v2 conversation state transitions that this interface currently renders. +// Owns web-v2's selected-session subscription effects. Activity facts and +// shared activity policies stay in core/client-shared; this hook only writes +// React state and cache entries for the active conversation surface. export function useControlPlaneSessionEvents({ workspaceId, sessionId, @@ -33,6 +36,7 @@ export function useControlPlaneSessionEvents({ setSession, setRunning, setLiveStatus, + setActivePlan, }: UseControlPlaneSessionEventsArgs): ControlPlaneSessionEventsState { const utils = trpcReact.useUtils(); const [streamConnected, setStreamConnected] = useState(false); @@ -75,9 +79,10 @@ export function useControlPlaneSessionEvents({ } }, setLiveStatus, + setActivePlan, setRunning, })); - }, [invalidateWorkspaceDiff, refresh, refreshPendingApproval, setLiveStatus, setRunning, setSession, utils.controlPlane.session, workspaceId]); + }, [invalidateWorkspaceDiff, refresh, refreshPendingApproval, setActivePlan, setLiveStatus, setRunning, setSession, utils.controlPlane.session, workspaceId]); const subscription = trpcReact.controlPlane.sessionEvents.useSubscription( sessionId && workspaceId ? { sessionId, workspaceId } : skipToken, @@ -101,13 +106,15 @@ export function useControlPlaneSessionEvents({ if (!sessionId || !workspaceId) { setRunning(false); setLiveStatus(undefined); + setActivePlan(undefined); setStreamConnected(false); return; } setRunning(false); setLiveStatus(undefined); - }, [sessionId, setLiveStatus, setRunning, workspaceId]); + setActivePlan(undefined); + }, [sessionId, setActivePlan, setLiveStatus, setRunning, workspaceId]); useEffect(() => { setStreamConnected(subscription.status === 'pending'); @@ -145,6 +152,7 @@ type SessionActivityContext = { updateSession: Dispatch>; setRunning: Dispatch>; setLiveStatus: Dispatch>; + setActivePlan: Dispatch>; }; type ControlPlaneSessionActivity = Extract['activities'][number]; @@ -174,6 +182,12 @@ function applySessionActivity(activity: ControlPlaneSessionActivity, context: Se } void context.refresh(context.sessionId, { silent: true }); }, + onPlanUpdated: (plan) => { + context.setActivePlan(plan); + }, + onPlanCleared: () => { + context.setActivePlan(undefined); + }, onLiveStatus: (_statusActivity, liveStatus) => { if (liveStatus !== undefined) { context.setLiveStatus(liveStatus); diff --git a/src/web-v2/hooks/useControlPlaneAppState.ts b/src/web-v2/hooks/useControlPlaneAppState.ts index 65ec9b38..0e24b0bf 100644 --- a/src/web-v2/hooks/useControlPlaneAppState.ts +++ b/src/web-v2/hooks/useControlPlaneAppState.ts @@ -141,6 +141,7 @@ export function useControlPlaneAppState() { running: selectedSession.running, cancelling: selectedSession.cancelling, liveStatus: selectedSession.liveStatus, + activePlan: selectedSession.activePlan, pendingApproval: selectedSession.pendingApproval, approvalResolving: selectedSession.approvalResolving, approvalError: selectedSession.approvalError, diff --git a/src/web-v2/styles/conversation.css b/src/web-v2/styles/conversation.css index 4a4a26f8..4e5952ae 100644 --- a/src/web-v2/styles/conversation.css +++ b/src/web-v2/styles/conversation.css @@ -161,4 +161,116 @@ .v2-live-error-line { color: color-mix(in oklab, var(--color-destructive) 82%, var(--color-foreground)); } + + .v2-agent-plan-region { + padding: 0 1.5rem 0.5rem; + } + + .v2-agent-plan-panel { + margin-inline: auto; + width: min(100%, 48rem); + min-width: 0; + border-radius: var(--radius-lg); + border: 1px solid color-mix(in oklab, var(--color-border) 70%, transparent); + background: color-mix(in oklab, var(--color-background) 90%, var(--color-muted)); + } + + .v2-agent-plan-summary { + display: flex; + min-width: 0; + cursor: pointer; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 0.75rem; + list-style: none; + } + + .v2-agent-plan-toggle { + height: 0.875rem; + width: 0.875rem; + flex: 0 0 auto; + color: var(--color-muted-foreground); + transition: transform 120ms ease; + } + + .v2-agent-plan-panel[open] .v2-agent-plan-toggle { + transform: rotate(180deg); + } + + .v2-agent-plan-summary::-webkit-details-marker { + display: none; + } + + .v2-agent-plan-kicker { + flex: 0 0 auto; + color: var(--color-foreground); + font-size: 0.75rem; + font-weight: 600; + line-height: 1.35; + } + + .v2-agent-plan-current, + .v2-agent-plan-explanation, + .v2-agent-plan-item { + color: var(--color-muted-foreground); + font-size: 0.8125rem; + line-height: 1.45; + } + + .v2-agent-plan-current { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .v2-agent-plan-explanation { + border-top: 1px solid color-mix(in oklab, var(--color-border) 58%, transparent); + padding: 0.5rem 0.75rem 0; + } + + .v2-agent-plan-list { + display: flex; + min-width: 0; + flex-direction: column; + gap: 0.35rem; + padding: 0.5rem 0.75rem 0.7rem; + } + + .v2-agent-plan-item { + display: flex; + min-width: 0; + align-items: flex-start; + gap: 0.45rem; + } + + .v2-agent-plan-item svg { + margin-top: 0.1rem; + height: 0.875rem; + width: 0.875rem; + flex: 0 0 auto; + } + + .v2-agent-plan-item span { + min-width: 0; + overflow-wrap: anywhere; + } + + .v2-agent-plan-item[data-status="in_progress"] { + color: var(--color-foreground); + } + + .v2-agent-plan-item[data-status="completed"] { + color: color-mix(in oklab, var(--color-muted-foreground) 78%, var(--color-foreground)); + } + + @media (max-width: 38rem) { + .v2-agent-plan-region { + padding: 0 1rem 0.5rem; + } + + .v2-agent-plan-panel:not([open]) .v2-agent-plan-summary { + padding-block: 0.45rem; + } + } }