Skip to content

Commit edb6009

Browse files
committed
feat(sessions): 深链毫秒链闭合并临时切三轨视图
1 parent beca954 commit edb6009

7 files changed

Lines changed: 329 additions & 12 deletions

File tree

‎app/src/components/SessionDetailPanel.test.tsx‎

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,3 +233,58 @@ describe("SessionDetailPanel · 批 6 T25 容器接线(音频槽在生产路
233233
).toEqual(["2500"]);
234234
});
235235
});
236+
237+
/**
238+
* 批 7 T17 —— `[[ts:ms]]` 深链在**面板这一层**的闭合(C27.4 的 M3 · C10.3 的 R9.4 · §C11.2)。
239+
*
240+
* @ai_context 判据分两半:① **消费** = 播放头落到 ms ∧ 视图切到三轨(不切则「定位到 ms」不可感知)
241+
* ∧ 消费回调恰 1 次;② **不落痕** = `localStorage` 的视图记忆**未被写入 / 未被改变**
242+
* —— §C11.2 逐字要求的那条断言。它的变异体 = 把非持久路径改回 `setViewKey`(持久化)⇒ ② 必红。
243+
* @ai_context ③ 是对照:**用户自己点切换器**必须照旧写盘 —— 证明 ② 的观测面看得见写入
244+
* (否则「storage 为空」可能是「仪器根本看不到写入」的空真)。
245+
*/
246+
describe("SessionDetailPanel · 批 7 T17 [[ts:ms]] 深链(R9.4 四件闭合 · §C11.2 禁持久化)", () => {
247+
const MEMORY = "view:default:session";
248+
/** `localStorage` 全量快照(键 + 值逐字)——「未被写入」与「未被改变」两半都判 */
249+
const snapshot = (): Record<string, string> => {
250+
const out: Record<string, string> = {};
251+
for (let i = 0; i < localStorage.length; i += 1) {
252+
const k = localStorage.key(i);
253+
if (k !== null) out[k] = localStorage.getItem(k) ?? "";
254+
}
255+
return out;
256+
};
257+
const playheads = (c: HTMLElement): (string | null)[] =>
258+
[...c.querySelectorAll('[data-testid="session-timerail-playhead"]')].map((el) => el.getAttribute("data-ms"));
259+
260+
beforeEach(() => {
261+
localStorage.clear();
262+
invokeMock.mockImplementation((cmd: string) =>
263+
cmd === "session_audio_path"
264+
? Promise.resolve({ path: "C:/tmp/1042.wav", aligned: true, durationMs: 8000 })
265+
: Promise.reject(new Error(`invoke not mocked: ${cmd}`)),
266+
);
267+
});
268+
269+
it("D1 深链到达:播放头 = ms ∧ 切到三轨 ∧ 消费回调恰 1 次 ∧ 视图记忆逐字未变", async () => {
270+
localStorage.setItem(MEMORY, "proof"); // 用户既有记忆:深链**不许**改它
271+
const before = snapshot();
272+
const onFocusSeekConsumed = vi.fn();
273+
const { container } = render(
274+
<SessionDetailPanel {...propsOf(detailOf(), { focusSeekMs: { ms: 2500, key: 1 }, onFocusSeekConsumed })} />,
275+
);
276+
await screen.findByTestId("session-tritrack-view");
277+
expect(playheads(container), "深链的 ms 没落到播放头上").toEqual(["2500"]);
278+
expect(onFocusSeekConsumed, "消费回调必须恰一次(App 靠它复位焦点,防陈旧 ms 复触发)").toHaveBeenCalledTimes(1);
279+
expect(snapshot(), "深链自动切视图改动了 localStorage(§C11.2 逐字禁止)").toEqual(before);
280+
expect(localStorage.getItem(MEMORY), "一次深链把用户的默认视图改掉了").toBe("proof");
281+
});
282+
283+
it("D2 对照:用户自己点「三轨对齐」**照旧**写视图记忆(证明 D1 的观测面看得见写入)", async () => {
284+
localStorage.setItem(MEMORY, "proof");
285+
render(<SessionDetailPanel {...propsOf(detailOf())} />);
286+
fireEvent.click(findButton("三轨对齐"));
287+
await screen.findByTestId("session-tritrack-view");
288+
expect(localStorage.getItem(MEMORY), "切换器的持久化语义变了(既有调用点被破坏)").toBe("tritrack");
289+
});
290+
});

