Skip to content

Commit 9db052b

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 ec0249b commit 9db052b

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'
@@ -74,7 +76,17 @@ interface StoppedSegment {
7476
type: 'stopped'
7577
}
7678

77-
type MessageSegment = TextSegment | AgentGroupSegment | OptionsSegment | StoppedSegment
79+
interface PlanSegment {
80+
type: 'plan'
81+
items: AgentPlanItem[]
82+
}
83+
84+
type MessageSegment =
85+
| TextSegment
86+
| AgentGroupSegment
87+
| OptionsSegment
88+
| StoppedSegment
89+
| PlanSegment
7890

7991
function getAgentGroupActivityKey(items: AgentGroupItem[]): string {
8092
return items
@@ -115,6 +127,9 @@ function getVisibleStreamActivityKey(segments: MessageSegment[]): string {
115127
return `options:${segment.items.map((item) => `${item.id}:${item.label.length}`).join(',')}`
116128
}
117129
if (segment.type === 'stopped') return 'stopped'
130+
if (segment.type === 'plan') {
131+
return `plan:${segment.items.map((item) => `${item.status}:${item.step.length}`).join(',')}`
132+
}
118133
return [
119134
'agent',
120135
segment.id,
@@ -462,6 +477,12 @@ function parseBlocksWithSpanTree(blocks: ContentBlock[]): MessageSegment[] {
462477
continue
463478
}
464479

480+
if (block.type === 'plan') {
481+
if (!block.planItems?.length) continue
482+
segments.push({ type: 'plan', items: block.planItems })
483+
continue
484+
}
485+
465486
if (block.type === 'subagent_end') {
466487
if (block.spanId) {
467488
const g = groupsBySpanId.get(block.spanId)
@@ -713,6 +734,13 @@ function parseBlocksLegacy(blocks: ContentBlock[]): MessageSegment[] {
713734
continue
714735
}
715736

737+
if (block.type === 'plan') {
738+
if (!block.planItems?.length) continue
739+
flushLanes()
740+
segments.push({ type: 'plan', items: block.planItems })
741+
continue
742+
}
743+
716744
if (block.type === 'subagent_end') {
717745
if (block.parentToolCallId) {
718746
for (const [key, g] of groupsByKey) {
@@ -1046,6 +1074,8 @@ function MessageContentInner({
10461074
<Options items={segment.items} onSelect={onOptionSelect} />
10471075
</div>
10481076
)
1077+
case 'plan':
1078+
return <PlanChecklist key={`plan-${i}`} items={segment.items} />
10491079
// The stopped row renders in the tail region below, in the
10501080
// shimmer's place — a stop while the shimmer is visible must read
10511081
// 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
@@ -116,12 +116,15 @@ export const ContentBlockType = {
116116
subagent_thinking: 'subagent_thinking',
117117
options: 'options',
118118
stopped: 'stopped',
119+
plan: 'plan',
119120
} as const
120121
export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType]
121122

122123
export interface ContentBlock {
123124
type: ContentBlockType
124125
content?: string
126+
/** The agent's plan checklist (plan blocks only); whole-list, latest wins. */
127+
planItems?: import('@/lib/mothership/request/types').AgentPlanItem[]
125128
subagent?: string
126129
/** Orchestrator-chosen display name for a `subagent` start block (shown instead of the generic agent label). */
127130
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)