,
) {
diff --git a/crates/agent-ui/src/i18n/taskTranslations.ts b/crates/agent-ui/src/i18n/taskTranslations.ts
index cd905549f..ed8989833 100644
--- a/crates/agent-ui/src/i18n/taskTranslations.ts
+++ b/crates/agent-ui/src/i18n/taskTranslations.ts
@@ -6,6 +6,8 @@ export const TASK_TRANSLATIONS = {
"chat.taskProgress.pending": "待处理",
"chat.taskProgress.paused": "已暂停或中断",
"chat.taskProgress.completed": "全部完成",
+ "chat.taskProgress.taskPaused": "已暂停",
+ "chat.taskProgress.taskCompleted": "已完成",
"chat.taskProgress.completedCount": "已完成",
"settings.builtinTool.task_create.name": "创建任务",
"settings.builtinTool.task_create.desc": "向当前运行添加一个任务",
@@ -26,6 +28,8 @@ export const TASK_TRANSLATIONS = {
"chat.taskProgress.pending": "Pending",
"chat.taskProgress.paused": "Paused or interrupted",
"chat.taskProgress.completed": "All completed",
+ "chat.taskProgress.taskPaused": "Paused",
+ "chat.taskProgress.taskCompleted": "Completed",
"chat.taskProgress.completedCount": "completed",
"settings.builtinTool.task_create.name": "Create Task",
"settings.builtinTool.task_create.desc": "Add one task to the current run",
diff --git a/crates/agent-ui/src/i18n/translations/enUSCommon.ts b/crates/agent-ui/src/i18n/translations/enUSCommon.ts
index 494ec6d42..680422c77 100644
--- a/crates/agent-ui/src/i18n/translations/enUSCommon.ts
+++ b/crates/agent-ui/src/i18n/translations/enUSCommon.ts
@@ -364,7 +364,55 @@ export const EN_US_COMMON_TRANSLATIONS = {
"chat.send": "Send",
"chat.stopGeneration": "Stop Generation",
"chat.thinking": "Thinking",
+ "chat.thinkingActive": "Thinking",
+ "chat.thoughtFor": "Thought for",
+ "chat.thinkingComplete": "Thinking complete",
"chat.thinkingProcess": "Thinking Process",
+ "chat.time.seconds": "seconds",
+ "chat.work.running": "Processing",
+ "chat.work.activity": "Processed",
+ "chat.work.awaitingDecision": "Waiting for your decision",
+ "chat.tool.batch.read": "Read files",
+ "chat.tool.batch.search": "Searched",
+ "chat.tool.batch.edit": "Edited files",
+ "chat.tool.batch.command": "Ran commands",
+ "chat.tool.batch.list": "Inspected folders",
+ "chat.tool.batch.agent": "Used agents",
+ "chat.tool.batch.other": "Performed operations",
+ "chat.tool.activity.read.running": "Reading",
+ "chat.tool.activity.read.completed": "Read",
+ "chat.tool.activity.read.failed": "Read failed",
+ "chat.tool.activity.search.running": "Searching",
+ "chat.tool.activity.search.completed": "Searched",
+ "chat.tool.activity.search.failed": "Search failed",
+ "chat.tool.activity.edit.running": "Changing",
+ "chat.tool.activity.edit.completed": "Changed",
+ "chat.tool.activity.edit.failed": "Change failed",
+ "chat.tool.activity.command.running": "Running",
+ "chat.tool.activity.command.completed": "Ran",
+ "chat.tool.activity.command.failed": "Command failed",
+ "chat.tool.shell.runningTitle": "Running command",
+ "chat.tool.shell.completedTitle": "Ran command",
+ "chat.tool.shell.failedTitle": "Command failed",
+ "chat.tool.activity.list.running": "Inspecting",
+ "chat.tool.activity.list.completed": "Inspected",
+ "chat.tool.activity.list.failed": "Inspection failed",
+ "chat.tool.activity.agent.running": "Using agent",
+ "chat.tool.activity.agent.completed": "Used agent",
+ "chat.tool.activity.agent.failed": "Agent failed",
+ "chat.tool.activity.other.running": "Running",
+ "chat.tool.file.read.running": "Reading",
+ "chat.tool.file.read.completed": "Read",
+ "chat.tool.file.read.failed": "Read failed",
+ "chat.tool.file.create.running": "Creating",
+ "chat.tool.file.create.completed": "Created",
+ "chat.tool.file.create.failed": "Create failed",
+ "chat.tool.file.edit.running": "Editing",
+ "chat.tool.file.edit.completed": "Edited",
+ "chat.tool.file.edit.failed": "Edit failed",
+ "chat.tool.file.delete.running": "Deleting",
+ "chat.tool.file.delete.completed": "Deleted",
+ "chat.tool.file.delete.failed": "Delete failed",
"chat.retryDetailsToggle": "Retry details ({count})",
"chat.retryAttemptLabel": "Retry {attempt}/{maxAttempts}",
"chat.runtime.thinkingOn": "Thinking enabled",
@@ -436,6 +484,8 @@ export const EN_US_COMMON_TRANSLATIONS = {
"chat.contextCheckpoint.title": "Context Checkpoint",
"chat.contextCheckpoint.messageCount": "{count} msgs",
"chat.contextCheckpoint.compressed": "Compressed",
+ "chat.contextCheckpoint.seam": "Context compressed, continued",
+ "chat.contextCheckpoint.tokensAfter": "{tokens} after",
"chat.manualCompactTitle": "Compact context manually?",
"chat.manualCompactDescription":
"Folds earlier messages into a summary checkpoint to free context space.",
@@ -633,6 +683,10 @@ export const EN_US_COMMON_TRANSLATIONS = {
"chat.askUser.customOption": "Other (type your own)",
"chat.askUser.customPlaceholder": "Type your answer…",
"chat.askUser.progress": "selected",
+ "chat.askUser.previousQuestion": "Previous question",
+ "chat.askUser.nextQuestion": "Next question",
+ "chat.askUser.skip": "Skip",
+ "chat.askUser.continue": "Continue",
"chat.askUser.submit": "Submit answers",
"chat.askUser.submitting": "Submitting…",
"chat.askUser.answered": "Answers submitted",
diff --git a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts
index ed23ae857..41c29c169 100644
--- a/crates/agent-ui/src/i18n/translations/zhCNCommon.ts
+++ b/crates/agent-ui/src/i18n/translations/zhCNCommon.ts
@@ -329,7 +329,55 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"chat.send": "发送",
"chat.stopGeneration": "停止生成",
"chat.thinking": "思考中",
+ "chat.thinkingActive": "正在思考",
+ "chat.thoughtFor": "思考了",
+ "chat.thinkingComplete": "思考完成",
"chat.thinkingProcess": "思考过程",
+ "chat.time.seconds": "秒",
+ "chat.work.running": "处理中",
+ "chat.work.activity": "已处理",
+ "chat.work.awaitingDecision": "等待你的决定",
+ "chat.tool.batch.read": "读取了文件",
+ "chat.tool.batch.search": "进行了搜索",
+ "chat.tool.batch.edit": "编辑了文件",
+ "chat.tool.batch.command": "运行了命令",
+ "chat.tool.batch.list": "查看了目录",
+ "chat.tool.batch.agent": "调用了代理",
+ "chat.tool.batch.other": "执行了操作",
+ "chat.tool.activity.read.running": "正在读取",
+ "chat.tool.activity.read.completed": "已读取",
+ "chat.tool.activity.read.failed": "读取失败",
+ "chat.tool.activity.search.running": "正在搜索",
+ "chat.tool.activity.search.completed": "已搜索",
+ "chat.tool.activity.search.failed": "搜索失败",
+ "chat.tool.activity.edit.running": "正在修改",
+ "chat.tool.activity.edit.completed": "已修改",
+ "chat.tool.activity.edit.failed": "修改失败",
+ "chat.tool.activity.command.running": "正在运行",
+ "chat.tool.activity.command.completed": "已运行",
+ "chat.tool.activity.command.failed": "运行失败",
+ "chat.tool.shell.runningTitle": "正在运行命令",
+ "chat.tool.shell.completedTitle": "已运行命令",
+ "chat.tool.shell.failedTitle": "命令运行失败",
+ "chat.tool.activity.list.running": "正在查看",
+ "chat.tool.activity.list.completed": "已查看",
+ "chat.tool.activity.list.failed": "查看失败",
+ "chat.tool.activity.agent.running": "正在调用代理",
+ "chat.tool.activity.agent.completed": "已调用代理",
+ "chat.tool.activity.agent.failed": "代理失败",
+ "chat.tool.activity.other.running": "正在执行",
+ "chat.tool.file.read.running": "正在读取",
+ "chat.tool.file.read.completed": "已读取",
+ "chat.tool.file.read.failed": "读取失败",
+ "chat.tool.file.create.running": "正在创建",
+ "chat.tool.file.create.completed": "已创建",
+ "chat.tool.file.create.failed": "创建失败",
+ "chat.tool.file.edit.running": "正在编辑",
+ "chat.tool.file.edit.completed": "已编辑",
+ "chat.tool.file.edit.failed": "编辑失败",
+ "chat.tool.file.delete.running": "正在删除",
+ "chat.tool.file.delete.completed": "已删除",
+ "chat.tool.file.delete.failed": "删除失败",
"chat.retryDetailsToggle": "重试详情 ({count})",
"chat.retryAttemptLabel": "第 {attempt}/{maxAttempts} 次重试",
"chat.runtime.thinkingOn": "Thinking 已开启",
@@ -397,6 +445,8 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"chat.contextCheckpoint.title": "上下文检查点",
"chat.contextCheckpoint.messageCount": "{count} 条消息",
"chat.contextCheckpoint.compressed": "已压缩",
+ "chat.contextCheckpoint.seam": "已压缩上下文,继续处理",
+ "chat.contextCheckpoint.tokensAfter": "压缩后 {tokens}",
"chat.manualCompactTitle": "手动压缩上下文?",
"chat.manualCompactDescription": "将历史消息折叠为摘要检查点,释放上下文空间。",
"chat.manualCompactConfirm": "压缩",
@@ -589,6 +639,10 @@ export const ZH_CN_COMMON_TRANSLATIONS = {
"chat.askUser.customOption": "其他(自行输入)",
"chat.askUser.customPlaceholder": "输入你的回答…",
"chat.askUser.progress": "已选择",
+ "chat.askUser.previousQuestion": "上一题",
+ "chat.askUser.nextQuestion": "下一题",
+ "chat.askUser.skip": "跳过",
+ "chat.askUser.continue": "继续",
"chat.askUser.submit": "提交回答",
"chat.askUser.submitting": "提交中…",
"chat.askUser.answered": "已提交回答",
diff --git a/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts b/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts
index b137bbf0b..9f42d9250 100644
--- a/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts
+++ b/crates/agent-ui/src/lib/chat-scroll/scrollFollowCore.ts
@@ -1,6 +1,6 @@
// Scroll-follow core for bottom-pinned live views: the chat transcript
-// viewport and the thinking-block both run on this reducer via
-// useScrollFollow.
+// viewport and the streaming thinking-block scroller both run on this reducer
+// via useScrollFollow.
//
// This replaces the 2026-07 scrollFollowPolicy after four rounds of
// device-specific patches. Instead of refining the "was that scroll the user
diff --git a/crates/agent-ui/src/lib/chat/composerOverlayMetrics.ts b/crates/agent-ui/src/lib/chat/composerOverlayMetrics.ts
new file mode 100644
index 000000000..ea82a85b7
--- /dev/null
+++ b/crates/agent-ui/src/lib/chat/composerOverlayMetrics.ts
@@ -0,0 +1,46 @@
+type VerticalRect = Pick;
+type HorizontalRect = Pick;
+
+export type ComposerOverlayRect = VerticalRect;
+export type ComposerOverlayHorizontalRect = HorizontalRect;
+
+export type ComposerOverlayMetrics = {
+ /** 输入区常规占位高度(已扣除队列面板),驱动正文底部预留。 */
+ heightPx: number;
+ /**
+ * 预留线之上、被浮动元素额外占据的高度:队列面板从 heightPx 中扣除、
+ * 任务进度药丸绝对定位在卡片列之外,两者都不推高底部预留,却会盖住同样
+ * 浮在输入区上方的控件(如回到底部按钮)。这类控件要在 heightPx 之上再让出
+ * 这段距离。
+ */
+ floatingOverhangPx: number;
+ /**
+ * 卡片列中心相对输入区层中心的水平偏移(向右为正)。desktop 卡片为对齐
+ * 正文会整体右移,居中锚定在输入区上方的控件要跟着平移才能与卡片、药丸对齐。
+ */
+ centerOffsetPx: number;
+};
+
+export function measureComposerOverlay(input: {
+ layer: VerticalRect & HorizontalRect;
+ queueHeight: number;
+ /** 任务进度药丸容器;未渲染药丸时高度为 0。 */
+ floating: VerticalRect | null | undefined;
+ /** 卡片列(队列面板、输入卡片、药丸的共同水平参照)。 */
+ column: HorizontalRect | null | undefined;
+}): ComposerOverlayMetrics {
+ const { layer, queueHeight, floating, column } = input;
+ const heightPx = Math.ceil(Math.max(0, layer.height - queueHeight));
+ const reserveLine = layer.bottom - heightPx;
+ const floatingTop =
+ floating && floating.height > 0 ? Math.min(layer.top, floating.top) : layer.top;
+ const centerOffsetPx =
+ column && column.width > 0
+ ? Math.round(column.left + column.width / 2 - (layer.left + layer.width / 2))
+ : 0;
+ return {
+ heightPx,
+ floatingOverhangPx: Math.ceil(Math.max(0, reserveLine - floatingTop)),
+ centerOffsetPx,
+ };
+}
diff --git a/crates/agent-ui/src/lib/chat/formatTokenCount.ts b/crates/agent-ui/src/lib/chat/formatTokenCount.ts
new file mode 100644
index 000000000..8d77f2ca7
--- /dev/null
+++ b/crates/agent-ui/src/lib/chat/formatTokenCount.ts
@@ -0,0 +1,22 @@
+const tokenCountFormatterByLocale = new Map();
+
+function getTokenCountFormatter(locale: string): Intl.NumberFormat {
+ const cached = tokenCountFormatterByLocale.get(locale);
+ if (cached) return cached;
+
+ const formatter = new Intl.NumberFormat(locale, {
+ maximumFractionDigits: 2,
+ useGrouping: false,
+ });
+ tokenCountFormatterByLocale.set(locale, formatter);
+ return formatter;
+}
+
+export function formatTokenCount(value: number, locale: string): string {
+ const roundedValue = Number.isFinite(value) ? Math.max(0, Math.round(value)) : 0;
+ const formatter = getTokenCountFormatter(locale);
+ if (roundedValue < 1_000) {
+ return formatter.format(roundedValue);
+ }
+ return `${formatter.format(roundedValue / 1_000)}K`;
+}
diff --git a/crates/agent-ui/src/lib/chat/replyContinuity.ts b/crates/agent-ui/src/lib/chat/replyContinuity.ts
new file mode 100644
index 000000000..a4f7f6154
--- /dev/null
+++ b/crates/agent-ui/src/lib/chat/replyContinuity.ts
@@ -0,0 +1,234 @@
+// Reply continuity across context compaction.
+//
+// A compaction that lands in the middle of a reply is persisted as
+// `assistant(A) → checkpoint → assistant(B)` with no user message between
+// the halves. Rendered naively that reads as two replies separated by a
+// card. This module is the single place that folds such a chain back into
+// ONE reply: the checkpoint becomes a zero-block "seam round" placed between
+// the halves, so every downstream consumer (turn layout, changed-files
+// aggregation, usage entries, reply text) sees one continuous round
+// sequence and the transcript renders one avatar, one work trace with an
+// inline seam milestone, one answer and one footer.
+//
+// Both frontends feed their own row/item shapes through the generic
+// stitcher; the seam payload is a structural subset of the GUI summary item
+// and the WebUI checkpoint row, so either can be passed as-is.
+
+export type CompactionSeam = {
+ key: string;
+ summaryId: string;
+ content: string;
+ coveredMessageCount: number;
+ generatedBy: {
+ providerId: string;
+ model: string;
+ promptVersion?: string;
+ };
+ contextUsageTokens?: number;
+ timestamp?: number;
+};
+
+/**
+ * A checkpoint projected into the round sequence of a reply. It carries no
+ * content blocks, so block-level consumers (changed files, reply text,
+ * usage) skip it transparently; only the turn layout materializes it as a
+ * `checkpoint` work entry.
+ */
+export type CompactionSeamRound = {
+ round: number;
+ key: string;
+ blocks: [];
+ runningToolCallIds: [];
+ thinkingOpen: false;
+ /** Never set; present so the seam satisfies round-shaped consumers. */
+ meta?: undefined;
+ checkpoint: CompactionSeam;
+};
+
+const seamRoundCache = new WeakMap();
+
+export function createCompactionSeamRound(seam: CompactionSeam): CompactionSeamRound {
+ const cached = seamRoundCache.get(seam);
+ if (cached) return cached;
+ const round: CompactionSeamRound = {
+ round: 0,
+ key: seam.key,
+ blocks: [],
+ runningToolCallIds: [],
+ thinkingOpen: false,
+ checkpoint: seam,
+ };
+ seamRoundCache.set(seam, round);
+ return round;
+}
+
+export function getCompactionSeam(round: object): CompactionSeam | null {
+ const candidate = (round as { checkpoint?: unknown }).checkpoint;
+ if (!candidate || typeof candidate !== "object") return null;
+ const seam = candidate as Partial;
+ return typeof seam.key === "string" && typeof seam.content === "string"
+ ? (seam as CompactionSeam)
+ : null;
+}
+
+export function countCompactionSeams(rounds: readonly object[]): number {
+ let count = 0;
+ for (const round of rounds) {
+ if (getCompactionSeam(round)) count += 1;
+ }
+ return count;
+}
+
+// ---------------------------------------------------------------------------
+// Round re-keying
+
+type RekeyableRound = { round: number; key?: string };
+
+const rekeyedRoundCache = new Map>();
+
+/**
+ * Round keys are per-group ordinals (`r1`, `r2`, …) in persisted history and
+ * runner round numbers while streaming, so the halves of a stitched reply
+ * collide (`r1` in both) and a streamed continuation may not match its
+ * persisted twin (the runner keeps counting after a post-tool compaction
+ * but restarts after a mid-stream one). Every part after the first is
+ * re-keyed by its POSITION inside the part (`p:r`): the live
+ * continuation and its persisted twin then agree by construction, which is
+ * what keeps settling a stitched reply remount-free. Part 0 keeps its
+ * original keys so ordinary replies are untouched.
+ */
+export function rekeyContinuationRounds(
+ rounds: readonly T[],
+ partOrdinal: number,
+): T[] {
+ if (partOrdinal <= 0) return rounds as T[];
+ let cache = rekeyedRoundCache.get(partOrdinal);
+ if (!cache) {
+ cache = new WeakMap();
+ rekeyedRoundCache.set(partOrdinal, cache);
+ }
+ return rounds.map((round, index) => {
+ const cached = cache.get(round);
+ if (cached) return cached as T;
+ const rekeyed = { ...round, key: `p${partOrdinal}:r${index + 1}` };
+ cache.set(round, rekeyed);
+ return rekeyed;
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Timeline stitching
+
+export type ReplyStitchClass = "user" | "assistant" | "checkpoint" | "other";
+
+export type StitchedTimelineGroup =
+ | { kind: "single"; item: T; index: number }
+ | {
+ kind: "reply";
+ items: T[];
+ start: number;
+ /** Exclusive end index into the source list. */
+ end: number;
+ };
+
+/**
+ * Group a transcript into render units. A checkpoint is a seam of the reply
+ * it interrupts exactly when an assistant part follows it without a user
+ * message in between; a checkpoint that ends a run (idle manual compaction,
+ * pre-send compaction, a run that stopped right after compacting) stays a
+ * standalone divider. Leading checkpoints of a reply (compaction fired
+ * before any content) fold into that reply as its first seam.
+ */
+export function stitchCompactedReplies(
+ items: readonly T[],
+ classify: (item: T) => ReplyStitchClass,
+): StitchedTimelineGroup[] {
+ const groups: StitchedTimelineGroup[] = [];
+ let index = 0;
+ while (index < items.length) {
+ const item = items[index] as T;
+ const itemClass = classify(item);
+ if (itemClass !== "assistant" && itemClass !== "checkpoint") {
+ groups.push({ kind: "single", item, index });
+ index += 1;
+ continue;
+ }
+
+ let runEnd = index;
+ while (runEnd < items.length) {
+ const runClass = classify(items[runEnd] as T);
+ if (runClass !== "assistant" && runClass !== "checkpoint") break;
+ runEnd += 1;
+ }
+
+ let replyEnd = runEnd;
+ while (replyEnd > index && classify(items[replyEnd - 1] as T) === "checkpoint") {
+ replyEnd -= 1;
+ }
+
+ if (replyEnd - index === 1) {
+ // A lone assistant item is an ordinary reply; keep it on the untouched
+ // single-item path so nothing about it changes.
+ groups.push({ kind: "single", item: items[index] as T, index });
+ } else if (replyEnd > index) {
+ groups.push({
+ kind: "reply",
+ items: items.slice(index, replyEnd) as T[],
+ start: index,
+ end: replyEnd,
+ });
+ }
+ for (let single = replyEnd; single < runEnd; single += 1) {
+ groups.push({ kind: "single", item: items[single] as T, index: single });
+ }
+ index = runEnd;
+ }
+ return groups;
+}
+
+export type ContinuousReplyAdapters = {
+ classify: (item: TItem) => ReplyStitchClass;
+ roundsOf: (item: TItem) => readonly TRound[];
+ seamOf: (item: TItem) => CompactionSeam;
+ /**
+ * Whether assistant parts after the first need positional re-keying. GUI
+ * history rounds are per-group ordinals and collide; WebUI rounds already
+ * carry a group-unique prefix.
+ */
+ rekeyParts: boolean;
+};
+
+export type ContinuousReply = {
+ rounds: (TRound | CompactionSeamRound)[];
+ seamCount: number;
+ partCount: number;
+};
+
+/**
+ * Flatten the members of a stitched reply into one round sequence:
+ * assistant parts contribute their rounds (re-keyed by part ordinal when
+ * requested), checkpoints contribute a seam round at their position.
+ */
+export function assembleContinuousReply(
+ items: readonly TItem[],
+ adapters: ContinuousReplyAdapters,
+): ContinuousReply {
+ const rounds: (TRound | CompactionSeamRound)[] = [];
+ let partCount = 0;
+ let seamCount = 0;
+ for (const item of items) {
+ const itemClass = adapters.classify(item);
+ if (itemClass === "checkpoint") {
+ rounds.push(createCompactionSeamRound(adapters.seamOf(item)));
+ seamCount += 1;
+ continue;
+ }
+ if (itemClass !== "assistant") continue;
+ const partRounds = adapters.roundsOf(item);
+ rounds.push(
+ ...(adapters.rekeyParts ? rekeyContinuationRounds(partRounds, partCount) : partRounds),
+ );
+ partCount += 1;
+ }
+ return { rounds, seamCount, partCount };
+}
diff --git a/crates/agent-ui/src/lib/chat/thinkingDurations.ts b/crates/agent-ui/src/lib/chat/thinkingDurations.ts
new file mode 100644
index 000000000..cdc80c316
--- /dev/null
+++ b/crates/agent-ui/src/lib/chat/thinkingDurations.ts
@@ -0,0 +1,47 @@
+// Reasoning blocks carry no timing metadata from providers, so "思考了 Xs" is
+// measured client-side while a segment streams. State lives at module scope,
+// keyed by the transcript entry key, so it survives virtualization unmounts
+// mid-run; after an app reload the header simply falls back to the untimed
+// "思考过程" label. Both maps are idempotent per key, which lets renderers
+// call resolveThinkingDurationMs during render (StrictMode double-render safe).
+
+const MAX_TRACKED_SEGMENTS = 512;
+
+const startedAt = new Map();
+const settledDurations = new Map();
+
+function trimOldest(map: Map) {
+ while (map.size > MAX_TRACKED_SEGMENTS) {
+ const oldest = map.keys().next().value;
+ if (oldest === undefined) return;
+ map.delete(oldest);
+ }
+}
+
+/**
+ * Reads the settled duration for a reasoning segment, advancing the tracker
+ * as a side effect: an active segment starts (or keeps) its clock and reports
+ * null; an inactive segment settles a pending clock exactly once. A segment
+ * that was never observed streaming (history reload) always reports null.
+ */
+export function resolveThinkingDurationMs(
+ key: string,
+ active: boolean,
+ now = Date.now(),
+): number | null {
+ if (active) {
+ if (!settledDurations.has(key) && !startedAt.has(key)) {
+ startedAt.set(key, now);
+ trimOldest(startedAt);
+ }
+ return null;
+ }
+
+ const start = startedAt.get(key);
+ if (start !== undefined) {
+ startedAt.delete(key);
+ settledDurations.set(key, Math.max(0, now - start));
+ trimOldest(settledDurations);
+ }
+ return settledDurations.get(key) ?? null;
+}
diff --git a/crates/agent-ui/src/lib/chat/thinkingOverlayModel.ts b/crates/agent-ui/src/lib/chat/thinkingOverlayModel.ts
deleted file mode 100644
index 9afe7ab3f..000000000
--- a/crates/agent-ui/src/lib/chat/thinkingOverlayModel.ts
+++ /dev/null
@@ -1,59 +0,0 @@
-export type ThinkingOverlayRect = {
- left: number;
- right: number;
- top: number;
- bottom: number;
- width: number;
- height: number;
-};
-
-export type ThinkingOverlayViewport = { width: number; height: number };
-
-export type ThinkingOverlayPlacement = {
- side: "above" | "below";
- left: number;
- width: number;
- maxHeight: number;
- top?: number;
- bottom?: number;
-};
-
-const VIEWPORT_MARGIN_PX = 12;
-const OVERLAY_GAP_PX = 8;
-const MAX_OVERLAY_WIDTH_PX = 640;
-const MIN_PREFERRED_HEIGHT_PX = 180;
-
-export function resolveThinkingOverlayPlacement(
- trigger: ThinkingOverlayRect,
- viewport: ThinkingOverlayViewport,
-): ThinkingOverlayPlacement {
- const viewportWidth = Math.max(1, viewport.width);
- const horizontalMargin = Math.min(VIEWPORT_MARGIN_PX, Math.max(0, (viewportWidth - 1) / 2));
- const availableWidth = Math.max(1, viewportWidth - horizontalMargin * 2);
- const width = Math.min(MAX_OVERLAY_WIDTH_PX, availableWidth);
- const centeredLeft = trigger.left + (trigger.width - width) / 2;
- const left = Math.min(
- Math.max(horizontalMargin, centeredLeft),
- Math.max(horizontalMargin, viewportWidth - horizontalMargin - width),
- );
- const above = Math.max(0, trigger.top - OVERLAY_GAP_PX - VIEWPORT_MARGIN_PX);
- const below = Math.max(0, viewport.height - trigger.bottom - OVERLAY_GAP_PX - VIEWPORT_MARGIN_PX);
- const side = above >= MIN_PREFERRED_HEIGHT_PX || above >= below ? "above" : "below";
-
- if (side === "above") {
- return {
- side,
- left,
- width,
- maxHeight: above,
- bottom: viewport.height - trigger.top + OVERLAY_GAP_PX,
- };
- }
- return {
- side,
- left,
- width,
- maxHeight: below,
- top: trigger.bottom + OVERLAY_GAP_PX,
- };
-}
diff --git a/crates/agent-ui/src/lib/chat/userMessageContent.tsx b/crates/agent-ui/src/lib/chat/userMessageContent.tsx
index 9a4e4b157..7a206c415 100644
--- a/crates/agent-ui/src/lib/chat/userMessageContent.tsx
+++ b/crates/agent-ui/src/lib/chat/userMessageContent.tsx
@@ -1057,15 +1057,15 @@ export const UserMessageContent = memo(function UserMessageContent({
) : null;
if (!hasChip) {
return (
- <>
+
{normalizedText}
{trailingNewlineAnchor}
- >
+
);
}
return (
- <>
+
{parts.map((part, idx) => {
const key = userMessageSegmentKey(part, idx);
if (part.type === "mention") {
@@ -1101,6 +1101,6 @@ export const UserMessageContent = memo(function UserMessageContent({
return {part.value};
})}
{trailingNewlineAnchor}
- >
+
);
});
diff --git a/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx b/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx
index 978d66ba4..78761008f 100644
--- a/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx
+++ b/crates/agent-ui/src/pages/chat/ChatComposerBar.tsx
@@ -29,6 +29,7 @@ import {
} from "@liveagent/ui/components/chat/MentionComposer";
import { GitBranchSelector } from "@liveagent/ui/components/git/GitBranchSelector";
import {
+ ArrowUp,
ChevronDown,
ChevronUp,
Clock3,
@@ -41,7 +42,6 @@ import {
Paperclip,
Play,
Plus,
- Send,
Square,
SquarePen,
Trash2,
@@ -57,6 +57,7 @@ import {
} from "@liveagent/ui/components/ui/dropdown-menu";
import { LabelTooltip as RuntimeControlTooltip } from "@liveagent/ui/components/ui/label-tooltip";
import { useLocale } from "@liveagent/ui/i18n/index";
+import { measureComposerOverlay } from "@liveagent/ui/lib/chat/composerOverlayMetrics";
import {
type ConversationReferenceInsertResult,
getActiveConversationReferenceDrag,
@@ -364,6 +365,18 @@ export type ChatComposerBarProps = {
/** 澄清系统提示词附带的轻量工作区信息。 */
clarifyContext?: ClarifyContext;
onHeightChange?: (height: number) => void;
+ /**
+ * 预留线之上被队列面板、任务进度药丸额外占据的高度(见
+ * composerOverlayMetrics)。它们不计入 onHeightChange,但浮在输入区上方的
+ * 控件(回到底部按钮)要再让出这段距离才不会被盖住。仅 desktop 上报。
+ */
+ onFloatingOverhangChange?: (height: number) => void;
+ /**
+ * 卡片列中心相对输入区层中心的水平偏移(向右为正)。desktop 卡片为对齐正文
+ * 整体右移,居中锚定在输入区上方的控件要按此平移才能与卡片、药丸对齐。
+ * 仅 desktop 上报。
+ */
+ onCenterOffsetChange?: (offsetPx: number) => void;
/** 当前会话任务进度(存在时渲染在审批栏和队列面板之上)。 */
taskProgressBar?: ReactNode;
/** 待审批时替换输入卡片的集中审批面板。 */
@@ -449,6 +462,8 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer
runClarifyTurn,
clarifyContext,
onHeightChange,
+ onFloatingOverhangChange,
+ onCenterOffsetChange,
taskProgressBar,
approvalBar,
fileDropOverlay,
@@ -483,6 +498,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer
onError: onSttError,
});
const [isComposerExpanded, setIsComposerExpanded] = useState(false);
+ const [composerHasOverflow, setComposerHasOverflow] = useState(false);
const isComposerExpandedRef = useRef(false);
const glassCardRef = useRef(null);
const conversationDragDepthRef = useRef(0);
@@ -499,8 +515,13 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer
const expandFromHeightRef = useRef(null);
const expandAnimationRef = useRef(null);
const scheduleHeightMeasureRef = useRef<(() => void) | null>(null);
+ const scheduleComposerOverflowMeasureRef = useRef<(() => void) | null>(null);
const composerLayerRef = useRef(null);
+ const composerColumnRef = useRef(null);
const queuePanelRef = useRef(null);
+ // 走 state 而非 ref:容器随 taskProgressBar 插槽挂载/卸载,测量 effect 要
+ // 跟着重新观察它。
+ const [taskProgressBarElement, setTaskProgressBarElement] = useState(null);
const queueListRef = useRef(null);
const queueScrollbarTrackRef = useRef(null);
const queueScrollbarDragRef = useRef<{
@@ -857,6 +878,7 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer
}
// 还原方向的高度上报在动画期间被冻结,落定后补测一次。
scheduleHeightMeasureRef.current?.();
+ scheduleComposerOverflowMeasureRef.current?.();
};
animation.onfinish = clear;
animation.oncancel = clear;
@@ -934,6 +956,56 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer
onSend();
}, [clarifyOpen, onSend, setComposerExpanded]);
+ useEffect(() => {
+ const editor = glassCardRef.current?.querySelector(".mention-composer");
+ if (!editor) return;
+
+ let animationFrame: number | null = null;
+ const measureOverflow = () => {
+ animationFrame = null;
+ // 展开态的编辑区拥有更大的视口,不能用它覆盖常规态的溢出结果;
+ // 否则放大后按钮会立刻消失,用户将无法还原。
+ if (isComposerExpandedRef.current || expandAnimationRef.current) return;
+ const nextHasOverflow = editor.scrollHeight - editor.clientHeight > 1;
+ setComposerHasOverflow((current) =>
+ current === nextHasOverflow ? current : nextHasOverflow,
+ );
+ };
+ const scheduleMeasure = () => {
+ if (animationFrame !== null) return;
+ animationFrame = window.requestAnimationFrame(measureOverflow);
+ };
+
+ scheduleComposerOverflowMeasureRef.current = scheduleMeasure;
+ scheduleMeasure();
+ editor.addEventListener("input", scheduleMeasure);
+ window.addEventListener("resize", scheduleMeasure);
+
+ const resizeObserver =
+ typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure);
+ resizeObserver?.observe(editor);
+ const mutationObserver =
+ typeof MutationObserver === "undefined" ? null : new MutationObserver(scheduleMeasure);
+ mutationObserver?.observe(editor, {
+ characterData: true,
+ childList: true,
+ subtree: true,
+ });
+
+ return () => {
+ if (animationFrame !== null) window.cancelAnimationFrame(animationFrame);
+ if (scheduleComposerOverflowMeasureRef.current === scheduleMeasure) {
+ scheduleComposerOverflowMeasureRef.current = null;
+ }
+ editor.removeEventListener("input", scheduleMeasure);
+ window.removeEventListener("resize", scheduleMeasure);
+ resizeObserver?.disconnect();
+ mutationObserver?.disconnect();
+ };
+ }, []);
+
+ const showComposerExpandToggle = isComposerExpanded || composerHasOverflow;
+
const shouldShowQueueScrollbar = !queueCollapsed && queuedTurns.length > 2;
const updateQueueScrollbar = useCallback(() => {
@@ -1069,15 +1141,21 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer
if (!composerLayer) return;
if (surface === "desktop") {
- if (!onHeightChange) return;
+ if (!onHeightChange && !onFloatingOverhangChange && !onCenterOffsetChange) return;
let animationFrame: number | null = null;
const measure = () => {
animationFrame = null;
if (isComposerExpandedRef.current || expandAnimationRef.current) return;
- const composerLayerHeight = composerLayer.getBoundingClientRect().height;
- const queueHeight = queuePanelRef.current?.getBoundingClientRect().height ?? 0;
- onHeightChange(Math.ceil(Math.max(0, composerLayerHeight - queueHeight)));
+ const metrics = measureComposerOverlay({
+ layer: composerLayer.getBoundingClientRect(),
+ queueHeight: queuePanelRef.current?.getBoundingClientRect().height ?? 0,
+ floating: taskProgressBarElement?.getBoundingClientRect(),
+ column: composerColumnRef.current?.getBoundingClientRect(),
+ });
+ onHeightChange?.(metrics.heightPx);
+ onFloatingOverhangChange?.(metrics.floatingOverhangPx);
+ onCenterOffsetChange?.(metrics.centerOffsetPx);
};
const scheduleMeasure = () => {
if (animationFrame !== null) return;
@@ -1089,6 +1167,10 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer
const resizeObserver =
typeof ResizeObserver === "undefined" ? null : new ResizeObserver(scheduleMeasure);
resizeObserver?.observe(composerLayer);
+ // 卡片列宽度跟随正文宽度设置,可在 composerLayer 尺寸不变时独立变化。
+ if (composerColumnRef.current) resizeObserver?.observe(composerColumnRef.current);
+ // 药丸容器绝对定位在卡片列之外,出现/消失不会改变 composerLayer 的尺寸。
+ if (taskProgressBarElement) resizeObserver?.observe(taskProgressBarElement);
window.addEventListener("resize", scheduleMeasure);
return () => {
@@ -1098,7 +1180,9 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer
}
resizeObserver?.disconnect();
window.removeEventListener("resize", scheduleMeasure);
- onHeightChange(0);
+ onHeightChange?.(0);
+ onFloatingOverhangChange?.(0);
+ onCenterOffsetChange?.(0);
};
}
@@ -1137,14 +1221,20 @@ export const ChatComposerBar = memo(function ChatComposerBar(props: ChatComposer
resizeObserver.disconnect();
chatFrame.style.removeProperty("--gateway-chat-composer-overlay-height");
};
- }, [onHeightChange, surface]);
+ }, [
+ onHeightChange,
+ onFloatingOverhangChange,
+ onCenterOffsetChange,
+ surface,
+ taskProgressBarElement,
+ ]);
return (
) : null}
+ {/* Desktop aligns to the assistant message body, not the avatar rail:
+ transcript px-5 + 28px avatar + 12px gap + px-5 = 80px removed;
+ the remaining body is right-shifted 20px inside the centered column.
+ The card extends 4px past the body's left edge so scrolling content
+ cannot peek around its rounded lower-left corner. */}
- {taskProgressBar}
+ {taskProgressBar ? (
+
+ {taskProgressBar}
+
+ ) : null}
{queuedTurns.length > 0 ? (
) : null}
- {/* macOS material rim-light */}
- {/* subtle inner gloss gradient */}
-
-
- {pendingUploadedFiles.length > 0 ? (
-
- {pendingUploadedFiles.map((file) => (
-
- ))}
-
- ) : null}
-
-
-
- {/* 用量环位于卡片右侧控制列的垂直中心,保持在展开与发送按钮之间。
- "ring" / "both" 展示模式渲染,"statsBar" 模式整枚不渲染(§4.7)。 */}
- {contextDisplayMode === "ring" || contextDisplayMode === "both" ? (
-
-
-
- ) : null}
+ >
+ {pendingUploadedFiles.length > 0 ? (
+
+ {pendingUploadedFiles.map((file) => (
+
+ ))}
+
+ ) : null}
+
+ {showComposerExpandToggle ? (
+
+ ) : null}
- {/* 常驻 flex-1:动画把卡片钳在中间高度时由本区吸收伸缩,工具栏才能
+ {/* 常驻 flex-1:动画把卡片钳在中间高度时由本区吸收伸缩,工具栏才能
全程贴住卡片底边。min-h-0 只在展开态加——折叠态靠自动最小高度
(= 编辑器钳制高) 撑起卡片的固有高度,加了会塌缩。
- pr-12 让出右侧控制列:展开/用量环/发送都是 right-3 + w-8,占据卡片
- 右缘 44px 宽的竖直轨道。让位必须做在本容器上,**不能只给编辑器加
+ pr-12 为右上角展开按钮让位。让位必须做在本容器上,**不能只给编辑器加
pr-8**——padding 不改变滚动条位置(滚动条恒贴 border box 右缘),
- 只挡文字不挡滚动条,溢出时那条 6px 轨会直接压在环与展开图标上。
- 收窄编辑器 border box 才能把滚动条一并推到轨道左侧;48px = 44 轨道
- + 4px 间隙,文本可用宽度与原先 px-4 + 编辑器 pr-8 完全一致。 */}
-
0 ? "pt-1.5" : "pt-2.5",
- isComposerExpanded && "min-h-0",
- )}
- onFocusCapture={onPrepareChatRuntime}
- onInput={handleComposerInput}
- >
-
item.id !== conversationId)
- : []
- }
- searchConversations={
- isAgentExecutionMode(executionMode) ? searchMentionableConversations : undefined
- }
- currentConversationId={conversationId}
- mentionApps={mentionApps}
+ 只挡文字不挡滚动条,溢出时那条 6px 轨会直接压在展开图标上。
+ 收窄编辑器 border box 才能把滚动条一并推到按钮左侧。 */}
+ 0 ? "pt-1.5" : "pt-3",
+ isComposerExpanded && "min-h-0",
)}
- />
-
+ onFocusCapture={onPrepareChatRuntime}
+ onInput={handleComposerInput}
+ >
+ item.id !== conversationId)
+ : []
+ }
+ searchConversations={
+ isAgentExecutionMode(executionMode) ? searchMentionableConversations : undefined
+ }
+ currentConversationId={conversationId}
+ mentionApps={mentionApps}
+ className={cn(
+ // 右让位由外层容器 pr-12 统一承担(见上),此处不再补 pr——
+ // 编辑器自身的右内距只会把文字推开、留下滚动条压在控制列上。
+ // min-h 覆盖编辑器默认 70px,为卡片下方的会话统计栏留出高度预算。
+ "min-h-[60px] px-0 py-0",
+ isComposerExpanded &&
+ (surface === "desktop" ? "h-full max-h-none" : "h-full! max-h-none!"),
+ )}
+ />
+
-
-
-
- 0
- ? "text-sky-600 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
- : "text-muted-foreground hover:text-foreground dark:hover:text-white",
- )}
- />
- }
- >
- {isUploadingFiles ? (
-
- ) : (
-
- )}
- {pendingUploadedFiles.length > 0 ? (
-
- {pendingUploadedFiles.length}
-
- ) : null}
-
-
-
- {t("chat.upload.addSection")}
-
-
-
- {t("chat.upload.files")}
-
-
-
- {t("chat.upload.folder")}
-
- {isAgentMode ? (
- // 计划模式开关行:整行即开关,右侧迷你 switch 呈现状态。
- // closeOnClick=false 让切换就地生效——开关动画可见,菜单
- // 不弹跳;行为说明降为 hover 提示,不再挤占行内小字。
-
- onChatRuntimeControlsChange({
- planModeEnabled: !chatRuntimeControls.planModeEnabled,
- })
- }
- className="composer-safety-item items-center gap-2 rounded-md py-1.5 text-xs"
- >
-
+
+
+ 0
+ ? "text-sky-600 hover:text-sky-700 dark:text-sky-300 dark:hover:text-sky-200"
+ : "text-muted-foreground hover:text-foreground dark:hover:text-white",
)}
/>
-
- {t("chat.runtime.planModeTitle")}
-
- {/* 视觉开关(aria 由行上的 menuitemcheckbox 承担):与计划
- pill 同用 sky 色系,状态一眼可辨。 */}
+ }
+ >
+ {isUploadingFiles ? (
+
+ ) : (
+
+ )}
+ {pendingUploadedFiles.length > 0 ? (
-
+ ) : null}
+
+
+
+ {t("chat.upload.addSection")}
+
+
+
+ {t("chat.upload.files")}
+
+
+
+ {t("chat.upload.folder")}
+
+ {isAgentMode ? (
+ // 计划模式开关行:整行即开关,右侧迷你 switch 呈现状态。
+ // closeOnClick=false 让切换就地生效——开关动画可见,菜单
+ // 不弹跳;行为说明降为 hover 提示,不再挤占行内小字。
+
+ onChatRuntimeControlsChange({
+ planModeEnabled: !chatRuntimeControls.planModeEnabled,
+ })
+ }
+ className="composer-safety-item items-center gap-2 rounded-md py-1.5 text-xs"
+ >
+
-
-
- ) : null}
-
-
-
- {/* 计划模式开启指示(Codex 风格 pill):一眼可见,点击即关。 */}
- {isAgentMode && chatRuntimeControls.planModeEnabled ? (
-
- ) : null}
+
+ {t("chat.runtime.planModeTitle")}
+
+ {/* 视觉开关(aria 由行上的 menuitemcheckbox 承担):与计划
+ pill 同用 sky 色系,状态一眼可辨。 */}
+
+
+
+
+ ) : null}
+
+
- {clarifyEnabled ? (
-
+ {/* 计划模式开启指示(Codex 风格 pill):一眼可见,点击即关。 */}
+ {isAgentMode && chatRuntimeControls.planModeEnabled ? (
-
- ) : null}
+ ) : null}
- {stt.available ? (
-
-
-
- ) : null}
+ {clarifyEnabled ? (
+
+
+
+ ) : null}
+
+ {stt.available ? (
+
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
{isAgentMode && commandSafetyMode && onCommandSafetyModeChange ? (
-
-
-
+ {contextDisplayMode === "ring" || contextDisplayMode === "both" ? (
+
+ ) : null}
{fileDropOverlay}
diff --git a/crates/agent-ui/src/pages/chat/transcript/TranscriptWidthControls.tsx b/crates/agent-ui/src/pages/chat/transcript/TranscriptWidthControls.tsx
index d765651ee..d28a4d35a 100644
--- a/crates/agent-ui/src/pages/chat/transcript/TranscriptWidthControls.tsx
+++ b/crates/agent-ui/src/pages/chat/transcript/TranscriptWidthControls.tsx
@@ -27,12 +27,12 @@ import {
export const CHAT_TRANSCRIPT_WIDTH_CSS_VAR = "--chat-transcript-content-width";
-// Who writes CHAT_TRANSCRIPT_WIDTH_CSS_VAR: the host element's own inline
-// style carries the *preferred* (persisted) width, so a fresh mount already
-// paints at the user's width. This component then narrows that to what the
-// stage can host — from a layout effect, before paint. A passive effect lands
-// after paint and would flash the unclamped width for a frame whenever a
-// commit has to be clamped.
+// Who writes CHAT_TRANSCRIPT_WIDTH_CSS_VAR: the nearest shared width owner
+// carries the *preferred* (persisted) width, so transcript and composer paint
+// at one width on the first frame. This component then narrows that owner to
+// what the stage can host — from a layout effect, before paint. A passive
+// effect would flash the unclamped width for a frame whenever a commit has to
+// be clamped.
type TranscriptWidthControlsProps = {
hostRef: RefObject
;
@@ -60,8 +60,15 @@ function measureStageMaxWidth(host: HTMLElement | null) {
return resolveStageMaxWidth(host?.getBoundingClientRect().width ?? null);
}
+function resolveWidthOwner(host: HTMLElement | null): HTMLElement | null {
+ return host?.closest("[data-chat-width-owner]") ?? host;
+}
+
function applyWidth(host: HTMLElement | null, width: number) {
- host?.style.setProperty(CHAT_TRANSCRIPT_WIDTH_CSS_VAR, `${Math.round(width)}px`);
+ resolveWidthOwner(host)?.style.setProperty(
+ CHAT_TRANSCRIPT_WIDTH_CSS_VAR,
+ `${Math.round(width)}px`,
+ );
}
export function TranscriptWidthControls(props: TranscriptWidthControlsProps) {
diff --git a/crates/agent-ui/src/styles/base.css b/crates/agent-ui/src/styles/base.css
index 06b3236e5..97f6400d3 100644
--- a/crates/agent-ui/src/styles/base.css
+++ b/crates/agent-ui/src/styles/base.css
@@ -143,6 +143,41 @@
}
}
+@keyframes chat-work-pixel {
+ 0%,
+ 55%,
+ 100% {
+ opacity: 0.15;
+ transform: scale(0.8);
+ }
+
+ 25% {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+.chat-work-pixel {
+ opacity: 0.15;
+ animation: chat-work-pixel 650ms ease-in-out var(--chat-work-delay) infinite;
+}
+
+/* Frozen while the turn waits on the user: still legible as an indicator,
+ but visibly not cycling. */
+.chat-work-pixel[data-paused] {
+ opacity: 0.45;
+ transform: none;
+ animation: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .chat-work-pixel {
+ opacity: 0.15;
+ transform: none;
+ animation: none;
+ }
+}
+
/*
* Route Tailwind's mono theme font through the user-configurable code font.
* A hand-written `.font-mono` override only covers bare class usage — variant
diff --git a/crates/agent-ui/src/styles/common-components.css b/crates/agent-ui/src/styles/common-components.css
index 043c07a22..87fdfabd9 100644
--- a/crates/agent-ui/src/styles/common-components.css
+++ b/crates/agent-ui/src/styles/common-components.css
@@ -14,215 +14,250 @@
--text-xl: calc(1.25rem * var(--zone-font-scale, 1));
--text-2xl: calc(1.5rem * var(--zone-font-scale, 1));
}
+}
- .chat-markdown {
- @apply text-[calc(15px*var(--zone-font-scale,1))] leading-8 text-foreground;
- }
+/* Streamdown ships element-level Tailwind utilities. Keep the chat profile
+ * unlayered and scoped so these product overrides win without !important.
+ * Structural changes still belong in Markdown.tsx component overrides.
+ * Reading rhythm: body 14/22, H1 22/30, H2 17/26, H3 14/22. */
+.chat-markdown {
+ @apply text-[calc(14px*var(--zone-font-scale,1))] leading-[calc(22px*var(--zone-font-scale,1))] tracking-[0] text-foreground;
+ font-weight: 450;
+}
- .chat-markdown p {
- @apply my-2 min-w-0 max-w-full break-words text-[calc(15px*var(--zone-font-scale,1))] leading-8 text-foreground [overflow-wrap:anywhere];
- }
+.chat-markdown p {
+ @apply my-0 min-w-0 max-w-full break-words text-[calc(14px*var(--zone-font-scale,1))] leading-[calc(22px*var(--zone-font-scale,1))] tracking-[0] text-foreground [overflow-wrap:anywhere];
+ font-weight: 450;
+}
- .chat-markdown strong,
- .chat-markdown [data-streamdown="strong"] {
- @apply font-medium text-foreground;
- }
+.chat-markdown p + p {
+ @apply mt-2.5;
+}
- .chat-markdown em {
- @apply text-foreground/90;
- }
+.chat-markdown strong,
+.chat-markdown [data-streamdown="strong"] {
+ @apply font-medium text-foreground;
+}
- /* Hide the retained Streamdown caret without removing its inline slot. */
- .chat-markdown--caret-off > *:last-child::after {
- opacity: 0;
- }
+.chat-markdown em {
+ @apply text-foreground/90;
+}
- /* Headings */
- .chat-markdown [data-streamdown="heading-1"] {
- @apply mb-2 mt-6 text-xl font-semibold tracking-tight text-foreground;
- }
- .chat-markdown [data-streamdown="heading-2"] {
- @apply mb-2 mt-5 text-lg font-semibold tracking-tight text-foreground;
- }
- .chat-markdown [data-streamdown="heading-3"] {
- @apply mb-1.5 mt-4 text-base font-semibold tracking-tight text-foreground;
- }
- .chat-markdown [data-streamdown="heading-4"],
- .chat-markdown [data-streamdown="heading-5"] {
- @apply mb-1 mt-4 text-sm font-semibold tracking-tight text-foreground;
- }
- .chat-markdown [data-streamdown="heading-6"] {
- @apply mb-1 mt-4 text-xs font-semibold tracking-wide text-muted-foreground;
- }
+/* Hide the retained Streamdown caret without removing its inline slot. */
+.chat-markdown--caret-off > *:last-child::after {
+ opacity: 0;
+}
- /* Links */
- .chat-markdown [data-streamdown="link"] {
- @apply rounded-xs font-medium text-primary underline decoration-primary/30 underline-offset-2 transition-colors hover:decoration-primary/70;
- }
+/* Headings */
+.chat-markdown [data-streamdown="heading-1"] {
+ @apply mb-2.5 mt-7 text-[calc(22px*var(--zone-font-scale,1))] font-semibold leading-[calc(30px*var(--zone-font-scale,1))] tracking-[-0.018em] text-foreground;
+}
+.chat-markdown [data-streamdown="heading-2"] {
+ @apply mb-2 mt-6 text-[calc(17px*var(--zone-font-scale,1))] font-semibold leading-[calc(26px*var(--zone-font-scale,1))] tracking-[-0.012em] text-foreground;
+}
+.chat-markdown [data-streamdown="heading-3"] {
+ @apply mb-1.5 mt-5 text-[calc(14px*var(--zone-font-scale,1))] font-semibold leading-[calc(22px*var(--zone-font-scale,1))] tracking-[-0.006em] text-foreground;
+}
+.chat-markdown [data-streamdown="heading-4"],
+.chat-markdown [data-streamdown="heading-5"] {
+ @apply mb-1 mt-4 text-[calc(14px*var(--zone-font-scale,1))] font-semibold leading-[calc(22px*var(--zone-font-scale,1))] tracking-[0] text-foreground;
+}
+.chat-markdown [data-streamdown="heading-6"] {
+ @apply mb-1 mt-4 text-[calc(13px*var(--zone-font-scale,1))] font-semibold leading-[calc(20px*var(--zone-font-scale,1))] tracking-wide text-muted-foreground;
+}
+.chat-markdown [data-streamdown="heading-1"]:first-child,
+.chat-markdown [data-streamdown="heading-2"]:first-child,
+.chat-markdown [data-streamdown="heading-3"]:first-child {
+ @apply mt-0;
+}
- /* Inline code */
- .chat-markdown [data-streamdown="inline-code"] {
- @apply whitespace-pre-wrap break-words rounded-md border-0 bg-foreground/[0.05] px-1.5 py-0.5 font-mono text-[0.92em] font-normal text-foreground [overflow-wrap:anywhere];
- }
+/* Links */
+.chat-markdown [data-streamdown="link"] {
+ @apply rounded-xs font-medium text-sky-600 no-underline decoration-sky-500/45 underline-offset-2 transition-colors hover:underline dark:text-sky-400;
+}
- /* Code blocks — container, header, body (safe in components layer) */
- .chat-markdown [data-streamdown="code-block"] {
- @apply relative my-4 w-full min-w-0 overflow-visible rounded-none border-0 bg-transparent p-0 shadow-none outline-none ring-0;
- }
- .chat-markdown [data-streamdown="code-block-header"] {
- @apply h-8 border-0 bg-transparent px-2 py-0 text-[calc(11px*var(--zone-font-scale,1))] font-medium tracking-[0.06em] text-muted-foreground/85 shadow-none;
- }
- .chat-markdown [data-streamdown="code-block-header"] > span {
- @apply ml-0 font-mono tracking-[0.06em];
- }
- .chat-markdown [data-streamdown="code-block-body"] {
- @apply w-full min-w-0 overflow-x-auto rounded-xl border-0 bg-muted/40 p-4 text-[calc(13px*var(--zone-font-scale,1))] shadow-none outline-none ring-0;
- }
- .chat-markdown [data-streamdown="code-block-body"] pre {
- @apply leading-6;
- }
- .chat-markdown--streaming [data-streamdown="code-block"][data-incomplete="true"] {
- @apply relative border-0 bg-transparent;
- }
- .chat-markdown--streaming [data-streamdown="code-block"][data-incomplete="true"]::after {
- display: none;
- }
- .chat-markdown--streaming
- [data-streamdown="code-block"][data-incomplete="true"]
- [data-streamdown="code-block-header"] {
- @apply border-0 bg-transparent text-muted-foreground/85;
- }
+/* Inline code */
+.chat-markdown [data-streamdown="inline-code"] {
+ @apply whitespace-pre-wrap break-words rounded-xs border-0 bg-foreground/[0.085] px-[5px] py-px font-mono text-[0.92em] font-normal leading-[1.45] text-foreground/95 [overflow-wrap:anywhere];
+}
- /* Blockquotes */
- .chat-markdown [data-streamdown="blockquote"] {
- @apply my-3 min-w-0 max-w-full border-l-2 border-border/70 pl-4 italic text-muted-foreground [overflow-wrap:anywhere];
- }
- .chat-markdown [data-streamdown="blockquote"] p {
- @apply my-0;
- }
+/* Code blocks — container, header, body */
+.chat-markdown [data-streamdown="code-block"] {
+ @apply relative my-4 w-full min-w-0 overflow-hidden rounded-lg border-0 bg-foreground/[0.045] p-0 shadow-none outline-none ring-0;
+}
+.chat-markdown [data-streamdown="code-block-header"] {
+ @apply h-8 border-0 border-b border-border/45 bg-transparent px-3 py-0 text-[calc(11px*var(--zone-font-scale,1))] font-medium tracking-[0] text-muted-foreground/85 shadow-none;
+}
+.chat-markdown [data-streamdown="code-block-header"] > span {
+ @apply ml-0 font-sans tracking-[0] normal-case;
+}
+.chat-markdown [data-streamdown="code-block-body"] {
+ @apply w-full min-w-0 overflow-x-auto rounded-none border-0 bg-transparent p-0 text-[calc(13px*var(--zone-font-scale,1))] shadow-none outline-none ring-0;
+}
+.chat-markdown [data-streamdown="code-block-body"] pre {
+ @apply leading-[calc(22px*var(--zone-font-scale,1))];
+}
+.chat-markdown [data-streamdown="code-block-body"] pre code > span::before {
+ display: none;
+ width: 0;
+ margin: 0;
+ content: none;
+}
+.chat-markdown--streaming [data-streamdown="code-block"][data-incomplete="true"] {
+ @apply relative border-0 bg-transparent;
+}
+.chat-markdown--streaming [data-streamdown="code-block"][data-incomplete="true"]::after {
+ display: none;
+}
+.chat-markdown--streaming
+ [data-streamdown="code-block"][data-incomplete="true"]
+ [data-streamdown="code-block-header"] {
+ @apply border-0 bg-transparent text-muted-foreground/85;
+}
- /* Lists */
- .chat-markdown [data-streamdown="unordered-list"] {
- @apply my-2 ml-1 list-disc space-y-0.5 pl-5;
- }
- .chat-markdown [data-streamdown="ordered-list"] {
- @apply my-2 ml-1 list-decimal space-y-0.5 pl-5;
- }
- .chat-markdown [data-streamdown="list-item"] {
- @apply min-w-0 max-w-full break-words pl-1 text-[calc(15px*var(--zone-font-scale,1))] leading-8 [overflow-wrap:anywhere];
- }
- .chat-markdown .contains-task-list {
- @apply list-none pl-0;
- }
- .chat-markdown .task-list-item {
- @apply flex items-start gap-2 pl-0;
- }
- .chat-markdown .task-list-item input[type="checkbox"] {
- @apply mt-[0.45rem] h-3.5 w-3.5 rounded border border-border align-middle accent-emerald-500;
- }
+/* Blockquotes */
+.chat-markdown [data-streamdown="blockquote"] {
+ @apply my-4 min-w-0 max-w-full border-l-2 border-border/75 pl-3.5 not-italic text-foreground/82 [overflow-wrap:anywhere];
+}
+.chat-markdown [data-streamdown="blockquote"] p {
+ @apply my-0 text-[calc(14px*var(--zone-font-scale,1))] leading-[calc(22px*var(--zone-font-scale,1))] text-foreground/82;
+}
- /* Tables */
- .chat-markdown [data-streamdown="table-wrapper"] {
- @apply my-4 w-full min-w-0 gap-0 rounded-none border-0 bg-transparent p-0 shadow-none outline-none ring-0;
- }
- .chat-markdown [data-streamdown="table-wrapper"] > div:first-child {
- @apply justify-end gap-1 px-0 pt-0;
- }
- .chat-markdown [data-streamdown="table-wrapper"] > div:last-child {
- @apply w-full min-w-0 overflow-x-auto overflow-y-hidden rounded-none border-0 bg-transparent p-0 shadow-none outline-none ring-0;
- }
- .chat-markdown [data-streamdown="table-wrapper"] button {
- @apply rounded-md border-0 bg-transparent shadow-none transition-colors hover:bg-foreground/[0.04] hover:text-foreground;
- }
- .chat-markdown [data-streamdown="table"] {
- @apply my-2 w-full min-w-full table-auto border-collapse border-0 bg-transparent text-sm shadow-none outline-none ring-0;
- }
- .chat-markdown [data-streamdown="table-header"] {
- @apply bg-transparent;
- }
- .chat-markdown [data-streamdown="table-header-cell"] {
- @apply border-0 border-b border-border/50 px-0 py-2 pr-8 text-left align-bottom text-sm font-semibold tracking-tight text-foreground;
- }
- .chat-markdown [data-streamdown="table-cell"] {
- @apply border-0 border-b border-border/30 px-0 py-1 pr-8 text-left align-middle leading-8 text-foreground/90;
- }
- .chat-markdown [data-streamdown="table-body"] [data-streamdown="table-row"]:nth-child(even) {
- @apply bg-transparent;
- }
- .chat-markdown [data-streamdown="table-header-cell"]:last-child,
- .chat-markdown [data-streamdown="table-cell"]:last-child {
- @apply pr-0;
- }
- .chat-markdown [data-streamdown="table-row"]:last-child [data-streamdown="table-cell"] {
- @apply border-b-0;
- }
+/* Lists */
+.chat-markdown [data-streamdown="unordered-list"] {
+ @apply my-2 ml-0 list-outside list-disc space-y-1 pl-5;
+}
+.chat-markdown [data-streamdown="ordered-list"] {
+ @apply my-2 ml-0 list-outside list-decimal space-y-1 pl-5;
+}
+.chat-markdown [data-streamdown="list-item"] {
+ @apply min-w-0 max-w-full break-words py-0 pl-0 text-[calc(14px*var(--zone-font-scale,1))] leading-[calc(22px*var(--zone-font-scale,1))] [overflow-wrap:anywhere];
+ font-weight: 450;
+}
+.chat-markdown .contains-task-list {
+ @apply list-none pl-0;
+}
+.chat-markdown .task-list-item {
+ @apply flex items-start gap-2 pl-0;
+}
+.chat-markdown .task-list-item input[type="checkbox"] {
+ @apply mt-[0.28rem] h-3.5 w-3.5 rounded border border-border align-middle accent-emerald-500;
+}
- /* Mermaid */
- .chat-markdown [data-streamdown="mermaid-block"] {
- @apply my-4 flex w-full min-w-0 gap-2 rounded-none border-0 bg-transparent p-0 shadow-none;
- }
- .chat-markdown [data-streamdown="mermaid-block"] > div:last-child,
- .chat-markdown [data-streamdown="mermaid"] {
- @apply w-full min-w-0 rounded-none border-0 bg-transparent p-0 shadow-none;
- }
- .chat-markdown [data-streamdown="mermaid"] {
- @apply my-0 block max-h-[280px] overflow-hidden;
- }
- .chat-markdown [data-streamdown="mermaid"] svg {
- @apply mx-auto block h-auto max-h-[280px] max-w-full bg-transparent;
- }
- .chat-markdown [data-streamdown="mermaid-block-actions"] {
- @apply gap-2 rounded-none border-0 bg-transparent p-0 shadow-none backdrop-blur-none;
- }
+/* Tables */
+.chat-markdown [data-streamdown="table-wrapper"] {
+ @apply my-4 w-full min-w-0 gap-0 rounded-none border-0 bg-transparent p-0 shadow-none outline-none ring-0;
+}
+.chat-markdown [data-streamdown="table-wrapper"] > div:first-child {
+ @apply justify-end gap-1 px-0 pt-0;
+}
+.chat-markdown [data-streamdown="table-wrapper"] > div:last-child {
+ @apply w-full min-w-0 overflow-x-auto overflow-y-hidden rounded-none border-0 bg-transparent p-0 shadow-none outline-none ring-0;
+}
+.chat-markdown [data-streamdown="table-wrapper"] button {
+ @apply rounded-md border-0 bg-transparent shadow-none transition-colors hover:bg-foreground/[0.04] hover:text-foreground;
+}
+.chat-markdown [data-streamdown="table"] {
+ @apply my-0 w-full min-w-full table-auto border-collapse border-0 bg-transparent text-[calc(14px*var(--zone-font-scale,1))] shadow-none outline-none ring-0;
+}
+.chat-markdown [data-streamdown="table-header"] {
+ @apply bg-transparent;
+}
+.chat-markdown [data-streamdown="table-header-cell"] {
+ @apply border-0 border-b border-border/55 px-0 py-2 pr-8 text-left align-bottom text-[calc(14px*var(--zone-font-scale,1))] font-semibold leading-[calc(22px*var(--zone-font-scale,1))] tracking-[0] text-foreground;
+}
+.chat-markdown [data-streamdown="table-cell"] {
+ @apply border-0 border-b border-border/35 px-0 py-2 pr-8 text-left align-middle text-[calc(14px*var(--zone-font-scale,1))] leading-[calc(22px*var(--zone-font-scale,1))] text-foreground/88;
+ font-weight: 450;
+}
+.chat-markdown [data-streamdown="table-body"] [data-streamdown="table-row"]:nth-child(even) {
+ @apply bg-transparent;
+}
+.chat-markdown [data-streamdown="table-header-cell"]:last-child,
+.chat-markdown [data-streamdown="table-cell"]:last-child {
+ @apply pr-0;
+}
+.chat-markdown [data-streamdown="table-row"]:last-child [data-streamdown="table-cell"] {
+ @apply border-b-0;
+}
- /* Math */
- .chat-markdown .katex {
- @apply text-[1.04em];
- }
- .chat-markdown .katex-display {
- @apply my-3.5 block max-w-full overflow-x-auto overflow-y-hidden px-1 py-1.5 text-center;
- }
+/* Mermaid */
+.chat-markdown [data-streamdown="mermaid-block"] {
+ @apply my-4 flex w-full min-w-0 gap-2 rounded-none border-0 bg-transparent p-0 shadow-none;
+}
+.chat-markdown [data-streamdown="mermaid-block"] > div:last-child,
+.chat-markdown [data-streamdown="mermaid"] {
+ @apply w-full min-w-0 rounded-none border-0 bg-transparent p-0 shadow-none;
+}
+.chat-markdown [data-streamdown="mermaid"] {
+ @apply my-0 block max-h-[280px] overflow-hidden;
+}
+.chat-markdown [data-streamdown="mermaid"] svg {
+ @apply mx-auto block h-auto max-h-[280px] max-w-full bg-transparent;
+}
+.chat-markdown [data-streamdown="mermaid-block-actions"] {
+ @apply gap-2 rounded-none border-0 bg-transparent p-0 shadow-none backdrop-blur-none;
+}
- /* Thinking markdown */
- .chat-markdown.thinking-markdown {
- @apply text-[calc(12px*var(--zone-font-scale,1))] leading-6 text-muted-foreground/80;
- }
- .chat-markdown.thinking-markdown p {
- @apply my-1 text-[calc(12px*var(--zone-font-scale,1))] leading-6 text-muted-foreground/80;
- }
- .chat-markdown.thinking-markdown [data-streamdown^="heading-"] {
- @apply mb-0 mt-1 text-[calc(12px*var(--zone-font-scale,1))] font-medium leading-6 tracking-normal text-muted-foreground/90;
- }
- .chat-markdown.thinking-markdown [data-streamdown="unordered-list"],
- .chat-markdown.thinking-markdown [data-streamdown="ordered-list"] {
- @apply my-1 pl-4;
- }
- .chat-markdown.thinking-markdown [data-streamdown="list-item"] {
- @apply text-[calc(12px*var(--zone-font-scale,1))] leading-5 text-muted-foreground/80;
- }
- .chat-markdown.thinking-markdown [data-streamdown="blockquote"] {
- @apply my-1 border-l-0 pl-0 not-italic text-muted-foreground/75;
- }
- .chat-markdown.thinking-markdown [data-streamdown="inline-code"] {
- @apply bg-foreground/[0.03] text-[calc(11px*var(--zone-font-scale,1))] text-muted-foreground/90;
- }
- .chat-markdown.thinking-markdown [data-streamdown="code-block-body"] {
- @apply bg-muted/20 p-3 text-[calc(11px*var(--zone-font-scale,1))];
- }
+/* Math */
+.chat-markdown .katex {
+ @apply text-[1.04em];
+}
+.chat-markdown .katex-display {
+ @apply my-3.5 block max-w-full overflow-x-auto overflow-y-hidden px-1 py-1.5 text-center;
+}
- /* Images */
- .chat-markdown [data-streamdown="image-wrapper"] {
- @apply my-4 overflow-visible rounded-none border-0 bg-transparent shadow-none;
- }
- .chat-markdown [data-streamdown="image"] {
- @apply block max-h-[34rem] max-w-full rounded-xl border border-border/60 bg-muted/10 object-contain;
- }
+/* Thinking markdown — reasoning disclosures reuse chat structure but read
+ * visually recessed: smaller, muted ink and regular weight. The base chat
+ * rules re-assert 450/strong-at-full-foreground per element, so weight and
+ * emphasis must be overridden here too or reasoning looks bold all over. */
+.chat-markdown.thinking-markdown {
+ @apply text-[calc(12px*var(--zone-font-scale,1))] leading-6 text-muted-foreground/80;
+ font-weight: 400;
+}
+.chat-markdown.thinking-markdown p {
+ @apply my-1 text-[calc(12px*var(--zone-font-scale,1))] leading-6 text-muted-foreground/80;
+ font-weight: 400;
+}
+.chat-markdown.thinking-markdown strong,
+.chat-markdown.thinking-markdown [data-streamdown="strong"] {
+ @apply font-medium text-muted-foreground/95;
+}
+.chat-markdown.thinking-markdown [data-streamdown^="heading-"] {
+ @apply mb-0 mt-1 text-[calc(12px*var(--zone-font-scale,1))] font-medium leading-6 tracking-normal text-muted-foreground/90;
+}
+.chat-markdown.thinking-markdown [data-streamdown="unordered-list"],
+.chat-markdown.thinking-markdown [data-streamdown="ordered-list"] {
+ @apply my-1 pl-4;
+}
+.chat-markdown.thinking-markdown [data-streamdown="list-item"] {
+ @apply text-[calc(12px*var(--zone-font-scale,1))] leading-5 text-muted-foreground/80;
+ font-weight: 400;
+}
+.chat-markdown.thinking-markdown [data-streamdown="blockquote"] {
+ @apply my-1 border-l-0 pl-0 not-italic text-muted-foreground/75;
+}
+.chat-markdown.thinking-markdown [data-streamdown="inline-code"] {
+ @apply bg-foreground/[0.03] text-[calc(11px*var(--zone-font-scale,1))] text-muted-foreground/90;
+}
+.chat-markdown.thinking-markdown [data-streamdown="code-block-body"] {
+ @apply bg-muted/20 p-3 text-[calc(11px*var(--zone-font-scale,1))];
+}
- /* Horizontal rule */
- .chat-markdown [data-streamdown="horizontal-rule"] {
- @apply my-5 border-border/45;
- }
+/* Images */
+.chat-markdown [data-streamdown="image-wrapper"] {
+ @apply my-4 overflow-visible rounded-none border-0 bg-transparent shadow-none;
+}
+.chat-markdown [data-streamdown="image"] {
+ @apply block max-h-[34rem] max-w-full rounded-xl border border-border/60 bg-muted/10 object-contain;
+}
+
+/* Horizontal rule */
+.chat-markdown [data-streamdown="horizontal-rule"] {
+ @apply my-6 border-border/45;
+}
+@layer components {
/* Long-form files and documentation use a calmer, denser reading rhythm
* than conversational Markdown. This profile is shared by Skill and
* workspace file previews; chat typography remains unchanged. */
@@ -442,6 +477,63 @@
animation: checkpointShimmer 2.5s ease-in-out infinite;
}
+ /* Compaction band — shared chrome for the live "compressing context"
+ status and the settled in-reply seam. The active band carries a sweeping
+ progress line along its bottom edge and a breathing icon tile; the
+ settled band is static so history stays calm. */
+ @keyframes compactionBandSweep {
+ 0% {
+ transform: translateX(-100%);
+ }
+ 100% {
+ transform: translateX(300%);
+ }
+ }
+
+ @keyframes compactionBandBreathe {
+ 0%,
+ 100% {
+ transform: scale(1);
+ opacity: 0.85;
+ }
+ 50% {
+ transform: scale(1.08);
+ opacity: 1;
+ }
+ }
+
+ .compaction-band-progress {
+ position: absolute;
+ left: 0;
+ bottom: 0;
+ height: 2px;
+ width: 34%;
+ border-radius: 9999px;
+ background: linear-gradient(
+ 90deg,
+ transparent 0%,
+ hsl(270 80% 62% / 0.75) 50%,
+ transparent 100%
+ );
+ animation: compactionBandSweep 1.6s cubic-bezier(0.4, 0, 0.2, 1) infinite;
+ pointer-events: none;
+ }
+
+ .compaction-band-icon-active {
+ animation: compactionBandBreathe 1.8s ease-in-out infinite;
+ }
+
+ @media (prefers-reduced-motion: reduce) {
+ .compaction-band-progress,
+ .compaction-band-icon-active {
+ animation: none;
+ }
+ .compaction-band-progress {
+ width: 100%;
+ opacity: 0.5;
+ }
+ }
+
/* Tool card animations & micro-interactions */
@keyframes toolExpandIn {
from {
@@ -454,8 +546,7 @@
}
}
- .tool-expand,
- .lazy-collapse-reveal {
+ .tool-expand {
animation: toolExpandIn 0.22s cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -509,7 +600,6 @@
/* Respect reduced motion */
@media (prefers-reduced-motion: reduce) {
.tool-expand,
- .lazy-collapse-reveal,
.ask-question-enter-forward,
.ask-question-enter-backward,
.ask-custom-input-enter,
diff --git a/crates/agent-ui/src/styles/common-overlays.css b/crates/agent-ui/src/styles/common-overlays.css
index af7402354..99d06c24d 100644
--- a/crates/agent-ui/src/styles/common-overlays.css
+++ b/crates/agent-ui/src/styles/common-overlays.css
@@ -16,128 +16,6 @@
user-select: none;
}
-/* Edit tool result diff, scoped away from Git Review's full-file diff UI. */
-.edit-tool-diff-view {
- --edit-diff-add-bg: hsl(142 76% 90%);
- --edit-diff-add-gutter-bg: hsl(142 72% 80%);
- --edit-diff-del-bg: hsl(3 100% 91%);
- --edit-diff-del-gutter-bg: hsl(3 100% 86%);
- --edit-diff-plain-bg: hsl(0 0% 100% / 0.76);
- --edit-diff-plain-gutter-bg: hsl(0 0% 98% / 0.82);
- --edit-diff-hunk-bg: hsl(205 90% 96%);
- color: hsl(var(--foreground) / 0.88);
-}
-
-.dark .edit-tool-diff-view {
- --edit-diff-add-bg: hsl(149 42% 18%);
- --edit-diff-add-gutter-bg: hsl(148 38% 23%);
- --edit-diff-del-bg: hsl(0 42% 19%);
- --edit-diff-del-gutter-bg: hsl(0 38% 24%);
- --edit-diff-plain-bg: hsl(224 18% 11% / 0.9);
- --edit-diff-plain-gutter-bg: hsl(224 16% 14% / 0.94);
- --edit-diff-hunk-bg: hsl(215 42% 17%);
- color: hsl(var(--foreground) / 0.9);
-}
-
-.edit-tool-diff-view .diff-tailwindcss-wrapper {
- min-width: max-content;
- background: transparent;
-}
-
-.edit-tool-diff-view .diff-tailwindcss-wrapper[data-theme] .diff-style-root {
- --diff-add-content--: var(--edit-diff-add-bg);
- --diff-add-lineNumber--: var(--edit-diff-add-gutter-bg);
- --diff-del-content--: var(--edit-diff-del-bg);
- --diff-del-lineNumber--: var(--edit-diff-del-gutter-bg);
- --diff-plain-content--: var(--edit-diff-plain-bg);
- --diff-plain-lineNumber--: var(--edit-diff-plain-gutter-bg);
- --diff-expand-content--: var(--edit-diff-plain-bg);
- --diff-expand-lineNumber--: var(--edit-diff-plain-gutter-bg);
- --diff-hunk-content--: var(--edit-diff-hunk-bg);
- --diff-hunk-lineNumber--: var(--edit-diff-hunk-bg);
- --diff-plain-lineNumber-color--: hsl(var(--muted-foreground) / 0.82);
- --diff-expand-lineNumber-color--: hsl(var(--muted-foreground) / 0.66);
- --diff-add-content-highlight--: hsl(143 73% 78%);
- --diff-del-content-highlight--: hsl(3 100% 84%);
- --diff-hunk-content-color--: hsl(var(--muted-foreground) / 0.72);
- background: transparent;
-}
-
-.dark .edit-tool-diff-view .diff-tailwindcss-wrapper[data-theme] .diff-style-root {
- --diff-add-content-highlight--: hsl(148 46% 28%);
- --diff-del-content-highlight--: hsl(0 48% 31%);
-}
-
-.edit-tool-diff-view .diff-view-wrapper,
-.edit-tool-diff-view .unified-diff-view,
-.edit-tool-diff-view .unified-diff-table-wrapper {
- background: transparent;
-}
-
-.edit-tool-diff-view .unified-diff-table-wrapper {
- overflow: visible;
-}
-
-.edit-tool-diff-view .unified-diff-table {
- min-width: 100%;
-}
-
-.edit-tool-diff-view .diff-table-body {
- line-height: 1.55;
-}
-
-.edit-tool-diff-view .diff-line-hunk {
- display: none;
-}
-
-.edit-tool-diff-view .diff-tailwindcss-wrapper .diff-line-num {
- padding-left: 0.55rem;
- padding-right: 0.45rem;
- font-variant-numeric: tabular-nums;
-}
-
-.edit-tool-diff-view .diff-tailwindcss-wrapper .diff-line-content {
- min-width: 22rem;
- padding-right: 0.75rem;
-}
-
-.edit-tool-diff-view .diff-tailwindcss-wrapper .diff-line-content-item {
- min-height: 1.55em;
- padding-left: 2.35rem;
-}
-
-.edit-tool-diff-view .diff-tailwindcss-wrapper .diff-line-content-operator {
- width: 1.6rem;
- margin-left: -2rem;
- text-align: center;
- text-indent: 0;
- font-weight: 700;
-}
-
-.edit-tool-diff-view .diff-line-content-operator[data-operator="+"] {
- color: hsl(150 55% 24%);
-}
-
-.edit-tool-diff-view .diff-line-content-operator[data-operator="-"] {
- color: hsl(0 62% 35%);
-}
-
-.dark .edit-tool-diff-view .diff-line-content-operator[data-operator="+"] {
- color: hsl(150 62% 74%);
-}
-
-.dark .edit-tool-diff-view .diff-line-content-operator[data-operator="-"] {
- color: hsl(0 72% 78%);
-}
-
-.edit-tool-diff-view .diff-line-content-raw,
-.edit-tool-diff-view .diff-line-syntax-raw,
-.edit-tool-diff-view .diff-line-content-raw *,
-.edit-tool-diff-view .diff-line-syntax-raw * {
- -webkit-user-select: text;
- user-select: text;
-}
-
/* Keep right-sidebar drag responsive when very large diff DOM is mounted. */
[data-project-tools-resizing="true"] .git-review-diff-selectable {
contain: layout paint style;
diff --git a/crates/agent-ui/src/styles/transcript.css b/crates/agent-ui/src/styles/transcript.css
index 9141d6b51..90c9c7706 100644
--- a/crates/agent-ui/src/styles/transcript.css
+++ b/crates/agent-ui/src/styles/transcript.css
@@ -9,3 +9,59 @@
.chat-user-trailing-newline-anchor::before {
content: "\200b";
}
+
+/*
+ * 消息操作条(复制/编辑/时间戳):只要设备上有任意可悬停指针
+ * (含触屏笔记本外接鼠标),就默认隐藏、真正 :hover / :focus-within 再显示。
+ * Tailwind 的 group-hover 被关在主指针 `hover: hover` 里,触屏主指针的
+ * 笔记本即使用着鼠标也不会触发,所以这里用 any-hover + 原生 :hover。
+ * 纯触屏(any-hover: none)保持常显。规则放在 @layer 外,才能压过 utilities。
+ */
+.chat-row-hover-chrome {
+ transition-property: opacity;
+ transition-duration: 150ms;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .chat-row-hover-chrome {
+ transition: none;
+ }
+}
+
+@media (any-hover: hover) {
+ .chat-row-hover-chrome {
+ opacity: 0;
+ }
+
+ .chat-user-bubble-wrap:hover .chat-row-hover-chrome,
+ .chat-user-bubble-wrap:focus-within .chat-row-hover-chrome,
+ .group\/assistant:hover .chat-row-hover-chrome,
+ .group\/assistant:focus-within .chat-row-hover-chrome,
+ .group\/assistant[data-actions-visible="true"] .chat-row-hover-chrome {
+ opacity: 1;
+ }
+
+ .chat-user-bubble-wrap:hover .chat-row-hover-chrome--actions,
+ .chat-user-bubble-wrap:focus-within .chat-row-hover-chrome--actions,
+ .group\/assistant:hover .chat-row-hover-chrome--actions,
+ .group\/assistant:focus-within .chat-row-hover-chrome--actions,
+ .group\/assistant[data-actions-visible="true"] .chat-row-hover-chrome--actions {
+ pointer-events: auto;
+ }
+}
+
+@media (any-hover: none) {
+ .chat-row-hover-chrome {
+ opacity: 1;
+ }
+
+ .chat-row-hover-chrome--actions {
+ pointer-events: auto;
+ }
+}
+
+.chat-row-hover-chrome[data-force-visible="true"],
+.chat-assistant-actions:has(.settings-confirm-popover) .chat-row-hover-chrome {
+ opacity: 1;
+ pointer-events: auto;
+}
diff --git a/docs/operations/deployment.md b/docs/operations/deployment.md
index 7ed412b6d..3e62e86e2 100644
--- a/docs/operations/deployment.md
+++ b/docs/operations/deployment.md
@@ -142,7 +142,7 @@ Keychain 中必须是带私钥的 `Developer ID Application` identity。若 macO
| Windows x64 | `windows-latest` | `LiveAgent-vX.Y.Z-Windows-x64.msi`、`LiveAgent-vX.Y.Z-Windows-x64-Setup.exe`,以及 updater 使用的 `.zip` / `.sig`。 |
| Linux x64 | `ubuntu-latest` | `LiveAgent-vX.Y.Z-Linux-x86_64.AppImage`、`.deb`、`.rpm`,以及 updater 使用的 `.tar.gz` / `.sig`。 |
-macOS DMG 的安装窗口布局(背景图、窗口尺寸、图标位置)由 `tauri.conf.json > bundle > macOS > dmg` 配置,tauri-bundler 通过 AppleScript 驱动 Finder 写入 DMG 根目录的 `.DS_Store` 来落盘。tauri-bundler 只要检测到 `CI=true` 就会跳过这一步,产出没有任何布局的白底 DMG;GitHub 托管的 macOS runner 实际具备 GUI 会话,因此 `make desktop-build-macos-release` 固定设置 `TAURI_BUNDLER_DMG_IGNORE_CI=true` 让布局在 CI 中照常生效,并在签名、公证之前用 `scripts/release/verify-macos-dmg-layout.sh` 挂载 DMG 校验 `.DS_Store`、`.background/`、`LiveAgent.app` 与 `Applications` 链接是否齐全,缺失即中止发布。本地排查时可以直接对任意 DMG 运行该脚本。
+macOS DMG 的安装窗口布局(背景图、窗口尺寸、图标位置)落盘在 DMG 根目录的 `.DS_Store`。tauri-bundler 通过 AppleScript 驱动 Finder 写入这份文件,但只要检测到 `CI=true` 就会跳过这一步,产出没有任何布局的白底 DMG。因此 `make desktop-build-macos-release` 不直接发布 tauri-bundler 生成的 DMG,而是在 `.app` 签名校验通过后用 [dmgbuild](https://github.com/dmgbuild/dmgbuild)(`dmgbuild==1.6.5`,CI 由 `desktop-release.yml` 的 `Install deterministic DMG builder` 步骤安装)按 `scripts/release/macos-dmg-settings.py` 重新生成 DMG:dmgbuild 直接写入 Finder 元数据,不依赖 GUI 会话,布局在任何 runner 上都是确定的。随后 DMG 再签名、公证、stapler 校验,并用 `scripts/release/verify-macos-dmg.sh` 挂载校验 `.DS_Store`、`.background.png`、`LiveAgent.app` 与 `Applications` 链接是否齐全,缺失即中止发布。本地排查时可以直接对任意 DMG 运行该脚本(`make desktop-verify-macos` 也会执行它)。
发布 job 会在上传平台产物后生成并上传 `latest.json`。桌面端「设置 -> 关于」会根据用户是否允许预发布,从 GitHub Releases 中筛选带 `latest.json` 的正式 / 预发布版本;未允许预发布时只检查正式 Release。
diff --git a/docs/worklog/task-progress-indicator.md b/docs/worklog/task-progress-indicator.md
index 050aa8413..1954ae66e 100644
--- a/docs/worklog/task-progress-indicator.md
+++ b/docs/worklog/task-progress-indicator.md
@@ -20,6 +20,8 @@ LiveAgent 的任务清单必须属于当前 Agent Run,而不是属于前端进
GUI 与 Gateway WebUI 只读取成功 `TaskCreate`、`TaskUpdate`、`TaskList` 结果中的完整 canonical snapshot。投影不读取流式参数,不按文案或位置猜测身份,也不做延时序列兼容。任务工具块在 transcript 中保持 standalone 并统一隐藏,输入框上方的进度指示器以 `task.id` 作为 React key。
+指示器只常驻一个按内容收缩的步进药丸;任务清单是绝对定位的 hover 浮层,指针移开或焦点离开即收起,因此任何状态下都不占据 transcript 的布局高度,也不参与 composer 的高度预留。
+
## 不变量
| 不变量 | 保证方式 |
diff --git a/scripts/release/macos-dmg-settings.py b/scripts/release/macos-dmg-settings.py
new file mode 100644
index 000000000..d9aadec61
--- /dev/null
+++ b/scripts/release/macos-dmg-settings.py
@@ -0,0 +1,54 @@
+"""Deterministic Finder layout for the LiveAgent release DMG.
+
+Unlike Finder/AppleScript-based DMG builders, dmgbuild writes the Finder
+metadata directly. This keeps the release layout stable on hosted CI runners.
+"""
+
+import os
+
+
+def required_path(name):
+ value = defines.get(name) # noqa: F821 - provided by dmgbuild
+ if not value:
+ raise RuntimeError(f"missing required dmgbuild define: {name}")
+
+ path = os.path.abspath(value)
+ if not os.path.exists(path):
+ raise RuntimeError(f"dmgbuild path does not exist: {path}")
+ return path
+
+
+application = required_path("app")
+app_name = os.path.basename(application)
+
+format = "UDZO"
+filesystem = "HFS+"
+
+files = [application]
+symlinks = {"Applications": "/Applications"}
+
+icon = required_path("volume_icon")
+background = required_path("background")
+
+window_rect = ((100, 100), (660, 400))
+default_view = "icon-view"
+show_status_bar = False
+show_tab_view = False
+show_toolbar = False
+show_pathbar = False
+show_sidebar = False
+show_icon_preview = False
+include_icon_view_settings = True
+include_list_view_settings = False
+
+arrange_by = None
+grid_offset = (0, 0)
+grid_spacing = 100
+scroll_position = (0, 0)
+label_pos = "bottom"
+text_size = 16
+icon_size = 128
+icon_locations = {
+ app_name: (180, 210),
+ "Applications": (480, 210),
+}
diff --git a/scripts/release/verify-macos-dmg-layout.sh b/scripts/release/verify-macos-dmg-layout.sh
deleted file mode 100755
index 7d13374aa..000000000
--- a/scripts/release/verify-macos-dmg-layout.sh
+++ /dev/null
@@ -1,75 +0,0 @@
-#!/usr/bin/env bash
-# Mounts a Tauri-built DMG read-only and asserts that the Finder installer layout
-# (background, window size, icon positions) was actually baked into it.
-#
-# tauri-bundler applies that layout by driving Finder through AppleScript, and it
-# persists as a `.DS_Store` at the DMG root. When the bundler sees `CI=true` it passes
-# `--skip-jenkins` to bundle_dmg and silently skips that step, yielding a DMG that opens
-# as a plain white Finder window. Release builds opt back in via
-# `TAURI_BUNDLER_DMG_IGNORE_CI=true`; this guard makes sure a regression never ships.
-set -euo pipefail
-
-usage() {
- echo "Usage: $0 [app-name]" >&2
-}
-
-fail() {
- echo "verify-macos-dmg-layout: $*" >&2
- exit 1
-}
-
-if [ "$#" -lt 1 ] || [ "$#" -gt 2 ]; then
- usage
- exit 2
-fi
-
-for command_name in hdiutil mktemp find; do
- command -v "$command_name" >/dev/null 2>&1 || fail "missing required command: $command_name"
-done
-
-dmg_path="$1"
-app_name="${2:-LiveAgent}"
-test -f "$dmg_path" || fail "DMG not found: $dmg_path"
-
-mount_point="$(mktemp -d "${TMPDIR:-/tmp}/liveagent-dmg-verify.XXXXXX")"
-mounted=0
-cleanup() {
- if [ "$mounted" -eq 1 ]; then
- hdiutil detach "$mount_point" -quiet -force >/dev/null 2>&1 || true
- fi
- rmdir -- "$mount_point" 2>/dev/null || true
-}
-trap cleanup EXIT
-
-hdiutil attach "$dmg_path" -readonly -nobrowse -noautoopen -noverify -mountpoint "$mount_point" >/dev/null
-mounted=1
-
-failures=0
-check() {
- local description="$1"
- shift
- if "$@"; then
- echo " ok $description"
- else
- echo " FAIL $description"
- failures=$((failures + 1))
- fi
-}
-
-has_background_image() {
- find "$1/.background" -maxdepth 1 -type f -print -quit 2>/dev/null | grep -q .
-}
-
-echo "Verifying Finder layout of $dmg_path"
-check "$app_name.app bundle present" test -d "$mount_point/$app_name.app"
-check "Applications symlink present" test -L "$mount_point/Applications"
-check ".background/ image present" has_background_image "$mount_point"
-check ".DS_Store present (Finder window size, background, icon positions)" test -s "$mount_point/.DS_Store"
-
-if [ "$failures" -ne 0 ]; then
- fail "$failures check(s) failed. The DMG would open as a plain Finder window without the configured background and icon layout.
- When building on CI, make sure TAURI_BUNDLER_DMG_IGNORE_CI=true is set: tauri-bundler skips the Finder AppleScript whenever CI=true.
- See https://github.com/tauri-apps/tauri/issues/1731 and Stack-Cairn/LiveAgent#558."
-fi
-
-echo "DMG Finder layout verified: $dmg_path"
diff --git a/scripts/release/verify-macos-dmg.sh b/scripts/release/verify-macos-dmg.sh
new file mode 100644
index 000000000..a13ee67f2
--- /dev/null
+++ b/scripts/release/verify-macos-dmg.sh
@@ -0,0 +1,49 @@
+#!/usr/bin/env bash
+
+set -euo pipefail
+
+dmg_path="${1:?usage: verify-macos-dmg.sh /path/to/LiveAgent.dmg}"
+if [[ ! -f "$dmg_path" ]]; then
+ echo "macOS DMG not found: $dmg_path" >&2
+ exit 1
+fi
+
+mount_path="$(mktemp -d -t liveagent-dmg-verify.XXXXXX)"
+device_name=""
+
+cleanup() {
+ set +e
+ if [[ -n "$device_name" ]]; then
+ hdiutil detach "$device_name" >/dev/null
+ fi
+ rmdir "$mount_path" 2>/dev/null
+}
+trap cleanup EXIT
+
+attach_output="$(hdiutil attach -readonly -nobrowse -noautoopen -mountpoint "$mount_path" "$dmg_path")"
+device_name="$(printf '%s\n' "$attach_output" | awk '/^\/dev\// { print $1; exit }')"
+if [[ -z "$device_name" ]]; then
+ echo "Unable to determine mounted device for: $dmg_path" >&2
+ exit 1
+fi
+
+required_paths=(
+ ".DS_Store"
+ ".background.png"
+ "LiveAgent.app"
+ "Applications"
+)
+
+for required_path in "${required_paths[@]}"; do
+ if [[ ! -e "$mount_path/$required_path" ]]; then
+ echo "macOS DMG is missing required Finder layout content: $required_path" >&2
+ exit 1
+ fi
+done
+
+if [[ ! -L "$mount_path/Applications" ]]; then
+ echo "macOS DMG Applications entry is not a symbolic link" >&2
+ exit 1
+fi
+
+echo "macOS DMG Finder layout verified: $dmg_path"