‎app/src/components/SessionDetailPanel.tsx‎

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,8 +50,12 @@ import WebArticleView from "../components/WebArticleView";
5050
import SpeakerSwitchCard from "../components/SpeakerSwitchCard";
5151
import { useViewMemory } from "../views/useViewMemory";
5252
import type { SessionViewSlot, ViewSpec } from "../views/registry";
53+
import type { FocusSeek } from "../shell/focusRouting";
5354
import type { GlossaryTerm, SessionDetail } from "../types";
5455

56+
/** 「三轨对齐」的视图键(= `views/registry.ts` 的 `SESSION_VIEWS[1]`)—— `[[ts:ms]]` 深链的落点视图。 */
57+
const TRITRACK_KEY = "tritrack";
58+
5559
/** 术语表 summary 的三种文案(null=加载中 / 0 条 / N 条)——原文案逐字,条件整串注入视图 */
5660
const glossarySummaryOf = (glossary: GlossaryTerm[] | null): string =>
5761
glossary === null
@@ -74,9 +78,15 @@ interface Props {
7478
autoRefineTaskId?: number | null;
7579
onAutoTaskConsumed?: () => void;
7680
onRefineTaskStarted?: (sessionId: number, taskId: number) => void;
81+
/** 批 7 T17(C10.3 的 R9.4):`[[ts:ms]]` 深链的 **ms 载体**(`null`/缺省 = 不 seek)。
82+
* @ai-context 为什么是 `FocusSeek`(`{ms,key}`)而不是裸 number:裸值 setState 同值不触发 ⇒
83+
* 已打开该会话时再点同一条时间码不会重新定位(`key` 是单调判别键,见 `shell/focusRouting`)。 */
84+
focusSeekMs?: FocusSeek | null;
85+
/** 批 7 T17:`focusSeekMs` **消费完成**回调(`SessionsPage` → App 清空焦点,防陈旧 ms 跨导航复触发) */
86+
onFocusSeekConsumed?: () => void;
7787
}
7888

