prts-terrarchive 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.en.md +84 -18
  2. package/README.md +64 -15
  3. package/THIRD_PARTY_NOTICES.md +10 -0
  4. package/bin/build-runtime.mjs +32 -0
  5. package/bin/check-runtime.mjs +56 -0
  6. package/bin/install.js +24 -5
  7. package/contracts/corpus_i18n_v1.schema.json +10 -10
  8. package/cordis.patch.yml +4 -4
  9. package/docs/web-access.md +24 -0
  10. package/lib/client.js +84 -23
  11. package/lib/rhine/rhine.css +1 -1
  12. package/lib/rhine/rhine.js +208 -208
  13. package/lib/runtime/licenses/js-yaml-MIT.txt +21 -0
  14. package/lib/runtime/licenses/zod-MIT.txt +21 -0
  15. package/lib/runtime/versions.json +4 -0
  16. package/lib/runtime/yaml.js +3062 -0
  17. package/lib/runtime/zod.js +14356 -0
  18. package/lib/skins/common.css +59 -0
  19. package/lib/skins/endfield-aic.css +22 -9
  20. package/lib/skins/prts-agent.css +15 -5
  21. package/lib/skins/rhine-lab.css +9 -0
  22. package/package.json +15 -8
  23. package/presets/composition.js +53 -0
  24. package/presets/definition.js +2 -2
  25. package/presets/prts/agent.cordis.yml +1 -1
  26. package/presets/prts/preset.yml +1 -1
  27. package/presets/register.js +23 -10
  28. package/resources/runtime/yaml.js +1 -0
  29. package/resources/runtime/zod.js +1 -0
  30. package/skills/prts-composition/SKILL.md +60 -0
  31. package/skills/prts-investigation/SKILL.md +19 -102
  32. package/skills/prts-investigation/references/board-guide.md +131 -0
  33. package/skills/prts-retrieval/SKILL.md +5 -3
  34. package/src/composition-skill.js +19 -0
  35. package/src/i18n.js +11 -17
  36. package/src/index.js +63 -81
  37. package/src/investigation-review.js +14 -6
  38. package/src/investigation-store.js +97 -25
  39. package/src/investigation-tools.js +67 -30
  40. package/src/investigation-validation.js +33 -0
  41. package/src/manual-search.js +1 -1
  42. package/src/skill.js +25 -16
  43. package/src/skin-capabilities.js +28 -0
  44. package/src/tools.js +13 -0
  45. package/src/web-guidance.js +44 -0
  46. package/ui/rhine/archive-rack.ts +10 -8
  47. package/ui/rhine/catalogue.ts +16 -4
  48. package/ui/rhine/evidence-board-panel.ts +15 -0
  49. package/ui/rhine/investigation-board.css +3 -0
  50. package/ui/rhine/investigation-board.ts +71 -24
  51. package/ui/rhine/investigation-history.ts +15 -0
  52. package/ui/rhine/reading-room.css +14 -3
  53. package/ui/rhine/scene.ts +44 -24
  54. package/ui/rhine/tool-activity.ts +13 -6
  55. package/ui/rhine/types.ts +6 -0
  56. package/ui/rhine/workbench.ts +82 -40
@@ -9,6 +9,7 @@ import { boardPlaneTransform } from './board-plane-transform';
9
9
  import { INVESTIGATION_BOARD_PLANE } from './investigation-board-plane';
10
10
  import { InvestigationEditRevisions } from './investigation-edits';
11
11
  import { referenceSource } from './source-reading';
12
+ import { investigationVersion } from './investigation-history';
12
13
  import './investigation-board.css';
13
14
 
14
15
  type Panel = { closeTools():void; getCards(): EvidenceCard[]; remapIds(mapping:Record<string,string>,revisions?:Record<string,{content_revision:number;layout_revision:number}>):void; replaceCards(cards: EvidenceCard[], reset?: boolean): void; select(id: string | null, edit?: boolean): void };
@@ -18,9 +19,13 @@ export interface InvestigationContext {
18
19
  selectedId: string;
19
20
  workingId: string;
20
21
  ready: boolean;
22
+ version: number;
23
+ readOnly: boolean;
24
+ reportAvailable: boolean;
25
+ report?: { version:number; title:string; clues:number; sources:number };
21
26
  }
22
27
  type Options = { onContext?(value: InvestigationContext): void; onRackRevision?(revision:number):void; sessionId: string; api(endpoint: string, payload?: any, signal?: AbortSignal): Promise<any>;
23
- panel: Panel; onInbox(value:EvidenceInboxView,open:boolean):void; askInbox?(boardId:string,title:string):Promise<void>; onReading(value: ReadingObject | null): void; onCards(cards: EvidenceCard[]): void; openSource(source: ArchiveSource, inbox?:boolean): void; notify(text: string): void };
28
+ panel: Panel; onInbox(value:EvidenceInboxView,open:boolean):void; askInbox?(boardId:string,title:string):Promise<void>; onReading(value: ReadingObject | null): void; onCards(cards: EvidenceCard[], total: number): void; openSource(source: ArchiveSource, inbox?:boolean): void; notify(text: string): void };
24
29
  const REPORT_ID = 'investigation-report';
25
30
  const labels: Record<string, string> = { excerpt: '原文摘录', finding: '研究发现', time: '时间节点', relation: '关键关联', question: '未解问题', contrast: '交叉对照' };
26
31
  const uid = () => crypto.randomUUID();
@@ -35,6 +40,8 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
35
40
  let sessionId = options.sessionId, board: any = null, catalog: any[] = [], sourceRows: any[] = [];
36
41
  let selectedId = '', workingId = '', followWorking = true, page = 0, commitSeq = 0, disposed = false, active = false;
37
42
  let contextReady = false;
43
+ let liveBoard:any=null, liveSources:any[]=[];
44
+ let selectedVersion=0, historyComplete=true;
38
45
  let syncing = false, saving = 0, refreshPending = false, loadEpoch = 0, loading = false;
39
46
  let baseline: EvidenceCard[] = [], sceneCards: EvidenceCard[] = [];
40
47
  let edits = new InvestigationEditRevisions(), pendingReset = false;
@@ -56,11 +63,12 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
56
63
  const index = el('span','rhine-investigation-index');
57
64
  index.append(el('b','','RHINE LAB'),el('small','','RESEARCH / 研究调查'));
58
65
  const picker = el('select','rhine-investigation-picker'); picker.setAttribute('aria-label','切换调查板');
