feat: redesign live-note sidebar with Objective / Last run / Details tabs

Flatten the panel to match the rest of the app's design language. Splits
the surface into three tabs:

- Objective: full-height markdown render of the objective, in-tab plain
  monospace editor (no card-in-card chrome).
- Last run: fetches via `runs:fetch` and shows the agent's full
  transcript — summary at top, then a compact chat of user/assistant
  turns with collapsible tool calls (Parameters/Result).
- Details: triggers (single cron + windows + events with display/edit
  toggle) and collapsed Advanced (model/provider/danger zone) ending in
  "Convert to static note →".

Adds a 2-column status strip (Last run · Triggers) above the tabs and a
context-aware footer. Adopts the app's signature `uppercase tracking-wider
text-muted-foreground` label style; drops nested bordered cards.

New helper `lib/run-to-conversation.ts` converts `Run.log` events into
ConversationItems for read-only playback — adapted from App.tsx's live
converter, trimmed for static history (no streaming/permission flows,
skips lifecycle and system/tool-role messages).

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Ramnique Singh 2026-05-11 15:33:19 +05:30
parent e3d2a0988b
commit ab23cb4543
2 changed files with 801 additions and 324 deletions

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,138 @@
import type z from 'zod'
import type { RunEvent } from '@x/shared/dist/runs.js'
import {
type ChatMessage,
type ConversationItem,
type ToolCall,
normalizeToolInput,
} from './chat-conversation'
type RunLog = z.infer<typeof RunEvent>[]
/**
* Convert a closed Run.log into a flat list of ConversationItems suitable
* for read-only playback. Adapted from App.tsx's live-streaming converter
* (lines ~1731-1843) but trimmed for static history:
*
* - drops llm-stream-event (reasoning lands in the final message)
* - drops run-processing-* / start / spawn-subflow (lifecycle, not content)
* - drops system/tool-role messages (only user + assistant surface)
* - drops permission/ask-human (live-only flows)
*/
export function runLogToConversation(log: RunLog): ConversationItem[] {
const items: ConversationItem[] = []
const toolCallMap = new Map<string, ToolCall>()
for (const event of log) {
switch (event.type) {
case 'message': {
const msg = event.message
if (msg.role !== 'user' && msg.role !== 'assistant') break
let textContent = ''
let msgAttachments: ChatMessage['attachments']
if (typeof msg.content === 'string') {
textContent = msg.content
} else if (Array.isArray(msg.content)) {
const parts = msg.content as Array<{
type: string
text?: string
path?: string
filename?: string
mimeType?: string
size?: number
toolCallId?: string
toolName?: string
arguments?: unknown
}>
textContent = parts
.filter((p) => p.type === 'text')
.map((p) => p.text ?? '')
.join('')
const attachmentParts = parts.filter((p) => p.type === 'attachment' && p.path)
if (attachmentParts.length > 0) {
msgAttachments = attachmentParts.map((p) => ({
path: p.path!,
filename: p.filename || p.path!.split('/').pop() || p.path!,
mimeType: p.mimeType || 'application/octet-stream',
size: p.size,
}))
}
if (msg.role === 'assistant') {
for (const part of parts) {
if (part.type === 'tool-call' && part.toolCallId && part.toolName) {
const toolCall: ToolCall = {
id: part.toolCallId,
name: part.toolName,
input: normalizeToolInput(part.arguments as ToolCall['input']),
status: 'pending',
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
}
toolCallMap.set(toolCall.id, toolCall)
items.push(toolCall)
}
}
}
}
if (textContent || msgAttachments) {
items.push({
id: event.messageId,
role: msg.role,
content: textContent,
attachments: msgAttachments,
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
})
}
break
}
case 'tool-invocation': {
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
if (existing) {
existing.input = normalizeToolInput(event.input)
existing.status = 'running'
} else {
const toolCall: ToolCall = {
id: event.toolCallId || `tool-${items.length}`,
name: event.toolName,
input: normalizeToolInput(event.input),
status: 'running',
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
}
if (event.toolCallId) toolCallMap.set(toolCall.id, toolCall)
items.push(toolCall)
}
break
}
case 'tool-result': {
const existing = event.toolCallId ? toolCallMap.get(event.toolCallId) : null
if (existing) {
existing.result = event.result
existing.status = 'completed'
}
break
}
case 'error': {
items.push({
id: `error-${items.length}`,
kind: 'error',
message: event.error,
timestamp: event.ts ? new Date(event.ts).getTime() : Date.now(),
})
break
}
// Everything else is lifecycle/streaming — not part of the rendered transcript.
default:
break
}
}
return items
}