finish initial design

This commit is contained in:
Douglas 2026-06-23 13:20:08 +01:00
parent 63c9e6ac9b
commit cad53d982c
15 changed files with 1096 additions and 265 deletions

View file

@ -16,6 +16,7 @@ import { Button } from '@/components/ui/button';
import { type ChatTaskStatusType } from '@/types/constants';
import { TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import type { AskInputDescriptor } from '../ask/askPayload';
import { BoxHeaderConfirm, BoxHeaderSave } from './BoxHeader';
import { FileAttachment, Inputbox, InputboxProps } from './InputBox';
import { QueuedBox, QueuedMessage } from './QueuedBox';
@ -23,6 +24,8 @@ import {
UsageLimitBanner,
type UsageLimitBannerProps,
} from './UsageLimitBanner';
import { QuestionVariant } from './variants/QuestionVariant';
import { resolveBottomBoxVariant } from './variants/registry';
export type BottomBoxState =
| 'input'
@ -60,6 +63,15 @@ interface BottomBoxProps {
inputProps: Omit<InputboxProps, 'className'> & { className?: string };
usageLimitBanner?: UsageLimitBannerProps | null;
/**
* Active structured ask awaiting an answer. When present and non-text, the
* BottomBox switches to the question variant (question + option buttons)
* instead of the free-text composer.
*/
askInput?: AskInputDescriptor | null;
/** Submit a structured ask answer. `reply` is canonical, `display` is echoed. */
onAnswerAsk?: (reply: string, display?: string) => void | Promise<void>;
// Loading states
loading?: boolean;
@ -79,6 +91,8 @@ export default function BottomBox({
onEdit,
inputProps,
usageLimitBanner,
askInput = null,
onAnswerAsk,
loading = false,
noModelOverlay = false,
onSelectModel,
@ -86,16 +100,20 @@ export default function BottomBox({
const { t } = useTranslation();
const enableQueuedBox = true; //TODO: Fix the reason of queued box disable in https://github.com/eigent-ai/eigent/issues/684
// Which input affordance to render inside the box shell.
const variant = resolveBottomBoxVariant({ ask: askInput });
const isQuestion = variant === 'question' && !!askInput && !!onAnswerAsk;
// Background color reflects current state only
let backgroundClass = 'bg-ds-bg-neutral-subtle-default';
if (state === 'confirm' || state === 'save')
backgroundClass = 'bg-ds-bg-completed-subtle-default';
return (
<div className="relative z-50 flex w-full flex-col rounded-t-2xl bg-ds-bg-neutral-subtle-default backdrop-blur-xl">
<div className="rounded-t-2xl bg-ds-bg-neutral-subtle-default backdrop-blur-xl relative z-50 flex w-full flex-col">
{/* QueuedBox overlay (should not affect BoxMain layout) */}
{enableQueuedBox && queuedMessages.length > 0 && (
<div className="pointer-events-auto z-50 px-2">
<div className="px-2 pointer-events-auto z-50">
<QueuedBox
queuedMessages={queuedMessages}
onRemoveQueuedMessage={onRemoveQueuedMessage}
@ -104,38 +122,46 @@ export default function BottomBox({
)}
{/* BoxMain */}
<div
className={`relative mb-sm flex w-full flex-col rounded-3xl ${backgroundClass}`}
className={`mb-sm rounded-3xl relative flex w-full flex-col ${backgroundClass}`}
>
{/* BoxHeader variants */}
{state === 'confirm' && (
<BoxHeaderConfirm
subtitle={subtitle}
onStartTask={onStartTask}
onEdit={onEdit}
loading={loading}
autoStartDeadline={autoStartDeadline}
/>
)}
{state === 'save' && (
<BoxHeaderSave
subtitle={subtitle}
onSave={onSavePlan}
onEdit={onEdit}
loading={loading}
/>
)}
{/* Inputbox (always visible) */}
{usageLimitBanner && <UsageLimitBanner {...usageLimitBanner} />}
<Inputbox {...inputProps} />
{isQuestion ? (
// Question-and-answer mode: the model's ask drives the input surface.
<QuestionVariant ask={askInput!} onAnswer={onAnswerAsk!} />
) : (
<>
{/* BoxHeader variants */}
{state === 'confirm' && (
<BoxHeaderConfirm
subtitle={subtitle}
onStartTask={onStartTask}
onEdit={onEdit}
loading={loading}
autoStartDeadline={autoStartDeadline}
/>
)}
{state === 'save' && (
<BoxHeaderSave
subtitle={subtitle}
onSave={onSavePlan}
onEdit={onEdit}
loading={loading}
/>
)}
{/* Inputbox (default free-text composer) */}
<Inputbox {...inputProps} />
</>
)}
{noModelOverlay && onSelectModel ? (
<div
className="absolute inset-0 z-[15] flex flex-row items-center justify-center gap-3 rounded-3xl bg-ds-bg-warning-subtle-default px-4 py-5 backdrop-blur-lg"
className="inset-0 gap-3 rounded-3xl bg-ds-bg-warning-subtle-default px-4 py-5 backdrop-blur-lg absolute z-[15] flex flex-row items-center justify-center"
role="alert"
>
<TriangleAlert
className="h-4 w-4 shrink-0 text-ds-icon-warning-default-default"
className="h-4 w-4 text-ds-icon-warning-default-default shrink-0"
aria-hidden
/>
<p className="text-sm font-medium leading-snug text-ds-text-warning-default-default">

View file

@ -0,0 +1,270 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/**
* BottomBox "question and answer" wizard.
*
* All ask kinds are normalised into a flat list of 1..N questions and
* shown one at a time. Layout per question:
*
*
* [1 / 4] Question text here
*
* [Option A]
* [Option B] or textarea for text
*
* [ Back] [Next ]
*
*
* Calls `onAnswer(reply, display)` exactly once when the user reaches
* the last question and submits.
*/
import { Button } from '@/components/ui/button';
import { ArrowLeft } from 'lucide-react';
import { useState } from 'react';
import {
labelForOption,
type AskInputDescriptor,
type AskOption,
} from '../../ask/askPayload';
export interface QuestionVariantProps {
ask: AskInputDescriptor;
onAnswer: (reply: string, display?: string) => void | Promise<void>;
disabled?: boolean;
}
// ── Internal wizard question shape ──────────────────────────────────────────
interface WizardQuestion {
id: string;
text: string;
inputKind: 'text' | 'single' | 'multi' | 'confirm';
options: AskOption[];
}
type AnswerMap = Record<string, { selected: string[]; text: string }>;
/** Flatten any AskInputDescriptor into a 1..N question list. */
function normalizeQuestions(ask: AskInputDescriptor): WizardQuestion[] {
if (ask.kind === 'followup' && ask.questions?.length) {
return ask.questions.map((q) => ({
id: q.id,
text: q.prompt,
inputKind: q.inputKind,
options: q.options,
}));
}
// Single-question kinds.
const options =
ask.options && ask.options.length > 0
? ask.options
: ask.kind === 'confirm'
? [
{ value: 'yes', label: 'Yes' },
{ value: 'no', label: 'No' },
]
: [];
return [
{
id: '__root__',
text: ask.question,
inputKind:
ask.kind === 'confirm'
? 'confirm'
: ask.kind === 'multi'
? 'multi'
: ask.kind === 'text'
? 'text'
: 'single',
options,
},
];
}
function hasAnswer(q: WizardQuestion, answers: AnswerMap): boolean {
const a = answers[q.id];
if (!a) return false;
return q.inputKind === 'text'
? (a.text ?? '').trim().length > 0
: (a.selected ?? []).length > 0;
}
function buildReply(
ask: AskInputDescriptor,
questions: WizardQuestion[],
answers: AnswerMap
): { reply: string; display: string } {
if (ask.kind === 'followup') {
const payload = questions.map((q) => {
const a = answers[q.id] ?? { selected: [], text: '' };
const labels =
q.inputKind === 'text'
? [a.text]
: a.selected.map((v) => labelForOption(q.options, v));
return { question: q.text, answer: labels };
});
const display = payload
.map((p) => `${p.question}${p.answer.join(', ')}`)
.join('\n');
return { reply: JSON.stringify(payload), display };
}
const a = answers['__root__'] ?? { selected: [], text: '' };
if (ask.kind === 'text') return { reply: a.text, display: a.text };
const labels = a.selected.map((v) => labelForOption(ask.options ?? [], v));
return { reply: a.selected.join(', '), display: labels.join(', ') };
}
// ── Styles ───────────────────────────────────────────────────────────────────
const optionBase =
'w-full rounded-xl px-3 py-2 text-left text-sm font-medium transition-colors disabled:opacity-50';
const optionIdle =
'bg-ds-bg-neutral-subtle-default text-ds-text-neutral-default-default hover:bg-ds-bg-neutral-subtle-hover active:bg-ds-bg-neutral-subtle-hover';
const optionSelected =
'bg-ds-bg-information-subtle-default text-ds-text-information-default-default';
// ── Main component ────────────────────────────────────────────────────────────
export function QuestionVariant({
ask,
onAnswer,
disabled = false,
}: QuestionVariantProps) {
const [submitting, setSubmitting] = useState(false);
const [currentIdx, setCurrentIdx] = useState(0);
const [answers, setAnswers] = useState<AnswerMap>({});
const busy = disabled || submitting;
const questions = normalizeQuestions(ask);
const total = questions.length;
const current = questions[currentIdx]!;
const isLast = currentIdx === total - 1;
const answered = hasAnswer(current, answers);
const selectedForCurrent = answers[current.id]?.selected ?? [];
const textForCurrent = answers[current.id]?.text ?? '';
const toggleOption = (value: string) => {
setAnswers((prev) => {
const cur = prev[current.id]?.selected ?? [];
const multi = current.inputKind === 'multi';
const next = multi
? cur.includes(value)
? cur.filter((v) => v !== value)
: [...cur, value]
: [value];
return {
...prev,
[current.id]: { ...(prev[current.id] ?? { text: '' }), selected: next },
};
});
};
const setTextAnswer = (text: string) => {
setAnswers((prev) => ({
...prev,
[current.id]: { ...(prev[current.id] ?? { selected: [] }), text },
}));
};
const handleNext = async () => {
if (busy || !answered) return;
if (!isLast) {
setCurrentIdx((i) => i + 1);
return;
}
setSubmitting(true);
try {
const { reply, display } = buildReply(ask, questions, answers);
await onAnswer(reply, display);
} finally {
setSubmitting(false);
}
};
return (
<div className="gap-3 rounded-3xl bg-ds-bg-neutral-default-default px-3 py-3 flex w-full flex-col">
{/* ── Header: number tag + question ── */}
<div className="gap-2 flex flex-col items-start">
<span className="bg-ds-bg-neutral-subtle-default px-2 py-0.5 text-label-xs font-bold text-ds-text-neutral-muted-default mt-px shrink-0 rounded-full tabular-nums">
{currentIdx + 1}&thinsp;/&thinsp;{total}
</span>
<span className="text-body-sm font-semibold leading-snug text-ds-text-neutral-default-default">
{current.text}
</span>
</div>
{/* ── Answer controls ── */}
{current.inputKind === 'text' ? (
<textarea
value={textForCurrent}
onChange={(e) => setTextAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleNext();
}
}}
disabled={busy}
placeholder="Type your reply… (Enter to send, Shift+Enter for newline)"
rows={2}
className="rounded-xl bg-ds-bg-neutral-subtle-default px-3 py-2 text-sm text-ds-text-neutral-default-default placeholder:text-ds-text-neutral-muted-default w-full resize-none focus:outline-none disabled:opacity-50"
/>
) : (
<div className="gap-1.5 flex flex-col">
{current.options.map((opt) => {
const isOn = selectedForCurrent.includes(opt.value);
return (
<button
key={opt.value}
type="button"
disabled={busy}
className={`${optionBase} ${isOn ? optionSelected : optionIdle}`}
onClick={() => toggleOption(opt.value)}
>
{opt.label}
</button>
);
})}
</div>
)}
{/* ── Footer: back | submit/next ── */}
<div className="flex items-center justify-between">
<button
type="button"
disabled={currentIdx === 0 || busy}
onClick={() => setCurrentIdx((i) => i - 1)}
className="gap-1 rounded-lg px-2 py-1.5 text-sm font-medium text-ds-text-neutral-muted-default hover:bg-ds-bg-neutral-subtle-hover flex items-center transition-colors disabled:pointer-events-none disabled:opacity-0"
>
<ArrowLeft size={13} aria-hidden />
Back
</button>
<Button
variant="primary"
size="sm"
buttonRadius="full"
disabled={busy || !answered}
onClick={handleNext}
>
{isLast ? 'Submit' : 'Next\u00a0→'}
</Button>
</div>
</div>
);
}

View file

@ -0,0 +1,46 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import type {
BottomBoxVariantContext,
BottomBoxVariantDef,
BottomBoxVariantKind,
} from './types';
/**
* Ordered variant table the first matching def wins. Order = priority, so
* specific matchers go above the catch-all `default`.
*/
const VARIANT_DEFS: BottomBoxVariantDef[] = [
{
// Any active ask (including plain-text) surfaces the question variant so
// the user always sees the question and a dedicated reply control.
kind: 'question',
match: (ctx) => !!ctx.ask,
},
{
kind: 'default',
match: () => true,
},
];
/** Resolve which BottomBox variant to render for the current input context. */
export function resolveBottomBoxVariant(
ctx: BottomBoxVariantContext
): BottomBoxVariantKind {
const def =
VARIANT_DEFS.find((d) => d.match(ctx)) ??
VARIANT_DEFS[VARIANT_DEFS.length - 1]!;
return def.kind;
}

View file

@ -0,0 +1,45 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/**
* BottomBox input-variant system.
*
* The BottomBox is a shell (queued messages, usage banner, model overlay). What
* it renders *inside* is chosen by a variant, resolved from the current input
* context today driven by the type of ask the model sent, and open to other
* signals (e.g. the page/route) later.
*
* Adding a new input affordance = add a `BottomBoxVariantKind`, a matcher in
* `registry.ts`, and a render branch in `BottomBox/index.tsx`. No giant switch.
*/
import type { AskInputDescriptor } from '../../ask/askPayload';
export type BottomBoxVariantKind =
/** Free-text composer + plan confirm/save headers (today's default). */
| 'default'
/** Question + vertically-stacked option buttons / follow-up form. */
| 'question';
/** Signals a variant can match on. Extend as new variant triggers appear. */
export interface BottomBoxVariantContext {
/** Active structured ask awaiting an answer, or null. */
ask: AskInputDescriptor | null;
}
export interface BottomBoxVariantDef {
kind: BottomBoxVariantKind;
/** First matching def (in registry order) wins. */
match: (ctx: BottomBoxVariantContext) => boolean;
}

View file

@ -0,0 +1,99 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import type { ChainOfThoughtItem } from '@/types/sessionChannel';
import { AnimatePresence, motion } from 'framer-motion';
import { Brain } from 'lucide-react';
import { useEffect, useRef } from 'react';
interface ChainOfThoughtBoxProps {
item: ChainOfThoughtItem;
/** True while the parent task is still running (drives cursor blink). */
isStreaming: boolean;
}
/**
* Fixed-height 100px scrollable box that renders chain-of-thought reasoning.
* New entries slide in from below and the scroll position tracks the bottom
* while content is streaming.
*/
export function ChainOfThoughtBox({
item,
isStreaming,
}: ChainOfThoughtBoxProps) {
const scrollRef = useRef<HTMLDivElement>(null);
// Follow new content to the bottom while streaming.
useEffect(() => {
const el = scrollRef.current;
if (!el) return;
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, [item.cot.length]);
if (!item.cot.length && !isStreaming) return null;
return (
<motion.div
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2 }}
className="mx-6 rounded-xl bg-ds-bg-neutral-subtle-default overflow-hidden"
>
{/* Header row */}
<div className="gap-1.5 px-3 pt-2.5 pb-1 flex items-center">
<Brain
size={11}
className="text-ds-icon-neutral-muted-default shrink-0"
/>
<span className="text-label-xs font-medium text-ds-text-neutral-muted-default">
{isStreaming ? 'Thinking' : 'Thought'}
</span>
{isStreaming && (
<span className="h-1.5 w-1.5 animate-pulse bg-ds-icon-neutral-muted-default inline-block rounded-full" />
)}
</div>
{/* Scrollable content — fixed 100px height */}
<div
ref={scrollRef}
className="scrollbar-hide px-3 pb-2.5 h-[100px] overflow-y-auto"
>
<div className="gap-0.5 flex flex-col">
<AnimatePresence initial={false}>
{item.cot.map((line, i) => (
<motion.p
key={i}
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
className="text-label-xs font-normal text-ds-text-neutral-muted-default leading-[18px]"
>
{line}
</motion.p>
))}
</AnimatePresence>
{/* Blinking cursor while streaming */}
{isStreaming && (
<span
aria-hidden
className="mt-0.5 h-3 w-0.5 animate-pulse bg-ds-icon-neutral-muted-default inline-block rounded-full"
/>
)}
</div>
</div>
</motion.div>
);
}

View file

@ -0,0 +1,176 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/**
* Shared parser for human-in-the-loop `ask` events.
*
* Frontend-only contract: the backend ask event is unchanged. When the model
* wants a richer answer than free text it emits the structured ask as a JSON
* block inside the ask message *content*; we parse `message.content` here. A
* plain-text ask (today's behavior) simply yields `kind: 'text'`.
*
* Supported shapes (inside a ```json … ``` fence or as a bare object):
* single { question, input_kind?: 'single', options: [...] }
* multi { question, input_kind: 'multi', options: [...] }
* confirm { question, input_kind: 'confirm', options?: [...] }
* followup { question?, questions: [{ question, input_kind?, options }] }
*
* `options` entries may be `"value"` strings or `{ value, label }` objects.
*
* This is the single source both the session-channel reducer (to derive
* `ask` / `followup-questions` channel items) and the BottomBox question
* variant (to render the answer controls) read from they never diverge.
*/
import { AgentStep } from '@/types/constants';
/** Answer shape requested by an ask. `followup` carries N sub-questions. */
export type AskKind = 'text' | 'single' | 'multi' | 'confirm' | 'followup';
export interface AskOption {
value: string;
label: string;
}
export interface AskSubQuestion {
id: string;
prompt: string;
inputKind: 'single' | 'multi';
options: AskOption[];
}
/** Unified read-model of the active ask, consumed by every renderer. */
export interface AskInputDescriptor {
/** Agent the reply is addressed to (matches `task.activeAsk`). */
agent: string;
/** Headline question (for `followup` this is the optional intro). */
question: string;
kind: AskKind;
/** Present for single / multi / confirm. */
options?: AskOption[];
/** Present for followup. */
questions?: AskSubQuestion[];
}
/** Pull the first balanced `{ … }` object out of text (fenced or bare). */
function extractJsonObject(text: string): Record<string, unknown> | null {
if (!text) return null;
const fence = text.match(/```(?:json)?\s*([\s\S]*?)```/i);
const candidate = fence ? fence[1]! : text;
const start = candidate.indexOf('{');
const end = candidate.lastIndexOf('}');
if (start === -1 || end === -1 || end < start) return null;
try {
const parsed = JSON.parse(candidate.slice(start, end + 1));
return parsed && typeof parsed === 'object' ? parsed : null;
} catch {
return null;
}
}
function normalizeOptions(raw: unknown): AskOption[] {
if (!Array.isArray(raw)) return [];
return raw
.map((o): AskOption | null => {
if (typeof o === 'string') return { value: o, label: o };
if (o && typeof o === 'object') {
const value = (o as any).value ?? (o as any).label;
const label = (o as any).label ?? (o as any).value;
if (value == null) return null;
return { value: String(value), label: String(label) };
}
return null;
})
.filter((o): o is AskOption => o !== null && o.label.length > 0);
}
/** Parse a single ASK message into a descriptor (defaults to free text). */
export function parseAskMessage(message: Message): AskInputDescriptor {
const agent = message.agent_id ?? message.agent_name ?? '';
const content = message.content ?? '';
const parsed = extractJsonObject(content);
// Follow-up questionnaire: N sub-questions answered together.
if (parsed && Array.isArray((parsed as any).questions)) {
const questions: AskSubQuestion[] = (parsed as any).questions
.map(
(q: any, i: number): AskSubQuestion => ({
id: String(q?.id ?? `q${i}`),
prompt: String(q?.question ?? q?.prompt ?? ''),
inputKind:
q?.input_kind === 'multi' || q?.type === 'multi'
? 'multi'
: 'single',
options: normalizeOptions(q?.options),
})
)
.filter((q: AskSubQuestion) => q.prompt && q.options.length > 0);
if (questions.length > 0) {
return {
agent,
kind: 'followup',
question: String(
(parsed as any).question ?? (parsed as any).prompt ?? ''
),
questions,
};
}
}
// Single / multi / confirm choice.
if (parsed && Array.isArray((parsed as any).options)) {
const options = normalizeOptions((parsed as any).options);
if (options.length > 0) {
const rawKind = (parsed as any).input_kind ?? (parsed as any).type;
const kind: AskKind =
rawKind === 'multi'
? 'multi'
: rawKind === 'confirm'
? 'confirm'
: 'single';
return {
agent,
kind,
question: String(
(parsed as any).question ?? (parsed as any).prompt ?? content
),
options,
};
}
}
return { agent, kind: 'text', question: content };
}
/**
* Resolve the ask currently awaiting an answer on a live task. `task.activeAsk`
* holds the agent name; the matching ask is the most recent ASK message.
*/
export function deriveActiveAsk(
task: { activeAsk?: string; messages?: Message[] } | null | undefined
): AskInputDescriptor | null {
if (!task?.activeAsk) return null;
const messages = task.messages ?? [];
for (let i = messages.length - 1; i >= 0; i--) {
if (messages[i]!.step === AgentStep.ASK) {
return { ...parseAskMessage(messages[i]!), agent: task.activeAsk };
}
}
return null;
}
/** Resolve an option value to its display label. */
export function labelForOption(options: AskOption[], value: string): string {
return options.find((o) => o.value === value)?.label ?? value;
}

View file

@ -12,10 +12,53 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import type { ChannelItem } from '@/types/sessionChannel';
import type { ChannelItem, UnsupportedItem } from '@/types/sessionChannel';
import React from 'react';
import type { ChannelRenderContext } from './context';
import { rendererRegistry } from './rendererRegistry';
import { UnsupportedRenderer } from './renderers/structureRenderers';
/**
* Per-item error boundary: if a renderer throws on a stale legacy data shape, we
* degrade that single item to the hidden+inspectable `unsupported` fallback
* instead of taking down the whole turn/channel. Scoped to one item so a bad
* item never hides its siblings.
*/
class ItemErrorBoundary extends React.Component<
{ item: ChannelItem; ctx: ChannelRenderContext; children: React.ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
componentDidCatch(error: unknown) {
console.error(
`[session-channel] renderer threw for item ${this.props.item.id} (kind=${this.props.item.kind})`,
error
);
}
render() {
if (this.state.failed) {
const { item, ctx } = this.props;
const fallback: UnsupportedItem = {
id: `unsup-render-${item.id}`,
kind: 'unsupported',
turnId: item.turnId,
seq: item.seq,
createdAt: item.createdAt,
reason: 'render-error',
sourceStep: item.kind,
messageId: item.id,
};
return <UnsupportedRenderer item={fallback} ctx={ctx} />;
}
return this.props.children;
}
}
/** Looks up `registry[item.kind]` and renders it, or nothing for an unknown kind. */
export const ChannelItemRenderer: React.FC<{
@ -24,5 +67,9 @@ export const ChannelItemRenderer: React.FC<{
}> = ({ item, ctx }) => {
const Renderer = rendererRegistry[item.kind];
if (!Renderer) return null;
return <Renderer item={item as never} ctx={ctx} />;
return (
<ItemErrorBoundary item={item} ctx={ctx}>
<Renderer item={item as never} ctx={ctx} />
</ItemErrorBoundary>
);
};

View file

@ -20,6 +20,7 @@ import {
ChainOfThoughtRenderer,
ErrorRenderer,
FileOutputRenderer,
FollowupQuestionsRenderer,
SkipMarkerRenderer,
UserMessageRenderer,
} from './renderers/messageRenderers';
@ -27,6 +28,7 @@ import {
PlanRenderer,
PreparingRenderer,
TurnBoundaryRenderer,
UnsupportedRenderer,
WorkLogRenderer,
} from './renderers/structureRenderers';
@ -50,5 +52,7 @@ export const rendererRegistry: Record<
failed: ErrorRenderer as ChannelRenderer<never>,
'file-output': FileOutputRenderer as ChannelRenderer<never>,
ask: AskRenderer as ChannelRenderer<never>,
'followup-questions': FollowupQuestionsRenderer as ChannelRenderer<never>,
'skip-marker': SkipMarkerRenderer as ChannelRenderer<never>,
unsupported: UnsupportedRenderer as ChannelRenderer<never>,
};

View file

@ -13,55 +13,31 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
/**
* HITL ask renderer (Stage 3). Shows the question and when this is the active
* unanswered ask a control chosen by `inputKind`:
* - text no inline control (the BottomBox textarea handles text replies)
* - single one click-to-submit button per option
* - multi checkbox list + Submit
* - confirm Yes / No buttons
* HITL ask renderer. Shows the question text in the conversation log. The
* interactive answer controls (text input, option buttons, follow-up form) are
* owned by the BottomBox question variant see `BottomBox/variants`. This
* keeps a single input surface and avoids duplicate/competing controls.
*
* `inputKind` defaults to `text`, so against the current backend (text-only asks)
* this preserves today's behavior; single/multi/confirm are forward-compatible
* and light up automatically if the `ask` payload ever carries options.
*
* A single 30s auto-skip timer is owned here (keyed on the active ask) and
* routes through the same reply path with the `skip` sentinel.
* The 30s auto-skip for an unanswered ask is owned here (keyed on the active
* ask) and routes through the shared `submitHumanReply` with the `skip`
* sentinel, so it fires regardless of which input variant is showing.
*/
import type { AskItem } from '@/types/sessionChannel';
import { motion } from 'framer-motion';
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { AgentMessageCard } from '../../MessageItem/AgentMessageCard';
import type { ChannelRenderer } from '../context';
import { serializeReply, submitHumanReply } from '../submitHumanReply';
import { submitHumanReply } from '../submitHumanReply';
const AUTO_SKIP_MS = 30000;
const optionBtn =
'rounded-lg border border-ds-border-neutral-default-default bg-ds-bg-neutral-default-default px-3 py-1.5 text-sm font-medium text-ds-text-neutral-default-default transition-colors hover:bg-ds-bg-neutral-default-hover active:bg-ds-bg-neutral-default-active disabled:opacity-50';
export const AskRenderer: ChannelRenderer<AskItem> = ({ item, ctx }) => {
const resolved = ctx.resolveTurn(item.turnId);
const liveTask = resolved?.chatStore.getState().tasks[resolved.taskId];
// The ask is active while the live task is still asking for this agent.
const isActive = !!liveTask?.activeAsk && liveTask.activeAsk === item.agent;
const [submitting, setSubmitting] = useState(false);
const [checked, setChecked] = useState<Record<string, boolean>>({});
const send = async (reply: string, displayContent?: string) => {
if (!resolved || submitting) return;
setSubmitting(true);
await submitHumanReply({
projectId: ctx.projectId,
chatStore: resolved.chatStore,
taskId: resolved.taskId,
reply,
displayContent,
});
setSubmitting(false);
};
// Robust single 30s auto-skip for the active ask.
useEffect(() => {
if (!isActive || !resolved) return;
@ -78,118 +54,18 @@ export const AskRenderer: ChannelRenderer<AskItem> = ({ item, ctx }) => {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isActive, item.id, resolved?.taskId, ctx.projectId]);
const question = (
<AgentMessageCard
id={item.id}
content={item.question}
onTyping={() => {}}
/>
);
if (!isActive || item.inputKind === 'text') {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="gap-4 flex flex-col"
>
{question}
</motion.div>
);
}
const options = item.options ?? [];
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="gap-3 px-6 flex flex-col"
className="gap-4 flex flex-col"
>
{question}
{item.inputKind === 'single' && (
<div className="gap-2 flex flex-wrap">
{options.map((opt) => (
<button
key={opt.value}
type="button"
disabled={submitting}
className={optionBtn}
onClick={() =>
send(
serializeReply('single', { selected: [opt.value] }),
opt.label
)
}
>
{opt.label}
</button>
))}
</div>
)}
{item.inputKind === 'confirm' && (
<div className="gap-2 flex">
<button
type="button"
disabled={submitting}
className={optionBtn}
onClick={() => send('yes', 'Yes')}
>
Yes
</button>
<button
type="button"
disabled={submitting}
className={optionBtn}
onClick={() => send('no', 'No')}
>
No
</button>
</div>
)}
{item.inputKind === 'multi' && (
<div className="gap-2 flex flex-col">
{options.map((opt) => (
<label
key={opt.value}
className="gap-2 text-sm text-ds-text-neutral-default-default flex cursor-pointer items-center"
>
<input
type="checkbox"
checked={!!checked[opt.value]}
onChange={(e) =>
setChecked((c) => ({ ...c, [opt.value]: e.target.checked }))
}
/>
{opt.label}
</label>
))}
<div>
<button
type="button"
disabled={submitting}
className={optionBtn}
onClick={() => {
const selected = options
.filter((o) => checked[o.value])
.map((o) => o.value);
const labels = options
.filter((o) => checked[o.value])
.map((o) => o.label)
.join(', ');
send(serializeReply('multi', { selected }), labels);
}}
>
Submit
</button>
</div>
</div>
)}
<AgentMessageCard
id={item.id}
content={item.question}
onTyping={() => {}}
/>
</motion.div>
);
};

View file

@ -18,6 +18,7 @@ import type {
ChainOfThoughtItem,
ErrorItem,
FileOutputItem,
FollowupQuestionsItem,
SkipMarkerItem,
UserMessageItem,
} from '@/types/sessionChannel';
@ -25,7 +26,7 @@ import { motion } from 'framer-motion';
import { ChevronDown, FileText } from 'lucide-react';
import React, { useState } from 'react';
import { AgentMessageCard } from '../../MessageItem/AgentMessageCard';
import { NoticeCard } from '../../MessageItem/NoticeCard';
import { ChainOfThoughtBox } from '../../MessageItem/ChainOfThoughtBox';
import { UserMessageCard } from '../../MessageItem/UserMessageCard';
import type { ChannelRenderer } from '../context';
@ -147,12 +148,15 @@ export const AgentMessageRenderer: ChannelRenderer<AgentMessageItem> = ({
(task?.type === 'replay' && task?.delayTime !== 0);
const msgs = task?.messages ?? [];
const last = msgs[msgs.length - 1];
// Compare against the raw source id (`item.messageId`), not the prefixed
// channel id (`item.id` is `am-…`) — otherwise this never matches and the
// typewriter animation for the streaming message never plays.
const typewriter =
replayAllows &&
task?.status === ChatTaskStatus.RUNNING &&
!!last &&
last.role === 'agent' &&
last.id === item.id;
last.id === item.messageId;
return (
<motion.div
@ -219,9 +223,15 @@ export const ErrorRenderer: ChannelRenderer<ErrorItem> = ({ item }) => (
</motion.div>
);
export const ChainOfThoughtRenderer: ChannelRenderer<
ChainOfThoughtItem
> = () => <NoticeCard />;
export const ChainOfThoughtRenderer: ChannelRenderer<ChainOfThoughtItem> = ({
item,
ctx,
}) => {
const resolved = ctx.resolveTurn(item.turnId);
const task = resolved?.chatStore.getState().tasks[resolved.taskId];
const isStreaming = task?.status === ChatTaskStatus.RUNNING;
return <ChainOfThoughtBox item={item} isStreaming={!!isStreaming} />;
};
export const FileOutputRenderer: ChannelRenderer<FileOutputItem> = ({
item,
@ -233,3 +243,53 @@ export const FileOutputRenderer: ChannelRenderer<FileOutputItem> = ({
onOpen={(file) => ctx.openFilePreview(item.turnId, file)}
/>
);
/**
* Follow-up questionnaire read-only record in the conversation log. The
* interactive answer controls live in the BottomBox question variant; once the
* user submits, their choices appear as a normal user-message below this item.
* When already answered, each question shows the chosen option(s) inline.
*/
export const FollowupQuestionsRenderer: ChannelRenderer<
FollowupQuestionsItem
> = ({ item }) => (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
className="gap-3 px-6 flex flex-col"
>
{item.question ? (
<div className="text-body-sm font-medium text-ds-text-neutral-default-default">
{item.question}
</div>
) : null}
{item.questions.map((q, qi) => {
const selected = item.answers?.[q.id] ?? [];
return (
<div key={q.id} className="gap-1.5 flex flex-col">
<div className="text-body-sm font-bold text-ds-text-neutral-default-default">
{qi + 1}. {q.prompt}
</div>
<div className="gap-1.5 flex flex-wrap">
{q.options.map((opt) => {
const isChosen = selected.includes(opt.value);
return (
<span
key={opt.value}
className={`rounded-lg px-2.5 py-1 text-label-xs font-medium border ${
isChosen
? 'border-ds-border-information-default-default bg-ds-bg-information-subtle-default text-ds-text-information-default-default'
: 'border-ds-border-neutral-default-default text-ds-text-neutral-muted-default'
}`}
>
{opt.label}
</span>
);
})}
</div>
</div>
);
})}
</motion.div>
);

View file

@ -12,14 +12,17 @@
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import { ChatTaskStatus } from '@/types/constants';
import { ChatTaskStatus, SessionMode } from '@/types/constants';
import type {
PlanItem,
PreparingItem,
TurnBoundaryItem,
UnsupportedItem,
WorkLogItem,
} from '@/types/sessionChannel';
import { motion } from 'framer-motion';
import { Inspect } from 'lucide-react';
import { useState } from 'react';
import { PreparingToExecuteTasks } from '../../MessageItem/PreparingToExecuteTasks';
import { TaskWorkLogAccordion } from '../../MessageItem/TaskWorkLogAccordion';
import { PlanTaskBox } from '../../TaskBox/PlanTaskBox';
@ -53,6 +56,8 @@ export const PlanRenderer: ChannelRenderer<PlanItem> = ({ item, ctx }) => {
const chatState = chatStore.getState();
const task = chatState.tasks[taskId];
if (task?.sessionMode === SessionMode.SINGLE_AGENT) return null;
const hasConfirmedSubTasks = item.confirmed && item.subTasks.length > 0;
const isRunning = task?.status === ChatTaskStatus.RUNNING;
const isPlanning =
@ -122,3 +127,52 @@ export const PreparingRenderer: ChannelRenderer<PreparingItem> = () => (
<PreparingToExecuteTasks />
</div>
);
/**
* Fallback for a message the channel can't confidently render (structural step
* leaked into `messages[]`, an unknown/renamed legacy step, or a renderer that
* threw). It shows nothing inline only a small, low-contrast inspect icon.
* Clicking it toggles a diagnostic chip (reason + original step/role/content
* preview) so engineers can see exactly what was demoted, without ever surfacing
* a raw internal payload to the user.
*/
export const UnsupportedRenderer: ChannelRenderer<UnsupportedItem> = ({
item,
}) => {
const [open, setOpen] = useState(false);
const reasonLabel =
item.reason === 'structural-step'
? 'Structural step (not displayable)'
: item.reason === 'render-error'
? 'Renderer error'
: 'Unknown / legacy step';
return (
<div className="px-6 py-0.5">
<button
type="button"
onClick={() => setOpen((v) => !v)}
aria-label="Inspect unsupported message"
aria-expanded={open}
className="text-ds-icon-neutral-muted-default opacity-30 transition-opacity hover:opacity-100"
>
<Inspect className="h-3.5 w-3.5" aria-hidden />
</button>
{open && (
<div className="mt-1 gap-1 rounded-lg border-ds-border-neutral-default-default bg-ds-bg-neutral-subtle-default px-2 py-1.5 text-label-xs text-ds-text-neutral-muted-default flex flex-col border">
<span className="font-medium">
Unsupported channel item · {reasonLabel}
</span>
{item.sourceStep && <span>step: {item.sourceStep}</span>}
{item.sourceRole && <span>role: {item.sourceRole}</span>}
{item.messageId && <span>messageId: {item.messageId}</span>}
{item.preview && (
<span className="break-words whitespace-pre-wrap opacity-80">
{item.preview}
</span>
)}
</div>
)}
</div>
);
};

View file

@ -48,8 +48,10 @@ import {
import { useTranslation } from 'react-i18next';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { toast } from 'sonner';
import { deriveActiveAsk } from './ask/askPayload';
import BottomBox from './BottomBox';
import { SessionChannel } from './channel/SessionChannel';
import { submitHumanReply } from './channel/submitHumanReply';
import { PLAN_OVERLAY_SLOT_ID } from './TaskBox/PlanTaskBox';
/** Minimum scroll padding under messages (matches previous ~8rem floor). */
@ -582,6 +584,34 @@ export default function ChatBox(): JSX.Element {
}
}, []);
// Active structured ask (single/multi/confirm/followup) parsed from the live
// task. Drives the BottomBox question variant; null for plain-text asks, which
// keep the default free-text composer.
const activeAskInput = useMemo(
() => deriveActiveAsk(activeTask),
[activeTask]
);
// Submit a structured ask answer through the shared HITL path (one reply for
// all answers — e.g. a follow-up questionnaire posts a single human-reply).
const handleAnswerAsk = useCallback(
async (reply: string, display?: string) => {
const taskId = chatStore?.activeTaskId;
const projectId = projectStore.activeProjectId;
const store = projectStore.getActiveChatStore();
if (!taskId || !projectId || !store) return;
await submitHumanReply({
projectId,
chatStore: store,
taskId,
reply,
displayContent: display,
});
scrollToBottom();
},
[chatStore?.activeTaskId, projectStore, scrollToBottom]
);
// Handle scrollbar visibility on scroll
useEffect(() => {
const scrollContainer = scrollContainerRef.current;
@ -1174,6 +1204,9 @@ export default function ChatBox(): JSX.Element {
return 'running';
}
if (task.status === ChatTaskStatus.RUNNING) {
// Always surface the input when the agent is waiting for a human reply.
if (task.activeAsk) return 'input';
const hasSubTasks = task.messages.some(
(m) => m.step === AgentStep.TO_SUB_TASKS
);
@ -1236,12 +1269,14 @@ export default function ChatBox(): JSX.Element {
className="scrollbar-always-visible min-h-0 min-w-0 pl-2 flex-1 overflow-x-hidden overflow-y-auto"
>
{hasAnyMessages ? (
<SessionChannel
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={scrollBottomInsetPx}
onSkip={handleSkip}
isPauseResumeLoading={isPauseResumeLoading}
/>
<div className="px-2 mx-auto w-full max-w-[600px]">
<SessionChannel
scrollContainerRef={scrollContainerRef}
scrollBottomInsetPx={scrollBottomInsetPx}
onSkip={handleSkip}
isPauseResumeLoading={isPauseResumeLoading}
/>
</div>
) : (
<div className="mx-auto flex min-h-full w-full max-w-[600px] flex-col">
<div className="gap-1 pb-4 flex flex-1 flex-col items-center justify-end"></div>
@ -1336,6 +1371,8 @@ export default function ChatBox(): JSX.Element {
onPauseResume={handlePauseResume}
pauseResumeLoading={isPauseResumeLoading}
loading={loading}
askInput={activeAskInput}
onAnswerAsk={handleAnswerAsk}
inputProps={{
value: message,
onChange: setMessage,

View file

@ -32,6 +32,7 @@
* this fold is replaced by direct appends; the item shapes stay identical.
*/
import { parseAskMessage } from '@/components/ChatBox/ask/askPayload';
import {
buildAgentBlocks,
mergeTaggedAgentLogs,
@ -59,6 +60,38 @@ export interface TurnInput {
task: TurnTask;
}
/**
* Structural / tool-lifecycle / state steps. In the live flow these are folded
* into the work-log (`taskAssigning[].log`) and never appear as standalone
* conversation messages. If one does leak into `messages[]` (legacy project
* shape), it must NOT render as a free-text agent answer it would surface a
* raw internal payload. We demote it to an `unsupported` item instead.
*/
const STRUCTURAL_STEPS: ReadonlySet<string> = new Set<string>([
AgentStep.CREATE_AGENT,
AgentStep.NEW_TASK_STATE,
AgentStep.TASK_STATE,
AgentStep.ACTIVATE_AGENT,
AgentStep.DEACTIVATE_AGENT,
AgentStep.ASSIGN_TASK,
AgentStep.ACTIVATE_TOOLKIT,
AgentStep.DEACTIVATE_TOOLKIT,
AgentStep.TERMINAL,
AgentStep.WRITE_FILE,
AgentStep.TODO_STATE,
AgentStep.ADD_TASK,
AgentStep.REMOVE_TASK,
AgentStep.DECOMPOSE_TEXT,
AgentStep.CONFIRMED,
AgentStep.WAIT_CONFIRM,
]);
/** Truncate original content for the (dev-only) inspect chip. */
function previewOf(content: string | undefined): string | undefined {
if (!content) return undefined;
return content.length > 120 ? `${content.slice(0, 120)}` : content;
}
/** Steps that map to an `error`/`failed` channel item. */
function errorTypeFor(step: string): {
kind: 'error' | 'failed';
@ -78,35 +111,6 @@ function errorTypeFor(step: string): {
}
}
/** Read-model for the HITL `ask` payload — forward-compatible, defaults to text. */
function deriveAsk(message: Message): {
question: string;
agent: string;
inputKind: AskInputKind;
options?: { value: string; label: string }[];
} {
// Backend currently only emits a free-text question; `input_kind`/`options`
// are read opportunistically so richer kinds light up with no code change.
const data = (message as unknown as { data?: Record<string, unknown> }).data;
const rawKind = (data?.input_kind ?? data?.inputKind) as string | undefined;
const inputKind: AskInputKind =
rawKind === 'single' ||
rawKind === 'multi' ||
rawKind === 'confirm' ||
rawKind === 'text'
? rawKind
: 'text';
const rawOptions = (data?.options ?? undefined) as
| { value: string; label: string }[]
| undefined;
return {
question: message.content ?? '',
agent: message.agent_id ?? message.agent_name ?? '',
inputKind,
options: Array.isArray(rawOptions) ? rawOptions : undefined,
};
}
/**
* Fold an ordered list of per-turn tasks into the project's session channel.
* Items carry a monotonically increasing `seq` across the whole channel.
@ -165,7 +169,12 @@ export function buildProjectChannel(turns: TurnInput[]): ChannelItem[] {
mergeTaggedAgentLogs(task.taskAssigning),
sessionMode === SessionMode.SINGLE_AGENT
);
if (!blocks.length) return;
// Reserve the slot in the channel while the task is still running even
// if no blocks have arrived yet — this locks the position BEFORE any
// agent-message/ask items so the order is always:
// plan → work-log → agent-message / ask
// The WorkLogRenderer shows a preparing spinner for empty running blocks.
if (!blocks.length && task.status !== 'running') return;
workLogEmitted = true;
push({
id: `wl-${turnId}-0`,
@ -228,22 +237,78 @@ export function buildProjectChannel(turns: TurnInput[]): ChannelItem[] {
}
if (message.step === AgentStep.ASK) {
const ask = deriveAsk(message);
const answered = task.activeAsk !== message.content;
// Ensure plan and work-log appear BEFORE the ask in the channel
// (same anchor used for answer/end messages above).
emitPlan();
emitWorkLog();
const desc = parseAskMessage(message);
const agentId = message.agent_id ?? message.agent_name ?? '';
// `activeAsk` holds the agent name; an ask is unanswered while it is the
// task's active ask. (Previously compared against the question text,
// which is never equal, so `answered` was always true.)
const answered = task.activeAsk !== agentId;
if (desc.kind === 'followup') {
push({
id: `fq-${message.id}`,
kind: 'followup-questions',
turnId,
createdAt,
agent: agentId,
question: desc.question,
questions: desc.questions ?? [],
answered,
});
return;
}
push({
id: `ask-${message.id}`,
kind: 'ask',
turnId,
createdAt,
question: ask.question,
agent: ask.agent,
inputKind: ask.inputKind,
options: ask.options,
question: desc.question,
agent: agentId,
// `followup` is handled above; the rest map 1:1 onto AskInputKind.
inputKind: desc.kind as AskInputKind,
options: desc.options,
answered,
});
return;
}
// Everything reaching here should be displayable agent narration: a known
// answer/end/agent-end/notice step, or a plain streamed answer (empty
// step). Any other step is either a structural/tool event that leaked into
// `messages[]` or an unknown/renamed step from a legacy project — demote it
// to a hidden `unsupported` item rather than render its raw payload.
const step = message.step ?? '';
const isDisplayableStep =
step === '' ||
step === AgentStep.END ||
step === AgentStep.AGENT_END ||
step === AgentStep.AGENT_SUMMARY_END ||
step === AgentStep.NOTICE ||
step === AgentStep.NOTICE_CARD;
if (!isDisplayableStep) {
push({
id: `unsup-${message.id}`,
kind: 'unsupported',
turnId,
createdAt,
reason: STRUCTURAL_STEPS.has(step)
? 'structural-step'
: 'unknown-step',
sourceStep: step || undefined,
sourceRole: message.role,
preview: previewOf(message.content),
messageId: message.id,
});
return;
}
const variant: AgentMessageItem['variant'] =
message.step === AgentStep.END
? 'end'
@ -273,6 +338,7 @@ export function buildProjectChannel(turns: TurnInput[]): ChannelItem[] {
variant,
fileList: message.fileList,
streaming: false,
messageId: message.id,
});
});

View file

@ -55,7 +55,8 @@
/* Code blocks styling */
.markdown-body pre {
background-color: #f6f8fa;
background-color: var(--ds-bg-neutral-muted-default);
color: var(--ds-text-neutral-default-default);
border-radius: 6px;
padding: 16px;
overflow-x: auto;
@ -65,7 +66,8 @@
}
.markdown-body code {
background-color: rgba(175, 184, 193, 0.2);
background-color: var(--ds-bg-neutral-muted-default);
color: var(--ds-text-neutral-default-default);
padding: 0.2em 0.4em;
border-radius: 3px;
font-size: 85%;
@ -81,6 +83,7 @@
.markdown-body pre code {
background-color: transparent;
color: inherit;
padding: 0;
font-size: 100%;
}
@ -97,21 +100,21 @@
.markdown-body table th,
.markdown-body table td {
padding: 6px 13px;
border: 1px solid #d0d7de;
border: 1px solid var(--ds-border-neutral-subtle-default);
}
.markdown-body table th {
font-weight: 600;
background-color: #f6f8fa;
background-color: var(--ds-bg-neutral-subtle-default);
}
.markdown-body table tr {
background-color: #ffffff;
border-top: 1px solid #d0d7de;
background-color: transparent;
border-top: 1px solid var(--ds-border-neutral-subtle-default);
}
.markdown-body table tr:nth-child(2n) {
background-color: #f6f8fa;
background-color: var(--ds-bg-neutral-subtle-default);
}
/* List styling */
@ -132,14 +135,14 @@
/* Blockquote styling */
.markdown-body blockquote {
padding: 0 1em;
color: #57606a;
border-left: 0.25em solid #d0d7de;
color: var(--ds-text-neutral-muted-default);
border-left: 0.25em solid var(--ds-border-neutral-subtle-default);
margin-bottom: 16px;
}
/* Link styling */
.markdown-body a {
color: #0969da;
color: var(--ds-text-information-default-default);
text-decoration: none;
}
@ -165,44 +168,10 @@
border-top: 2px solid var(--ds-border-neutral-default-default);
}
/* Dark mode support - matches app's data-theme attribute */
[data-theme='dark'] .markdown-body {
color: #e6edf3;
}
[data-theme='dark'] .markdown-body pre {
background-color: #161b22;
}
[data-theme='dark'] .markdown-body code {
background-color: rgba(110, 118, 129, 0.4);
}
[data-theme='dark'] .markdown-body table th {
background-color: #161b22;
}
[data-theme='dark'] .markdown-body table th,
[data-theme='dark'] .markdown-body table td {
border-color: #30363d;
}
[data-theme='dark'] .markdown-body table tr {
background-color: transparent;
border-top-color: #30363d;
}
[data-theme='dark'] .markdown-body table tr:nth-child(2n) {
background-color: rgba(110, 118, 129, 0.1);
}
[data-theme='dark'] .markdown-body blockquote {
color: #8b949e;
border-left-color: #30363d;
}
[data-theme='dark'] .markdown-body a {
color: #58a6ff;
/* Dark / transparent theme — all colours via tokens, no hardcoded values */
[data-theme='dark'] .markdown-body,
[data-theme='transparent'] .markdown-body {
color: var(--ds-text-neutral-default-default);
}
/* Task list styling */

View file

@ -56,7 +56,9 @@ export type ChannelItemKind =
| 'failed'
| 'file-output'
| 'ask'
| 'skip-marker';
| 'followup-questions'
| 'skip-marker'
| 'unsupported';
/**
* Fields shared by every channel item.
@ -106,6 +108,11 @@ export interface AgentMessageItem extends ChannelItemBase {
fileList?: FileInfo[];
/** True while the text is still streaming in (suppressed for replay@0ms). */
streaming?: boolean;
/**
* Raw source `Message.id` (the channel item id is prefixed `am-…`). The
* typewriter check compares this against the live task's last message id.
*/
messageId?: string;
}
export interface PlanItem extends ChannelItemBase {
@ -150,6 +157,31 @@ export interface AskItem extends ChannelItemBase {
autoSkipDeadline?: number;
}
/** One sub-question in a follow-up questionnaire. */
export interface FollowupQuestion {
id: string;
prompt: string;
inputKind: 'single' | 'multi';
options: { value: string; label: string }[];
}
/**
* A group of follow-up questions the model asks after a query (e.g. 3 questions,
* each with options). All sub-questions are answered together and submitted as a
* single human reply. The interactive answer controls render in the BottomBox
* question variant; this channel item records the questionnaire in the log.
*/
export interface FollowupQuestionsItem extends ChannelItemBase {
kind: 'followup-questions';
agent: string;
/** Optional intro shown above the questions. */
question: string;
questions: FollowupQuestion[];
answered: boolean;
/** questionId → selected option values (set once answered). */
answers?: Record<string, string[]>;
}
export interface SkipMarkerItem extends ChannelItemBase {
kind: 'skip-marker';
reason: 'agent' | 'timeout' | 'user-stop';
@ -175,6 +207,28 @@ export interface PreparingItem extends ChannelItemBase {
kind: 'preparing';
}
/**
* Fallback for a source message the channel can't confidently render a
* structural/internal step that leaked into `messages[]`, an unknown/renamed
* step from a legacy project, or a renderer that threw on a stale data shape.
*
* It carries no user-facing content: the renderer shows nothing inline (so
* legacy projects don't surface raw internal payloads), only a small inspect
* affordance that reveals the diagnostic chip on demand. The original message is
* never dropped from the channel it is demoted, so the gap is inspectable.
*/
export interface UnsupportedItem extends ChannelItemBase {
kind: 'unsupported';
reason: 'structural-step' | 'unknown-step' | 'render-error';
/** Original message `step`, for diagnostics (never shown to end users). */
sourceStep?: string;
/** Original message `role`, for diagnostics. */
sourceRole?: string;
/** Truncated original content — diagnostic only, behind the inspect toggle. */
preview?: string;
messageId?: string;
}
/** Discriminated union of all channel items (discriminant: `kind`). */
export type ChannelItem =
| TurnBoundaryItem
@ -183,11 +237,13 @@ export type ChannelItem =
| PlanItem
| WorkLogItem
| AskItem
| FollowupQuestionsItem
| SkipMarkerItem
| ErrorItem
| FileOutputItem
| ChainOfThoughtItem
| PreparingItem;
| PreparingItem
| UnsupportedItem;
/** Narrowing helper: maps each `kind` to its concrete item type. */
export type ChannelItemOfKind<K extends ChannelItemKind> = Extract<