v0.8.5: WebUI process timeline, Gemini-style images, aligned layout

Process timeline: a run of consecutive thinking and tool calls is one
block that folds to a single line ("思考 4 次 · 工具 6 次"; while running
it shows the current action) and opens into a compact timeline, one row
per thought or call. Visible text, a note or the next prompt closes the
block; history and the live stream follow the same rule. show_thinking
now also applies while streaming instead of forcing thoughts open.

Layout: the avatar gutter and the flex-wrap that dropped it under long
answers are gone, so answer, process blocks, notes and composer share one
left edge. The text column and composer are both 720px (--content); the
rail and run panel grow with clamp() on wide screens. A rebuild after
each turn no longer replays every entrance animation, and the turn time
survives it. Message actions are icon buttons and only appear under text.

Images: a prompt's images sit above the bubble, right-aligned (one keeps
its shape, several are 152px rounded tiles) and open in a lightbox (Esc,
arrow keys). Images uploaded from the page show as thumbnails over the
composer and appear on the prompt as soon as it is sent.

Also: ?theme= is no longer overridden by app.js, the user bubble is
visible in the light theme, and the narrow top bar no longer wraps.
This commit is contained in:
WHC
2026-10-10 14:54:36 +08:00
parent 7e1dbd9afa
commit 9bf7707912
5 changed files with 487 additions and 162 deletions
+20 -3
View File
@@ -209,6 +209,22 @@ Markdown 在**服务端**渲染(复用 TUI 用的 sashidown 流式 API),
对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文 对话开始后自动隐去;右栏只给三件事——任务清单、本轮文件改动行数(`+N −M`)、上下文
占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。 占用与 prompt cache 命中率。布局类在页面脚本之前就写进 `<body>`,避免首帧闪一下再收起。
对话列:正文、过程块、提示行和输入框共用同一条左边线,正文宽度上限 720px(`--content`);
左右两栏随屏宽在 `clamp()` 范围内变宽,超宽屏下中间列不会无限拉开。
过程时间线:一段**连续**的思考和工具调用合成一个过程块,折叠时只有一行
("思考 4 次 · 工具 6 次";运行中显示当前动作,比如"执行 run_shell…"),展开后是一条
细竖线串起的时间线,每次思考、每个工具调用各占一行,思考只露第一行,点开看全文。
出现可见的正文(或引擎提示、下一句提问)时过程块就此结束,后面的思考/工具另起一块——
历史重建(`/api/session`)和实时事件流用的是同一条规则,所以刷新前后看到的一样。
`/thinking on|off`(`show_thinking`)决定过程块和其中的思考默认展开还是收起,流式输出时也照此执行;
`/details` 只管工具行是否展开。
图片:提问附带的图片显示在气泡**上方**、靠右,一张保持原比例(最长 260px),多张是
152px 的圆角方块;点开是大图预览(Esc 关闭,←/→ 切换)。从本页上传/粘贴/拖入的图片在
输入框上方显示缩略图,发送后立刻出现在新提问上(本地预览,本轮结束后换成 `/api/attach/<rel>`);
从别的客户端加的待发图片仍然显示为文字条。
### 命令与快捷键 ### 命令与快捷键
输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,Tab 补全, 输入框以 `/` 开头时是命令,在页面里执行,不发给模型:打 `/` 弹出列表(↑↓ 选,Tab 补全,
@@ -259,10 +275,11 @@ sh scripts/e2e_webui.sh tests/fixtures/sse/deepseek-ctl-heredoc ctl-heredoc
1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。 1. 打开首页 → 顶部显示 workspace/模型/审批模式,右下状态"就绪"。
2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。 2. 输入一句话 → 助手气泡逐字出现;工具卡片自动展开、结束后带 `exit=` 标记。
3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。 3. 切到 `ask` 审批 → 写文件时弹窗;点"允许一次"后继续,点"拒绝"则该工具返回失败但对话继续。
4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):工具调用折叠成"本轮工具调用", 4. `会话` 按钮:新建/切换后历史正确重建(刷新页面也一样):连续的思考和工具调用折叠成一行
点开能看到参数和输出;搜索、重命名、删除可用。 过程块,展开是时间线,工具行点开能看到参数和输出;搜索、重命名、删除可用。
`/thinking off` 时流式输出的思考也保持收起。
4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。 4a. 最后一句提问上有"编辑重发",最后一个回答上有"重新生成";右栏文件行点开是真实 diff。
5. 拖入一张图片 → 底部出现待发送提示,发送后历史里能看到图。 5. 拖入一张图片 → 输入框上方出现缩略图,发送后图片立刻出现在提问气泡上方,点开是大图预览。
6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。 6. 关掉标签页再打开 → 事件流自动续传,不丢已完成的消息。
## 限制与已知问题 ## 限制与已知问题
+1 -1
View File
@@ -3,7 +3,7 @@
#define CST_VERSION_MAJOR 0 #define CST_VERSION_MAJOR 0
#define CST_VERSION_MINOR 8 #define CST_VERSION_MINOR 8
#define CST_VERSION_PATCH 4 #define CST_VERSION_PATCH 5
#define CST_VERSION_STR_(x) #x #define CST_VERSION_STR_(x) #x
#define CST_VERSION_STR(x) CST_VERSION_STR_(x) #define CST_VERSION_STR(x) CST_VERSION_STR_(x)
+297 -96
View File
@@ -165,8 +165,11 @@ const log = $('log');
const scroll = $('scroll'); const scroll = $('scroll');
let state = {}; let state = {};
let live = null; /* streaming assistant block */ let live = null; /* streaming assistant block */
let steps = null; /* this turn's tool list */ let proc = null; /* the open process block: thinking + tool calls */
let stepIndex = new Map(); /* tool call id -> step row */ let stepIndex = new Map(); /* tool call id -> step row */
let lastTurn = null; /* { session, ms }: the last turn's time, kept across the rebuild */
let pendingPreviews = []; /* { src, name }: object URLs of the images uploaded from this page */
let shownPreviews = []; /* the ones the newest prompt still shows until the rebuild */
let files = new Map(); /* this turn's file changes: path -> {add, del} */ let files = new Map(); /* this turn's file changes: path -> {add, del} */
let permReq = null; let permReq = null;
let askReq = null; let askReq = null;
@@ -277,58 +280,61 @@ function updateJump() {
function showEmpty(show) { function showEmpty(show) {
$('empty').classList.toggle('gone', !show); $('empty').classList.toggle('gone', !show);
} }
/* every row of the transcript shares one left edge, the composer's: no avatar
* column, the assistant reads as plain text, only errors carry a mark */
function bubble(role, cls) { function bubble(role, cls) {
const m = el('div', 'msg ' + (cls || role)); const m = el('div', 'msg ' + (cls || role));
/* no avatar per message: the assistant reads as plain text in the column,
* the way the reference transcript does; only errors get a mark */
const g = el('div', 'gutter', role === 'error' ? '!' : '');
const b = el('div', 'body'); const b = el('div', 'body');
m.appendChild(g);
m.appendChild(b); m.appendChild(b);
log.appendChild(m); log.appendChild(m);
showEmpty(false); showEmpty(false);
scrollDown(true); scrollDown(true);
return { root: m, body: b }; return { root: m, body: b };
} }
function copyButton(text) { function actBtn(name, title, fn) {
const b = el('button', null, '复制'); const b = el('button', 'act');
b.onclick = async () => { b.title = title;
try { b.setAttribute('aria-label', title);
await navigator.clipboard.writeText(text); b.appendChild(icon(name));
b.textContent = '已复制'; b.onclick = fn;
setTimeout(() => { b.textContent = '复制'; }, 1200);
} catch { b.textContent = '复制失败'; }
};
return b; return b;
} }
function attachStrip(atts) { function copyButton(text) {
const strip = el('div', 'attach-strip'); const b = actBtn('copy', '复制', async () => {
for (const a of atts) { try {
const img = el('img'); await navigator.clipboard.writeText(text);
img.alt = a.name || 'image'; setIcon(b, 'check');
img.loading = 'lazy'; b.title = '已复制';
img.src = attachURL(a.rel); setTimeout(() => { setIcon(b, 'copy'); b.title = '复制'; }, 1200);
strip.appendChild(img); } catch { toast('浏览器不允许写剪贴板'); }
});
return b;
} }
/* images sit above the prompt, outside the bubble: one keeps its shape, more
* than one become a row of square tiles; a click opens the full picture */
function attachStrip(srcs) {
const strip = el('div', 'attach-strip' + (srcs.length === 1 ? ' one' : ''));
srcs.forEach((s, i) => {
const b = el('button', 'thumb');
b.title = s.name || '查看图片';
const img = el('img');
img.alt = s.name || 'image';
img.loading = 'lazy';
img.decoding = 'async';
img.src = s.src;
b.appendChild(img);
b.onclick = () => openLightbox(srcs, i);
strip.appendChild(b);
});
return strip; return strip;
} }
/* reasoning belongs inside .body: as a sibling it becomes a second flex item function userBubble(text, srcs) {
* and squeezes the answer into a narrow column */ const b = bubble('user');
function thinkInto(body, text, open) { b.body.textContent = text;
let d = body.querySelector(':scope > details.think'); if (srcs && srcs.length) b.root.insertBefore(attachStrip(srcs), b.body);
let t; b.root.dataset.prompt = '1';
if (!d) { actionsFor(b.root, text);
d = el('details', 'think'); return b;
d.appendChild(el('summary', null, '思考过程'));
t = el('div');
d.appendChild(t);
body.insertBefore(d, body.firstChild);
} else {
t = d.querySelector('div');
}
if (open) d.open = true;
t.textContent += text;
return d;
} }
function actionsFor(root, text) { function actionsFor(root, text) {
const row = el('div', 'msg-actions'); const row = el('div', 'msg-actions');
@@ -336,6 +342,119 @@ function actionsFor(root, text) {
root.appendChild(row); root.appendChild(row);
return row; return row;
} }
/* --- lightbox ------------------------------------------------------------ */
let lb = null; /* { srcs, i } while a picture is open */
function showLightbox() {
const s = lb.srcs[lb.i];
$('lb-img').src = s.src;
$('lb-img').alt = s.name || '';
const many = lb.srcs.length > 1;
$('lb-count').textContent = many ? (lb.i + 1) + ' / ' + lb.srcs.length : '';
$('lb-prev').hidden = !many;
$('lb-next').hidden = !many;
}
function openLightbox(srcs, i) {
lb = { srcs: srcs.slice(), i };
showLightbox();
$('lightbox').hidden = false;
}
function closeLightbox() {
$('lightbox').hidden = true;
$('lb-img').removeAttribute('src');
lb = null;
}
function stepLightbox(d) {
if (!lb || lb.srcs.length < 2) return;
lb.i = (lb.i + d + lb.srcs.length) % lb.srcs.length;
showLightbox();
}
/* --- process timeline -----------------------------------------------------
* Consecutive thinking and tool calls share one block that folds to a single
* line; visible text (or a note, or the next prompt) closes it. History and
* the live stream follow the same rule, so a reload draws what streamed. */
function procLabel(p) {
const parts = [];
if (p.thinks) parts.push(p.thinks > 1 ? '思考 ' + p.thinks + ' 次' : '已思考');
if (p.tools) parts.push('工具 ' + p.tools + ' 次');
const counts = parts.join(' · ');
if (p.root.classList.contains('run')) {
p.lbl.textContent = p.act || '处理中…';
p.meta.textContent = counts;
} else {
p.lbl.textContent = counts || '过程';
p.meta.textContent = '';
}
}
function ensureProc() {
if (proc) return proc;
if (live && live.html) sealLive();
const d = el('details', 'proc');
d.open = !!state.show_thinking;
const s = el('summary', 'proc-sum');
s.appendChild(icon('chevron', 'proc-chev'));
const lbl = el('span', 'proc-lbl');
const meta = el('span', 'proc-meta');
s.appendChild(lbl);
s.appendChild(meta);
const box = el('div', 'tl');
d.appendChild(s);
d.appendChild(box);
log.appendChild(d);
showEmpty(false);
proc = { root: d, lbl, meta, box, thinks: 0, tools: 0, cur: null, act: '' };
procLabel(proc);
return proc;
}
function sealProc() {
if (!proc) return;
proc.root.classList.remove('run');
procLabel(proc);
proc = null;
}
function procAct(p, text) {
p.root.classList.add('run');
p.act = text;
procLabel(p);
}
function firstLine(s) {
const l = String(s).split('\n').find((x) => x.trim()) || '';
return l.trim().slice(0, 200);
}
function procThink(text, running) {
const p = ensureProc();
let t = p.cur && p.cur.kind === 'think' ? p.cur : null;
if (!t) {
const d = el('details', 'tl-item think');
d.open = !!state.show_thinking;
const s = el('summary');
s.appendChild(el('span', 'tl-dot'));
s.appendChild(el('span', 'tl-name', '思考'));
const prev = el('span', 'tl-prev');
s.appendChild(prev);
const body = el('div', 'think-text');
const node = document.createTextNode('');
body.appendChild(node);
d.appendChild(s);
d.appendChild(body);
p.box.appendChild(d);
t = { kind: 'think', root: d, prev, node, hasLine: false };
p.cur = t;
p.thinks++;
}
t.node.appendData(text);
/* the preview is the first line, so it stops changing once that is whole */
if (!t.hasLine) {
t.prev.textContent = firstLine(t.node.data);
t.hasLine = /\S[^\n]*\n/.test(t.node.data);
}
if (running) procAct(p, '正在思考…');
else procLabel(p);
return t;
}
/* "[cst] Plan mode is now ON: ..." notes the engine adds to the history; the /* "[cst] Plan mode is now ON: ..." notes the engine adds to the history; the
* model reads the English, the page shows one line per change */ * model reads the English, the page shows one line per change */
const APPROVAL_ZH = { ask: '每次询问', auto: '自动', yolo: '完全放行' }; const APPROVAL_ZH = { ask: '每次询问', auto: '自动', yolo: '完全放行' };
@@ -355,6 +474,7 @@ function noteLine(text) {
.map((l) => zhOf(NOTE_ZH, l) || l); .map((l) => zhOf(NOTE_ZH, l) || l);
const n = el('div', 'note-line', shown.join(' · ')); const n = el('div', 'note-line', shown.join(' · '));
n.title = String(text || ''); n.title = String(text || '');
sealProc();
log.appendChild(n); log.appendChild(n);
} }
/* history: the stored calls become the same step lines a live turn draws, /* history: the stored calls become the same step lines a live turn draws,
@@ -381,15 +501,23 @@ function renderMessage(m) {
if (m.note) return noteLine(m.content); if (m.note) return noteLine(m.content);
if (m.role === 'tool' || m.tool_result) return historyResult(m); if (m.role === 'tool' || m.tool_result) return historyResult(m);
const isUser = m.role === 'user'; const isUser = m.role === 'user';
if (isUser) steps = null; /* each prompt starts its own step list */
const text = m.text !== undefined ? m.text : (m.content || ''); const text = m.text !== undefined ? m.text : (m.content || '');
if (isUser || text.trim() || m.reasoning || (m.attach && m.attach.length)) { if (isUser) {
const b = bubble(isUser ? 'user' : 'assistant'); sealProc();
if (!isUser && m.html) b.body.innerHTML = m.html; stepIndex = new Map();
const srcs = (m.attach || []).map((a) => ({ src: attachURL(a.rel), name: a.name }));
userBubble(text, srcs);
return;
}
if (m.reasoning) {
procThink(m.reasoning, false);
proc.cur = null; /* the next round's thinking is its own entry */
}
if (text.trim()) {
sealProc();
const b = bubble('assistant');
if (m.html) b.body.innerHTML = m.html;
else b.body.textContent = text; else b.body.textContent = text;
if (m.reasoning) thinkInto(b.body, m.reasoning, !!state.show_thinking);
if (m.attach && m.attach.length) b.body.insertBefore(attachStrip(m.attach), b.body.firstChild);
if (isUser) b.root.dataset.prompt = '1';
actionsFor(b.root, text); actionsFor(b.root, text);
} }
for (const c of m.calls || []) historyCall(c); for (const c of m.calls || []) historyCall(c);
@@ -400,12 +528,12 @@ function lastPromptActions() {
const p = prompts[prompts.length - 1]; const p = prompts[prompts.length - 1];
if (!p) return; if (!p) return;
const row = p.querySelector('.msg-actions'); const row = p.querySelector('.msg-actions');
if (row) row.appendChild(Object.assign(el('button', null, '编辑重发'), { onclick: () => editLast(p) })); if (row) row.appendChild(actBtn('edit', '编辑重发', () => editLast(p)));
const answers = log.querySelectorAll('.msg.assistant'); const answers = log.querySelectorAll('.msg.assistant');
const a = answers[answers.length - 1]; const a = answers[answers.length - 1];
if (a && (p.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING)) { if (a && (p.compareDocumentPosition(a) & Node.DOCUMENT_POSITION_FOLLOWING)) {
const r = a.querySelector('.msg-actions'); const r = a.querySelector('.msg-actions');
if (r) r.appendChild(Object.assign(el('button', null, '重新生成'), { onclick: () => retry() })); if (r) r.appendChild(actBtn('retry', '重新生成', () => retry()));
} }
} }
@@ -415,17 +543,23 @@ async function loadSession() {
stepIndex = new Map(); stepIndex = new Map();
files = new Map(); files = new Map();
renderFilesPanel(); renderFilesPanel();
steps = null; proc = null;
live = null; live = null;
for (const m of data.messages) renderMessage(m); for (const m of data.messages) renderMessage(m);
for (const d of log.querySelectorAll('details.turn-steps')) d.open = detailsAll; sealProc();
steps = null;
stepIndex = new Map(); stepIndex = new Map();
lastPromptActions(); lastPromptActions();
if (lastTurn && lastTurn.session === state.session && log.lastChild) turnMeta(lastTurn.ms);
if (turnErr && turnErr.session === state.session) { if (turnErr && turnErr.session === state.session) {
if (turnErr.stopped) noteLine('已停止'); if (turnErr.stopped) noteLine('已停止');
else bubble('error').body.textContent = turnErr.text; else bubble('error').body.textContent = turnErr.text;
} }
/* a rebuild is not new content: settle every entrance animation at once */
if (log.getAnimations) {
for (const a of log.getAnimations({ subtree: true })) {
try { a.finish(); } catch { /* an infinite pulse cannot finish; it keeps running */ }
}
}
showEmpty(log.children.length === 0); showEmpty(log.children.length === 0);
const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user' && !m.note && !m.tool_result); const last = [...(data.messages || [])].reverse().find((m) => m.role === 'user' && !m.note && !m.tool_result);
document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'CST-Agent'; document.title = (last && last.content ? last.content.slice(0, 40) + ' · ' : '') + 'CST-Agent';
@@ -435,28 +569,22 @@ async function loadSession() {
/* --- live blocks --------------------------------------------------------- */ /* --- live blocks --------------------------------------------------------- */
/* text closes the process block above it; thinking or a tool call after the
* text closes the answer, so the next words start a bubble below them */
function ensureLive() { function ensureLive() {
if (live) return live; if (live) return live;
sealProc();
const b = bubble('assistant'); const b = bubble('assistant');
b.body.classList.add('live'); b.body.classList.add('live');
/* the answer gets its own box: rewriting body.innerHTML would drop the
* reasoning <details> that thinkInto keeps at the top of body */
const out = el('div', 'live-html'); const out = el('div', 'live-html');
b.body.appendChild(out); b.body.appendChild(out);
live = { root: b.root, body: b.body, out, html: '' }; live = { root: b.root, body: b.body, out, html: '' };
return live; return live;
} }
function ensureSteps() { function sealLive() {
if (steps) return steps; if (!live) return;
const d = el('details', 'turn-steps'); live.body.classList.remove('live');
d.open = true; live = null;
const s = el('summary', 'steps-sum', '本轮工具调用');
d.appendChild(s);
const box = el('div', 'steps-box');
d.appendChild(box);
log.appendChild(d);
steps = { root: d, sum: s, box, count: 0 };
return steps;
} }
function phaseText(p) { function phaseText(p) {
return { open: '准备中', ready: '待执行', run: '执行中', progress: '进行中', done: '完成' }[p] || p || ''; return { open: '准备中', ready: '待执行', run: '执行中', progress: '进行中', done: '完成' }[p] || p || '';
@@ -471,24 +599,26 @@ function argsLine(args) {
function stepFor(ev) { function stepFor(ev) {
let st = stepIndex.get(ev.id); let st = stepIndex.get(ev.id);
if (st) return st; if (st) return st;
const c = ensureSteps(); const p = ensureProc();
const node = el('details', 'step-line run'); const node = el('details', 'tl-item step-line run');
node.open = detailsAll; node.open = detailsAll;
const sum = el('summary'); const sum = el('summary');
const name = el('span', 'name', ev.name || 'tool'); const name = el('span', 'name', ev.name || 'tool');
const args = el('span', 'args'); const args = el('span', 'args');
const phase = el('span', 'phase', '准备中'); const phase = el('span', 'phase', '准备中');
sum.appendChild(el('span', 'tl-dot'));
sum.appendChild(name); sum.appendChild(name);
sum.appendChild(args); sum.appendChild(args);
sum.appendChild(phase); sum.appendChild(phase);
const body = el('div', 'step-body'); const body = el('div', 'step-body');
node.appendChild(sum); node.appendChild(sum);
node.appendChild(body); node.appendChild(body);
c.box.appendChild(node); p.box.appendChild(node);
st = { root: node, sum, name, args, phase, body, t0: Date.now(), bad: false }; st = { kind: 'tool', root: node, sum, name, args, phase, body, t0: Date.now(), bad: false, proc: p };
stepIndex.set(ev.id, st); stepIndex.set(ev.id, st);
c.count++; p.cur = st;
c.sum.textContent = '本轮工具调用 · ' + c.count + ' 次'; p.tools++;
procLabel(p);
return st; return st;
} }
@@ -630,6 +760,10 @@ function onTool(ev) {
st.root.classList.add('done'); st.root.classList.add('done');
st.phase.textContent = '完成 · ' + ((Date.now() - st.t0) / 1000).toFixed(1) + 's'; st.phase.textContent = '完成 · ' + ((Date.now() - st.t0) / 1000).toFixed(1) + 's';
} }
if (st.proc === proc) {
procAct(proc, ev.phase === 'done' ? '等待模型…'
: (ev.phase === 'progress' && ev.detail ? ev.detail : '执行 ' + (ev.name || '工具') + '…'));
}
scrollDown(false); scrollDown(false);
} }
function onToolResult(ev) { function onToolResult(ev) {
@@ -642,6 +776,7 @@ function onToolResult(ev) {
if (st.args.textContent) st.body.appendChild(el('pre', 'step-args-full', st.args.textContent)); if (st.args.textContent) st.body.appendChild(el('pre', 'step-args-full', st.args.textContent));
if (ev.out) st.body.appendChild(el('pre', 'step-out', ev.out + (ev.truncated ? '\n… (截断)' : ''))); if (ev.out) st.body.appendChild(el('pre', 'step-out', ev.out + (ev.truncated ? '\n… (截断)' : '')));
if (!st.bad) noteFileChange(ev); if (!st.bad) noteFileChange(ev);
if (st.proc === proc) procAct(proc, '等待模型…');
scrollDown(false); scrollDown(false);
} }
function turnMeta(ms) { function turnMeta(ms) {
@@ -741,29 +876,62 @@ function applyState(s) {
syncSend(); syncSend();
if (!busy && !permReq) $('status').textContent = '就绪'; if (!busy && !permReq) $('status').textContent = '就绪';
renderPending();
renderTodo(s.todo);
if ('show_thinking' in s && s.show_thinking !== shownThinking) {
shownThinking = s.show_thinking;
for (const d of log.querySelectorAll('details.proc, details.tl-item.think')) d.open = !!s.show_thinking;
}
}
const revokeAll = (list) => { for (const s of list) URL.revokeObjectURL(s.src); };
/* images waiting for the next prompt: thumbnails when this page uploaded all
* of them, otherwise (another client, the CLI) the server's one-line notes */
let pendingSeen = 0; /* the server's count last time: an upload's reply can beat its state event */
function renderPending() {
const p = $('pending'); const p = $('pending');
const n = (s.pending_images || []).length; const list = state.pending_images || [];
const n = list.length;
if (!n && pendingSeen && pendingPreviews.length) {
revokeAll(pendingPreviews);
pendingPreviews = [];
}
pendingSeen = n;
p.hidden = !n; p.hidden = !n;
if (n) {
p.textContent = ''; p.textContent = '';
for (const line of s.pending_images) { if (!n) return;
const clear = () => post('api/attach', undefined, 'DELETE').catch(toastErr);
const clearTitle = n > 1 ? '清空全部 ' + n + ' 张待发图片' : '移除这张图片';
if (pendingPreviews.length === n) {
pendingPreviews.forEach((s, i) => {
const t = el('div', 'pend-thumb');
const img = el('img');
img.src = s.src;
img.alt = s.name || 'image';
img.title = list[i];
img.onclick = () => openLightbox(pendingPreviews, i);
const x = el('button', 'pend-x');
x.title = clearTitle;
x.setAttribute('aria-label', clearTitle);
x.appendChild(icon('close'));
x.onclick = clear;
t.appendChild(img);
t.appendChild(x);
p.appendChild(t);
});
} else {
for (const line of list) {
const chip = el('span', 'chip'); const chip = el('span', 'chip');
chip.appendChild(el('span', null, line)); chip.appendChild(el('span', null, line));
const x = el('button', 'icon tiny'); const x = el('button', 'icon tiny');
x.appendChild(icon('close')); x.appendChild(icon('close'));
x.title = '清空待发图片'; x.title = '清空待发图片';
x.onclick = () => post('api/attach', undefined, 'DELETE').catch(toastErr); x.onclick = clear;
chip.appendChild(x); chip.appendChild(x);
p.appendChild(chip); p.appendChild(chip);
} }
if (s.sees_images === 0)
p.appendChild(el('span', 'chip warn', (s.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发'));
}
renderTodo(s.todo);
if ('show_thinking' in s && s.show_thinking !== shownThinking) {
shownThinking = s.show_thinking;
for (const d of log.querySelectorAll('details.think')) d.open = !!s.show_thinking;
} }
if (state.sees_images === 0)
p.appendChild(el('span', 'chip warn', (state.model || '当前模型') + ' 不识图:图片不会发给模型,换个识图模型再发'));
} }
/* --- SSE ----------------------------------------------------------------- */ /* --- SSE ----------------------------------------------------------------- */
@@ -787,7 +955,7 @@ function connect() {
}); });
on('think_delta', (d) => { on('think_delta', (d) => {
markBusy(); markBusy();
thinkInto(ensureLive().body, d.text || '', true); procThink(d.text || '', true);
scrollDown(false); scrollDown(false);
}); });
on('tool', (ev) => { markBusy(); onTool(ev); }); on('tool', (ev) => { markBusy(); onTool(ev); });
@@ -795,8 +963,9 @@ function connect() {
on('todo', (d) => renderTodo(d.text)); on('todo', (d) => renderTodo(d.text));
on('session', (d) => { on('session', (d) => {
closePop(); closePop();
live = null; steps = null; stepIndex = new Map(); live = null; proc = null; stepIndex = new Map();
turnErr = null; turnErr = null;
lastTurn = null;
loadSession().then(loadSessions).catch(toastErr); loadSession().then(loadSessions).catch(toastErr);
closeDrawers(); closeDrawers();
if (d && d.id) $('status').textContent = '已切换会话'; if (d && d.id) $('status').textContent = '已切换会话';
@@ -805,16 +974,24 @@ function connect() {
on('permission_done', hideGate); on('permission_done', hideGate);
on('ask_user', showAsk); on('ask_user', showAsk);
on('turn_end', (d) => { on('turn_end', (d) => {
if (turnStart) { turnMeta(Date.now() - turnStart); turnStart = 0; } lastTurn = turnStart ? { session: state.session, ms: Date.now() - turnStart } : null;
if (live) live.body.classList.remove('live'); turnStart = 0;
live = null; steps = null; stepIndex = new Map(); sealLive();
sealProc();
stepIndex = new Map();
/* the reload below rebuilds the log, so the reason is drawn after it */ /* the reload below rebuilds the log, so the reason is drawn after it */
turnErr = d && d.rc ? { turnErr = d && d.rc ? {
text: turnError(d), text: turnError(d),
stopped: /cancelled$/.test(d.error || ''), stopped: /cancelled$/.test(d.error || ''),
session: state.session, session: state.session,
} : null; } : null;
loadSession().then(loadSessions).catch(toastErr); loadSession().then(() => {
/* the rebuilt prompt shows the server's copies now */
if (!lb || !lb.srcs.some((s) => shownPreviews.includes(s))) {
revokeAll(shownPreviews);
shownPreviews = [];
}
}).then(loadSessions).catch(toastErr);
}); });
/* the browser resumes from Last-Event-ID; a restarted server has none of /* the browser resumes from Last-Event-ID; a restarted server has none of
* those events, so its state (and maybe session) is fetched afresh */ * those events, so its state (and maybe session) is fetched afresh */
@@ -887,6 +1064,12 @@ async function answerGate(value) {
} }
} }
window.addEventListener('keydown', (e) => { window.addEventListener('keydown', (e) => {
if (lb) {
if (e.key === 'Escape') { e.preventDefault(); closeLightbox(); }
else if (e.key === 'ArrowLeft') { e.preventDefault(); stepLightbox(-1); }
else if (e.key === 'ArrowRight') { e.preventDefault(); stepLightbox(1); }
return;
}
/* typing "3" or "y" into the box must not grant a permission */ /* typing "3" or "y" into the box must not grant a permission */
const typing = e.isComposing || e.ctrlKey || e.metaKey || e.altKey || const typing = e.isComposing || e.ctrlKey || e.metaKey || e.altKey ||
(e.target && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName))); (e.target && (e.target.isContentEditable || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target.tagName)));
@@ -1668,15 +1851,16 @@ function editLast(node) {
syncSend(); syncSend();
input.focus(); input.focus();
} }
function freshTurn(text) { function freshTurn(text, srcs) {
const b = bubble('user'); sealProc();
b.body.textContent = text; sealLive();
b.root.dataset.prompt = '1'; revokeAll(shownPreviews);
steps = null; shownPreviews = srcs || [];
userBubble(text, shownPreviews);
stepIndex = new Map(); stepIndex = new Map();
files = new Map(); files = new Map();
live = null;
turnErr = null; turnErr = null;
lastTurn = null;
renderFilesPanel(); renderFilesPanel();
turnStart = Date.now(); turnStart = Date.now();
} }
@@ -1731,11 +1915,23 @@ async function send(text) {
setEditing(false); setEditing(false);
return retry(body); return retry(body);
} }
freshTurn(body); /* the images go out with this prompt: draw them now from the local copies */
const pend = (state.pending_images || []).length;
let srcs = [];
if (pend && pendingPreviews.length === pend) {
srcs = pendingPreviews;
pendingPreviews = [];
}
freshTurn(body, srcs);
try { try {
await post('api/turn', { text: body }); await post('api/turn', { text: body });
if (busy) $('status').textContent = '已开始…'; if (busy) $('status').textContent = '已开始…';
} catch (e) { } catch (e) {
if (srcs.length && !pendingPreviews.length && shownPreviews === srcs) {
pendingPreviews = srcs; /* still waiting on the server: keep the thumbnails */
shownPreviews = [];
renderPending();
}
bubble('error').body.textContent = errMsg(e); bubble('error').body.textContent = errMsg(e);
} }
} }
@@ -1805,7 +2001,7 @@ function setThinking(a) {
function setDetails(a) { function setDetails(a) {
detailsAll = onOffArg(a, detailsAll); detailsAll = onOffArg(a, detailsAll);
localStorage.setItem('cst.details', detailsAll ? '1' : '0'); localStorage.setItem('cst.details', detailsAll ? '1' : '0');
for (const d of log.querySelectorAll('details.turn-steps, details.step-line')) d.open = detailsAll; for (const d of log.querySelectorAll('details.step-line')) d.open = detailsAll;
toast(detailsAll ? '工具输出全部展开' : '工具输出全部收起', 'ok'); toast(detailsAll ? '工具输出全部展开' : '工具输出全部收起', 'ok');
} }
async function providerCmd(a) { async function providerCmd(a) {
@@ -2220,6 +2416,8 @@ async function upload(file) {
body: file, body: file,
}); });
try { await readReply(r); } catch (e) { toast('上传失败:' + errMsg(e)); return; } try { await readReply(r); } catch (e) { toast('上传失败:' + errMsg(e)); return; }
pendingPreviews.push({ src: URL.createObjectURL(file), name: file.name || 'pasted.png' });
renderPending();
if (state.sees_images === 0) toast((state.model || '当前模型') + ' 不识图,这张图不会发给模型', 'warn'); if (state.sees_images === 0) toast((state.model || '当前模型') + ' 不识图,这张图不会发给模型', 'warn');
} }
@@ -2335,10 +2533,13 @@ document.addEventListener('drop', async (e) => {
for (const f of e.dataTransfer.files) await upload(f); for (const f of e.dataTransfer.files) await upload(f);
}); });
$('backdrop').onclick = closeDrawers; $('backdrop').onclick = closeDrawers;
$('lightbox').onclick = (e) => { if (e.target === $('lightbox')) closeLightbox(); };
$('lb-close').onclick = closeLightbox;
$('lb-prev').onclick = () => stepLightbox(-1);
$('lb-next').onclick = () => stepLightbox(1);
/* --- go ------------------------------------------------------------------ */ /* --- go ------------------------------------------------------------------ */
applyTheme(localStorage.getItem('cst.theme') || 'dark');
/* the rail/panel/theme classes are already on <body> from the inline script in /* the rail/panel/theme classes are already on <body> from the inline script in
* index.html, so nothing here has to re-derive them */ * index.html, so nothing here has to re-derive them */
autoGrow(); autoGrow();
+11
View File
@@ -41,6 +41,10 @@
<symbol id="i-lv4" viewBox="0 0 24 24"><path d="M4 18.5V13h2.6v5.5zM8.7 18.5V10h2.6v8.5zM13.4 18.5V6.8h2.6v11.7zM18.1 18.5V4h2.6v14.5z" class="f"/></symbol> <symbol id="i-lv4" viewBox="0 0 24 24"><path d="M4 18.5V13h2.6v5.5zM8.7 18.5V10h2.6v8.5zM13.4 18.5V6.8h2.6v11.7zM18.1 18.5V4h2.6v14.5z" class="f"/></symbol>
<symbol id="i-file" viewBox="0 0 24 24"><path d="M13.5 3.5H7A2.5 2.5 0 0 0 4.5 6v12A2.5 2.5 0 0 0 7 20.5h10A2.5 2.5 0 0 0 19.5 18V9.5z"/><path d="M13.5 3.5v6h6"/></symbol> <symbol id="i-file" viewBox="0 0 24 24"><path d="M13.5 3.5H7A2.5 2.5 0 0 0 4.5 6v12A2.5 2.5 0 0 0 7 20.5h10A2.5 2.5 0 0 0 19.5 18V9.5z"/><path d="M13.5 3.5v6h6"/></symbol>
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.2V12l3.2 2"/></symbol> <symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5"/><path d="M12 7.2V12l3.2 2"/></symbol>
<symbol id="i-edit" viewBox="0 0 24 24"><path d="M14.8 5.2l4 4L9 19H5v-4z"/><path d="M12.6 7.4l4 4"/></symbol>
<symbol id="i-retry" viewBox="0 0 24 24"><path d="M4.5 12a7.5 7.5 0 0 1 13-5.1M19.5 12a7.5 7.5 0 0 1-13 5.1"/><path d="M17.8 3.6v3.6h-3.6M6.2 20.4v-3.6h3.6"/></symbol>
<symbol id="i-left" viewBox="0 0 24 24"><path d="M14.5 6l-6 6 6 6"/></symbol>
<symbol id="i-right" viewBox="0 0 24 24"><path d="M9.5 6l6 6-6 6"/></symbol>
<symbol id="i-folder" viewBox="0 0 24 24"><path d="M3.5 6.5A1.5 1.5 0 0 1 5 5h4l2 2.5h8A1.5 1.5 0 0 1 20.5 9v9A1.5 1.5 0 0 1 19 19.5H5A1.5 1.5 0 0 1 3.5 18z"/></symbol> <symbol id="i-folder" viewBox="0 0 24 24"><path d="M3.5 6.5A1.5 1.5 0 0 1 5 5h4l2 2.5h8A1.5 1.5 0 0 1 20.5 9v9A1.5 1.5 0 0 1 19 19.5H5A1.5 1.5 0 0 1 3.5 18z"/></symbol>
</svg> </svg>
@@ -216,6 +220,13 @@
<div id="backdrop"></div> <div id="backdrop"></div>
<div id="pop" class="pop" hidden></div> <div id="pop" class="pop" hidden></div>
<div id="modal" hidden><div id="modal-box"></div></div> <div id="modal" hidden><div id="modal-box"></div></div>
<div id="lightbox" hidden>
<img id="lb-img" alt="">
<button id="lb-close" class="lb-btn" title="关闭 (Esc)"><svg class="i"><use href="#i-close"/></svg></button>
<button id="lb-prev" class="lb-btn" title="上一张 (←)"><svg class="i"><use href="#i-left"/></svg></button>
<button id="lb-next" class="lb-btn" title="下一张 (→)"><svg class="i"><use href="#i-right"/></svg></button>
<span id="lb-count"></span>
</div>
<div id="toast" class="toast" role="status" aria-live="polite" hidden></div> <div id="toast" class="toast" role="status" aria-live="polite" hidden></div>
<script> <script>
/* Layout is decided here, before app.js runs and before the first paint: the /* Layout is decided here, before app.js runs and before the first paint: the
+158 -62
View File
@@ -35,6 +35,7 @@
--composer-surface-secondary: #202026; --composer-surface-secondary: #202026;
--bg-code: #08080a; --bg-code: #08080a;
--bubble-user: var(--main-surface-tertiary);
/* every hover/active tint is alpha, so it lands correctly on any surface */ /* every hover/active tint is alpha, so it lands correctly on any surface */
--hover: color-mix(in srgb, var(--text-primary) 7%, transparent); --hover: color-mix(in srgb, var(--text-primary) 7%, transparent);
@@ -68,8 +69,11 @@
--accent: var(--accent-primary); --accent: var(--accent-primary);
--danger: var(--text-error); --danger: var(--text-error);
--rail: 252px; /* the side columns grow with the screen so a wide window does not turn into
--panel: 296px; * one wide middle: the text stays near the run panel it is read against */
--rail: clamp(240px, 14vw, 280px);
--panel: clamp(296px, 20vw, 380px);
--content: 720px; /* text column and composer share this edge */
--radius: 12px; --radius: 12px;
--radius-lg: 22px; --radius-lg: 22px;
--shadow: 0 16px 40px rgba(0, 0, 0, .45); --shadow: 0 16px 40px rgba(0, 0, 0, .45);
@@ -103,6 +107,7 @@
--composer-surface-secondary: #f4f4f6; --composer-surface-secondary: #f4f4f6;
--bg-code: #f5f5f7; --bg-code: #f5f5f7;
--bubble-user: #eef0f4;
--accent-primary: #0f9d70; --accent-primary: #0f9d70;
--accent-fg: #ffffff; --accent-fg: #ffffff;
@@ -306,7 +311,7 @@ body.no-side #side { pointer-events: none; }
border-radius: 99px; padding: 2px; position: relative; border-radius: 99px; padding: 2px; position: relative;
} }
.seg button { .seg button {
height: 25px; padding: 0 12px; border-radius: 99px; color: var(--fg-dim); font-size: 12.5px; height: 25px; padding: 0 12px; white-space: nowrap; border-radius: 99px; color: var(--fg-dim); font-size: 12.5px;
transition: color var(--t-fast), background var(--t); transition: color var(--t-fast), background var(--t);
} }
.seg button.on { background: var(--bg-lift); color: var(--fg); font-weight: 600; } .seg button.on { background: var(--bg-lift); color: var(--fg); font-weight: 600; }
@@ -316,7 +321,7 @@ body.no-side #side { pointer-events: none; }
flex: 1 1 auto; overflow-y: auto; padding: 22px 0 10px; flex: 1 1 auto; overflow-y: auto; padding: 22px 0 10px;
scroll-behavior: smooth; scrollbar-gutter: stable; scroll-behavior: smooth; scrollbar-gutter: stable;
} }
#log { max-width: 760px; margin: 0 auto; padding: 0 20px; } #log { max-width: calc(var(--content) + 40px); margin: 0 auto; padding: 0 20px; }
#log:empty { display: none; } #log:empty { display: none; }
/* --- empty state ---------------------------------------------------------- */ /* --- empty state ---------------------------------------------------------- */
@@ -330,16 +335,22 @@ body.no-side #side { pointer-events: none; }
} }
/* --- transcript ----------------------------------------------------------- */ /* --- transcript ----------------------------------------------------------- */
.msg { display: flex; gap: 12px; padding: 13px 0 0; animation: fade-up var(--t) var(--ease) both; } /* one left edge for everything (answer, process block, notes), the same edge
.msg .gutter { flex: 0 0 24px; padding-top: 2px; color: var(--fg-faint); font-size: 11px; text-align: center; user-select: none; } * as the composer; prompts hang on the right edge with their images above */
.msg .body { min-width: 0; flex: 1 1 auto; } .msg { display: flex; flex-direction: column; align-items: stretch; padding: 14px 0 0; animation: fade-up var(--t) var(--ease) both; }
.msg.user { justify-content: flex-end; } .msg .body { min-width: 0; max-width: 100%; }
.msg.user .gutter { display: none; } .msg.user { align-items: flex-end; padding-top: 24px; }
#log > .msg.user:first-child { padding-top: 6px; }
.msg.user .body { .msg.user .body {
background: var(--bg-lift); border-radius: 18px 18px 6px 18px; background: var(--bubble-user); border-radius: 24px;
padding: 9px 15px; max-width: 76%; white-space: pre-wrap; word-break: break-word; padding: 11px 18px; max-width: min(76%, 560px); white-space: pre-wrap; word-break: break-word;
} }
.msg.error .body { color: var(--danger); white-space: pre-wrap; } .msg.error .body { color: var(--danger); white-space: pre-wrap; }
.msg.error .body::before {
content: "!"; display: inline-flex; align-items: center; justify-content: center;
width: 18px; height: 18px; margin-right: 8px; border-radius: 50%; vertical-align: 1px;
background: color-mix(in srgb, var(--danger) 18%, transparent); font-size: 12px; font-weight: 800;
}
.body > :first-child, .live-html > :first-child { margin-top: 0; } .body > :first-child, .live-html > :first-child { margin-top: 0; }
.body > :last-child, .live-html > :last-child { margin-bottom: 0; } .body > :last-child, .live-html > :last-child { margin-bottom: 0; }
.body h1, .body h2, .body h3, .body h4 { line-height: 1.35; margin: 1.1em 0 .5em; } .body h1, .body h2, .body h3, .body h4 { line-height: 1.35; margin: 1.1em 0 .5em; }
@@ -370,63 +381,102 @@ body.no-side #side { pointer-events: none; }
background: var(--accent); animation: blink 1.1s steps(2, start) infinite; background: var(--accent); animation: blink 1.1s steps(2, start) infinite;
} }
.attach-strip { display: flex; flex-wrap: wrap; gap: 8px; margin: 2px 0 8px; justify-content: flex-end; } /* a prompt's images: above the bubble and outside it, square tiles when there
.attach-strip img { * are several, the picture's own shape when there is one */
width: 108px; height: 108px; object-fit: cover; border-radius: 12px; .attach-strip { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 12px; margin: 0 0 10px; max-width: 100%; }
border: 1px solid var(--line); animation: rise var(--t) var(--ease) both; .attach-strip .thumb {
display: block; width: 152px; height: 152px; padding: 0; overflow: hidden; cursor: zoom-in;
border-radius: 20px; border: 1px solid var(--line); background: var(--bg-soft);
animation: rise var(--t) var(--ease) both;
transition: border-color var(--t-fast), transform 90ms var(--ease);
}
.attach-strip .thumb:hover { border-color: var(--line-strong); }
.attach-strip .thumb:active { transform: scale(.985); }
.attach-strip .thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.attach-strip.one .thumb { width: auto; height: auto; }
.attach-strip.one .thumb img {
width: auto; height: auto; min-width: 72px; min-height: 72px;
max-width: min(260px, 70vw); max-height: 260px; object-fit: contain;
} }
.turn-meta { display: flex; align-items: center; gap: 10px; padding: 10px 0 2px 36px; } .turn-meta { display: flex; align-items: center; gap: 10px; padding: 8px 0 2px; }
.turn-meta details > summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12px; transition: color var(--t-fast); } .turn-meta details > summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12px; transition: color var(--t-fast); }
.turn-meta details > summary::-webkit-details-marker { display: none; } .turn-meta details > summary::-webkit-details-marker { display: none; }
.turn-meta details > summary:hover { color: var(--fg-dim); } .turn-meta details > summary:hover { color: var(--fg-dim); }
.msg:has(> .msg-actions) { flex-wrap: wrap; row-gap: 0; } /* the actions take a row only under messages that have text to act on */
.msg-actions { display: flex; flex-basis: 100%; gap: 2px; padding: 4px 0 0 36px; opacity: 0; transition: opacity var(--t) var(--ease); } .msg-actions { display: flex; gap: 2px; padding-top: 4px; margin-left: -7px; opacity: 0; transition: opacity var(--t) var(--ease); }
.msg.user .msg-actions { margin: 0 -7px 0 0; }
.msg:hover .msg-actions, .msg-actions:focus-within { opacity: 1; } .msg:hover .msg-actions, .msg-actions:focus-within { opacity: 1; }
.msg-actions button { .act {
height: 26px; padding: 0 9px; border-radius: 8px; color: var(--fg-faint); font-size: 12px; width: 30px; height: 28px; border-radius: 8px; color: var(--fg-faint);
display: inline-flex; align-items: center; justify-content: center;
transition: background var(--t-fast), color var(--t-fast), transform 90ms var(--ease);
}
.act svg.i { width: 16px; height: 16px; }
.act:hover { background: var(--bg-hover); color: var(--fg); }
.act:active { transform: scale(.94); }
/* --- process block: a run of thinking and tool calls, folded to one line --- */
.proc { margin: 10px 0 0; }
.proc-sum {
display: inline-flex; align-items: center; gap: 7px; max-width: 100%; height: 28px;
margin-left: -7px; padding: 0 10px 0 6px; border-radius: 8px;
cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 13px; user-select: none;
transition: background var(--t-fast), color var(--t-fast); transition: background var(--t-fast), color var(--t-fast);
} }
.msg-actions button:hover { background: var(--bg-hover); color: var(--fg); } .proc-sum::-webkit-details-marker { display: none; }
.proc-sum:hover { background: var(--bg-hover); color: var(--fg-dim); }
.think { margin: .1em 0 .5em; } .proc-chev { width: 14px; height: 14px; transform: rotate(-90deg); transition: transform var(--t) var(--ease); }
.think summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12.5px; transition: color var(--t-fast); } .proc[open] > .proc-sum .proc-chev { transform: none; }
.think summary::-webkit-details-marker { display: none; } .proc-lbl { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.think summary::before { content: "▸ "; } .proc-meta { flex: 0 0 auto; color: var(--fg-quat); font-size: 12px; white-space: nowrap; }
.think[open] summary::before { content: "▾ "; } .proc-meta:empty { display: none; }
.think summary:hover { color: var(--fg-dim); } /* the running block's label shimmers instead of spinning */
.think > div { .proc.run .proc-lbl {
color: var(--fg-dim); font-size: 13.5px; white-space: pre-wrap; background: linear-gradient(90deg, var(--fg-faint) 0%, var(--fg) 45%, var(--fg-faint) 90%);
padding: 6px 0 2px 14px; border-left: 2px solid var(--line); background-size: 220% 100%; -webkit-background-clip: text; background-clip: text;
animation: fade-in var(--t) var(--ease) both; color: transparent; animation: shimmer 1.8s linear infinite;
} }
/* tool calls: one line, opens on demand (like "已执行 python · <<PY>") */ /* the timeline: one hairline, one dot per entry */
.turn-steps { padding: 6px 0 0 36px; } .tl { position: relative; margin: 2px 0 0; padding: 2px 0 2px 20px; animation: fade-in var(--t) var(--ease) both; }
.turn-steps > summary { cursor: pointer; list-style: none; color: var(--fg-faint); font-size: 12.5px; transition: color var(--t-fast); } .tl::before {
.turn-steps > summary::-webkit-details-marker { display: none; } content: ""; position: absolute; left: 4px; top: 14px; bottom: 14px; width: 1px;
.turn-steps > summary::before { content: "⌘ "; } background: var(--line);
.turn-steps > summary:hover { color: var(--fg-dim); } }
.steps-box { padding: 2px 0 0 12px; margin-top: 4px; border-left: 2px solid var(--line); } .tl-item { position: relative; animation: fade-up var(--t) var(--ease) both; }
.step-line { animation: fade-up var(--t) var(--ease) both; } .tl-item > summary {
.step-line > summary { display: flex; align-items: center; gap: 8px; min-height: 28px; padding: 2px 0;
display: flex; align-items: baseline; gap: 8px; cursor: pointer; list-style: none; cursor: pointer; list-style: none; font-size: 13px; color: var(--fg-dim);
padding: 5px 0; font-size: 13px; color: var(--fg-dim);
transition: color var(--t-fast); transition: color var(--t-fast);
} }
.step-line > summary::-webkit-details-marker { display: none; } .tl-item > summary::-webkit-details-marker { display: none; }
.step-line > summary:hover { color: var(--fg); } .tl-item > summary:hover { color: var(--fg); }
.step-line .name { font-family: var(--mono); font-size: 12.5px; color: var(--fg); } .tl-dot {
.step-line .args { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12px; } position: absolute; left: -20px; top: 9px; width: 9px; height: 9px; border-radius: 50%;
.step-line .phase { font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; display: inline-flex; align-items: center; gap: 6px; } background: var(--bg); border: 1.5px solid var(--line-strong);
.step-line.run .phase { color: var(--warn); }
.step-line.run .phase::before {
content: ""; width: 6px; height: 6px; border-radius: 50%;
background: var(--warn); animation: pulse 1.1s var(--ease) infinite;
} }
.tl-item.think .tl-dot { border-color: color-mix(in srgb, var(--violet) 75%, transparent); }
.tl-name { flex: 0 0 auto; font-size: 12.5px; color: var(--fg-dim); }
.tl-prev {
flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
color: var(--fg-faint); font-size: 12.5px;
}
.tl-item.think[open] .tl-prev { visibility: hidden; }
.think-text {
color: var(--fg-dim); font-size: 13px; line-height: 1.65; white-space: pre-wrap; word-break: break-word;
padding: 2px 0 8px; animation: fade-in var(--t) var(--ease) both;
}
/* tool calls: one line each, open for arguments and output */
.step-line .name { flex: 0 0 auto; font-family: var(--mono); font-size: 12.5px; color: var(--fg); }
.step-line .args { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--mono); font-size: 12px; color: var(--fg-faint); }
.step-line .phase { flex: 0 0 auto; font-size: 11.5px; color: var(--fg-faint); white-space: nowrap; }
.step-line.run .phase { color: var(--warn); }
.step-line.run .tl-dot { border-color: var(--warn); background: color-mix(in srgb, var(--warn) 30%, var(--bg)); animation: pulse 1.1s var(--ease) infinite; }
.step-line.done .tl-dot { border-color: var(--accent-line); background: var(--accent-weak); }
.step-line.bad .tl-dot { border-color: var(--danger); background: color-mix(in srgb, var(--danger) 20%, var(--bg)); }
.step-line.bad .phase, .step-line.bad .name { color: var(--danger); } .step-line.bad .phase, .step-line.bad .name { color: var(--danger); }
.step-line.done .phase::before { content: "✓"; }
.step-line.bad .phase::before { content: "✕"; }
/* a sub-agent's latest steps, next to its task */ /* a sub-agent's latest steps, next to its task */
.step-line .step-detail { .step-line .step-detail {
flex: 0 1 40%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 0 1 40%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
@@ -435,7 +485,7 @@ body.no-side #side { pointer-events: none; }
.step-line.done .step-detail { display: none; } .step-line.done .step-detail { display: none; }
/* "[cst] plan mode is now ON" and other engine notes in the history */ /* "[cst] plan mode is now ON" and other engine notes in the history */
.note-line { .note-line {
padding: 6px 0 2px 36px; font-size: 12px; color: var(--fg-faint); padding: 8px 0 2px; font-size: 12px; color: var(--fg-faint);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
} }
.note-line::before { content: "· "; } .note-line::before { content: "· "; }
@@ -459,9 +509,24 @@ body.no-side #side { pointer-events: none; }
/* --- composer ------------------------------------------------------------- */ /* --- composer ------------------------------------------------------------- */
#composer { padding: 8px 20px 12px; } #composer { padding: 8px 20px 12px; }
#composer > * { max-width: 760px; margin: 0 auto; } #composer > * { max-width: var(--content); margin: 0 auto; }
.pending { display: flex; flex-wrap: wrap; gap: 8px; padding: 0 0 8px; } .pending { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; padding: 6px 0 10px; }
.pending .chip { background: var(--bg-soft); border-color: var(--line); } .pending .chip { background: var(--bg-soft); border-color: var(--line); }
/* images waiting for the next prompt, the way they will look once sent */
.pend-thumb { position: relative; width: 56px; height: 56px; flex: 0 0 auto; animation: rise var(--t) var(--ease) both; }
.pend-thumb img {
display: block; width: 100%; height: 100%; object-fit: cover; cursor: zoom-in;
border-radius: 12px; border: 1px solid var(--line);
}
.pend-x {
position: absolute; top: -7px; right: -7px; width: 20px; height: 20px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: var(--main-surface-tertiary); border: 1px solid var(--line-strong); color: var(--fg-dim);
opacity: 0; transition: opacity var(--t-fast), color var(--t-fast);
}
.pend-x svg.i { width: 12px; height: 12px; stroke-width: 2.2; }
.pend-thumb:hover .pend-x, .pend-x:focus-visible { opacity: 1; }
.pend-x:hover { color: var(--fg); }
.edit-bar { .edit-bar {
display: flex; align-items: center; gap: 8px; padding: 6px 10px; margin-bottom: 8px; display: flex; align-items: center; gap: 8px; padding: 6px 10px; margin-bottom: 8px;
border-radius: 10px; border: 1px solid var(--line); background: var(--bg-soft); border-radius: 10px; border: 1px solid var(--line); background: var(--bg-soft);
@@ -698,9 +763,6 @@ body.no-panel #panel { pointer-events: none; }
.todo-list .boxmark { color: var(--fg-faint); } .todo-list .boxmark { color: var(--fg-faint); }
.todo-list li.done { color: var(--fg-faint); text-decoration: line-through; } .todo-list li.done { color: var(--fg-faint); text-decoration: line-through; }
.todo-list li.doing { color: var(--fg); font-weight: 600; } .todo-list li.doing { color: var(--fg); font-weight: 600; }
#steps-body .step-line > summary { font-size: 12.5px; }
#steps-body .step-line .phase { font-size: 11px; }
/* --- modal sheet (session list) ------------------------------------------- */ /* --- modal sheet (session list) ------------------------------------------- */
#modal { #modal {
position: fixed; inset: 0; background: rgba(0, 0, 0, .5); z-index: 40; position: fixed; inset: 0; background: rgba(0, 0, 0, .5); z-index: 40;
@@ -808,11 +870,44 @@ body.drawer-open #backdrop { display: block; }
body.side-open #side, body.panel-open #panel { transform: none; } body.side-open #side, body.panel-open #panel { transform: none; }
.only-narrow { display: inline-flex; } .only-narrow { display: inline-flex; }
#to-bottom { bottom: 150px; right: 16px; } #to-bottom { bottom: 150px; right: 16px; }
.hero h1 { font-size: 21px; }
.msg.user .body { max-width: 88%; } .msg.user .body { max-width: 88%; }
.turn-meta, .msg-actions, .turn-steps, .note-line { padding-left: 0; } .attach-strip .thumb { width: 120px; height: 120px; }
#lightbox { padding: 56px 12px; }
}
@media (max-width: 560px) {
#chip-approval .lbl { display: none; }
.seg button { padding: 0 9px; }
} }
@media (min-width: 901px) { #backdrop { display: none !important; } } @media (min-width: 901px) { #backdrop { display: none !important; } }
/* touch screens have no hover: the actions stay visible */
@media (hover: none) {
.msg-actions, .pend-x { opacity: 1; }
}
/* --- lightbox ------------------------------------------------------------- */
#lightbox {
position: fixed; inset: 0; z-index: 70; padding: 56px 72px;
display: flex; align-items: center; justify-content: center;
background: rgba(0, 0, 0, .84); animation: fade-in var(--t) var(--ease) both;
}
#lb-img {
max-width: 100%; max-height: 100%; object-fit: contain; border-radius: 12px;
box-shadow: 0 20px 60px rgba(0, 0, 0, .5); animation: pop-in 180ms var(--ease) both;
}
.lb-btn {
position: absolute; width: 40px; height: 40px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
background: rgba(255, 255, 255, .1); color: #fff;
transition: background var(--t-fast), transform 90ms var(--ease);
}
.lb-btn:hover { background: rgba(255, 255, 255, .2); }
#lb-close { top: 14px; right: 14px; }
#lb-prev { left: 14px; top: calc(50% - 20px); }
#lb-next { right: 14px; top: calc(50% - 20px); }
#lb-count {
position: absolute; bottom: 18px; left: 50%; transform: translateX(-50%);
color: rgba(255, 255, 255, .75); font-size: 13px; font-variant-numeric: tabular-nums;
}
/* --- motion --------------------------------------------------------------- */ /* --- motion --------------------------------------------------------------- */
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } } @keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }
@@ -821,6 +916,7 @@ body.drawer-open #backdrop { display: block; }
@keyframes pop-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } to { opacity: 1; transform: none; } } @keyframes pop-in { from { opacity: 0; transform: translateY(-4px) scale(.97); } to { opacity: 1; transform: none; } }
@keyframes pulse { 0%, 100% { opacity: .3; transform: scale(.85); } 50% { opacity: 1; transform: scale(1); } } @keyframes pulse { 0%, 100% { opacity: .3; transform: scale(.85); } 50% { opacity: 1; transform: scale(1); } }
@keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } } @keyframes blink { 0%, 100% { opacity: 1; } 50% { opacity: 0; } }
@keyframes shimmer { from { background-position: 100% 0; } to { background-position: -120% 0; } }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; } *, *::before, *::after { animation-duration: 1ms !important; transition-duration: 1ms !important; }
#scroll { scroll-behavior: auto; } #scroll { scroll-behavior: auto; }