mirror of
https://github.com/eigent-ai/eigent.git
synced 2026-07-09 17:28:35 +00:00
finish initial design
This commit is contained in:
parent
63c9e6ac9b
commit
cad53d982c
15 changed files with 1096 additions and 265 deletions
|
|
@ -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">
|
||||
|
|
|
|||
270
src/components/ChatBox/BottomBox/variants/QuestionVariant.tsx
Normal file
270
src/components/ChatBox/BottomBox/variants/QuestionVariant.tsx
Normal 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} / {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>
|
||||
);
|
||||
}
|
||||
46
src/components/ChatBox/BottomBox/variants/registry.ts
Normal file
46
src/components/ChatBox/BottomBox/variants/registry.ts
Normal 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;
|
||||
}
|
||||
45
src/components/ChatBox/BottomBox/variants/types.ts
Normal file
45
src/components/ChatBox/BottomBox/variants/types.ts
Normal 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;
|
||||
}
|
||||
99
src/components/ChatBox/MessageItem/ChainOfThoughtBox.tsx
Normal file
99
src/components/ChatBox/MessageItem/ChainOfThoughtBox.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
176
src/components/ChatBox/ask/askPayload.ts
Normal file
176
src/components/ChatBox/ask/askPayload.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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<
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue