From 70e83d0b09b205d96dffe4a160c84ef8c6904fdf Mon Sep 17 00:00:00 2001 From: 18628386721 <18628386721@163.com> Date: Mon, 3 Aug 2026 13:10:46 +0800 Subject: [PATCH] =?UTF-8?q?=E5=B7=A5=E4=BD=9C=E6=97=A5=E5=BF=97:=E4=B8=80?= =?UTF-8?q?=E8=A1=8C=E6=91=86=E9=A6=96=E5=8F=A5,=E5=B1=95=E5=BC=80?= =?UTF-8?q?=E5=B0=B1=E6=8A=8A=E8=AF=9D=E8=AF=B4=E5=AE=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 从前一条只有一行标题,而人真想回看的东西全躺在留痕里没露过面 —— 凭什么算这一步办完了、那一笔落下哪几行、被拒到底拒在哪儿、发的是哪种卡送到哪儿。 现在每条可展开,细节由 detailOf 摘成**一句句人话**(不摆 execution 这类内部名); 摘不出就是空数组,前端据此不给展开钮 —— 点开是空抽屉比不给还糟。 不摆二级条目(用户定):细节不是「键:值」两栏 —— 那是把留痕的字段名搬到人面前, 人还得自己在脑子里连成句子。每句自带主语与语境,单拎出来也读得通。 连带修一处文档与实现不符:「一轮一句」的口径说埋头办事那几拍的交代「留进工作日志」, 而实现把 internal 整类滤掉了,那几句哪儿都看不到。现在带交代的放行, 章写「交代进展」、记在 AI 名下;排障用的内部痕照旧不摆。 两种交代统一「首句上行、全文进抽屉」,一句话说完的不重复摆;截了才加省略号。 契约:任务详情的日志行新增可选 detail(字符串数组),api-report 同批更新。 门禁:typecheck / lint / depcruise / test(1378)全绿;浏览器实测折叠行高 34-36px 齐平、 展开无键值两栏残留。 Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 15 ++ .../packages/contracts/src/api/workbench.d.ts | 2 + apps/api/src/app/routes/log-lines.ts | 3 + .../memory/__tests__/task-memory.test.ts | 104 ++++++++++- .../memory/internal/domain/task-log.ts | 167 +++++++++++++++++- apps/web/src/app/styles/app.css | 12 ++ .../desk-task/internal/ViewRegion.tsx | 58 ++++-- packages/contracts/src/api/workbench.ts | 7 + 8 files changed, 352 insertions(+), 16 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 76dce7a..6f557c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -768,6 +768,21 @@ 「结构化出参只吐 schema 里声明过的键」(ai/ledger 当年就是这么丢的)。 `quote-intake@49.5.0` 已装;1366 测、typecheck/lint/depcruise 绿。 用户在途那单已见到全指导书;e2e 复跑等测试台空出来再补(他的单还开着,不顶)。 +- **工作日志:一行摆首句,展开才看细节**(2026-08-03 用户:「细节展开不够好」): + 从前一条只有一行标题,而留痕里那些人真想回看的东西 —— 凭什么算这一步办完了 + (`rationale`)、那一笔写了哪几行(主键名单)、被拒到底拒在哪儿(`reason`+`hint`)、 + 发的是哪种卡送到哪儿 —— **一个字都没露过面**。现在 `detailOf` 把它们摘成 + **人读键值对**(与 title 同一条纪律:不摆内部英文名、认不出的不摆), + 折叠不占地方、展开才现身;**摘不出就是空数组,前端据此不给展开钮** —— + 点开是空的抽屉比不给还糟。 + 连带修一处**文档与实现不符**:「一轮一句」口径说埋头办事那几拍的交代 + 「留进工作日志」,而实现把 `internal` 整类滤掉了 —— 那几句**哪儿都看不到**。 + 现在带 `say` 的放行(它是人话),章写「交代进展」、记在 AI 名下; + 排障用的内部痕(重问原文)照旧不摆。 + 两种交代统一「首句上行、全文进抽屉」(一句话说完的不重复摆,免得点开发现一模一样); + 截了才加省略号 —— 加了等于说「后面还有」。 + 实测:一单 8 行、6 行有抽屉、行高 34-36px 齐平,展开那两行 141/103px。 + 14 条新测(detailOf 五类 + 压下交代放行 + 首句去重)。 ## 文档权威地图 diff --git a/api-report/packages/contracts/src/api/workbench.d.ts b/api-report/packages/contracts/src/api/workbench.d.ts index 943ccfa..d674a91 100644 --- a/api-report/packages/contracts/src/api/workbench.d.ts +++ b/api-report/packages/contracts/src/api/workbench.d.ts @@ -1528,6 +1528,7 @@ export declare const taskDetailSchema: z.ZodObject<{ action: z.ZodString; title: z.ZodString; summary: z.ZodOptional; + detail: z.ZodDefault>; }, z.core.$strip>>; }, z.core.$strip>; export type TaskDetail = z.infer; @@ -2828,6 +2829,7 @@ export declare const taskDetailRoute: { action: z.ZodString; title: z.ZodString; summary: z.ZodOptional; + detail: z.ZodDefault>; }, z.core.$strip>>; }, z.core.$strip>; }; diff --git a/apps/api/src/app/routes/log-lines.ts b/apps/api/src/app/routes/log-lines.ts index e7d65e9..dda68d5 100644 --- a/apps/api/src/app/routes/log-lines.ts +++ b/apps/api/src/app/routes/log-lines.ts @@ -28,6 +28,8 @@ export type DisplayLogLine = { readonly action: string; readonly title: string; readonly summary?: string; + /** 展开才看的细节:一段一句人话(摘不出就是空数组 —— 前端据此不给展开钮) */ + readonly detail: readonly string[]; }; /** @@ -58,6 +60,7 @@ export async function displayLog(lines: readonly LogLine[], org: OrgService): Pr sideLabel: l.sideLabel, who: l.who, action: l.action, + detail: l.detail, title: `${actor}:${l.title}`, ...(l.summary === undefined ? {} : { summary: l.summary }), }; diff --git a/apps/api/src/modules/memory/__tests__/task-memory.test.ts b/apps/api/src/modules/memory/__tests__/task-memory.test.ts index 1b9a096..54f1d6c 100644 --- a/apps/api/src/modules/memory/__tests__/task-memory.test.ts +++ b/apps/api/src/modules/memory/__tests__/task-memory.test.ts @@ -10,7 +10,7 @@ import { windowOf, } from '../internal/domain/task-memory.ts'; import type { EntryKind, MemoryEntry } from '../internal/domain/task-memory.ts'; -import { computeMetrics, splitBySide, structuredLog } from '../internal/domain/task-log.ts'; +import { computeMetrics, detailOf, firstSentence, splitBySide, structuredLog } from '../internal/domain/task-log.ts'; import { assertThreePart, isBinding, NoEvidenceError, selectForLoading } from '../internal/domain/activity-memory.ts'; import type { ActivityMemoryEntry } from '../internal/domain/activity-memory.ts'; @@ -540,3 +540,105 @@ describe('放行那一刻的日志尾巴', () => { expect(line?.title).toBe('你改的那一笔已落账:目标价 → 0.31(落 quote_intake.record)'); }); }); + +/** + * **展开才看的细节:一段一句人话**(2026-08-03 用户两次定调 —— + * 先「细节展开不够好」,再「不要写二级条目,直接正文描述完」)。 + * + * 出的不是字段名而是句子:每句自带主语与语境,单拎出来也读得通。 + * 摘的纪律:人读、不摆内部英文名、摘不出就空。 + */ +describe('日志细节:说成人话,摘不出就空', () => { + it('收步那一条说清「凭什么算办完」—— 人回看时要核的就是它', () => { + const d = detailOf(entry('engine-transition', { title: '完成:归一受理', detail: { rationale: '九条已整份落表' } })); + expect(d).toEqual(['算办完的依据:九条已整份落表']); + }); + + it('被拒那一条说清缘由与下一步 —— 拒回的话本来就是写给下一拍的', () => { + const d = detailOf(entry('result', { detail: { reason: '这一步已办完', hint: '挂到当下这一步' } })); + expect(d).toEqual(['没通过的缘由:这一步已办完', '接下来该:挂到当下这一步']); + }); + + it('台账那一条说清落下哪几行 —— 「写了什么」是回看的第一问', () => { + const d = detailOf( + entry('ledger-write', { detail: { rows: 2, keys: [{ rfq_ref: 'R1', pn: 'A' }, { rfq_ref: 'R1', pn: 'B' }] } }), + ); + expect(d).toEqual(['落下这几行:R1 / A、R1 / B']); + }); + + it('**卡的种类与去处并成一句**,且摆人读名不摆 execution 这类内部词', () => { + const d = detailOf(entry('card-sent', { detail: { mode: 'execution', arrival: 'task-chat' } })); + expect(d).toEqual(['发的是执行工单,送到这一单的对话区。']); + }); + + it('摘不出东西 → 空数组(前端据此不给展开钮,不摆空抽屉)', () => { + expect(detailOf(entry('terminal', { detail: {} }))).toEqual([]); + expect(detailOf(entry('trigger'))).toEqual([]); + }); +}); + +/** + * **被压下的每拍交代要留在工作日志里** —— 2026-08-01 立的口径 + * (对话区一轮一句,埋头办事那几拍的交代「留进工作日志」), + * 而实现一直把 `internal` 整类滤掉:那几句哪儿都看不到。 + */ +describe('压下的交代不该消失', () => { + const suppressed = entry('observation', { + seq: 9, + title: '这一拍在往下办事,交代留日志、不进对话区', + detail: { internal: true, say: '三颗料都归一好了,两颗要替代。' }, + }); + + it('带交代的内部痕进日志,标题摆交代的首句(不是那句机器话)', () => { + const line = structuredLog([suppressed])[0]; + expect(line?.title).toBe('三颗料都归一好了,两颗要替代。'); + expect(line?.action).toBe('交代进展'); + // 是 AI 说的话,就记在 AI 名下 —— 从前一律署「系统」 + expect(line?.who).toBe('agent'); + }); + + it('其余内部痕(排障用的原文留档)照旧不摆', () => { + expect(structuredLog([entry('observation', { detail: { internal: true, raw: '{坏 JSON}' } })])).toHaveLength(0); + }); +}); + +/** + * 标题只摆首句、全文归抽屉 —— 这一屏的用法是**扫**,一行摆全文会把它撑成散文集。 + * 而一句话说完的交代**不摆全文**:抽屉里跟标题一模一样,人点开会觉得被耍了。 + */ +describe('交代:首句上行,全文进抽屉', () => { + const say = (text: string) => entry('observation', { detail: { internal: true, say: text } }); + + it('多句交代:标题截到首句(带省略号说明后面还有),抽屉摆全文', () => { + const long = '九条料都拆好了。只有 DAG 那颗缺年用量,已在卡上点名。请你审定。'; + expect(structuredLog([say(long)])[0]?.title).toBe('九条料都拆好了。…'); + expect(detailOf(say(long))[0]).toBe(long); + }); + + it('**一句话说完的不摆全文** —— 抽屉与标题一样就是白点一次', () => { + const one = '客户已认出,直接往下走。'; + expect(structuredLog([say(one)])[0]?.title).toBe(one); + expect(detailOf(say(one))).toEqual([]); + }); + + it('没截断就不加省略号 —— 加了等于说「后面还有」,而后面没有', () => { + expect(firstSentence('客户已认出。')).toBe('客户已认出。'); + // 没有断句标点的长句:按字数截,截了才加省略号 + expect(firstSentence('这是一段没有任何断句标点的交代'.repeat(5)).endsWith('…')).toBe(true); + }); +}); + +/** 进了对话区的那种交代也一样:首句上行、全文进抽屉(两种交代在这一屏是同一类东西) */ +describe('进对话区的交代也只摆首句', () => { + const long = entry('narration', { title: '九条料都拆好了。只有 DAG 那颗缺年用量,已在卡上点名。' }); + + it('标题截首句,抽屉摆全文与去处', () => { + expect(structuredLog([long])[0]?.title).toBe('九条料都拆好了。…'); + expect(detailOf(long)).toEqual([long.title, '这句已进对话区。']); + }); + + it('一句话的只摆去处,不重复摆全文', () => { + const one = entry('narration', { title: '客户已认出,直接往下走。' }); + expect(detailOf(one)).toEqual(['这句已进对话区。']); + }); +}); diff --git a/apps/api/src/modules/memory/internal/domain/task-log.ts b/apps/api/src/modules/memory/internal/domain/task-log.ts index b4a9e4e..0dc25b5 100644 --- a/apps/api/src/modules/memory/internal/domain/task-log.ts +++ b/apps/api/src/modules/memory/internal/domain/task-log.ts @@ -41,6 +41,20 @@ export type LogLine = { /** 内容本身,**不含主语** —— 主语由上层拼在前头(名字要查库才知道) */ readonly title: string; readonly summary: string | undefined; + /** + * **展开才看的细节 —— 一段话说完,不摆二级条目**(2026-08-03 用户两次定调: + * 先是「细节展开不够好」,再是「不要写二级条目,直接正文描述完」)。 + * + * 从前这一屏一条只有一行标题:凭什么算这一步办完了、那一笔写了哪几行、 + * 行动被拒到底拒在哪儿 —— 全躺在留痕的 `detail` 里,一个字都没露面。 + * 中间试过摘成「键:值」两栏,那是**把留痕的字段名搬到人面前**, + * 人还得自己在脑子里把它们连成句子。 + * + * 所以这里出的是**一句句人话**:每条自带主语与语境,单独拎出来也读得通 + * (与 `title` 同一条纪律:不摆系统内的英文名、认不出的宁可不摆)。 + * 说得完就一句,真有两三样要说的就两三句 —— 摘不出就是空数组。 + */ + readonly detail: readonly string[]; readonly eventRef: string | undefined; readonly position: string | undefined; }; @@ -88,7 +102,9 @@ const SKELETON: ReadonlySet = new Set([ * 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。 */ const TEMPLATE: Readonly string>> = { - observation: (e) => e.title, + // 带交代的内部痕:标题摆交代的**首句**(原标题是「这一拍在往下办事…」那句机器话; + // 全文归抽屉 —— 一行摆全文会把这一屏撑成散文集,而这一屏的用法是扫) + observation: (e) => (typeof e.detail?.['say'] === 'string' ? firstSentence(e.detail['say']) : e.title), thought: (e) => e.title, action: (e) => actionEntryOf(e).body, result: (e) => e.title, @@ -101,7 +117,8 @@ const TEMPLATE: Readonly string>> = { 'release-verdict': (e) => plain(e.title), terminal: (e) => plain(e.title), 'human-input': (e) => e.title, - narration: (e) => e.title, + // 交代一律「首句上行、全文进抽屉」—— 进没进对话区都一样,这一屏的用法是扫 + narration: (e) => firstSentence(e.title), control: (e) => e.title, trigger: (e) => e.title, }; @@ -210,7 +227,8 @@ function actionOf(e: MemoryEntry): string { case 'trigger': return '触发事件'; case 'observation': - return '系统事件'; + // 带交代的内部痕 = AI 这一拍的交代(没进对话区),章与对话区同词 + return typeof d['say'] === 'string' ? '交代进展' : '系统事件'; case 'engine-transition': return transitionOf(e.title).action; case 'action': @@ -254,8 +272,12 @@ function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined; const by = e.detail?.['by']; const id = typeof by === 'string' && by !== '' ? by : undefined; switch (e.kind) { - case 'trigger': case 'observation': + // 带交代的那种是 **AI 说的话**,记在它名下;其余系统事件照旧署系统 + return typeof e.detail?.['say'] === 'string' + ? { actor: '任务助手', actorId: undefined, who: 'agent' as const } + : { actor: '系统', actorId: undefined, who: 'system' as const }; + case 'trigger': case 'terminal': return { actor: '系统', actorId: undefined, who: 'system' }; /* @@ -317,7 +339,17 @@ function actorOf(e: MemoryEntry): { actor: string; actorId: string | undefined; const RESTATED: ReadonlyArray = [/^落地收敛[::]\s*合并/, /^落地收敛,发射输出口/]; function machinery(e: MemoryEntry): boolean { - if (e.detail?.['internal'] === true) return true; + /* + * **被压下的每拍交代要留在工作日志里**(2026-08-03 用户:「细节展开不够好」)。 + * + * 对话区一轮一句:只有交出控制权那一拍的交代摆到人面前,埋头办事那几拍的 + * 「留进工作日志」—— 这是 2026-08-01 立的口径,快照里也是这么写的。 + * 可实现是 `internal === true` 一律滤掉:那几句**哪儿都看不到**, + * 白烧的 token 不说,人想回看「这一拍它在干什么」时一片空白。 + * 现在带 `say` 的放行(它是人话,正是要留的那样东西); + * 其余内部痕(重问原文这类给排障用的)照旧不摆。 + */ + if (e.detail?.['internal'] === true && typeof e.detail?.['say'] !== 'string') return true; if (RESTATED.some((re) => re.test(e.title))) return true; if (e.kind === 'thought') return true; if (e.kind === 'engine-transition' && e.title.startsWith('装载案例模型')) return true; @@ -331,6 +363,130 @@ function machinery(e: MemoryEntry): boolean { return false; } +/** + * 首句 —— 交代常有三四句,一行只摆得下第一句(其余进抽屉)。 + * 断在句号/分号/换行,断不出就按字数截;**截了才加省略号**,没截不加 + * (加了等于告诉人「后面还有」,而后面没有)。 + */ +export function firstSentence(text: string, cap = 42): string { + const clean = text.trim().replace(/\s+/g, ' '); + const cut = clean.search(/[。;;\n!!??]/); + const head = cut > 0 && cut + 1 <= cap ? clean.slice(0, cut + 1) : clean.slice(0, cap); + return head.length < clean.length ? `${head}…` : head; +} + +/** 卡的种类:发的是哪一种人类工具 —— 摆人读名,不摆 `execution` 这类内部词 */ +const CARD_KIND_LABEL: Readonly> = { + execution: '执行工单', + acceptance: '验收工单', + decision: '决策卡', + entry: '数据录入卡', + rating: '评价卡', +}; + +/** 送到哪儿了 —— 同上,内部路由名换人话 */ +const ARRIVAL_LABEL: Readonly> = { + 'task-chat': '这一单的对话区', + inbox: '收件箱', + 'cross-task': '跨任务征答', +}; + +/** 值摆到日志上的样子:太长截断(细节是给人扫一眼的,不是把留痕抄一遍) */ +function short(value: unknown, cap = 300): string { + const text = + typeof value === 'string' + ? value + : typeof value === 'number' || typeof value === 'boolean' + ? String(value) + : JSON.stringify(value); + const clean = (text ?? '').trim(); + return clean.length > cap ? `${clean.slice(0, cap)}…` : clean; +} + +/** 主键名单摘成一句:`PN=A、PN=B` —— 「写了哪几行」是人回看时最想知道的一件事 */ +function keyList(keys: unknown): string | undefined { + if (!Array.isArray(keys) || keys.length === 0) return undefined; + const rows = keys.slice(0, 8).map((one) => + typeof one === 'object' && one !== null + ? Object.values(one as Record) + .map((v) => String(v)) + .join(' / ') + : String(one), + ); + return keys.length > 8 ? `${rows.join('、')}…(共 ${keys.length} 行)` : rows.join('、'); +} + +/** + * **展开看的那几句** —— 按 kind 各摘各的,认不出的一概不摆。 + * + * 出的是**人话**不是字段:每句自带主语与语境(「发的是执行工单,送到这一单的对话区」), + * 单拎出来也读得通。纪律三条:①不摆系统内的英文名(卡种/送达位置都换人话) + * ②不把留痕整段抄上去(长值截断)③摘不出就返回空数组 —— + * 前端据此不给展开钮,**没有细节就别摆一个空抽屉**。 + */ +export function detailOf(e: MemoryEntry): readonly string[] { + const d = e.detail ?? {}; + const out: string[] = []; + const say = (text: string | undefined): void => { + if (text !== undefined && text.trim() !== '') out.push(text.trim()); + }; + const has = (v: unknown): v is string | number | boolean => + v !== undefined && v !== null && v !== '' && (typeof v === 'string' ? v.trim() !== '' : true); + + switch (e.kind) { + case 'engine-transition': + // 「凭什么算办完了」—— 收步那一下的理由,人回看时最想核的就是它 + if (has(d['rationale'])) say(`算办完的依据:${short(d['rationale'], 400)}`); + break; + case 'result': + // 被拒/失败:理由与下一步(拒回的话本来就是写给下一拍的) + if (has(d['reason'] ?? d['error'])) say(`没通过的缘由:${short(d['reason'] ?? d['error'], 500)}`); + if (has(d['hint'])) say(`接下来该:${short(d['hint'], 300)}`); + break; + case 'card-sent': { + const kind = CARD_KIND_LABEL[String(d['mode'] ?? d['k'] ?? '')]; + const arrival = ARRIVAL_LABEL[String(d['arrival'] ?? '')]; + if (kind !== undefined) say(arrival === undefined ? `发的是${kind}。` : `发的是${kind},送到${arrival}。`); + break; + } + case 'card-reply': + if (has(d['summary'])) say(`他填的是:${short(d['summary'], 400)}`); + if (has(d['value'] ?? d['chosen'])) say(`他选的是:${short(d['value'] ?? d['chosen'], 200)}`); + break; + case 'ledger-write': { + if (has(d['from'])) say(`把${short(d['label'] ?? '这一格', 40)}从「${short(d['from'], 80)}」改成「${short(d['to'], 80)}」。`); + const rows = keyList(d['keys']); + if (rows !== undefined) say(`落下这几行:${rows}`); + else if (has(d['rows'])) say(`落下 ${short(d['rows'])} 行。`); + break; + } + case 'release-verdict': { + const verdict = d['verdict']; + if (verdict === 'pass') say('裁决:通过放行。'); + else if (verdict === 'redo') say('裁决:打回重做。'); + if (has(d['comment'])) say(`他的意见:${short(d['comment'], 300)}`); + break; + } + case 'narration': { + // 首句已经上行了,这儿摆全文;一句话说完的不重复摆(点开发现一模一样等于白点) + const full = e.title.trim().replace(/\s+/g, ' '); + if (firstSentence(e.title) !== full) say(short(e.title, 600)); + say(d['internal'] === true ? '这句只留在工作日志,没进对话区 —— 那一拍没有交出控制权。' : '这句已进对话区。'); + break; + } + case 'observation': { + // 被压下的每拍交代:全文在这儿(标题只摆得下第一句) + const text = d['say']; + if (typeof text === 'string' && firstSentence(text) !== text.trim().replace(/\s+/g, ' ')) say(short(text, 600)); + if (has(d['raw'])) say(`留档原文:${short(d['raw'], 400)}`); + break; + } + default: + break; + } + return out; +} + export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] { return entries .filter((e) => SKELETON.has(e.kind) && !machinery(e)) @@ -343,6 +499,7 @@ export function structuredLog(entries: readonly MemoryEntry[]): LogLine[] { ...actorOf(e), title: TEMPLATE[e.kind](e), summary: typeof e.detail?.['summary'] === 'string' ? e.detail['summary'] : undefined, + detail: detailOf(e), eventRef: e.eventRef, position: e.stamp.position, })); diff --git a/apps/web/src/app/styles/app.css b/apps/web/src/app/styles/app.css index 98008e8..065c167 100644 --- a/apps/web/src/app/styles/app.css +++ b/apps/web/src/app/styles/app.css @@ -3139,6 +3139,18 @@ .proto-root .jc-log-t { flex: 1; min-width: 0; } .proto-root .jc-log-t b { display: block; font-size: 12.5px; font-weight: 600; } .proto-root .jc-log-t em { display: block; margin-top: 2px; font-style: normal; font-size: 11px; line-height: 1.6; color: var(--ink-3); } +/* 细节抽屉(2026-08-03):折叠时一行不变,展开才现;没有细节的条目连钮都不给 */ +.proto-root .jc-log-more { display: flex; align-items: baseline; gap: 6px; width: 100%; padding: 0; border: 0; background: none; text-align: left; cursor: pointer; color: inherit; font: inherit; } +.proto-root .jc-log-more > svg { flex: none; margin-top: 1px; color: var(--ink-4); transition: color var(--t-fast); } +.proto-root .jc-log-more:hover > b { color: var(--brand-deep); } +.proto-root .jc-log-more:hover > svg { color: var(--brand); } +.proto-root .jc-log-more:focus-visible { outline: 2px solid var(--brand); outline-offset: 2px; border-radius: 4px; } +/* 展开的正文:一段一句,左缘一道细线把它归到这一条名下 —— 不做键值两栏 */ +.proto-root .jc-log-body { margin: 6px 0 2px; padding: 2px 0 2px 10px; border-left: 2px solid var(--line); } +.proto-root .jc-log-body p { margin: 0; font-size: 11.5px; line-height: 1.7; color: var(--ink-2); white-space: pre-wrap; overflow-wrap: anywhere; } +.proto-root .jc-log-body p + p { margin-top: 5px; } +/* 展开那一条轻轻提亮,眼睛回得来 */ +.proto-root .jc-log-row.on { background: #fbfcfe; border-radius: 8px; } /* 成果二级界面的返回键 —— 顶部页签不变,右槽换成它 */ .proto-root .jc-tnav-back { width: auto; padding: 0 9px; gap: 5px; font-size: 11.5px; font-weight: 600; } diff --git a/apps/web/src/features/desk-task/internal/ViewRegion.tsx b/apps/web/src/features/desk-task/internal/ViewRegion.tsx index d290d0d..64f67ea 100644 --- a/apps/web/src/features/desk-task/internal/ViewRegion.tsx +++ b/apps/web/src/features/desk-task/internal/ViewRegion.tsx @@ -427,16 +427,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi * (自主率要把「等外面」与「机器干的」分开);人扫这一屏只问 * 「这一步是我办的、AI 办的,还是流程自己走到的」。 */ -
- {atMinute(line.at)} - {/* 章摆的是**动作**(四字,与对话区同词),不是归侧 —— - 归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */} - {line.action} - - {line.title} - {line.summary !== undefined && {line.summary}} - -
+ ))} ) : ( @@ -652,6 +643,53 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi * 涟漪说「环在转」,微光工作线说「报告正在一行行写出来」; * 都是纯 CSS、只动 transform/opacity,prefers-reduced-motion 下静止但状态仍可读。 */ +/** + * 日志一行 —— **折叠时一行,展开才看细节**(2026-08-03 用户:「细节展开不够好」)。 + * + * 这一屏的用法是**扫**:一眼看完这单走过哪几步、谁办的。所以密度不能破 —— + * 细节收进抽屉,想深究哪一条点哪一条。**没有细节的条目不给展开钮**: + * 摆一个点开是空的抽屉,比不给还糟(点一次才知道白点)。 + * + * 展开不做动画高度:日志动辄几十条,一条一个过渡就是一屏都在抖。 + */ +function LogRow({ line }: { line: TaskDetail['log'][number] }) { + const [open, setOpen] = useState(false); + const has = line.detail.length > 0; + return ( +
+ {atMinute(line.at)} + {/* 章摆的是**动作**(四字,与对话区同词),不是归侧 —— + 归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */} + {line.action} + + {has ? ( + + ) : ( + {line.title} + )} + {line.summary !== undefined && {line.summary}} + {/* + * **展开就是把话说完,不摆二级条目**(2026-08-03 用户定)。 + * + * 从前展开出来是一摞「键:值」—— 那是把留痕的字段名搬到人面前, + * 人还得自己在脑子里把它们连成一句话。日志是给人读的: + * 该说的直接写成正文,一段说完;真有两三样要说的,一样一段。 + */} + {open && ( +
+ {line.detail.map((text, i) => ( +

{text}

+ ))} +
+ )} +
+
+ ); +} + function WorkingScene({ doneN }: { doneN: number }) { return (
diff --git a/packages/contracts/src/api/workbench.ts b/packages/contracts/src/api/workbench.ts index e59cc17..0d46690 100644 --- a/packages/contracts/src/api/workbench.ts +++ b/packages/contracts/src/api/workbench.ts @@ -180,6 +180,13 @@ export const taskDetailSchema = z.object({ /** 内容,**带主语**:`任务助手:…` / `柴浩:…` —— 日志不写没头脑的流水账 */ title: z.string(), summary: z.string().optional(), + /** + * **展开才看的细节 —— 一段一句人话**(2026-08-03 用户:「不要写二级条目, + * 直接正文描述完」):凭什么算这一步办完了、那一笔落下哪几行、被拒拒在哪儿。 + * 每句自带主语与语境,单拎出来也读得通;不摆系统内的英文名。 + * 摘不出东西就是空数组 —— 前端据此**不给展开钮**,没有细节不摆空抽屉。 + */ + detail: z.array(z.string()).default([]), }), ), });