修复对话面板过程卡与工具调用块的展示问题

- ProjectSupervisorView:把「任务执行中 / 正在思考中」过程卡从消息列表内部移到列表外,紧贴输入盒上方(固定在输入框上面,不随消息滚走)
- styles.css:过程卡离开列表后补回左右 16px 内缩,与消息内容、输入盒对齐(并补回底部间距)
- ToolCallGroup:未结束时不再显示「结束于 HH:mm」——只有全部工具落定后才显示块尾那行
- ToolCallGroup:进行中改为「现在 - 最早开始时间」并每秒重算,用时数字会实时跳动(此前只在新事件到达时变)
- ToolCallGroup:只有正在跑的回合才显示「进行中 / 执行中」;已结束回合里残留的 running 快照不再让用户看到永远停在「执行中」
- ProjectSupervisorView:给实时回合的折叠块传 active,历史回合的块不传(默认按已结束渲染)
- ComposerControls:推理档从原生 select 换成与模型选择器同一套观感的浮层(复用 conversation-model-* 触发钮与菜单样式,带选中勾选),不再出现操作系统原生下拉
This commit is contained in:
2026-09-15 19:35:57 +08:00
parent cf7e4775b9
commit 802114e14b
4 changed files with 186 additions and 87 deletions
@@ -5,7 +5,17 @@
* 这些组件只承载表现与交互;回合附件由 `App.tsx` 上传并落进 `DirectCodexTurnAttachment`
* 队列由 `chatComposerQueue.ts` 的纯函数维护。
*/
import { FileUp, Images, Mic, MicOff, Plus, Square, X } from 'lucide-react';
import {
Check,
ChevronDown,
FileUp,
Images,
Mic,
MicOff,
Plus,
Square,
X,
} from 'lucide-react';
import type { RefObject } from 'react';
import { useEffect, useRef, useState } from 'react';
@@ -335,6 +345,7 @@ export function ComposerReasoningEffortSelect({
}: {
disabled: boolean;
}) {
const [open, setOpen] = useState(false);
const [effort, setEffort] = useState<GameCreatorLlmReasoningEffort>(
DEFAULT_COMPOSER_REASONING_EFFORT,
);
@@ -414,23 +425,56 @@ export function ComposerReasoningEffortSelect({
return (
<span className="project-supervisor-reasoning-effort">
<select
aria-label="推理档"
className="project-supervisor-reasoning-effort-select"
value={effort}
disabled={disabled || saving}
onChange={(event) =>
selectEffort(
normalizeComposerReasoningEffort(event.currentTarget.value),
)
}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
{/* 与模型选择器同一套观感:复用 `conversation-model-*` 的触发钮与浮层样式,
不再用原生 `<select>`(原生下拉是操作系统菜单,圆角/描边/选中态都跟旁边不一致)。 */}
<div className="conversation-model-select">
<button
type="button"
className="conversation-model-trigger"
aria-label="推理档"
aria-haspopup="listbox"
aria-expanded={open}
disabled={disabled || saving}
onClick={() => setOpen((current) => !current)}
>
<span
className="conversation-model-trigger-status"
aria-hidden="true"
/>
<span className="conversation-model-trigger-label">
{options.find((option) => option.value === effort)?.label ?? effort}
</span>
<ChevronDown size={13} aria-hidden="true" />
</button>
{open ? (
<div
className="conversation-model-menu"
role="listbox"
aria-label="推理档"
>
{options.map((option) => (
<button
key={option.value}
type="button"
role="option"
aria-selected={option.value === effort}
disabled={disabled || saving}
onClick={() => {
setOpen(false);
selectEffort(option.value);
}}
>
<span className="conversation-model-menu-option-main">
<span>{option.label}</span>
</span>
{option.value === effort ? (
<Check size={13} aria-hidden="true" />
) : null}
</button>
))}
</div>
) : null}
</div>
{notice ? (
<span role="status" className="project-supervisor-composer-notice">
{notice}
@@ -450,6 +450,7 @@ export function ProjectSupervisorView({
<ToolCallGroup
calls={liveToolCalls}
userSentAt={userMessageUpdatedAtForTurn(liveToolCallTurnId)}
active
className="message-tool-call"
/>
) : null}
@@ -459,64 +460,6 @@ export function ProjectSupervisorView({
<pre>{designReasoning}</pre>
</details>
) : null}
{directCodex &&
(runtimePanelProps.controlBusy || Boolean(directProcessDetail)) ? (
<section
className={`project-supervisor-process-card${runtimePanelProps.controlBusy ? ' is-active' : ''}`}
aria-label="陶泥儿执行过程"
aria-live="polite"
aria-atomic="false"
role="status"
data-runtime-owned="true"
>
<header>
<span aria-hidden="true" />
<strong>
{directCodex
? directStatusTitle(directStatus)
: '陶泥儿正在处理'}
</strong>
</header>
{transientReply ? (
<div aria-label="陶泥儿实时回复">
<ChatMarkdownMessage
role="assistant"
text={transientReply}
streaming
/>
</div>
) : null}
{directProcessDetail ? (
<div className="project-supervisor-process-detail">
<p
className={
processDetailExpanded ? 'is-expanded' : undefined
}
aria-label="陶泥儿正在执行的内容"
>
{directProcessDetail}
</p>
{directProcessDetail.includes('\n') ||
directProcessDetail.length > 96 ? (
<button
type="button"
className="project-supervisor-process-toggle"
aria-expanded={processDetailExpanded}
onClick={() =>
setExpandedProcessKey((current) =>
current === directProcessKey
? null
: directProcessKey,
)
}
>
{processDetailExpanded ? '收起' : '展开'}
</button>
) : null}
</div>
) : null}
</section>
) : null}
{!directCodex && transientReply ? (
<div
className="message message--assistant"
@@ -597,6 +540,60 @@ export function ProjectSupervisorView({
onRetry={onDesignRetry ?? (() => undefined)}
/>
) : null}
{directCodex &&
(runtimePanelProps.controlBusy || Boolean(directProcessDetail)) ? (
<section
className={`project-supervisor-process-card${runtimePanelProps.controlBusy ? ' is-active' : ''}`}
aria-label="陶泥儿执行过程"
aria-live="polite"
aria-atomic="false"
role="status"
data-runtime-owned="true"
>
<header>
<span aria-hidden="true" />
<strong>
{directCodex
? directStatusTitle(directStatus)
: '陶泥儿正在处理'}
</strong>
</header>
{transientReply ? (
<div aria-label="陶泥儿实时回复">
<ChatMarkdownMessage
role="assistant"
text={transientReply}
streaming
/>
</div>
) : null}
{directProcessDetail ? (
<div className="project-supervisor-process-detail">
<p
className={processDetailExpanded ? 'is-expanded' : undefined}
aria-label="陶泥儿正在执行的内容"
>
{directProcessDetail}
</p>
{directProcessDetail.includes('\n') ||
directProcessDetail.length > 96 ? (
<button
type="button"
className="project-supervisor-process-toggle"
aria-expanded={processDetailExpanded}
onClick={() =>
setExpandedProcessKey((current) =>
current === directProcessKey ? null : directProcessKey,
)
}
>
{processDetailExpanded ? '收起' : '展开'}
</button>
) : null}
</div>
) : null}
</section>
) : null}
<form
className={`project-supervisor-composer${
directCodex ? ' is-direct-codex' : ''
@@ -6,7 +6,7 @@ import {
Terminal,
Wrench,
} from 'lucide-react';
import { useId, useState } from 'react';
import { useEffect, useId, useState } from 'react';
import type { GameCreatorDirectToolCall } from '../../app/types';
import {
@@ -35,31 +35,64 @@ import {
export function ToolCallGroup({
calls,
userSentAt = 0,
active = false,
className,
}: {
calls: GameCreatorDirectToolCall[];
/** 同一回合用户消息的 `updatedAt`;拿不到就传 0,只显示结束时间。 */
userSentAt?: number | null;
/**
* 这个块是否属于**正在跑的回合**。只有正在跑的回合才允许出现"执行中 / 进行中"
* 已结束的回合里若还留着 `running` 快照(进程被杀、completed 事件没到),
* 那是数据残留,不该让用户一直看到"执行中"。
*/
active?: boolean;
className?: string;
}) {
const [expanded, setExpanded] = useState(false);
const [tick, setTick] = useState(0);
const bodyId = useId();
const hasRunningSnapshot = calls.some((call) => call.status === 'running');
const running = active && hasRunningSnapshot;
// 正在跑时每秒重算一次"已用时",否则这个数字只在收到新事件时才变。
useEffect(() => {
if (!running) {
return undefined;
}
const timer = setInterval(() => setTick((current) => current + 1), 1000);
return () => clearInterval(timer);
}, [running]);
if (calls.length === 0) {
return null;
}
void tick;
const orderedCalls = [...calls].sort(
(left, right) => left.startedAt - right.startedAt,
);
const summary = toolCallGroupSummary(orderedCalls);
const totalDurationMs = turnToolCallDurationMs(orderedCalls);
const startedAt =
orderedCalls.find((call) => call.startedAt > 0)?.startedAt ?? 0;
// 进行中:用"现在 - 最早开始时间"(会随 tick 每秒变);已结束:用快照里的
// min(startedAt) → max(updatedAt),不掺入本地时钟。
const totalDurationMs = running
? startedAt > 0
? Math.max(0, Date.now() - startedAt)
: null
: turnToolCallDurationMs(orderedCalls);
const durationText = formatTurnDuration(totalDurationMs);
const timeLabel = turnToolCallTimeLabel(orderedCalls, userSentAt);
const headLabel = durationText ? `${summary},用时 ${durationText}` : summary;
const status = orderedCalls.some((call) => call.status === 'running')
const status = running
? 'running'
: orderedCalls.some((call) => call.status === 'failed')
? 'failed'
: 'completed';
const headLabel = [
summary,
running ? '进行中' : '',
durationText ? `用时 ${durationText}` : '',
]
.filter(Boolean)
.join('');
return (
<section
className={
@@ -85,7 +118,9 @@ export function ToolCallGroup({
<ToolCallKindIcon kind={orderedCalls[0]?.kind ?? 'other'} />
</span>
<span className="agent-tool-call-group-summary">{summary}</span>
{timeLabel ? (
{running ? (
<span className="agent-tool-call-group-running"></span>
) : timeLabel ? (
<span className="agent-tool-call-group-time">{timeLabel}</span>
) : null}
{durationText ? (
@@ -106,10 +141,12 @@ export function ToolCallGroup({
>
<ul className="agent-tool-call-group-rows">
{orderedCalls.map((call) => (
<ToolCallRow key={call.id} call={call} />
<ToolCallRow key={call.id} call={call} active={active} />
))}
</ul>
{timeLabel ? (
{/* 还有工具在跑时**不显示"结束于"**:那时的"结束"只是最后一条快照的 updatedAt
回合并没有结束。等全部落定后再显示块尾这一行。 */}
{!running && timeLabel ? (
<p
className="agent-tool-call-group-foot"
data-testid="agent-tool-call-group-end-time"
@@ -123,18 +160,29 @@ export function ToolCallGroup({
}
/** 展开态的一行工具;行可二级展开看命令 / 文件明细 / 输出。 */
function ToolCallRow({ call }: { call: GameCreatorDirectToolCall }) {
function ToolCallRow({
call,
active,
}: {
call: GameCreatorDirectToolCall;
active: boolean;
}) {
const [expanded, setExpanded] = useState(false);
const detailId = useId();
const text = toolCallRowText(call);
const durationMs = toolCallDurationMs(call);
const durationText = formatToolCallDuration(durationMs);
const statusText =
call.status === 'running'
// "执行中"只在**正在跑的回合**里显示;已结束的回合里残留的 running 快照按已完成处理,
// 否则用户会看到一条永远停在"执行中"的记录。
const statusText = active
? call.status === 'running'
? '执行中'
: call.status === 'failed'
? '失败'
: '';
: ''
: call.status === 'failed'
? '失败'
: '';
const rowLabel = [
text,
statusText,
+10
View File
@@ -11867,3 +11867,13 @@ button.design-workspace-tree__entry:hover,
.project-supervisor-composer-controls-left {
margin-left: -12px;
}
/* 过程卡任务执行中 / 正在思考中现在渲染在消息列表****紧贴输入盒上方固定在
输入框上面不随消息滚走它不再继承消息列表的左右 16px 内缩所以要自己补齐
才能与消息内容输入盒两侧对齐离开列表后列表的 padding-bottom 也不再作用于它 */
.game-workbench-chat
.project-supervisor-surface.is-direct-codex
.project-supervisor-conversation
> .project-supervisor-process-card {
margin: 0 16px 8px;
}