66
+ const history = el('select','rhine-investigation-version'); history.setAttribute('aria-label','证据板版本');
59
67
  const status = el('span','rhine-investigation-status'); status.setAttribute('role','status');
60
68
  const follow = el('button','rhine-investigation-follow','查看工作板 ↗'); follow.type='button'; follow.hidden=true;
61
69
  const directory = el('button','rhine-investigation-directory','线索目录 ↗'); directory.type='button';
62
70
  const add = el('button','rhine-investigation-add','+ 手动建板'); add.type='button';
63
- header.append(index,picker,status,follow,directory,add); hud.append(header);
71
+ header.append(index,picker,history,status,follow,directory,add); hud.append(header);
64
72
  const footer=el('div','rhine-investigation-footer');
65
73
  const previous=el('button','','←'), next=el('button','','→'), paging=el('span','','');
66
74
  previous.type=next.type='button'; previous.setAttribute('aria-label','上一组线索'); next.setAttribute('aria-label','下一组线索');
@@ -158,26 +166,50 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
158
166
  const latest=board?.reports.at(-1), running=catalog.find(b=>b.id===selectedId)?.run?.status==='running';
159
167
  sceneCards=board?[{id:REPORT_ID,title:latest?.title||(running?'调查进行中':'等待调查报告'),body:latest?.summary||'重要线索正在汇入,报告将在调查完成后发布。',kind:'note',stage:2,clueKind:'report',
160
168
  evidenceLabel:latest?`REPORT / V${String(latest.version).padStart(2,'0')}`:'RESEARCH / IN PROGRESS',sourceLabel:`${activeClues(board).length} 条线索 · ${latest?.sources?.length || 0} 份来源`,position:{x:0,y:0},rotation:0,scale:1},...baseline]:[];
161
- options.onCards(sceneCards);
169
+ options.onCards(sceneCards, activeClues(board).length);
162
170
  }
163
171
  function getContext(): InvestigationContext {
164
- return { sessionId, boards: catalog.map(({id,title})=>({id,title})), selectedId, workingId, ready: contextReady };
172
+ const report=board?.reports.at(-1);
173
+ return { sessionId, boards: catalog.map(({id,title})=>({id,title})), selectedId, workingId, ready: contextReady, version:selectedVersion, readOnly:!!selectedVersion, reportAvailable:!!report, ...(report?{report:{version:report.version,title:report.title,clues:report.clues.length,sources:report.sources.length}}:{}) };
165
174
  }
166
175
  function holdReadingContext() {
167
176
  // A source detour must return to the same board even if the agent starts another investigation.
168
177
  followWorking=false;
169
- try { if(selectedId)localStorage.setItem(preference(),selectedId); } catch {}
178
+ savePreference();
179
+ }
180
+ function savePreference(version=selectedVersion) {
181
+ try { if(selectedId)localStorage.setItem(preference(),JSON.stringify({id:selectedId,version})); } catch {}
182
+ }
183
+ function projectVersion() {
184
+ const view=investigationVersion(liveBoard,liveSources,selectedVersion);
185
+ board=view.board;sourceRows=view.sources;historyComplete=view.complete;
186
+ }
187
+ async function selectVersion(version:number, reading=false) {
188
+ const generation=aborter;
189
+ options.panel.closeTools();
190
+ await queue;
191
+ if(disposed||generation!==aborter)return;
192
+ if(version&&!liveBoard?.reports.some((r:any)=>r.version===version))return;
193
+ cancelReturn();drawer.hidden=true;report.hidden=true;inbox.hide(true);
194
+ selectedVersion=version;followWorking=false;page=0;savePreference();
195
+ projectVersion();edits=new InvestigationEditRevisions();renderCards(true);render();syncReading();
196
+ if(reading)showReport(version||undefined);
170
197
  }
171
198
  function render() {
172
199
  options.onContext?.(getContext());
173
- inbox.setData(board,sourceRows);
200
+ // The inbox is editable live state; historical views expose saved clues only.
201
+ inbox.setData(selectedVersion?null:board,selectedVersion?[]:sourceRows);
174
202
  const value=selectedId;picker.replaceChildren();
175
203
  for(const row of catalog){const option=el('option','',`${row.title} · ${row.clueCount} 条线索`);option.value=row.id;picker.append(option);}picker.value=value;
176
204
  picker.disabled=!catalog.length||!!saving||loading;picker.hidden=!catalog.length;empty.hidden=!!board;directory.disabled=!board;
177
205
  follow.disabled=add.disabled=!!saving||loading;
178
- follow.hidden=!workingId||workingId===selectedId;
206
+ follow.hidden=!selectedVersion&&(!workingId||workingId===selectedId);
207
+ history.replaceChildren();const current=el('option','','工作板');current.value='0';history.append(current);
208
+ for(const r of [...(liveBoard?.reports||[])].reverse()){const option=el('option','',`V${r.version}${r.boardSnapshot?'':' · 报告依据'}`);option.value=String(r.version);history.append(option);}
209
+ history.value=String(selectedVersion);history.hidden=!liveBoard?.reports.length;history.disabled=!!saving||loading;
210
+ hint.textContent=selectedVersion?historyComplete?'历史快照 · 只读 · 点击阅读':'旧版本仅保留报告依据,连线与完整布局未保存':'重要线索随调查加入 · 点击阅读,双击编辑';
179
211
  const row=catalog.find(b=>b.id===selectedId),latest=board?.reports.at(-1);
180
- status.textContent=saving?'正在保存…':loading?'正在同步…':!board?'尚无调查':row?.run?.status==='running'?'● Agent 正在整理':row?.run?.status==='interrupted'?'已暂停 · 线索已保留':latest?`报告 V${latest.version}${row?.reportStale?' · 有新线索待更新':''}`:'线索已保存';
212
+ status.textContent=selectedVersion?`V${selectedVersion} · ${historyComplete?'整板快照':'部分依据'} · 只读`:saving?'正在保存…':loading?'正在同步…':!board?'尚无调查':row?.run?.status==='running'?'● Agent 正在整理':row?.run?.status==='interrupted'?'已暂停 · 线索已保留':latest?`报告 V${latest.version}${row?.reportStale?' · 有新线索待更新':''}`:'线索已保存';
181
213
  const pages=pageCount(board);previous.disabled=page===0||!!saving||loading;next.disabled=page>=pages-1||!!saving||loading;
182
214
  paging.textContent=`${page+1} / ${pages} · ${activeClues(board).length} 条线索`;footer.hidden=!board;
183
215
  }
@@ -201,7 +233,10 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
201
233
  cancelReturn();page=0;drawer.hidden=true;report.hidden=true;edits=new InvestigationEditRevisions();editTarget={boardId:selectedId};
202
234
  }
203
235
  if(saving){refreshPending=true;loading=false;return;}
204
- board=result?.board||null;sourceRows=result?.sources||[];
236
+ if(oldId&&oldId!==selectedId)selectedVersion=0;
237
+ liveBoard=result?.board||null;liveSources=result?.sources||[];
238
+ if(selectedVersion&&!liveBoard?.reports.some((r:any)=>r.version===selectedVersion))selectedVersion=0;
239
+ projectVersion();
205
240
  page=Math.min(page,pageCount(board)-1);loading=false;contextReady=true;
206
241
  const resetCards=pendingReset||oldId!==selectedId;pendingReset=false;renderCards(resetCards);render();
207
242
  }
@@ -218,7 +253,7 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
218
253
  drawerLabel.textContent=`${clue.id} / ${labels[clue.kind]}`;drawerBody.replaceChildren();
219
254
  drawerBody.append(el('h2','',clue.title),el('p','rhine-investigation-provenance',`${clue.interpretation==='inference'?'推断':clue.interpretation==='question'?'待核验':'观察'} · ${clue.editedBy==='user'?'用户整理':'Agent 整理'}`));
220
255
  const content=el('div','rhine-investigation-clue-text rhine-markdown');renderReportMarkdown(content,clue.detail||clue.summary);drawerBody.append(content);
221
- const edit=el('button','','编辑这条线索 ↗');edit.type='button';edit.onclick=()=>{drawer.hidden=true;options.panel.select(id,true);};drawerBody.append(edit);
256
+ const edit=el('button','','编辑这条线索 ↗');edit.type='button';edit.hidden=!!selectedVersion;edit.onclick=()=>{drawer.hidden=true;options.panel.select(id,true);};drawerBody.append(edit);
222
257
  drawerBody.append(el('h3','','来源与核验'));
223
258
  for(const ref of clue.sources){const source=sourceFor(ref);if(!source)continue;const button=el('button','rhine-investigation-source',`${source.agentRead?'已读':'检索摘要'} · ${source.title} ↗`);button.type='button';button.onclick=()=>options.openSource(source);drawerBody.append(button);if(ref.quote)drawerBody.append(el('blockquote','',ref.quote));}
224
259
  if(!clue.sources.length)drawerBody.append(el('p','','尚无来源,不能作为已核实结论。'));
