Skip to content

Commit ffbd0bb

Browse files
committed
Render the agent's live plan as a checklist card
Handles the new plan envelope from the worker: the stream validator admits it, the handler upserts one plan content block in place (latest list wins), and persistence/display carry planItems through to a PlanChecklist card in the transcript. update_plan joins the hidden tool names since the card replaces its row. Claude-Session: https://claude.ai/code/session_01CgaxNAaeD3taGdghbXn17w
1 parent f7893d8 commit ffbd0bb

13 files changed

Lines changed: 204 additions & 4 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { PlanChecklist } from './plan-checklist'
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import { Check, cn } from '@sim/emcn'
2+
import type { AgentPlanItem } from '@/lib/mothership/request/types'
3+
4+
interface PlanChecklistProps {
5+
items: AgentPlanItem[]
6+
}
7+
8+
/**
9+
* The agent's live plan: one card, updated in place as the worker's
10+
* update_plan tool replaces the list. Read-only — progress display, not an
11+
* input surface.
12+
*/
13+
export function PlanChecklist({ items }: PlanChecklistProps) {
14+
if (items.length === 0) return null
15+
16+
return (
17+
<div className='my-1 rounded-[10px] border border-[var(--border)] bg-[var(--bg)] px-3 py-2'>
18+
<div className='flex flex-col gap-1'>
19+
{items.map((item, index) => (
20+
<div key={`${index}-${item.step}`} className='flex items-start gap-2'>
21+
<span
22+
className={cn(
23+
'mt-[3px] flex size-[14px] shrink-0 items-center justify-center rounded-full border',
24+
item.status === 'done' && 'border-[var(--brand-accent)] bg-[var(--brand-accent)]',
25+
item.status === 'active' && 'border-[var(--text-primary)]',
26+
item.status === 'pending' && 'border-[var(--border-1)]'
27+
)}
28+
>
29+
{item.status === 'done' && <Check className='size-[9px] text-[var(--bg)]' />}
30+
{item.status === 'active' && (
31+
<span className='size-[6px] animate-pulse rounded-full bg-[var(--text-primary)]' />
32+
)}
33+
</span>
34+
<span
35+
className={cn(
36+
'font-[family-name:var(--font-inter)] text-[13px] leading-[19px]',
37+
item.status === 'done' && 'text-[var(--text-tertiary)] line-through',
38+
item.status === 'active' && 'font-medium text-[var(--text-primary)]',
39+
item.status === 'pending' && 'text-[var(--text-secondary)]'
40+
)}
41+
>
42+
{item.step}
43+
</span>
44+
</div>
45+
))}
46+
</div>
47+
</div>
48+
)
49+
}

apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import {
1212
} from 'react'
1313
import { cn } from '@sim/emcn'
1414
import { PrepareFileEdit, Read as ReadTool } from '@/lib/mothership/generated/tool-catalog-v1'
15+
import type { AgentPlanItem } from '@/lib/mothership/request/types'
1516
import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools'
1617
import { resolveToolDisplay } from '@/lib/mothership/tools/client/store-utils'
1718
import { ClientToolCallState } from '@/lib/mothership/tools/client/tool-call-state'
@@ -21,6 +22,7 @@ import {
2122
humanizeToolName,
2223
} from '@/lib/mothership/tools/tool-display'
2324
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
25+
import { PlanChecklist } from '@/app/workspace/[workspaceId]/home/components/message-content/components/plan-checklist'
2426
import type { CredentialSubmissionPayload } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
2527
import { useCustomBlockOverlayVersion } from '@/blocks/custom/client-overlay'
2628
import type { ContentBlock, OptionItem, ToolCallData } from '../../types'
@@ -67,7 +69,17 @@ interface StoppedSegment {
6769
type: 'stopped'
6870
}
6971

70-
type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment
72+
interface PlanSegment {
73+
type: 'plan'
74+
items: AgentPlanItem[]
75+
}
76+
77+
type MessageSegment =
78+
| TextSegment
79+
| AgentGroupSegment
80+
| OptionsSegment
81+
| StoppedSegment
82+
| PlanSegment
7183

7284
function getAgentGroupActivityKey(items: AgentGroupItem[]): string {
7385
return items
@@ -108,6 +120,9 @@ function getVisibleStreamActivityKey(segments: MessageSegment[]): string {
108120
return `options:${segment.items.map((item) => `${item.id}:${item.label.length}`).join(',')}`
109121
}
110122
if (segment.type === 'stopped') return 'stopped'
123+
if (segment.type === 'plan') {
124+
return `plan:${segment.items.map((item) => `${item.status}:${item.step.length}`).join(',')}`
125+
}
111126
return [
112127
'agent',
113128
segment.id,
@@ -455,6 +470,12 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
455470
continue
456471
}
457472

473+
if (block.type === 'plan') {
474+
if (!block.planItems?.length) continue
475+
segments.push({ type: 'plan', items: block.planItems })
476+
continue
477+
}
478+
458479
if (block.type === 'subagent_end') {
459480
if (block.spanId) {
460481
const g = groupsBySpanId.get(block.spanId)
@@ -706,6 +727,13 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
706727
continue
707728
}
708729

730+
if (block.type === 'plan') {
731+
if (!block.planItems?.length) continue
732+
flushLanes()
733+
segments.push({ type: 'plan', items: block.planItems })
734+
continue
735+
}
736+
709737
if (block.type === 'subagent_end') {
710738
if (block.parentToolCallId) {
711739
for (const [key, g] of groupsByKey) {
@@ -1016,6 +1044,8 @@ function MessageContentInner({
10161044
<Options items={segment.items} onSelect={onOptionSelect} />
10171045
</div>
10181046
)
1047+
case 'plan':
1048+
return <PlanChecklist key={`plan-${i}`} items={segment.items} />
10191049
// The stopped row renders in the tail region below, in the
10201050
// shimmer's place — a stop while the shimmer is visible must read
10211051
// as an in-place replacement, not the shimmer vanishing from the

apps/sim/app/workspace/[workspaceId]/home/types.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,12 +107,15 @@ export const ContentBlockType = {
107107
subagent_thinking: 'subagent_thinking',
108108
options: 'options',
109109
stopped: 'stopped',
110+
plan: 'plan',
110111
} as const
111112
export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType]
112113

113114
export interface ContentBlock {
114115
type: ContentBlockType
115116
content?: string
117+
/** The agent's plan checklist (plan blocks only); whole-list, latest wins. */
118+
planItems?: import('@/lib/mothership/request/types').AgentPlanItem[]
116119
subagent?: string
117120
/** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */
118121
subagentName?: string

apps/sim/lib/mothership/chat/display-message.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,9 @@ function toDisplayBlock(block: PersistedContentBlock): ContentBlock | undefined
6666

6767
function toDisplayBlockBody(block: PersistedContentBlock): ContentBlock | undefined {
6868
switch (block.type) {
69+
case 'plan':
70+
if (!block.planItems?.length) return undefined
71+
return { type: ContentBlockType.plan, planItems: block.planItems }
6972
case MothershipStreamV1EventType.text:
7073
if (block.lane === 'subagent') {
7174
if (block.channel === 'thinking') {

apps/sim/lib/mothership/chat/persisted-message.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ interface PersistedToolCall {
3838
}
3939

4040
export interface PersistedContentBlock {
41-
type: MothershipStreamV1EventType
41+
type: MothershipStreamV1EventType | 'plan'
4242
lane?: MothershipStreamV1StreamScope['lane']
4343
/**
4444
* Subagent name on lane text blocks. The span-tree parser needs a name to
@@ -56,6 +56,8 @@ export interface PersistedContentBlock {
5656
/** Orchestrator-chosen display name on a subagent start block. */
5757
name?: string
5858
toolCall?: PersistedToolCall
59+
/** The agent's plan checklist (plan blocks only). */
60+
planItems?: import('@/lib/mothership/request/types').AgentPlanItem[]
5961
timestamp?: number
6062
endedAt?: number
6163
parentToolCallId?: string
@@ -227,6 +229,8 @@ function mapContentBlock(block: ContentBlock): PersistedContentBlock {
227229

228230
function mapContentBlockBody(block: ContentBlock): PersistedContentBlock {
229231
switch (block.type) {
232+
case 'plan':
233+
return { type: 'plan', ...(block.planItems ? { planItems: block.planItems } : {}) }
230234
case 'text':
231235
return {
232236
type: MothershipStreamV1EventType.text,

apps/sim/lib/mothership/generated/protocol.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,7 @@ export interface ExecuteMessage {
137137
*/
138138
export interface StreamEnvelope {
139139
v: 1;
140-
type: "session" | "text" | "tool" | "span" | "run" | "resource" | "error" | "complete";
140+
type: "session" | "text" | "tool" | "span" | "run" | "resource" | "plan" | "error" | "complete";
141141
seq: number;
142142
/** ISO timestamp. */
143143
ts: string;
@@ -149,6 +149,12 @@ export interface StreamEnvelope {
149149
payload: Record<string, unknown>;
150150
}
151151

152+
/** One step of the agent's visible plan (the update_plan tool's whole-list payload). */
153+
export interface PlanItem {
154+
step: string;
155+
status: "pending" | "active" | "done";
156+
}
157+
152158
/** One subagent lane: keyed by the delegating tool call; agentId/spanId identify the lane. */
153159
export interface StreamScope {
154160
lane: "subagent";

apps/sim/lib/mothership/request/handlers/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { MothershipStreamV1EventType } from '@/lib/mothership/generated/mothersh
33
import type { StreamEvent, StreamingContext } from '@/lib/mothership/request/types'
44
import { handleCompleteEvent } from './complete'
55
import { handleErrorEvent } from './error'
6+
import { handlePlanEvent } from './plan'
67
import { handleResourceEvent } from './resource'
78
import { handleRunEvent } from './run'
89
import { handleSessionEvent } from './session'
@@ -25,6 +26,7 @@ export const sseHandlers: Record<string, StreamHandler> = {
2526
[MothershipStreamV1EventType.complete]: handleCompleteEvent,
2627
[MothershipStreamV1EventType.error]: handleErrorEvent,
2728
[MothershipStreamV1EventType.span]: handleSpanEvent,
29+
plan: handlePlanEvent,
2830
}
2931

3032
export const subAgentHandlers: Record<string, StreamHandler> = {
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { describe, expect, it } from 'vitest'
5+
import { handlePlanEvent } from '@/lib/mothership/request/handlers/plan'
6+
import type { StreamEvent, StreamingContext } from '@/lib/mothership/request/types'
7+
8+
function contextWith(blocks: StreamingContext['contentBlocks']): StreamingContext {
9+
return { contentBlocks: blocks } as StreamingContext
10+
}
11+
12+
const planEvent = (items: unknown): StreamEvent =>
13+
({ type: 'plan', payload: { items } }) as unknown as StreamEvent
14+
15+
describe('handlePlanEvent', () => {
16+
it('creates one plan block and updates it in place on later events', () => {
17+
const context = contextWith([])
18+
handlePlanEvent(planEvent([{ step: 'a', status: 'active' }]), context, {} as never, {} as never)
19+
expect(context.contentBlocks).toHaveLength(1)
20+
expect(context.contentBlocks[0].planItems).toEqual([{ step: 'a', status: 'active' }])
21+
22+
handlePlanEvent(
23+
planEvent([
24+
{ step: 'a', status: 'done' },
25+
{ step: 'b', status: 'active' },
26+
]),
27+
context,
28+
{} as never,
29+
{} as never
30+
)
31+
expect(context.contentBlocks).toHaveLength(1)
32+
expect(context.contentBlocks[0].planItems).toHaveLength(2)
33+
expect(context.contentBlocks[0].planItems?.[0].status).toBe('done')
34+
})
35+
36+
it('ignores malformed payloads', () => {
37+
const context = contextWith([])
38+
handlePlanEvent(planEvent(undefined), context, {} as never, {} as never)
39+
handlePlanEvent(planEvent([]), context, {} as never, {} as never)
40+
expect(context.contentBlocks).toHaveLength(0)
41+
})
42+
})
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { AgentPlanItem, StreamEvent, StreamingContext } from '@/lib/mothership/request/types'
2+
import { ContentBlockType } from '@/lib/mothership/request/types'
3+
import type { StreamHandler } from './types'
4+
import { addContentBlock } from './types'
5+
6+
/**
7+
* The agent's visible plan: whole-list replacement semantics (the worker's
8+
* update_plan tool sends the complete list every call). One plan block per
9+
* message, updated in place so the checklist renders live instead of stacking
10+
* a card per update.
11+
*/
12+
export const handlePlanEvent: StreamHandler = (event: StreamEvent, context: StreamingContext) => {
13+
const items = (event.payload as { items?: AgentPlanItem[] } | undefined)?.items
14+
if (!Array.isArray(items) || items.length === 0) return
15+
16+
for (let i = context.contentBlocks.length - 1; i >= 0; i--) {
17+
const block = context.contentBlocks[i]
18+
if (block.type === ContentBlockType.plan) {
19+
block.planItems = items
20+
block.endedAt = Date.now()
21+
return
22+
}
23+
}
24+
addContentBlock(context, {
25+
type: ContentBlockType.plan,
26+
planItems: items,
27+
})
28+
}

0 commit comments

Comments
 (0)