Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
0648f24
feat(harness): queue messages and notifications during streaming (MOT…
andersonleal Jul 7, 2026
9349175
fix(harness): deliver plain react reactions to the owner session too
andersonleal Jul 7, 2026
5995eec
feat(console): queue messages sent while a turn streams (MOT-3837)
andersonleal Jul 7, 2026
811fba2
feat(console): show and inspect a session's registered triggers
andersonleal Jul 7, 2026
a99e737
fix(console): stop inventing a model for discovered sub-agent sessions
andersonleal Jul 7, 2026
2671d2f
merge feat/message-queue into worktree base
andersonleal Jul 8, 2026
f7a9b35
feat(harness,console): push queued-message events, drop the status po…
andersonleal Jul 8, 2026
9ae27e6
feat(console): collapse the triggers strip, label stage dependencies
andersonleal Jul 8, 2026
7c3474e
feat(console): surface the reaction spec in the triggers UI
andersonleal Jul 8, 2026
afe055a
feat(prompts,console): guard against silently-stalled reactive pipelines
andersonleal Jul 8, 2026
629e98d
fix(harness,console): stop rendering react-fired tasks as user messages
andersonleal Jul 8, 2026
f9fae14
Merge origin/main into feat/queued-message-push
andersonleal Jul 8, 2026
d5dc67e
chore(console): fix import order after merge resolution
andersonleal Jul 8, 2026
d563c7f
feat(console): collapse the reaction task's event blob into structure…
andersonleal Jul 8, 2026
194d6b5
fix(console): stop rendering double-encoded JSON in function-call panes
andersonleal Jul 8, 2026
ae664d7
feat(console): clamp, label, and copy affordances for function-call p…
andersonleal Jul 8, 2026
0351481
fix(console): blank terminal tab on batch engine::functions::info
andersonleal Jul 8, 2026
8184e18
feat(console): rich terminal view for engine::triggers::info
andersonleal Jul 8, 2026
f8d2916
fix(console): render double-encoded engine payloads in the structured…
andersonleal Jul 8, 2026
125eaa6
feat(console): redesign engine::register_trigger as a when→then rule
andersonleal Jul 8, 2026
a9f7705
test(console): end-to-end coerce chain for a double-encoded register_…
andersonleal Jul 8, 2026
6da81bd
Merge origin/main into feat/queued-message-push
andersonleal Jul 8, 2026
5181cdf
feat(console): clear-all + DAG flow view for the triggers strip
andersonleal Jul 8, 2026
49ff2e5
feat(harness,console): press ↑ to edit the last queued message
andersonleal Jul 8, 2026
2b11408
feat(console): ↑/↓ cycle through queued messages to edit
andersonleal Jul 8, 2026
6a3f076
feat(harness,console): edit queued messages in place, preserving posi…
andersonleal Jul 9, 2026
abc0394
feat(console): pull the edited queued message out of the strip
andersonleal Jul 9, 2026
d555a3d
fix(console): surface the real error when a queue edit/remove fails
andersonleal Jul 9, 2026
fe53404
chore: sync provider anthropic/openai Cargo.lock to llm-router 1.0.5
andersonleal Jul 9, 2026
30cfcbc
revert(tech-specs): restore harness.md to main
andersonleal Jul 9, 2026
8550256
fix(harness): array literal in unqueue test (clippy useless_vec)
andersonleal Jul 9, 2026
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
373 changes: 363 additions & 10 deletions console/web/src/components/chat/ChatView.tsx

Large diffs are not rendered by default.

120 changes: 108 additions & 12 deletions console/web/src/components/chat/Composer.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { LexicalEditor } from 'lexical'
import { ArrowUp, Square } from 'lucide-react'
import { useCallback, useRef, useState } from 'react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { PermissionModePicker } from '@/components/permissions/PermissionModePicker'
import { Button } from '@/components/ui/Button'
import type { PermissionMode } from '@/lib/backend/approval-settings'
import type { FunctionEntry } from '@/lib/functions'
import { cn } from '@/lib/utils'
Expand All @@ -18,6 +19,7 @@ import { DirectoryPicker, type WorktreePickerOptions } from './DirectoryPicker'
import { LexicalShell } from './LexicalShell'
import { ModelPicker } from './ModelPicker'
import { ModePicker } from './ModePicker'
import { nextHistoryTarget } from './queue-history'

export interface ComposerSubmitPayload {
text: string
Expand Down Expand Up @@ -67,6 +69,13 @@ interface ComposerProps {
onSubmit: (payload: ComposerSubmitPayload) => void
onStop?: () => void
isStreaming?: boolean
/**
* When true, the editor stays unlocked while streaming: a submit queues the
* message on the running turn (delivered when the stream ends) and the stop
* button stays available. When false (mock backends), streaming locks the
* editor as before.
*/
queueWhileStreaming?: boolean
/** External lock (e.g. harness not installed). Editor + send disabled. */
blocked?: boolean
/** Placeholder while `blocked` is true. */
Expand All @@ -76,6 +85,23 @@ interface ComposerProps {
/** Initial attachment chips (applied once on mount). */
initialAttachments?: Attachment[]
functionEntries?: FunctionEntry[]
/**
* Queued messages the composer can browse+edit with ↑/↓, oldest→newest.
* Non-destructive: browsing just loads a message; the change is committed on
* submit (see `onEditQueued`). When set alongside `onEditQueued`, ↑/↓ cycle.
*/
queuedForEdit?: Array<{ id: string; text: string; attachments: Attachment[] }>
/**
* Submit while browsing a queued message: save the edit in place (preserving
* its queue position) with the new text + attachments, or remove it when the
* payload is `null` (submitting an emptied composer). Given its id.
*/
onEditQueued?: (
id: string,
payload: { text: string; attachments: Attachment[] } | null,
) => void
/** Which queued message is being browsed (`null` = live draft), for highlight. */
onBrowseChange?: (id: string | null) => void
}

export function Composer({
Expand All @@ -101,29 +127,83 @@ export function Composer({
onSubmit,
onStop,
isStreaming,
queueWhileStreaming,
blocked,
blockedPlaceholder = 'chat unavailable…',
initialContent,
initialAttachments,
functionEntries,
queuedForEdit,
onEditQueued,
onBrowseChange,
}: ComposerProps) {
const [attachments, setAttachments] = useState<Attachment[]>(
initialAttachments ?? [],
)
const [clearToken, setClearToken] = useState(0)
const textRef = useRef('')

const inputDisabled = isStreaming || blocked
// ↑/↓ browse the queued messages for editing. `browseId` is the message the
// editor currently holds (null = a live draft). Navigation is non-destructive
// — the message is removed from the queue only when the edit is submitted.
const [browseId, setBrowseId] = useState<string | null>(null)
const setBrowse = useCallback(
(id: string | null) => {
setBrowseId(id)
onBrowseChange?.(id)
},
[onBrowseChange],
)

// Drop the browse cursor if the message it pointed at left the queue.
useEffect(() => {
if (browseId !== null && !queuedForEdit?.some((m) => m.id === browseId)) {
setBrowse(null)
}
}, [queuedForEdit, browseId, setBrowse])

// Apply the pure ↑/↓ decision (see queue-history): load the chosen message
// (returning its text for LexicalShell to insert) or return null to let the
// arrow move the caret.
const handleHistoryNav = useCallback(
(direction: 'up' | 'down'): string | null => {
const result = nextHistoryTarget(
queuedForEdit ?? [],
browseId,
textRef.current,
direction,
)
if (result.kind === 'noop') return null
setBrowse(result.target.id)
setAttachments(result.target.attachments)
textRef.current = result.target.text
return result.target.text
},
[queuedForEdit, browseId, setBrowse],
)

const inputDisabled = blocked || (isStreaming && !queueWhileStreaming)
// Turn options are frozen on the running turn; changing them mid-stream
// would silently not apply, so the pickers stay locked while streaming.
const optionsDisabled = isStreaming || blocked

const handleSubmit = useCallback(() => {
if (inputDisabled) return
const text = textRef.current.trim()
if (!text && attachments.length === 0) return
onSubmit({ text, attachments })
const empty = !text && attachments.length === 0
// Editing a queued message: save it in place (or remove it when emptied)
// instead of sending a new message. A blank live composer is a no-op.
if (browseId !== null) {
onEditQueued?.(browseId, empty ? null : { text, attachments })
setBrowse(null)
} else {
if (empty) return
onSubmit({ text, attachments })
}
textRef.current = ''
setAttachments([])
setClearToken((t) => t + 1)
}, [inputDisabled, attachments, onSubmit])
}, [inputDisabled, attachments, onSubmit, browseId, onEditQueued, setBrowse])

const handleAttach = useCallback((next: Attachment[]) => {
setAttachments((current) => [...current, ...next])
Expand Down Expand Up @@ -155,16 +235,19 @@ export function Composer({
onSubmit={handleSubmit}
clearToken={clearToken}
placeholder={
isStreaming
? 'streaming response…'
: blocked
? blockedPlaceholder
blocked
? blockedPlaceholder
: isStreaming
? queueWhileStreaming
? 'queue a message…'
: 'streaming response…'
: 'send a message…'
}
disabled={inputDisabled}
initialContent={initialContent}
functionEntries={functionEntries}
workingDir={workingDir}
onHistoryNav={onEditQueued ? handleHistoryNav : undefined}
/>
</div>

Expand All @@ -175,7 +258,7 @@ export function Composer({
value={workingDir ?? null}
onChange={onWorkingDirChange}
locked={workingDirLocked}
disabled={inputDisabled}
disabled={optionsDisabled}
externalError={workingDirError}
defaultDir={defaultWorkingDir}
worktrees={worktreePicker}
Expand All @@ -185,7 +268,7 @@ export function Composer({
<PermissionModePicker
value={permissionMode}
onChange={onPermissionModeChange}
disabled={inputDisabled || !!permissionModeLoading}
disabled={optionsDisabled || !!permissionModeLoading}
/>
) : null}
<ModelPicker
Expand All @@ -194,11 +277,24 @@ export function Composer({
thinkingLevel={thinkingLevel}
onChange={onModelChange}
onThinkingLevelChange={onThinkingLevelChange}
disabled={inputDisabled}
disabled={optionsDisabled}
loading={catalogLoading}
/>
<div className="flex-1 min-w-0" />
<AttachmentButton onAttach={handleAttach} disabled={inputDisabled} />
{isStreaming && queueWhileStreaming ? (
<Button
type="button"
variant="primary"
size="sm"
onClick={handleSubmit}
disabled={blocked}
aria-label="queue message"
>
send
<span aria-hidden>→</span>
</Button>
) : null}
{isStreaming ? (
<button
type="button"
Expand Down
65 changes: 65 additions & 0 deletions console/web/src/components/chat/LexicalShell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,13 @@ import { HistoryPlugin } from '@lexical/react/LexicalHistoryPlugin'
import { OnChangePlugin } from '@lexical/react/LexicalOnChangePlugin'
import { PlainTextPlugin } from '@lexical/react/LexicalPlainTextPlugin'
import {
$createParagraphNode,
$createTextNode,
$getRoot,
CLEAR_EDITOR_COMMAND,
COMMAND_PRIORITY_LOW,
KEY_ARROW_DOWN_COMMAND,
KEY_ARROW_UP_COMMAND,
KEY_ENTER_COMMAND,
type LexicalEditor,
} from 'lexical'
Expand Down Expand Up @@ -96,6 +100,63 @@ function SubmitOnEnterPlugin({
return null
}

/** Replace the whole editor with `text` (empty string clears it), caret at end. */
function loadEditorText(editor: LexicalEditor, text: string) {
editor.update(() => {
const root = $getRoot()
root.clear()
const paragraph = $createParagraphNode()
if (text.length > 0) paragraph.append($createTextNode(text))
root.append(paragraph)
paragraph.selectEnd()
})
}

/**
* Up / Down browse a message history (the queued messages — "↑ to edit,
* ↓ to cycle"). `onNav(direction)` owns the cursor and the pristine-gate
* (it only navigates when the editor hasn't been edited, so in-progress text
* and caret moves within a real edit are never clobbered); it returns the
* text to load ('' clears back to a live draft) or null to let the arrow do
* its normal caret move. Defers to an open typeahead (which owns Up/Down for
* option navigation).
*/
function HistoryNavPlugin({
onNav,
menuOpenRef,
}: {
onNav?: (direction: 'up' | 'down') => string | null
menuOpenRef: React.MutableRefObject<boolean>
}) {
const [editor] = useLexicalComposerContext()
useEffect(() => {
if (!onNav) return
const handler = (direction: 'up' | 'down') => (event: KeyboardEvent) => {
if (menuOpenRef.current) return false
const text = onNav(direction)
if (text === null) return false
event?.preventDefault()
loadEditorText(editor, text)
return true
}
const offUp = editor.registerCommand(
KEY_ARROW_UP_COMMAND,
handler('up'),
COMMAND_PRIORITY_LOW,
)
const offDown = editor.registerCommand(
KEY_ARROW_DOWN_COMMAND,
handler('down'),
COMMAND_PRIORITY_LOW,
)
return () => {
offUp()
offDown()
}
}, [editor, onNav, menuOpenRef])
return null
}

/**
* Imperatively expose a "clear" so the parent can wipe the editor after submit.
* We use Lexical's CLEAR_EDITOR_COMMAND, which the ClearEditorPlugin handles.
Expand Down Expand Up @@ -131,6 +192,8 @@ interface LexicalShellExtendedProps extends LexicalShellProps {
functionEntries?: FunctionEntry[]
/** Enables the `#` file-mention typeahead, scoped to this directory. */
workingDir?: string | null
/** Up/Down browse a message history: return text to load ('' clears), or null. */
onHistoryNav?: (direction: 'up' | 'down') => string | null
}

export function LexicalShell({
Expand All @@ -142,6 +205,7 @@ export function LexicalShell({
initialContent,
functionEntries,
workingDir,
onHistoryNav,
}: LexicalShellExtendedProps) {
/* LexicalComposer reads initialConfig once on mount; lock it behind useMemo
so the initializer callback identity doesn't trigger a remount on re-render. */
Expand Down Expand Up @@ -179,6 +243,7 @@ export function LexicalShell({
<ClearOnDemandPlugin token={clearToken} />
<ChangePlugin onChange={onChange} />
<SubmitOnEnterPlugin onSubmit={onSubmit} menuOpenRef={menuOpenRef} />
<HistoryNavPlugin onNav={onHistoryNav} menuOpenRef={menuOpenRef} />
<EditablePlugin disabled={disabled} />
<MentionsPlugin
menuOpenRef={menuOpenRef}
Expand Down
63 changes: 63 additions & 0 deletions console/web/src/components/chat/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { FilesystemAccessAction } from '@/components/permissions/Filesystem
import { Caret } from '@/components/ui/Caret'
import { Prompt } from '@/components/ui/Prompt'
import { Markdown } from '@/lib/markdown'
import { JsonHighlight } from '@/lib/syntax'
import { cn } from '@/lib/utils'
import type {
AssistantMessage as AssistantMessageType,
Expand Down Expand Up @@ -46,6 +47,8 @@ export function Message({
case 'user':
return message.notification ? (
<NotificationMessage message={message} />
) : message.reaction ? (
<ReactionTaskMessage message={message} />
) : (
<UserMessage message={message} />
)
Expand Down Expand Up @@ -158,6 +161,66 @@ function NotificationMessage({ message }: { message: UserMessageType }) {
)
}

/**
* The one-line hint for a reaction's collapsed payload: the firing session
* and status for an event, the predecessor keys for a join's inputs.
*/
function reactionEventHint(event: {
label: 'event' | 'inputs'
json: string
}): string | null {
try {
const v = JSON.parse(event.json) as Record<string, unknown>
if (v === null || typeof v !== 'object') return null
if (event.label === 'inputs') {
const keys = Object.keys(v)
return keys.length > 0 ? keys.join(' + ') : null
}
const parts = [v.session_id, v.status].filter(
(x): x is string => typeof x === 'string',
)
return parts.length > 0 ? parts.join(' · ') : null
} catch {
return null
}
}

/**
* A react-fired task delivered into this session (`harness::react`): the
* turn's input, but machine-sent — labeled "trigger" and left-aligned so it
* never reads as something the human typed. The appended firing event (or
* join inputs) collapses to a summary line, expandable to highlighted JSON.
*/
function ReactionTaskMessage({ message }: { message: UserMessageType }) {
const event = message.reactionEvent
const hint = event ? reactionEventHint(event) : null
return (
<article className="flex flex-col items-start gap-2">
<header className="font-mono text-[11px] uppercase tracking-[0.06em] text-ink-ghost">
<Prompt symbol="⚡">trigger · reaction task</Prompt>
</header>
<div className="max-w-[80%] border-l border-rule pl-4 pr-1 py-1 break-words text-ink-faint">
<Markdown>{message.content}</Markdown>
{event ? (
<details className="mt-2 group">
<summary className="cursor-pointer list-none select-none font-mono text-[11px] uppercase tracking-[0.06em] text-ink-ghost group-hover:text-ink transition-colors">
{event.label === 'inputs' ? 'join inputs' : 'firing event'}
{hint ? ` · ${hint}` : ''}
<span className="normal-case tracking-normal text-[10px]">
{' '}
· show json
</span>
</summary>
<div className="mt-1 max-h-64 overflow-auto border border-rule-2">
<JsonHighlight code={event.json} wrap />
</div>
</details>
) : null}
</div>
</article>
)
}

function UserMessage({ message }: { message: UserMessageType }) {
return (
<article className="flex flex-col items-end gap-2">
Expand Down
Loading
Loading