Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
66 commits
Select commit Hold shift + click to select a range
38663d6
feat(history-import): extract pure local-history import logic and add…
zxch3n Sep 3, 2026
87b6010
feat(components): read session history through ConversationView, writ…
zxch3n Sep 3, 2026
8823639
feat(ai-gui): render conversations over ConversationView with placeho…
zxch3n Sep 3, 2026
fb68b9b
chore(history-import): benchmark open, scroll, and stream over Conver…
zxch3n Sep 3, 2026
265c96b
test(components): stub the session store history writer in useSession…
zxch3n Sep 3, 2026
b9600a2
fix(history-import): free each benchmark LoroDoc so the x10 run canno…
zxch3n Sep 4, 2026
34605ba
perf(components): index a turn with one shallow read so a 2,400-turn …
zxch3n Sep 4, 2026
0c2c41c
test(components): build the control-plane Mirror fixture without a li…
zxch3n Sep 4, 2026
ffa2915
fix(components): invalidate derived facts when an unhydrated turn cha…
zxch3n Sep 4, 2026
701a3ad
test(components): pin the forward-compat and stray-write behaviour of…
zxch3n Sep 4, 2026
7bec90e
docs(components): make the one-read-one-write path for turns an enfor…
zxch3n Sep 4, 2026
8c43b62
refactor(components): remove duplicated definitions and dead surface …
zxch3n Sep 4, 2026
9516a51
fix(components): keep the conversation view importable outside the re…
zxch3n Sep 5, 2026
cc49d25
fix: consolidate windowed conversation reviews and storage compatibility
zxch3n Sep 6, 2026
062be06
feat: integrate windowed conversations with the shared history writer
zxch3n Sep 10, 2026
66c0249
chore: sync the latest main for ConversationView integration
zxch3n Sep 10, 2026
31170f6
fix: preserve conversation range ownership and refresh history readers
zxch3n Sep 11, 2026
b8b84f4
fix: keep permission and proposal writes local to their turns
zxch3n Sep 11, 2026
894218a
fix: index sticky role metadata before the composer can send
zxch3n Sep 11, 2026
359c2ee
chore: merge main into conversation view branch
zxch3n Sep 11, 2026
65a8157
fix(components): preserve shared history rendering after merging main
zxch3n Sep 12, 2026
16fac52
fix(components): preserve send config and complete history reads
zxch3n Sep 12, 2026
2ff5aac
feat(shared): add a CRDT-neutral session data port
zxch3n Sep 12, 2026
aa0fe6f
refactor(cli): route session history writes through the domain port
zxch3n Sep 12, 2026
e25c59e
fix(shared): keep an explicit non-terminal finished on reopen
zxch3n Sep 12, 2026
111c9a6
feat(shared): add a task-proposal resolution command
zxch3n Sep 12, 2026
14a0d11
refactor(components): route renderer history writes through the domai…
zxch3n Sep 12, 2026
e420ad0
test(components): run the renderer writer over the in-memory SessionData
zxch3n Sep 12, 2026
2359f7a
refactor(cli): page MCP history through the bounded session data reader
zxch3n Sep 12, 2026
f9acff1
docs: record the session data port in the windowed-reader note
zxch3n Sep 12, 2026
3755312
fix(shared,cli): harden the session data port
zxch3n Sep 12, 2026
110943a
refactor(shared,cli): decouple the session read model from the storag…
zxch3n Sep 12, 2026
67868e1
feat(shared): share the control plane and add a mark-seen command
zxch3n Sep 12, 2026
9d84f9e
docs: bind the session-data invariants in the nearest AGENTS scope
zxch3n Sep 12, 2026
d54ee19
fix(cli,shared): close the audited durability and MCP paging gaps
zxch3n Sep 12, 2026
7e5877b
test(shared): pin the audited shallow-identity read shape
zxch3n Sep 12, 2026
fd2f0fe
feat(cli,shared): finish the session-data port migration on the CLI
zxch3n Sep 12, 2026
316d47c
feat(cli): route queue promotion through the appendTurn domain command
zxch3n Sep 12, 2026
f989722
fix(shared): keep changed-only replace and shallow scalar writes in t…
zxch3n Sep 12, 2026
8c50274
feat(session-data): reader-backed display cache and storage-owned sna…
zxch3n Sep 12, 2026
fc1243c
fix(components,shared): close three reader display-cache regressions
zxch3n Sep 12, 2026
71ddbe3
fix(components,shared): one async acceptance rule for the reader disp…
zxch3n Sep 12, 2026
3d5de24
fix(components): invalidate by observed identity and keep leases owne…
zxch3n Sep 12, 2026
d8b6849
refactor(shared): make the snapshot capture/read/copy boundary async
zxch3n Sep 12, 2026
0457df4
refactor(shared,cli): replace the rollback callback with replaceEdita…
zxch3n Sep 12, 2026
eefd69b
fix(shared,cli): await the editable-tail compensation before follow-u…
zxch3n Sep 12, 2026
c84f5cc
refactor(session-data): close business history access behind domain c…
zxch3n Sep 12, 2026
53e8be2
fix(session-data): preserve legacy rows and close indexed reader life…
zxch3n Sep 12, 2026
794e46d
merge: align session data boundary with current main
zxch3n Sep 12, 2026
dfe8264
fix: close windowed history review regressions
zxch3n Sep 13, 2026
a77d700
refactor(perf): benchmark the shipped conversation reader only
zxch3n Sep 13, 2026
1a81f0e
refactor(conversation): use one window reader and identity change events
zxch3n Sep 13, 2026
1ae04af
refactor(session): narrow shared writes and reuse stored snapshot pro…
zxch3n Sep 13, 2026
4ebfb81
refactor(session): remove unused fallbacks and synchronous read wrappers
zxch3n Sep 13, 2026
c5ecfbd
chore: merge main into windowed conversation branch
zxch3n Sep 13, 2026
9a827c3
fix(components): keep conversation bottom scrolling stable
zxch3n Sep 13, 2026
ede159a
perf: reuse history indexes and load outline previews on demand
zxch3n Sep 13, 2026
75ffc7b
merge: reconcile main sharing changes with windowed conversations
zxch3n Sep 13, 2026
c37ad98
fix(components): anchor measured conversation height before paint
zxch3n Sep 13, 2026
729835a
fix(components): settle initial conversation window before showing it
zxch3n Sep 13, 2026
62c0622
fix(components): copy diagnostics from message list crash screen
zxch3n Sep 13, 2026
026a71c
chore: merge main into conversation view branch
zxch3n Sep 14, 2026
9c4a4cf
docs: keep hook invariants within the size limit
zxch3n Sep 14, 2026
8c75e76
fix(components): reveal conversations after virtual rows are measured
zxch3n Sep 14, 2026
47a8131
fix(shared): classify turn identity edits as structural changes
zxch3n Sep 14, 2026
2d59421
docs(sharing): keep merged guidance below the size limit
zxch3n Sep 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# Windowed conversation reads

Status: implemented
Translation: current

## Abstract

Opening a session uses a control-plane Mirror and one reader-backed
ConversationView. Directory rows are shallow; only leased or retained tail
bodies are materialized. Snapshot decoding and the initial directory still
scale with the whole conversation.

The existing HistoryWriter remains the single writer. Shared planners retain
permission, agent-output, import and editable-tail rules. Stored-copy handles
reuse the writer's provenance; they survive source disposal during a fork.

View events are structure changes or explicit changed turn ids. Derivations
invalidate evicted facts too. Async cache reads retain identity/epoch fences.
Storage and view events share structure/changed-id semantics. Goal, permission,
scheduling and file-diff consumers share one reference-counted fact table.
CLI reads are synchronous over the same storage reader; auto-seen reads only
shallow fields and permission decisions are checked before auto-approval.
Ordinary commands throw writer errors; only import and editable-tail replacement
retain phased outcomes. The array adapter serves static sharing pages. There is
no alternate session view, feature switch or complete memory command backend.

## Evidence boundary

Reader, writer and CLI regressions use synthetic Loro fixtures. The component
benchmark measures the shipped reader. Device-scale cold-open, streaming frame
time and long-session JS/WASM memory remain separate acceptance work.

## Initial viewport measurement

The first range promise only establishes data availability. Setting `scrollTop`
to the estimated end does not establish that Virtua has mounted and measured the
destination rows. Revealing at that point exposed an empty or intermediate window.
The viewport now waits for the measured destination, visible row geometry and
Virtua's observed offset to agree; mounted-row measurements also correct following
before deferred spacer resizes. Corrections use the sticky library's setter so
layout changes do not masquerade as upward user scrolling. There is no settle
sleep, and later window hydration does not hide the viewport again.

A browser regression holds the real destination-row ResizeObserver delivery:
the previous hook reveals while held; the fixed hook stays hidden and opens at
the measured tail after release, including remount. Unit coverage retains cached
reading positions, user escape, composer resize suppression and row growth.
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# 窗口读取对话历史