79-
export default function SessionDetailPanel({ detail, views, fusing, degradedBanner, onToNote, onRemove, onRefreshDetail, autoRefineTaskId, onAutoTaskConsumed, onRefineTaskStarted }: Props) {
89+
export default function SessionDetailPanel({ detail, views, fusing, degradedBanner, onToNote, onRemove, onRefreshDetail, autoRefineTaskId, onAutoTaskConsumed, onRefineTaskStarted, focusSeekMs, onFocusSeekConsumed }: Props) {
8090
const sessionId = detail.session.id;
8191
/** 默认视图键 = 注册表 `views[0].key`(A5①:`registry.ts` 的 `[0]` 就是「原文」) */
8292
const defaultKey = views.length > 0 ? views[0].key : "";
@@ -110,6 +120,18 @@ export default function SessionDetailPanel({ detail, views, fusing, degradedBann
110120
}
111121
}, [autoRefineTaskId, onAutoTaskConsumed, setDeepTaskId, setViewKey]);
112122

123+
// 批 7 T17(C10.3 的 R9.4 · §C11.2):`[[ts:ms]]` 深链到达 ⇒ ① 播放头落到该毫秒 ② 视图切到三轨。
124+
// Why 必须同时切视图:不切则「定位到 ms」**不可感知**(C10.3 逐字),等于没接完这条链。
125+
// 🔴 视图切换走**非持久**路径(`persist: false`):§C11.2 明令「必须临时切,禁止写记忆」——
126+
// 「一次深链永久改变默认视图」是用户可感知的副作用式行为变更,且本批测不了(真机跳过)。
127+
// 消费即回调 ⇒ App 清空焦点(复位后同一条时间码才能再次触发);`views` 里没有三轨键时只 seek 不切。
128+
useEffect(() => {
129+
if (focusSeekMs == null) return;
130+
setPlayheadMs(focusSeekMs.ms);
131+
if (views.some((spec) => spec.key === TRITRACK_KEY)) setViewKey(TRITRACK_KEY, { persist: false });
132+
onFocusSeekConsumed?.();
133+
}, [focusSeekMs, views, setViewKey, onFocusSeekConsumed]);
134+
113135
// v0.20.4(REQ-303):web 会话专用详情(文章阅读 + 元数据回链 + 转笔记;
114136
// 无时间轴/屏卡/精修面——h2 标题即页标题,改名在会话列表进行)
115137
if (detail.session.kind === "web") {
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* SessionsPage.deepLink.test.tsx — `[[ts:ms]]` 深链**落到会话页之后的那一跳**(批 7 T17)。
4+
*
5+
* @ai_context 这条判据补的是 §C27.4 的 M3(「`focusSeekMs` 链今天**是死的**」)在**页面层**的缺口:
6+
* `App.goSessions(sessionId, ms)` 写入的 ms 载体要经 `SessionsPage` →(配对目标会话)→
7+
* `SessionDetailPanel` → `SessionViewHost` → 三轨视图,最终**可见为播放头**。
8+
* 本件用**状态级/命令级代理**判它(真机 WebView2 冒烟由用户裁决跳过 ⇒ §C6.4):
9+
* ① `get_session_detail` 被以目标 id 调用;② 三轨视图出现且播放头 `data-ms` == 深链的 ms;
10+
* ③ 消费回调恰 1 次(App 靠它复位焦点);④ `localStorage` 的视图记忆**逐字未变**(§C11.2)。
11+
* 🔴 逐字登记:**这是代理判据**,不证明真机上「视频真的跳到了那一帧」(定位精度还受音频对齐
12+
* 块粒度 ±200 ms 限制)。
13+
* @ai_context 第二条用例钉**配对**(`SessionsPage` 的 `seekFor`):面板在 `detail` 到达前仍显示
14+
* 上一个会话 ⇒ 若把 ms 无条件下传,它会被旧会话的面板消费掉、换到新会话时焦点已清空
15+
* (深链静默失效)。该用例把「旧会话在位 + 新会话详情挂起」这一瞬态**构造出来**。
16+
* 仪器边界:只 mock 模块边界(三个 Tauri 模块);真 `SessionsPage` + 真 `SessionDetailPanel` +
17+
* 真 `SessionViewHost` + 真惰性三轨视图(与 `SessionDetailPanel.test.tsx` 同一形态)。
18+
*/
19+
import { act, cleanup, render, screen } from "@testing-library/react";
20+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
21+
import type { SessionDetail } from "../types";
22+
23+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
24+
vi.mock("@tauri-apps/api/core", () => ({
25+
invoke: invokeMock,
26+
convertFileSrc: (p: string) => `asset://localhost/${p}`,
27+
}));
28+
vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(() => Promise.resolve(() => {})) }));
29+
vi.mock("@tauri-apps/plugin-dialog", () => ({ confirm: vi.fn(() => Promise.resolve(true)) }));
30+
31+
import SessionsPage from "./SessionsPage";
32+
33+
const noop = (): void => {};
34+
const MEMORY = "view:default:session";
35+
36+
function detailOf(id: number, title: string): SessionDetail {
37+
return {
38+
session: { id, title, source_window: "Chrome", started_at: 0, ended_at: 8000, status: "finished", kind: null },
39+
segments: [{ id: id * 10 + 1, session_id: id, start_ms: 0, end_ms: 4000, text: `${title} 第一段`, source: "asr", confidence: 0.9 }],
40+
ocr_blocks: [],
41+
screens: [],
42+
};
43+
}
44+
45+
/** 详情响应的可控延迟(第二条用例要「新会话详情仍在飞」这一瞬态) */
46+
function deferred<T>(): { promise: Promise<T>; resolve: (v: T) => void } {
47+
let resolve!: (v: T) => void;
48+
const promise = new Promise<T>((r) => {
49+
resolve = r;
50+
});
51+
return { promise, resolve };
52+
}
53+
54+
function install(details: Record<number, SessionDetail | Promise<SessionDetail>>): void {
55+
invokeMock.mockImplementation((cmd: string, args?: Record<string, unknown>) => {
56+
const id = args?.id as number | undefined;
57+
if (cmd === "get_session_detail") {
58+
const hit = id === undefined ? undefined : details[id];
59+
return hit === undefined ? Promise.reject(new Error("未登记的会话")) : Promise.resolve(hit);
60+
}
61+
if (cmd === "session_audio_path") return Promise.resolve({ path: "C:/tmp/a.wav", aligned: true, durationMs: 8000 });
62+
if (cmd === "get_feature_flags") return Promise.resolve({ feedCapture: true });
63+
if (cmd.startsWith("list_") || cmd.startsWith("note_versions")) return Promise.resolve([]);
64+
return Promise.reject(new Error(`invoke not mocked: ${cmd}`));
65+
});
66+
}
67+
68+
const snapshot = (): Record<string, string> => {
69+
const out: Record<string, string> = {};
70+
for (let i = 0; i < localStorage.length; i += 1) {
71+
const k = localStorage.key(i);
72+
if (k !== null) out[k] = localStorage.getItem(k) ?? "";
73+
}
74+
return out;
75+
};
76+
const playheads = (c: HTMLElement): (string | null)[] =>
77+
[...c.querySelectorAll('[data-testid="session-timerail-playhead"]')].map((el) => el.getAttribute("data-ms"));
78+
79+
beforeEach(() => {
80+
invokeMock.mockReset();
81+
localStorage.clear();
82+
});
83+
afterEach(() => {
84+
cleanup();
85+
localStorage.clear();
86+
});
87+
88+
describe("SessionsPage · [[ts:ms]] 深链(T17 · C27.4 的 M3 的页面层闭合)", () => {
89+
it("① 深链到达:目标会话被打开 ∧ 视图切三轨 ∧ 播放头 = ms ∧ 消费恰 1 次 ∧ 视图记忆未变", async () => {
90+
install({ 1042: detailOf(1042, "会话甲") });
91+
localStorage.setItem(MEMORY, "proof"); // 用户既有记忆:深链**不许**改它
92+
const onFocusSeekConsumed = vi.fn();
93+
const { container } = render(
94+
<SessionsPage
95+
active
96+
focusSessionId={1042}
97+
focusSeekMs={{ ms: 5000, key: 1 }}
98+
onFocusSeekConsumed={onFocusSeekConsumed}
99+
onOpenNote={noop}
100+
/>,
101+
);
102+
await screen.findByTestId("session-tritrack-view");
103+
expect(invokeMock.mock.calls.filter((c) => c[0] === "get_session_detail"), "深链必须拉目标会话详情")
104+
.toEqual([["get_session_detail", { id: 1042 }]]);
105+
expect(playheads(container), "深链的 ms 没落到播放头上(「定位到 ms」不可感知)").toEqual(["5000"]);
106+
expect(onFocusSeekConsumed, "消费回调必须恰一次(App 靠它复位焦点,防陈旧 ms 复触发)").toHaveBeenCalledTimes(1);
107+
// 视图记忆是**逐字未变**(本页的列布局键 `layout:*` 不属视图记忆 ⇒ 只钉记忆前缀这一个面)
108+
expect(localStorage.getItem(MEMORY), "一次深链把用户的默认视图改掉了(§C11.2 禁止持久化)").toBe("proof");
109+
expect(Object.keys(snapshot()).filter((k) => k.startsWith("view:default:")), "深链不得新增视图记忆键")
110+
.toEqual([MEMORY]);
111+
});
112+
113+
it("② 配对:新会话详情仍在飞时,ms **不被**上一个会话的面板消费;到达后才消费恰 1 次", async () => {
114+
const pending = deferred<SessionDetail>();
115+
install({ 1042: detailOf(1042, "会话甲"), 2043: pending.promise });
116+
const onFocusSeekConsumed = vi.fn();
117+
const view = render(
118+
<SessionsPage active focusSessionId={1042} onFocusSeekConsumed={onFocusSeekConsumed} onOpenNote={noop} />,
119+
);
120+
await screen.findByText("会话甲 第一段"); // 旧会话的面板已就位
121+
view.rerender(
122+
<SessionsPage
123+
active
124+
focusSessionId={2043}
125+
focusSeekMs={{ ms: 5000, key: 2 }}
126+
onFocusSeekConsumed={onFocusSeekConsumed}
127+
onOpenNote={noop}
128+
/>,
129+
);
130+
await act(async () => {});
131+
expect(onFocusSeekConsumed, "目标会话还没到达就消费了 ms(换到新会话时焦点已清空 ⇒ 深链静默失效)").toHaveBeenCalledTimes(0);
132+
expect(screen.queryByTestId("session-tritrack-view"), "ms 把**旧**会话的视图切走了").toBeNull();
133+
134+
await act(async () => {
135+
pending.resolve(detailOf(2043, "会话乙"));
136+
});
137+
await screen.findByTestId("session-tritrack-view");
138+
expect(onFocusSeekConsumed).toHaveBeenCalledTimes(1);
139+
expect(playheads(view.container)).toEqual(["5000"]);
140+
});
141+
});