@@ -245,9 +280,9 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
245
280
  function showReport(version?:number) {
246
281
  cancelReturn();inbox.hide(true);
247
282
  if(!board?.reports.length){options.notify('报告尚未发布,已保存的线索可以先阅读。');return;}
248
- options.panel.closeTools();
249
- versions.replaceChildren();for(const r of [...board.reports].reverse()){const o=el('option','',`V${r.version} · ${new Date(r.publishedAt).toLocaleString()}`);o.value=String(r.version);versions.append(o);}
250
- const r=board.reports.find((r:any)=>r.version===version)||board.reports.at(-1);versions.value=String(r.version);reportTitle.textContent=r.title;
283
+ holdReadingContext();options.panel.closeTools();
284
+ versions.replaceChildren();for(const r of [...(liveBoard?.reports||board.reports)].reverse()){const o=el('option','',`V${r.version} · ${new Date(r.publishedAt).toLocaleString()}`);o.value=String(r.version);versions.append(o);}
285
+ const r=(liveBoard?.reports||board.reports).find((r:any)=>r.version===(version||selectedVersion))||board.reports.at(-1);versions.value=String(r.version);reportTitle.textContent=r.title;
251
286
  reportNotice.textContent=`${r.clues.length} 条依据 · ${r.sources.length} 份来源 · ${r.basisKnowledgeRevision!==board.knowledgeRevision?'发布后已有线索更新,以下保留当时版本':'已保存版本'}`;
252
287
  renderReportMarkdown(reportArticle,r.markdown.replace(/\[(C\d+)\](?!\()/g, (_match:string,id:string) => r.clues.some((c:any)=>c.id===id) ? `[${id}](#investigation-clue-${id})` : `[${id}]`));
253
288
  const details=el('details','rhine-investigation-report-evidence');details.append(el('summary','','查看此版本使用的线索与来源'));
@@ -257,6 +292,7 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
257
292
  }
258
293
  function userChange(rawCards:EvidenceCard[]) {
259
294
  if(syncing||disposed)return;
295
+ if(selectedVersion){renderCards(true);options.notify('历史板只读;返回工作板后可继续编辑。');return;}
260
296
  const cards=structuredClone(rawCards), before=structuredClone(baseline), sources=sourceRows;
261
297
  const ledger=edits, context=editTarget, target=board?.id||context.boardId, currentSession=sessionId, generation=aborter;
262
298
  const current=()=>!disposed&&generation===aborter;
@@ -287,9 +323,9 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
287
323
  return {changes,relations};
288
324
  }
289
325
  const {changes,relations}=prepare();
290
- if(!changes.length&&!relations.length){options.onCards([...sceneCards.filter(c=>c.id===REPORT_ID),...cards]);return;}
326
+ if(!changes.length&&!relations.length){options.onCards([...sceneCards.filter(c=>c.id===REPORT_ID),...cards],activeClues(board).length-toCards().length+cards.length);return;}
291
327
  baseline=cards;saving++;render();
292
- options.onCards([...sceneCards.filter(c=>c.id===REPORT_ID),...cards]);
328
+ options.onCards([...sceneCards.filter(c=>c.id===REPORT_ID),...cards],activeClues(board).length-toCards().length+cards.length);
293
329
  const send=(endpoint:string,payload:any)=>options.api(`investigation.${endpoint}`,{session_id:currentSession,...payload},generation.signal);
294
330
  queue=queue.then(async()=>{
295
331
  if(!current())throw new DOMException('编辑会话已切换','AbortError');
@@ -312,20 +348,31 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
312
348
  }
313
349
  async function start() {
314
350
  const generation=aborter;
315
- try{const preferenceValue=localStorage.getItem(preference());selectedId=preferenceValue||'';followWorking=!preferenceValue;}catch{}
351
+ try{const preferenceValue=localStorage.getItem(preference());
352
+ if(preferenceValue?.startsWith('{')){const saved=JSON.parse(preferenceValue);selectedId=typeof saved.id==='string'?saved.id:'';selectedVersion=Number.isSafeInteger(saved.version)&&saved.version>0?saved.version:0;}
353
+ else selectedId=preferenceValue||'';followWorking=!selectedId;
354
+ }catch{}
316
355
  try{await migrate();if(generation!==aborter||disposed)return;await refresh(true);if(generation===aborter&&!disposed)void loop(generation);}catch(error){if(generation===aborter&&!disposed){fail(error);void loop(generation);}}
317
356
  }
318
- picker.onchange=()=>{selectedId=picker.value;followWorking=false;page=0;try{localStorage.setItem(preference(),selectedId);}catch{};void refresh(true).catch(fail);};
319
- follow.onclick=()=>{followWorking=true;page=0;try{localStorage.removeItem(preference());}catch{};void refresh(true).catch(fail);};
357
+ picker.onchange=()=>{selectedId=picker.value;followWorking=false;page=0;savePreference(0);void refresh(true).catch(fail);};
358
+ history.onchange=()=>{void selectVersion(Number(history.value)).catch(fail);};
359
+ follow.onclick=()=>{void (async()=>{
360
+ const generation=aborter;
361
+ // Replace the historical projection before making the working board editable.
362
+ await selectVersion(0);
363
+ if(disposed||generation!==aborter)return;
364
+ followWorking=true;page=0;try{localStorage.removeItem(preference());}catch{}
365
+ await refresh(true);
366
+ })().catch(fail);};
320
367
  previous.onclick=()=>{page=Math.max(0,page-1);renderCards(true);render();};next.onclick=()=>{page++;renderCards(true);render();};
321
- directory.onclick=showDirectory;close.onclick=closeReading;reportClose.onclick=closeReading;versions.onchange=()=>showReport(Number(versions.value));
368
+ directory.onclick=showDirectory;close.onclick=closeReading;reportClose.onclick=closeReading;versions.onchange=()=>{void selectVersion(Number(versions.value),true).catch(fail);};
322
369
  add.onclick=()=>{cancelReturn();options.panel.closeTools();report.hidden=true;drawerLabel.textContent='NEW / 新建手动调查';drawerBody.replaceChildren();const input=el('input','');input.placeholder='调查标题';input.maxLength=120;input.setAttribute('aria-label','新调查标题');const button=el('button','','创建调查板');button.type='button';button.onclick=async()=>{if(!input.value.trim())return;button.disabled=true;try{const r=await request('create',{mutation_id:uid(),title:input.value.trim()});selectedId=r.board_id;followWorking=false;drawer.hidden=true;await refresh(true);}catch(error){fail(error);button.disabled=false;}};drawerBody.append(input,button);showReading(drawer);input.focus();};
323
370
  void start();
324
- return { userChange, handleKeydown, getContext, holdReadingContext, getCards:()=>sceneCards, select(id:string|null,edit=false){cancelReturn();drawer.hidden=true;report.hidden=true;if(id===REPORT_ID){showReport();return;}if(id&&!edit)showClue(id);else options.panel.select(id,edit);},
371
+ return { showReport, async prepareReport(){if(loading||!contextReady)await refresh();return !!board?.reports.length;}, userChange, handleKeydown, getContext, holdReadingContext, getCards:()=>sceneCards, select(id:string|null,edit=false){cancelReturn();drawer.hidden=true;report.hidden=true;if(id===REPORT_ID){showReport();return;}if(id&&(!edit||selectedVersion))showClue(id);else options.panel.select(id,edit);},
325
372
  resumeReading(kind:'report'|'clue'|'inbox'){if(!active)return;if(kind==='inbox'){inbox.open();return;}cancelReturn();if(kind==='report'){showReading(report);report.focus({preventScroll:true});}else showReading(drawer);syncReading();},
326
- setBoardFrame, setInboxAnchor:(value:EvidenceInboxAnchor)=>inbox.setAnchor(value), getInboxState:()=>inbox.state(), openInbox(){options.panel.closeTools();cancelReturn();report.hidden=true;drawer.hidden=true;syncReading();inbox.open();},
373
+ setBoardFrame, setInboxAnchor:(value:EvidenceInboxAnchor)=>inbox.setAnchor(value), getInboxState:()=>inbox.state(), openInbox(){if(selectedVersion){options.notify('历史板只读;返回工作板后查看重点证据盒。');return;}options.panel.closeTools();cancelReturn();report.hidden=true;drawer.hidden=true;syncReading();inbox.open();},
327
374
  setActive(value:boolean){active=value;inbox.setActive(value);hud.hidden=!value||!planeVisible;if(!value){cancelReturn();drawer.hidden=true;report.hidden=true;}syncReading();},
328
- async setSession(id:string){if(id===sessionId)return;cancelReturn();aborter.abort();aborter=new AbortController();loadEpoch++;sessionId=id;board=null;selectedId='';workingId='';contextReady=false;catalog=[];sourceRows=[];baseline=[];edits=new InvestigationEditRevisions();editTarget={boardId:''};queue=Promise.resolve();saving=0;refreshPending=false;pendingReset=false;commitSeq=0;page=0;drawer.hidden=true;report.hidden=true;renderCards(true);render();await start();},
375
+ async setSession(id:string){if(id===sessionId)return;cancelReturn();aborter.abort();aborter=new AbortController();loadEpoch++;sessionId=id;board=null;liveBoard=null;liveSources=[];selectedVersion=0;selectedId='';workingId='';contextReady=false;catalog=[];sourceRows=[];baseline=[];edits=new InvestigationEditRevisions();editTarget={boardId:''};queue=Promise.resolve();saving=0;refreshPending=false;pendingReset=false;commitSeq=0;page=0;drawer.hidden=true;report.hidden=true;renderCards(true);render();await start();},
329
376
  stageSource(source:ArchiveSource,targetId?:string){
330
377
  const currentSession=sessionId,generation=aborter,targetAtClick=targetId ?? (workingId||selectedId);
331
378
  const send=(endpoint:string,payload:Record<string,unknown>)=>options.api(`investigation.${endpoint}`,{session_id:currentSession,...payload},generation.signal);
@@ -341,8 +388,8 @@ export function mountInvestigationBoard(host: HTMLElement, options: Options) {
341
388
  });
342
389
  inboxStageQueue=task.catch(()=>{});return task;
343
390
  },
344
- async addSource(source:ArchiveSource){if(loading)await refresh();let target=selectedId;if(!target){const created=await request('create',{mutation_id:uid()});target=created.board_id;selectedId=target;followWorking=false;}await request('edit',{mutation_id:uid(),board_id:target,sources:[source],changes:[{title:source.title.slice(0,120),summary:source.excerpt.slice(0,480),detail:source.excerpt.slice(0,12000),kind:'excerpt',interpretation:'question',sources:[{source_id:source.id}]}]});await refresh();},
391
+ async addSource(source:ArchiveSource){if(selectedVersion)throw new Error('历史板只读;返回工作板后再添加线索。');if(loading)await refresh();let target=selectedId;if(!target){const created=await request('create',{mutation_id:uid()});target=created.board_id;selectedId=target;followWorking=false;}await request('edit',{mutation_id:uid(),board_id:target,sources:[source],changes:[{title:source.title.slice(0,120),summary:source.excerpt.slice(0,480),detail:source.excerpt.slice(0,12000),kind:'excerpt',interpretation:'question',sources:[{source_id:source.id}]}]});await refresh();},
345
392
  dispose(){disposed=true;inbox.dispose();cancelReturn();for(const transition of readingTransitions.values())transition.dispose();visibility.disconnect();options.onReading(null);aborter.abort();events.abort();hud.remove();drawer.remove();report.remove();},
346
- stats(){return{inbox:inbox.stats(),selectedId,workingId,boards:catalog.length,clues:activeClues(board).length,reportVersions:board?.reports.length||0,reportOpen:!report.hidden,returning:returnStartedAt!==undefined,drawerOpen:!drawer.hidden,commitSeq,active,saving};},
393
+ stats(){return{inbox:inbox.stats(),selectedVersion,historyComplete,selectedId,workingId,boards:catalog.length,clues:activeClues(board).length,reportVersions:board?.reports.length||0,reportOpen:!report.hidden,returning:returnStartedAt!==undefined,drawerOpen:!drawer.hidden,commitSeq,active,saving};},
347
394
  };
348
395
  }
@@ -0,0 +1,15 @@
1
+ /** Historical boards never borrow mutable clues or relations from the working board. */
2
+ export function investigationVersion(board: any, sources: any[], version: number) {
3
+ if (!board || !version) return { board, sources, complete: true };
4
+ const report = board.reports.find((item: any) => item.version === version);
5
+ if (!report) throw new Error('该调查版本不存在,请重新选择版本。');
6
+ const saved = report.boardSnapshot;
7
+ const historical = saved || {
8
+ id: board.id, title: report.title, objective: report.summary,
9
+ createdAt: report.publishedAt, updatedAt: report.publishedAt,
10
+ knowledgeRevision: report.basisKnowledgeRevision, layoutRevision: 0,
11
+ clues: report.clues, relations: [], openQuestions: [], evidenceInbox: [], inboxRevision: 0,
12
+ };
13
+ return { board: structuredClone({ ...historical, reports: [report] }),
14
+ sources: structuredClone(saved?.sources || report.sources), complete: Boolean(saved) };
15
+ }
@@ -1,4 +1,4 @@
1
- /* A physical document at left, a quiet transparent reading surface at right. */
1
+ /* A physical document at left, an opaque paper reading surface at right. */
2
2
  .rhine-workbench{--reading-ink:#30362b;--reading-muted:#7d806e;--reading-rule:#aca78f88}
3
3
  .rhine-workbench :is(.rhine-investigation-report,.rhine-investigation-drawer){position:absolute;inset:0;z-index:45;width:auto;max-width:none;padding:0;border:0;box-shadow:none;backdrop-filter:none;overflow:hidden;color:var(--reading-ink);background:linear-gradient(90deg,transparent 38%,#ebe8e04d 50%,#ebe8e0e0 72%,#ebe8e0eb 100%)}
4
4
  .rhine-workbench .rhine-investigation-report>header,.rhine-workbench .rhine-investigation-drawer-top{position:absolute;left:56%;right:5%;top:22%;height:36px;display:flex;align-items:center;justify-content:space-between;gap:14px;padding:0;border:0;font-size:11px;letter-spacing:.06em;color:var(--reading-muted)}
@@ -14,7 +14,7 @@
14
14
  .rhine-workbench .rhine-investigation-report .rhine-report-body>:not(.rhine-report-table){max-width:none}
15
15
  .rhine-workbench .rhine-investigation-provenance{padding-bottom:20px;border-bottom:1px solid var(--reading-rule);color:var(--reading-muted)}
16
16
  .rhine-workbench .rhine-report.modal-backdrop{background:transparent;backdrop-filter:none}
17
- .rhine-workbench .rhine-report-modal{position:absolute;left:56%;right:5%;top:22%;bottom:12%;width:auto;height:auto;max-height:none;padding:0;background:linear-gradient(90deg,#ebe8e0b3,#ebe8e0eb);box-shadow:none;border:0}
17
+ .rhine-workbench .rhine-report-modal{position:absolute;left:56%;right:5%;top:22%;bottom:12%;width:auto;height:auto;max-height:none;padding:0;background:#f4f1e8;box-shadow:0 12px 50px #393a2520;border:1px solid #c6c2ae;padding:24px;box-sizing:border-box}
18
18
  .rhine-workbench .rhine-report-layout{grid-template-columns:minmax(0,1fr);grid-template-rows:auto minmax(0,1fr) auto;gap:16px;padding-top:16px}
19
19
  .rhine-workbench .rhine-report-outline{max-height:68px;overflow:auto;border:0}
20
20
  .rhine-workbench .rhine-report-toc{display:flex;gap:16px;flex-wrap:wrap}
@@ -46,10 +46,21 @@
46
46
  .rhine-workbench :is(.rhine-investigation-report,.rhine-investigation-drawer){animation:none}
47
47
 
48
48
  /* Board controls stay in place behind the reading mode, then return unchanged. */
49
- .rhine-workbench.has-reading-object :is(.rhine-investigation-hud,.rhine-board-toolbar,.rhine-board-chalk-hit,.rhine-evidence-panel,.rhine-zone-nav,.rhine-scene-edges,.rhine-board-tool-status){visibility:hidden;pointer-events:none}
49
+ .rhine-workbench.has-reading-object :is(.rhine-investigation-hud,.rhine-board-toolbar,.rhine-board-chalk-hit,.rhine-evidence-panel,.rhine-zone-nav,.rhine-scene-edges,.rhine-board-tool-status,.rhine-investigation-brief,.rhine-agent-form,.rhine-rack-legend,.rhine-shelf-report){visibility:hidden;pointer-events:none}
50
50
  .rhine-workbench .rhine-investigation-report .rhine-report-body{box-sizing:border-box;margin:0;justify-self:stretch}
51
51
  .rhine-workbench .rhine-investigation-report .rhine-report-body>:not(.rhine-report-table){margin-left:0;margin-right:0;max-width:none}
52
52
 
53
53
  /* Keep the fading layer as the pointer shield until the fade completes. */
54
54
  .rhine-workbench :is(.rhine-investigation-report,.rhine-investigation-drawer)[data-transition=closing]{animation:none;pointer-events:auto}
55
55
  .rhine-workbench :is(.rhine-investigation-report,.rhine-investigation-drawer)[data-transition=closing]>*{pointer-events:none}
56
+
57
+ /* Saved reports keep their book on the left and a continuous paper surface on the right. */
58
+ .rhine-workbench .rhine-investigation-report::before,.rhine-workbench .rhine-investigation-drawer::before{content:"";position:absolute;left:52%;right:3%;top:15%;bottom:9%;background:#f4f1e8;border:1px solid #c6c2ae;box-shadow:0 12px 50px #393a2520;pointer-events:none}
59
+ .rhine-workbench .rhine-investigation-report>header,.rhine-workbench .rhine-investigation-drawer-top{left:54%;right:5%;top:18%;border-bottom:1px solid var(--reading-rule);padding-bottom:12px}
60
+ .rhine-workbench .rhine-investigation-report-scroll,.rhine-workbench .rhine-investigation-drawer-body{left:54%;top:26%;bottom:12%;padding-right:14px}
61
+ .rhine-workbench .rhine-investigation-report-intro>h2{font-size:clamp(21px,1.8vw,28px);line-height:1.5;overflow-wrap:anywhere}
62
+ .rhine-workbench .rhine-investigation-report .rhine-report-body{font-size:16px;line-height:1.9}
63
+ .rhine-workbench .rhine-report-title{font-size:25px;line-height:1.4;margin:12px 0}
64
+ .rhine-workbench :is([data-layout=compact],[data-layout=portrait]) :is(.rhine-investigation-report,.rhine-investigation-drawer)::before{left:49%;right:2%;top:13%;bottom:6%}
65
+ .rhine-workbench [data-layout=portrait] :is(.rhine-investigation-report,.rhine-investigation-drawer)::before{left:3%;right:3%;top:37%;bottom:3%}
66
+ .rhine-workbench [data-layout=portrait] .rhine-report-modal{padding:18px}
package/ui/rhine/scene.ts CHANGED
@@ -123,11 +123,12 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
123
123
  let userPriorityUntil = 0;
124
124
  let followAgent = true;
125
125
  const userOwnsView = () => !followAgent || performance.now() < userPriorityUntil;
126
- const prioritizeUser = () => { userPriorityUntil = performance.now() + 4000; scanElapsed = 0; };
126
+ const prioritizeUser = () => { userPriorityUntil = performance.now() + 4000; };
127
127
  let scanElapsed = ARCHIVE_STEP_SECONDS - 0.35;
128
128
  let scanSteps = 0;
129
129
  let readingScanDirection = 1;
130
130
  let arrivalScanRemaining = 0;
131
+ let searchVisualRemaining = 0;
131
132
  let deskAmount = 0;
132
133
  let boardAmount = 0;
133
134
  let boardZoom = BOARD_ZOOM_MIN;
@@ -261,10 +262,11 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
261
262
  const position = Math.max(0, lanes[lane].findIndex(item => sourceIdentity(item) === sourceIdentity(source)));
262
263
  return lane * 8 + wrap(slotRows[lane] + position - laneRows[lane], 8);
263
264
  };
264
- const readingActive = () => [...operations.values()].some(item => item.kind === 'read' && item.state === 'active')
265
- || (!!investigation?.running && investigation.phase === 'reading');
266
- const scanningActive = () => searching || [...operations.values()].some(item => item.kind === 'search' && item.state === 'active')
267
- || (!!investigation?.searching && investigation.phase !== 'reading') || arrivalScanRemaining > 0;
265
+ const activityCancelled = () => investigation?.outcome === 'interrupted' || investigation?.outcome === 'error';
266
+ const readingActive = () => !activityCancelled() && ([...operations.values()].some(item => item.kind === 'read' && item.state === 'active')
267
+ || (!!investigation?.running && investigation.phase === 'reading'));
268
+ const scanningActive = () => searching || !activityCancelled() && ([...operations.values()].some(item => item.kind === 'search' && item.state === 'active')
269
+ || (!!investigation?.searching && investigation.phase !== 'reading') || arrivalScanRemaining > 0 || searchVisualRemaining > 0);
268
270
  const archiveBusy = () => readingActive() || scanningActive();
269
271
 
270
272
  // Preserve the authored shell; resting files can share a captured interior.
@@ -467,36 +469,36 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
467
469
  }, target.axis === 'pointer' ? 5 : target.axis === 'activity' ? 4 : -2);
468
470
  }
469
471
  };
470
- const notifyArchive = () => {
472
+ const notifyArchive = (automatic = false) => {
471
473
  const source = currentArchiveSource();
472
474
  const inspected = location === 'desk' && selectedId ? sourceList.find(item => item.id === selectedId) : undefined;
473
475
  const labelSource = inspected || source;
474
476
  original.setArchiveLabel(labelSource ? archivePrint(labelSource) : null);
475
477
  syncLabelPrefetch();
476
- options.onArchiveSelect?.(archiveIndex);
478
+ if (!automatic || !userOwnsView()) options.onArchiveSelect?.(archiveIndex);
477
479
  // Empty lanes still participate in the original mechanical loop. Keep
478
480
  // the last real UI selection, otherwise source synchronization repeatedly
479
481
  // falls back to the first result and pulls the moving track backwards.
480
- if (source || !archiveBusy()) options.onArchiveSourceSelect?.(source?.id || null, archiveLaneIndex, userOwnsView());
482
+ if ((!automatic || !userOwnsView()) && (source || !archiveBusy())) options.onArchiveSourceSelect?.(source?.id || null, archiveLaneIndex, userOwnsView());
481
483
  };
482
- const chooseArchive = (index: number, navigation?: ArchiveNavigation) => {
484
+ const chooseArchive = (index: number, navigation?: ArchiveNavigation, automatic = false) => {
483
485
  archiveIndex = wrap(index, 40);
484
486
  archiveLaneIndex = fileLocation(archiveIndex).lane;
485
487
  slotRows[archiveLaneIndex] = archiveIndex % 8;
486
488
  if (detail) { detail = false; original.setMode('archive'); }
487
489
  original.select(archiveIndex, navigation);
488
- notifyArchive(); wake();
490
+ notifyArchive(automatic); wake();
489
491
  };
490
- const stepArchive = (axis: 'row' | 'lane', direction: number) => {
492
+ const stepArchive = (axis: 'row' | 'lane', direction: number, automatic = false) => {
491
493
  if (disposed || location !== 'archive' || detail) return;
492
494
  const step = direction < 0 ? -1 : 1;
493
495
  if (axis === 'row') {
494
496
  const count = lanes[archiveLaneIndex].length;
495
497
  if (count) laneRows[archiveLaneIndex] = wrap(laneRows[archiveLaneIndex] + step, count);
496
- chooseArchive(archiveLaneIndex * 8 + wrap(slotRows[archiveLaneIndex] + step, 8), { axis: 'row', direction: step });
498
+ chooseArchive(archiveLaneIndex * 8 + wrap(slotRows[archiveLaneIndex] + step, 8), { axis: 'row', direction: step }, automatic);
497
499
  } else {
498
500
  const next = wrap(archiveLaneIndex + step, archiveColumns.length);
499
- chooseArchive(next * 8 + slotRows[next], { axis: 'lane', direction: step });
501
+ chooseArchive(next * 8 + slotRows[next], { axis: 'lane', direction: step }, automatic);
500
502
  }
501
503
  };
502
504
  const acceptArchiveNavigation = (intent: Record<string, string | number> = {}) => {
@@ -711,7 +713,14 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
711
713
  || previous === undefined && (Number.isFinite(operation.completedAt)
712
714
  ? operation.completedAt! < activitySince : snapshot.loadingHistory));
713
715
  if (historical) historicalOperations.add(operation.id);
714
- if (operation.kind !== 'read' || operation.state === 'error' || historical
716
+ // A quick or repeated search must still finish a visible mechanical step.
717
+ // Refreshes of the same receipt never replay it; old chat history is a baseline.
718
+ if (!historical && !historicalOperations.has(operation.id) && operation.kind === 'search'
719
+ && operation.state !== 'error' && previous !== operation.state && active && !document.hidden && !reduced && !activityCancelled()) {
720
+ searchVisualRemaining = ARCHIVE_STEP_SECONDS + 0.4;
721
+ if (previous === undefined) scanElapsed = Math.max(scanElapsed, ARCHIVE_STEP_SECONDS - 0.2);
722
+ }
723
+ if (operation.kind !== 'read' || operation.state === 'error' || historical || activityCancelled()
715
724
  || previous === 'complete' && !scheduledReads.has(`${operation.id}:0`)) continue;
716
725
  const returned = operationSources(operation);
717
726
  const targets: (ArchiveSource | undefined)[] = returned.length ? returned : [undefined];
@@ -747,7 +756,7 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
747
756
  clearActivities(); transferred.clear(); waitingArrivals.clear();
748
757
  observedOperations.clear(); historicalOperations.clear(); scheduledReads.clear(); operations.clear(); operationsInitialized = false;
749
758
  activitySince = Date.now();
750
- userPriorityUntil = 0; searching = false; arrivalScanRemaining = 0;
759
+ userPriorityUntil = 0; searching = false; arrivalScanRemaining = 0; searchVisualRemaining = 0;
751
760
  scanElapsed = ARCHIVE_STEP_SECONDS - 0.35; scanSteps = 0;
752
761
  selectedId = null; focusedId = null; shelfPage = 0;
753
762
  setSources([], false); setArchiveSources([]);
@@ -755,7 +764,13 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
755
764
  sessionId = snapshot.sessionId;
756
765
  // Local directory loading and host investigation state have independent
757
766
  // lifetimes. Streaming snapshots must not reset the mechanical scan timer.
758
- observeOperations(); wake();
767
+ observeOperations();
768
+ if (activityCancelled()) {
769
+ searchVisualRemaining = arrivalScanRemaining = 0;
770
+ activityQueue.length = 0;
771
+ for (const actor of activityFiles) beginReturn(actor);
772
+ }
773
+ wake();
759
774
  };
760
775
 
761
776
  const occupyingArray = (actor: ActivityFile) => actor.stage !== 'travelling' || actor.elapsed < 2.4 * 0.28;
@@ -807,9 +822,9 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
807
822
  if (operation?.state === 'error' || request.age > 9 && operation?.state !== 'active') activityQueue.splice(index, 1);
808
823
  }
809
824
  if (activityQueue.length !== queuedBefore) { activityLabelSource = null; syncLabelPrefetch(); }
810
- // Explicit browsing owns the view. Pointer hover does not cancel a running
811
- // lift, and a finished fast tool still receives its full minimum sequence.
812
- if (!reduced && !detail && !cameraTravel && !userOwnsView()
825
+ // Manual browsing owns the camera and selected document, not Agent activity.
826
+ // A short read still receives its complete minimum physical sequence.
827
+ if (!reduced && !detail && !cameraTravel
813
828
  && activityFiles.length < MAX_ACTIVITY_FILES && activityQueue.length && activitySpacing === 0) {
814
829
  const requestSlot = (request: ActivityRequest) => request.source ? sourceSlot(request.source)
815
830
  : Array.from({ length: MAX_ACTIVITY_FILES + 1 }, (_, offset) => archiveLaneIndex * 8 + wrap(slotRows[archiveLaneIndex] + offset, 8))
@@ -834,7 +849,7 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
834
849
  }
835
850
  if (next >= 0) {
836
851
  const request = activityQueue.splice(next, 1)[0];
837
- const focusSource = location === 'archive' && !activityFiles.some(occupyingArray) && request.source;
852
+ const focusSource = !userOwnsView() && location === 'archive' && !activityFiles.some(occupyingArray) && request.source;
838
853
  if (focusSource) focusArchiveSource(focusSource.id);
839
854
  const index = requestSlot(request);
840
855
  const number = request.source ? sourceNumber(request.source) : -1;
@@ -1052,7 +1067,7 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
1052
1067
  try {
1053
1068
  const dt = lastFrame ? Math.min(0.05, (ms - lastFrame) / 1000) : 0.016;
1054
1069
  lastFrame = ms;
1055
- const scanning = scanningActive() && location === 'archive' && !detail && !reduced && !userOwnsView();
1070
+ const scanning = scanningActive() && location === 'archive' && !detail && !reduced;
1056
1071
  original.setInvestigationScanning(scanning);
1057
1072
  // Use precisely the original select path: track, shoulder, signed pulse and
1058
1073
  // preview lift. Allow its spring to settle before advancing to the next cell.
@@ -1065,15 +1080,16 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
1065
1080
  if (activityFiles.some(item => item.stage === 'holding')) {
1066
1081
  // A parallel search can keep moving beside a long read without
1067
1082
  // carrying its pinned cassette all the way out of the composition.
1068
- stepArchive('row', readingScanDirection);
1083
+ stepArchive('row', readingScanDirection, true);
1069
1084
  readingScanDirection *= -1;
1070
1085
  } else {
1071
1086
  readingScanDirection = 1;
1072
- stepArchive(++scanSteps % 3 === 0 ? 'lane' : 'row', 1);
1087
+ stepArchive(++scanSteps % 3 === 0 ? 'lane' : 'row', 1, true);
1073
1088
  }
1074
1089
  }
1075
1090
  }
1076
1091
  arrivalScanRemaining = Math.max(0, arrivalScanRemaining - dt);
1092
+ searchVisualRemaining = Math.max(0, searchVisualRemaining - dt);
1077
1093
  updateCollection(dt);
1078
1094
  evidenceBoard.update(dt);
1079
1095
  options.performanceProbe?.checkpoint(measured, 'collection');
@@ -1350,13 +1366,17 @@ export async function createRhineScene(host: HTMLElement, options: RhineSceneOpt
1350
1366
  pointRay(event);
1351
1367
  if(!boardPanKey&&!middlePan&&boardTool.mode==='select'&&evidenceInbox.pick(raycaster)){down=null;event.preventDefault();options.onInboxOpen?.();return;}
1352
1368
  let id = evidenceBoard.pick(raycaster);
1369
+ if (options.isBoardReadOnly?.() && !boardPanKey && !middlePan) {
1370
+ if (id) { down = null; event.preventDefault(); options.onBoardSelect?.(id, false); return; }
1371
+ if (evidenceBoard.pickTool(raycaster)) { down = null; return; }
1372
+ }
1353
1373
  if (!boardPanKey && !middlePan && (evidenceBoard.pickTool(raycaster) || boardTool.mode !== 'select')) {
1354
1374
  boardPress = { pointerId: event.pointerId, kind: evidenceBoard.pickTool(raycaster) ? 'chalk' : 'tool', cardId: id, moved: false };
1355
1375
  event.preventDefault(); original.renderer.domElement.setPointerCapture(event.pointerId);
1356
1376
  updateToolPreview(event); return;
1357
1377
  }
1358
1378
  if (!boardPanKey && !middlePan && boardTool.mode === 'select') {
1359
- let resizeId = pickResizeHandle(event);
1379
+ let resizeId = options.isBoardReadOnly?.() ? null : pickResizeHandle(event);
1360
1380
  if (resizeId) {
1361
1381
  // Flush/promote an edited sample before freezing the resize reference.
1362
1382
  options.onBoardSelect?.(resizeId, false);
@@ -47,12 +47,19 @@ export function operationSource(operation: ArchiveOperation, sources: ArchiveSou
47
47
 
48
48
  /** One card per real read call, including a repeat read of the same document. */
49
49
  export function latestReadFocus(snapshot: InvestigationSnapshot, sources: ArchiveSource[]) {
50
- const reads = (snapshot.operations || []).filter(call => call.kind === 'read');
51
- const operation = reads.at(-1);
52
- if (!operation) return undefined;
53
- const source = operationSource(operation, sources);
54
- if (!source) return undefined;
55
- return { key: `${snapshot.sessionId}:${snapshot.investigationId || ''}:${operation.id}`, source, operation };
50
+ const reads = (snapshot.operations || []).filter(call => call.kind === 'read').slice().reverse();
51
+ if (snapshot.lastRead) reads.push(snapshot.lastRead);
52
+ let failed: { key: string; source: ArchiveSource; operation: ArchiveOperation } | undefined;
53
+ for (const operation of reads) {
54
+ const source = operationSource(operation, sources);
55
+ if (!source) continue;
56
+ const focus = { key: `${snapshot.sessionId}:${operation.id}`, source, operation };
57
+ // A failed request must not replace the last actual reading target. Keep
58
+ // its error card only when there is no earlier read to return to.
59
+ if (operation.state !== 'error') return focus;
60
+ failed ??= focus;
61
+ }
62
+ return failed;
56
63
  }
57
64
 
58
65
  /** New calls move the whole card downward once; streamed snapshots stay still. */
package/ui/rhine/types.ts CHANGED
@@ -26,6 +26,8 @@ export interface ArchiveSource {
26
26
  origin: ArchiveOrigin;
27
27
  state: ArchiveState;
28
28
  agentRead?: boolean;
29
+ /** Delivered to the Agent in this session; independent of reading or citation. */
30
+ agentReceived?: boolean;
29
31
  excerpt: string;
30
32
  /** The exact web_fetch text received by the Agent, separate from the preview excerpt. */
31
33
  content?: string;
@@ -100,6 +102,8 @@ export interface InvestigationSnapshot {
100
102
  records?: { id: string; tool: string; state: 'active' | 'complete' | 'error'; query: string; text: string }[];
101
103
  /** Current-turn tool activities; parallel and already completed calls remain distinct. */
102
104
  operations?: ArchiveOperation[];
105
+ /** Latest successful read in loaded session history, retained across turns. */
106
+ lastRead?: ArchiveOperation;
103
107
  /** All current-turn Host tools, including nested and non-corpus calls. */
104
108
  toolCalls?: InvestigationToolCall[];
105
109
  }
@@ -141,6 +145,7 @@ export interface RhineScene {
141
145
  dispose(): void;
142
146
  }
143
147
  export interface RhineSceneOptions {
148
+ isBoardReadOnly?: () => boolean;
144
149
  assetBase: string;
145
150
  navigationLimiter?: import('./archive-navigation-limit').ArchiveNavigationLimiter;
146
151
  /** TEMPORARY RHINE PROFILER */
@@ -183,6 +188,7 @@ export interface RhineOptions {
183
188
  isSettingsOpen?: () => boolean;
184
189
  }
185
190
  export interface RhineHostState {
191
+ cancelling?: boolean;
186
192
  sessionId: string;
187
193
  title: string;
188
194
  workspace: string;