Status: implemented
Translation: current

## 摘要

打开会话时,控制面 Mirror 不读取历史正文。唯一的窗口读取实现先读浅目录,
再读取窗口和尾部正文;快照解码、初始目录仍与总历史长度有关。

HistoryWriter 仍是唯一写入者,共享 planner 保留业务规则。fork 直接持有
writer 的可信快照句柄,源文档卸载不会使已捕获的快照失效。

显示层只通知结构变化或明确的变更轮次 ID,逐出的事实也会失效。
异步读取保留身份和版本检查;存储层和显示层使用相同的结构/变更 ID 事件。
目标、权限、调度和文件 diff 共用一张有引用计数的事实表。
CLI 同步读取相同存储;auto-seen 只读浅字段,权限决定先于自动批准检查。
普通命令直接抛出 writer 错误,仅导入和编辑重发保留三相结果。
数组适配器用于静态分享页,不再保留备用会话路径或开关。
测试和基准使用正式 reader;真机冷开、流式帧耗时和内存仍需单独验收。

## 首屏视口测量

首个范围的 promise 只保证数据可用。把 `scrollTop` 写到估算底部,并不保证
Virtua 已挂载、测量目标行;此时显示视口会暴露空白或中间位置。
现在会等待目标行测量、可见行几何和 Virtua 观察到的偏移一致。
已挂载行的尺寸变化也会在延后的占位容器 resize 前完成贴底修正。
修正通过吸附库的 setter 写入,避免把布局变化误判为用户向上滚动。
没有固定等待时间,后续窗口加载也不会重新隐藏视口。

浏览器回归显式暂停目标行真实的 ResizeObserver 通知:旧 hook 在暂停时
已经显示,修复后保持隐藏,放行测量后直接显示末尾;重新挂载也覆盖。
单元回归保留历史阅读位置、用户脱离吸附、输入框高度抑制和行高增长检查。
4 changes: 4 additions & 0 deletions apps/cli/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ Root `AGENTS.md` applies; this file adds CLI context. Build, PR-poller, and adap

## Build and packaging

- The Node 22 bundle uses native top-level await. Do not run a browser TLA
compatibility transform over its output chunks. Validate the CLI SSR build
under `NODE_OPTIONS=--max-old-space-size=2048`; increasing the heap is not a fix.

- The public CLI defaults to the local platform, discovers no deployment dotenv files, and must
never initialize telemetry in local mode even if PostHog variables exist in the shell.
- INVARIANT: the dev output layout must match production's — `index.js` plus flat sibling
Expand Down
82 changes: 78 additions & 4 deletions apps/cli/src/commands/session-output.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,8 @@
import { createSessionAgentWrites } from '../lib/loro/session-agent-writes';
import { LoroDoc, LoroMap } from 'loro-crdt';
import { createHistoryWriter } from '@lody/shared';
import { createLoroSessionData } from '@lody/shared/session-data';
import { withHistoryPort } from '../../tests/history-port-fixture';
import { describe, expect, it, vi } from 'vitest';
import type { SessionHistoryInput, SessionId } from '@lody/shared';
import {
Expand Down Expand Up @@ -41,10 +46,12 @@ const createMirror = (initialState: MirrorState) => {
};
};

const createSessionDoc = (mirror: ReturnType<typeof createMirror>) => ({
sessionId: 'session-1' as SessionId,
mirror: mirror as unknown,
});
const createSessionDoc = (mirror: ReturnType<typeof createMirror>) =>
withHistoryPort({
sessionId: 'session-1' as SessionId,
readHistorySnapshot: () => mirror.getState().history ?? [],
subscribeAll: (listener: () => void) => mirror.subscribe(() => listener()),
});