‎app/src/pages/SessionsPage.tsx‎

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ interface Props {
5252
onFocusSeekConsumed?: () => void;
5353
}
5454

55-
export default function SessionsPage({ focusSessionId, onFocusSessionConsumed, focusRefineTaskId, onFocusRefineTaskConsumed, onRefineTaskStarted, active, onOpenNote }: Props) {
55+
export default function SessionsPage({ focusSessionId, onFocusSessionConsumed, focusRefineTaskId, onFocusRefineTaskConsumed, onRefineTaskStarted, active, onOpenNote, focusSeekMs, onFocusSeekConsumed }: Props) {
5656
// v0.15:左栏列状态(可拖拽 + 记忆 + 窄窗折叠;规格 §6.2 两列页阈值 1100)
5757
// 批 3 T8:规格来自 `columnRegistry`,执行仍由 hook 完成
5858
const listCol = useColumnLayout("sessions-list", columnSpec("sessions-list"));
@@ -146,11 +146,24 @@ export default function SessionsPage({ focusSessionId, onFocusSessionConsumed, f
146146
[showToast],
147147
);
148148

149-
// 2026-08 A4 / 批 5 C6:跨页直达自动打开目标会话详情,消费后回调 App 复位 focusSessionId
150-
// (复位才能让**同会话的再次跳转**重新触发:固定值不产生 prop 变化 ⇒ effect 不重跑)
149+
// 2026-08 A4 / 批 5 C6 / 批 7 T17(C27.4 的 M3):跨页直达自动打开目标会话详情,消费后回调 App 复位。
150+
// 🔴 T17:ms 载体与**目标会话**在这一刻配对(两者由 `App.goSessions` 同一次状态更新写入)。
151+
// Why 必须配对:`detail` 是异步取回的,面板在它到达前仍显示**上一个**会话 —— 若把 `focusSeekMs`
152+
// 无条件下传,ms 会被旧会话的面板消费掉(切到新会话时焦点已被清空)⇒ 深链静默失效。
153+
// `focusSeekMs` 进依赖但函数体先早退 ⇒ `focusSessionId` 被清空那一轮不会误清配对。
154+
const [seekFor, setSeekFor] = useState<{ sessionId: number; seek: FocusSeek } | null>(null);
151155
useEffect(() => {
152-
if (focusSessionId) { void openDetail(focusSessionId); onFocusSessionConsumed?.(); }
153-
}, [focusSessionId, openDetail, onFocusSessionConsumed]);
156+
if (!focusSessionId) return;
157+
setSeekFor(focusSeekMs ? { sessionId: focusSessionId, seek: focusSeekMs } : null);
158+
void openDetail(focusSessionId);
159+
onFocusSessionConsumed?.();
160+
}, [focusSessionId, focusSeekMs, openDetail, onFocusSessionConsumed]);
161+
162+
/** 深链 ms 消费完成(面板调)⇒ 清配对 + 回调 App 复位(复位后同一条时间码可再次触发) */
163+
const consumeSeek = () => {
164+
setSeekFor(null);
165+
onFocusSeekConsumed?.();
166+
};
154167

155168
// 融合事件(REQ-031 异步化)+ v0.7.1 会话完成事件驱动列表刷新
156169
useEffect(() => {
@@ -328,6 +341,9 @@ export default function SessionsPage({ focusSessionId, onFocusSessionConsumed, f
328341
autoRefineTaskId={focusRefineTaskId}
329342
onAutoTaskConsumed={onFocusRefineTaskConsumed}
330343
onRefineTaskStarted={onRefineTaskStarted}
344+
// 批 7 T17(C27.4 的 M3):`[[ts:ms]]` 只交给**目标会话**的面板(配对见上方 effect)
345+
focusSeekMs={detail.session.id === seekFor?.sessionId ? seekFor.seek : null}
346+
onFocusSeekConsumed={consumeSeek}
331347
/>
332348
)}
333349
</div>

0 commit comments

Comments
 (0)