工作日志:一行摆首句,展开就把话说完

从前一条只有一行标题,而人真想回看的东西全躺在留痕里没露过面 ——
凭什么算这一步办完了、那一笔落下哪几行、被拒到底拒在哪儿、发的是哪种卡送到哪儿。
现在每条可展开,细节由 detailOf 摘成**一句句人话**(不摆 execution 这类内部名);
摘不出就是空数组,前端据此不给展开钮 —— 点开是空抽屉比不给还糟。

不摆二级条目(用户定):细节不是「键:值」两栏 —— 那是把留痕的字段名搬到人面前,
人还得自己在脑子里连成句子。每句自带主语与语境,单拎出来也读得通。

连带修一处文档与实现不符:「一轮一句」的口径说埋头办事那几拍的交代「留进工作日志」,
而实现把 internal 整类滤掉了,那几句哪儿都看不到。现在带交代的放行,
章写「交代进展」、记在 AI 名下;排障用的内部痕照旧不摆。
两种交代统一「首句上行、全文进抽屉」,一句话说完的不重复摆;截了才加省略号。

契约:任务详情的日志行新增可选 detail(字符串数组),api-report 同批更新。
门禁:typecheck / lint / depcruise / test(1378)全绿;浏览器实测折叠行高 34-36px 齐平、
展开无键值两栏残留。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-03 13:10:46 +08:00
co-authored by Claude Opus 5
parent ade0fa5a94
commit 70e83d0b09
8 changed files with 352 additions and 16 deletions
+3
View File
@@ -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 }),
};
@@ -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(['这句已进对话区。']);
});
});
@@ -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<EntryKind> = new Set<EntryKind>([
* 主语也不在这儿拼:名字要查库,域里查不了(纯函数零 IO)。
*/
const TEMPLATE: Readonly<Record<EntryKind, (e: MemoryEntry) => 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<Record<EntryKind, (e: MemoryEntry) => 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<RegExp> = [/^落地收敛[::]\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<Record<string, string>> = {
execution: '执行工单',
acceptance: '验收工单',
decision: '决策卡',
entry: '数据录入卡',
rating: '评价卡',
};
/** 送到哪儿了 —— 同上,内部路由名换人话 */
const ARRIVAL_LABEL: Readonly<Record<string, string>> = {
'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<string, unknown>)
.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,
}));
+12
View File
@@ -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; }
@@ -427,16 +427,7 @@ export function ViewRegion({ detail, onReplyOrder, values, onField, locate }: Vi
* (自主率要把「等外面」与「机器干的」分开);人扫这一屏只问
* 「这一步是我办的、AI 办的,还是流程自己走到的」。
*/
<div key={line.id} className={'jc-log-row t-' + line.who}>
<span className="jc-log-at jc-mono">{atMinute(line.at)}</span>
{/* 章摆的是**动作**(四字,与对话区同词),不是归侧 ——
归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */}
<span className="jc-log-side">{line.action}</span>
<span className="jc-log-t">
<b>{line.title}</b>
{line.summary !== undefined && <em>{line.summary}</em>}
</span>
</div>
<LogRow key={line.id} line={line} />
))}
</div>
) : (
@@ -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 (
<div className={'jc-log-row t-' + line.who + (open ? ' on' : '')}>
<span className="jc-log-at jc-mono">{atMinute(line.at)}</span>
{/* 章摆的是**动作**(四字,与对话区同词),不是归侧 ——
归侧仍在 `t-${side}` 的配色里,度量也照旧靠它 */}
<span className="jc-log-side">{line.action}</span>
<span className="jc-log-t">
{has ? (
<button type="button" className="jc-log-more" onClick={() => setOpen(!open)} aria-expanded={open}>
<b>{line.title}</b>
<Ico name={open ? 'chevron-down' : 'chevron-right'} s={13} />
</button>
) : (
<b>{line.title}</b>
)}
{line.summary !== undefined && <em>{line.summary}</em>}
{/*
* **展开就是把话说完,不摆二级条目**(2026-08-03 用户定)。
*
* 从前展开出来是一摞「键:值」—— 那是把留痕的字段名搬到人面前,
* 人还得自己在脑子里把它们连成一句话。日志是给人读的:
* 该说的直接写成正文,一段说完;真有两三样要说的,一样一段。
*/}
{open && (
<div className="jc-log-body">
{line.detail.map((text, i) => (
<p key={i}>{text}</p>
))}
</div>
)}
</span>
</div>
);
}
function WorkingScene({ doneN }: { doneN: number }) {
return (
<div className="jc-working" aria-live="polite">