describe('session output helpers', () => {
it('finds the assistant entry linked to the target user turn', () => {
Expand Down Expand Up @@ -72,6 +79,73 @@ describe('session output helpers', () => {
expect(findAssistantEntryForUserTurn(history, 'user-1')?.id).toBe('assistant-target');
});

it('streams one linked turn without materializing unrelated bodies', async () => {
const doc = new LoroDoc();
const writer = createHistoryWriter(doc);
for (let i = 0; i < 100; i++)
writer.append(
createHistoryEntry({
id: `old-${i}`,
items: [{ type: 'text', text: 'large old body'.repeat(100) }],
})
);
writer.append(createHistoryEntry({ id: 'u', role: 'user', status: 'processing' }));
writer.append(
createHistoryEntry({
id: 'a',
userTurnId: 'u',
finished: false,
items: [{ type: 'text', text: 'first' }],
})
);
const data = createLoroSessionData({
sessionId: 'session-1' as SessionId,
doc,
writer,
});
const bodyReads: string[] = [];
const toJSON = LoroMap.prototype.toJSON;
const spy = vi.spyOn(LoroMap.prototype, 'toJSON').mockImplementation(function (this: LoroMap) {
const id = this.get('id');
if (typeof id === 'string') bodyReads.push(id);
return toJSON.call(this);
});
const first = Promise.withResolvers<void>();
const events: Array<Record<string, unknown>> = [];
const completion = waitForTurnCompletion({
sessionDoc: {
sessionId: 'session-1' as SessionId,
sessionData: data,
subscribeAll: (notify) => doc.subscribe(() => notify()),
},
userTurnId: 'u',
outputMode: 'jsonl',
timeoutMs: 0,
onEvent(event) {
events.push(event);
if (event.type === 'update') first.resolve();
},
});
try {
await first.promise;
await createSessionAgentWrites(data.writer).setTurnField('a', 'finished', {
kind: 'set',
value: true,
});
await createSessionAgentWrites(data.writer).setTurnField('u', 'status', {
kind: 'set',
value: 'handled',
});
expect((await completion).turnId).toBe('a');
expect(events.map((e) => e.type)).toEqual(['update', 'done']);
expect(bodyReads.length).toBeGreaterThan(0);
expect([...new Set(bodyReads)]).toEqual(['a']);
} finally {
spy.mockRestore();
data.dispose();
}
});

it('streams updated assistant items and resolves when the turn finishes', async () => {
const userTurn = createHistoryEntry({
id: 'user-1',
Expand Down
33 changes: 15 additions & 18 deletions apps/cli/src/commands/session-output.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,7 @@ import type { SessionDocument } from '@/lib/loro/doc';

export type StructuredSessionOutputMode = 'json' | 'jsonl';

type SessionDocMirrorState = {
history?: SessionHistoryInput[];
};

type SessionDocMirror = {
subscribe: (listener: (next: SessionDocMirrorState) => void) => () => void;
getState: () => SessionDocMirrorState;
};

type SessionDocForOutput = Pick<SessionDocument, 'sessionId' | 'mirror'>;
type SessionDocForOutput = Pick<SessionDocument, 'sessionId' | 'sessionData' | 'subscribeAll'>;

export type SessionTurnOutputEvent =
| {
Expand Down Expand Up @@ -138,8 +129,7 @@ export async function waitForTurnCompletion(options: {
signal?: AbortSignal;
onEvent?: (event: SessionTurnOutputEvent) => void;
}): Promise<CompletedAssistantTurn> {
const mirror = options.sessionDoc.mirror as SessionDocMirror | null;
if (!mirror) {
if (!options.sessionDoc.sessionData || !options.sessionDoc.subscribeAll) {
throw new Error('SessionDocument not initialized');
}

Expand Down Expand Up @@ -175,12 +165,11 @@ export async function waitForTurnCompletion(options: {
settle(() => reject(error));
};

const inspect = (next: SessionDocMirrorState) => {
const inspect = (history: SessionHistoryInput[]) => {
if (settled) {
return;
}

const history = Array.isArray(next.history) ? (next.history as SessionHistoryInput[]) : [];
const userTurn = findUserTurn(history, options.userTurnId);
if (userTurn?.status === 'failed') {
rejectWith(
Expand Down Expand Up @@ -256,13 +245,21 @@ export async function waitForTurnCompletion(options: {
}
};

// The in-process reader captures and inspects one observation synchronously.
const refresh = () => {
if (settled) return;
try {
inspect(options.sessionDoc.sessionData.history.readTurnOutput(options.userTurnId));
} catch (error) {
rejectWith(error instanceof Error ? error : new Error(String(error)));
}
};

const handleAbort = () => {
rejectWith(new Error('Turn completion wait aborted.'));
};

unsubscribe = mirror.subscribe((next) => {
inspect(next);
});
unsubscribe = options.sessionDoc.subscribeAll(refresh);
options.signal?.addEventListener('abort', handleAbort, { once: true });

if (options.timeoutMs > 0) {
Expand All @@ -280,6 +277,6 @@ export async function waitForTurnCompletion(options: {
}, options.timeoutMs);
}

inspect(mirror.getState());
refresh();
});
}
Loading
Loading