Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion console/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ async fn end_to_end_http_and_ws_proxy() {
assert!(
body.contains("id=\"root\""),
"GET / did not include the SPA mount point; body starts with: {}",
&body.chars().take(200).collect::<String>(),
body.chars().take(200).collect::<String>(),
);

// 2. Pluck the first asset href out of index.html and GET it.
Expand Down
59 changes: 56 additions & 3 deletions console/web/src/components/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,10 @@ import { useWorktreeBinding } from '@/hooks/use-worktree-binding'
import { useWorktreeEvents } from '@/hooks/use-worktree-events'
import type { ChatBackend } from '@/lib/backend'
import { predictedUserEntryId } from '@/lib/backend/harness-send'
import type { SessionTriggerInfo } from '@/lib/backend/triggers'
import {
mergeFiredTriggers,
type SessionTriggerInfo,
} from '@/lib/backend/triggers'
import type { CompactResult, QueuedMessagePreview } from '@/lib/backend/types'
import { useConversationsCtxOptional } from '@/lib/conversations-context'
import { expandFileMentions, parseFileMentions } from '@/lib/file-mentions'
Expand Down Expand Up @@ -54,6 +57,7 @@ import {
type SystemMessage,
type ThinkingLevel,
type ThoughtMessage,
type TriggerFiredData,
type UserMessage,
} from '@/types/chat'
import { Composer, type ComposerSubmitPayload } from './Composer'
Expand Down Expand Up @@ -232,15 +236,24 @@ export function ChatView({
const [sessionTriggers, setSessionTriggers] = useState<SessionTriggerInfo[]>(
[],
)
// Every full row this tab has EVER polled, by engine trigger id. When a
// once/join binding fires and retires, the poll drops it — this cache lets
// the fired ghost keep its full metadata (join grouping, spawn pin, task)
// so the workflow strip and flow DAG survive the pipeline completing.
const seenTriggersRef = useRef<Map<string, SessionTriggerInfo>>(new Map())
const refreshTriggers = useCallback(() => {
const listTriggers = backend.listTriggers
if (!listTriggers) return
listTriggers(conversation.id)
.then(setSessionTriggers)
.then((rows) => {
for (const row of rows) seenTriggersRef.current.set(row.id, row)
setSessionTriggers(rows)
})
.catch(() => {})
}, [backend.listTriggers, conversation.id])
useEffect(() => {
if (!backend.listTriggers) return
seenTriggersRef.current = new Map()
setSessionTriggers([])
refreshTriggers()
const timer = window.setInterval(refreshTriggers, 5000)
Expand Down Expand Up @@ -295,6 +308,28 @@ export function ChatView({
refreshTriggers,
])

// Fired-trigger history: durable `trigger_fired` transcript entries (mapped to
// system messages). Drives the panel's fired/unregistered ghost rows so a
// once-trigger stays visible after the engine drops it from the poll.
const firedTriggers = useMemo<TriggerFiredData[]>(() => {
const out: TriggerFiredData[] = []
for (const m of conversation.messages) {
if (m.role === 'system' && m.kind === 'trigger-fired' && m.trigger) {
out.push(m.trigger)
}
}
return out
}, [conversation.messages])
const mergedTriggers = useMemo(
() =>
mergeFiredTriggers(
sessionTriggers,
firedTriggers,
seenTriggersRef.current,
),
[sessionTriggers, firedTriggers],
)

// The strip's rows: this tab's drafts first, then server-queued rows not
// already covered by a draft or an arrived transcript row (a stale poll
// must not re-show a message that just drained into the chat).
Expand Down Expand Up @@ -1088,6 +1123,17 @@ export function ChatView({
void backend.abortRun?.(sessionId).catch(() => {})
}, [backend, sessionId])

// Rescue a parked stream loop: the session hit a terminal error server-side
// (status-changed arrives on the session-directory subscription) but the
// local `for await` is still waiting on a turn-completed that may never
// come. Abort locally — the generator returns silently on abort, and the
// red notice renders from the transcript's durable `error` entry.
useEffect(() => {
if (isStreaming && conversation.status === 'error') {
abortRef.current?.abort()
}
}, [isStreaming, conversation.status])

// Covers the gap between submit / fcall-end and the next streamed content,
// where the assistant/thought shimmer hasn't yet rendered.
const isThinking =
Expand Down Expand Up @@ -1315,6 +1361,13 @@ export function ChatView({
<MessageList
messages={conversation.messages}
isThinking={isThinking}
thinkingDetail={
conversation.status === 'working' && conversation.statusReason
? conversation.statusReason
: effectiveModel
? `dispatching ${effectiveModel}`
: undefined
}
density={density}
onResolveApproval={resolveApproval}
onAlwaysAllow={handleAlwaysAllow}
Expand Down Expand Up @@ -1368,7 +1421,7 @@ export function ChatView({
</div>
) : null}
<SessionTriggers
triggers={sessionTriggers}
triggers={mergedTriggers}
onUnregister={handleUnregisterTrigger}
onClearAll={
backend.unregisterTrigger ? handleClearAllTriggers : undefined
Expand Down
42 changes: 42 additions & 0 deletions console/web/src/components/chat/Message.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export function Message({
<NotificationMessage message={message} />
) : message.reaction ? (
<ReactionTaskMessage message={message} />
) : message.spawn ? (
<SpawnTaskMessage message={message} />
) : (
<UserMessage message={message} />
)
Expand Down Expand Up @@ -92,6 +94,8 @@ export function Message({
case 'system':
return message.kind === 'compaction' ? (
<CompactionMarker message={message} />
) : message.kind === 'trigger-fired' ? (
<TriggerFiredNotice message={message} />
) : (
<SystemNotice message={message} />
)
Expand Down Expand Up @@ -161,6 +165,26 @@ function NotificationMessage({ message }: { message: UserMessageType }) {
)
}

/**
* A subscription fire (`kind: 'trigger-fired'`): a turn-less notice that a
* registered trigger fired — a state/cron spawn, a notify wake, or a join
* edge. `message.content` is the pre-rendered one-liner (name · action).
*/
function TriggerFiredNotice({ message }: { message: SystemMessageType }) {
return (
<article className="border-l-2 border-l-rule pl-3 py-1 font-mono text-[12px] text-ink-faint flex items-start gap-2">
<span aria-hidden="true">⚡</span>
<span className="break-words">
<span className="uppercase tracking-[0.04em] text-ink-ghost">
trigger fired
</span>
{' · '}
{message.content}
</span>
</article>
)
}

/**
* 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.
Expand Down Expand Up @@ -221,6 +245,24 @@ function ReactionTaskMessage({ message }: { message: UserMessageType }) {
)
}

/**
* A direct `harness::spawn` seed task (`spawn: true`): the sub-agent's opening
* input, but sent by the PARENT agent — labeled and left-aligned like a
* reaction task so it never reads as something the human typed.
*/
function SpawnTaskMessage({ message }: { message: UserMessageType }) {
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="⚙">spawn · sub-agent 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>
</div>
</article>
)
}

function UserMessage({ message }: { message: UserMessageType }) {
return (
<article className="flex flex-col items-end gap-2">
Expand Down
7 changes: 6 additions & 1 deletion console/web/src/components/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ interface MessageListProps {
visible outputs (after submit, or between fcall-end and the next
turn's first token). */
isThinking?: boolean
/** Under-the-hood context shown as the waiting shimmer (e.g. "dispatching
zai::glm-5.2" or the session's status_reason). Falls back to "thinking…"
when absent. */
thinkingDetail?: string
density?: 'route' | 'dock'
onResolveApproval?: (
sessionId: string,
Expand Down Expand Up @@ -84,6 +88,7 @@ function groupConsecutiveFcalls(messages: MessageType[]): RenderItem[] {
export function MessageList({
messages,
isThinking,
thinkingDetail,
density = 'route',
onResolveApproval,
onAlwaysAllow,
Expand Down Expand Up @@ -187,7 +192,7 @@ export function MessageList({
)}
{isThinking ? (
<div className="font-mono text-[13px] italic thinking-shimmer text-ink-faint">
thinking…
{thinkingDetail ?? 'thinking…'}
</div>
) : null}
<div ref={bottomRef} />
Expand Down
24 changes: 21 additions & 3 deletions console/web/src/components/chat/ModelPicker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,13 @@ export function ModelPicker({

const presentIds = ctx?.presentProviders.map((p) => p.id) ?? []
const presentSet = new Set<string>(presentIds)
// Providers the router declares but whose worker is not loaded — their
// catalog models would only fail with `provider_unavailable` at dispatch.
const unavailableSet = new Set(
(ctx?.presentProviders ?? [])
.filter((p) => p.available === false)
.map((p) => p.id),
)

const optionsById = useMemo(
() => new Map(options.map((o) => [o.id, o])),
Expand Down Expand Up @@ -163,15 +170,20 @@ export function ModelPicker({
>
<SelectPrimitive.Viewport className="p-1 max-h-[60vh]">
{groups.map((g) => {
const unconfigured = g.options.length === 0
const unavailable = unavailableSet.has(g.label)
const unconfigured = !unavailable && g.options.length === 0
return (
<SelectPrimitive.Group key={g.label}>
<div className="flex items-center justify-between gap-2 pr-2 pt-2 pb-1">
<span className="flex min-w-0 items-baseline gap-1.5">
<SelectPrimitive.Label className="px-3 text-[11px] uppercase tracking-[0.12em] text-ink-faint">
{g.label}
</SelectPrimitive.Label>
{unconfigured ? (
{unavailable ? (
<span className="text-[10px] lowercase tracking-normal text-ink-ghost">
not loaded
</span>
) : unconfigured ? (
<span className="text-[10px] lowercase tracking-normal text-ink-ghost">
not configured
</span>
Expand All @@ -196,16 +208,22 @@ export function ModelPicker({
</div>
{g.options.map((opt) => {
const expanded = expandedModelId === opt.id
const showThinking = opt.supportsThinking === true
// Gates the edit button AND the level panel: picking a
// level calls onChange(opt.id), which must not select a
// model whose row is disabled as unavailable.
const showThinking =
opt.supportsThinking === true && !unavailable
return (
<div key={opt.id}>
<div className="group/model relative flex items-center">
<SelectPrimitive.Item
value={opt.id}
disabled={unavailable}
className={cn(
'relative flex flex-1 min-w-0 items-center pl-7 pr-12 py-1.5 cursor-pointer outline-none select-none',
'data-[highlighted]:bg-rule data-[highlighted]:text-ink',
'data-[state=checked]:text-ink',
'data-[disabled]:opacity-40 data-[disabled]:cursor-default',
)}
>
<SelectPrimitive.ItemIndicator className="absolute left-2 top-1/2 -translate-y-1/2 text-ink">
Expand Down
Loading
Loading