@baique/pi-web-sky 0.1.43 → 0.1.45
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.
- package/.next/BUILD_ID +1 -1
- package/.next/app-path-routes-manifest.json +14 -14
- package/.next/build-manifest.json +3 -3
- package/.next/react-loadable-manifest.json +3 -3
- package/.next/required-server-files.js +1 -1
- package/.next/required-server-files.json +1 -1
- package/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_global-error.html +1 -1
- package/.next/server/app/_global-error.rsc +1 -1
- package/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
- package/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/server/app/_not-found.html +1 -1
- package/.next/server/app/_not-found.rsc +3 -3
- package/.next/server/app/_not-found.segments/_full.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +3 -3
- package/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/api/app-update/route.js +1 -1
- package/.next/server/app/api/board-assets/[...path]/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/board-assets/route_client-reference-manifest.js +1 -1
- package/.next/server/app/api/git/status/route.js +1 -1
- package/.next/server/app/api/models/route.js +1 -1
- package/.next/server/app/api/project-trust/route.js +1 -1
- package/.next/server/app/api/terminal/route.js +1 -1
- package/.next/server/app/api/worktrees/route.js +1 -1
- package/.next/server/app/index.html +1 -1
- package/.next/server/app/index.rsc +5 -5
- package/.next/server/app/index.segments/__PAGE__.segment.rsc +5 -5
- package/.next/server/app/index.segments/_full.segment.rsc +5 -5
- package/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/server/app/page.js +2 -2
- package/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/server/app-paths-manifest.json +14 -14
- package/.next/server/chunks/402.js +6 -5
- package/.next/server/chunks/6525.js +2 -2
- package/.next/server/middleware-build-manifest.js +1 -1
- package/.next/server/middleware-react-loadable-manifest.js +1 -1
- package/.next/server/pages/404.html +1 -1
- package/.next/server/pages/500.html +1 -1
- package/.next/static/chunks/{752.b808ee0d01f76cbf.js → 752.395b1b6b288c0c89.js} +1 -1
- package/.next/static/chunks/9272.7b010df4c209349b.js +1 -0
- package/.next/static/chunks/app/{layout-bd9248a30e97ea3b.js → layout-19e02b79476a6b19.js} +1 -1
- package/.next/static/chunks/app/{page-3090c3cd6482d779.js → page-e4d01ab7584fd052.js} +1 -1
- package/.next/static/chunks/{webpack-8807c91e1b44a5e7.js → webpack-7e7a6ac5aadb603f.js} +1 -1
- package/.next/static/css/4a609fb185e7cff1.css +1 -0
- package/.next/trace +7 -42
- package/.next/trace-build +1 -1
- package/lib/card-emoji.test.mjs +2 -2
- package/lib/session-index-scanner.test.mjs +30 -4
- package/lib/session-index-scanner.ts +34 -15
- package/lib/session-reader.project.test.mjs +86 -0
- package/lib/session-reader.ts +75 -54
- package/lib/task-store.ts +1 -1
- package/lib/worktree.test.mjs +17 -1
- package/lib/worktree.ts +9 -0
- package/package.json +1 -1
- package/.next/static/chunks/7128.c3cb215470efb551.js +0 -1
- package/.next/static/css/eb9207b60f094f9b.css +0 -1
- /package/.next/static/{69e74OJnYIa0YtjFRWIBS → mi_AqqMn6gfU7PwatOjlB}/_buildManifest.js +0 -0
- /package/.next/static/{69e74OJnYIa0YtjFRWIBS → mi_AqqMn6gfU7PwatOjlB}/_ssgManifest.js +0 -0
package/.next/trace-build
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
[{"name":"run-webpack","duration":
|
|
1
|
+
[{"name":"run-webpack","duration":9055753,"timestamp":530754013031,"id":14,"parentId":1,"tags":{},"startTime":1788793247583,"traceId":"3e8aa30f90ee0535"},{"name":"run-typescript","duration":3317493,"timestamp":530763070348,"id":771,"parentId":1,"tags":{},"startTime":1788793256640,"traceId":"3e8aa30f90ee0535"},{"name":"static-check","duration":1974175,"timestamp":530766393578,"id":774,"parentId":1,"tags":{},"startTime":1788793259964,"traceId":"3e8aa30f90ee0535"},{"name":"static-generation","duration":2892213,"timestamp":530768544924,"id":918,"parentId":1,"tags":{},"startTime":1788793262115,"traceId":"3e8aa30f90ee0535"},{"name":"collect-build-traces","duration":17535952,"timestamp":530768369512,"id":915,"parentId":1,"tags":{},"startTime":1788793261940,"traceId":"3e8aa30f90ee0535"},{"name":"telemetry-flush","duration":19,"timestamp":530785907514,"id":927,"parentId":1,"tags":{},"startTime":1788793279478,"traceId":"3e8aa30f90ee0535"},{"name":"next-build","duration":32019077,"timestamp":530753888461,"id":1,"tags":{"buildMode":"default","version":"16.3.1","bundler":"webpack","has-custom-webpack-config":"false","use-build-worker":"true"},"startTime":1788793247459,"traceId":"3e8aa30f90ee0535"}]
|
package/lib/card-emoji.test.mjs
CHANGED
|
@@ -7,13 +7,13 @@ const { DEFAULT_EMOJI, RANDOM_EMOJIS, randomEmoji, resolveEmoji } = await jiti.i
|
|
|
7
7
|
|
|
8
8
|
test("类别默认:会话/任务/便笺固定初始值", () => {
|
|
9
9
|
assert.equal(DEFAULT_EMOJI.session, "💬");
|
|
10
|
-
assert.equal(DEFAULT_EMOJI.task, "
|
|
10
|
+
assert.equal(DEFAULT_EMOJI.task, "🚩");
|
|
11
11
|
assert.equal(DEFAULT_EMOJI.note, "📝");
|
|
12
12
|
});
|
|
13
13
|
|
|
14
14
|
test("resolveEmoji:用户设置优先于类别默认", () => {
|
|
15
15
|
assert.equal(resolveEmoji("session", "🐱"), "🐱");
|
|
16
|
-
assert.equal(resolveEmoji("task", undefined, "idle"), "
|
|
16
|
+
assert.equal(resolveEmoji("task", undefined, "idle"), "🚩");
|
|
17
17
|
assert.equal(resolveEmoji("note", "", null), "📝");
|
|
18
18
|
});
|
|
19
19
|
|
|
@@ -69,23 +69,49 @@ test("runSessionIndexScan:幂等(重复扫描不重复建行)", async () =
|
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
71
|
|
|
72
|
-
test("runSessionIndexScan:mtime
|
|
72
|
+
test("runSessionIndexScan:mtime 变化刷 modified + 回填 first_message(文件是内容事实源)", async () => {
|
|
73
73
|
freshDb();
|
|
74
74
|
const { root, projDir } = makeRoot();
|
|
75
75
|
try {
|
|
76
|
+
// 模拟 persistNewSessionFile 建行:文件尚无消息 → first_message NULL
|
|
76
77
|
const file = join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl");
|
|
77
|
-
writeFileSync(file,
|
|
78
|
+
writeFileSync(file, [
|
|
79
|
+
`{"type":"session","version":3,"id":"aaa","timestamp":"2026-01-01T00:00:00.000Z","cwd":"/home/u/project/alpha"}`,
|
|
80
|
+
].join("\n") + "\n");
|
|
78
81
|
await runSessionIndexScan(root);
|
|
82
|
+
assert.equal(getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='aaa'").get().first_message, null, "无消息 → NULL");
|
|
79
83
|
const before = getDb().prepare("SELECT modified FROM session_meta WHERE session_id='aaa'").get().modified;
|
|
80
84
|
|
|
81
|
-
//
|
|
85
|
+
// 用户发首条消息 → 文件 mtime 变
|
|
82
86
|
await new Promise((r) => setTimeout(r, 10));
|
|
83
|
-
writeFileSync(file, sessionFileLines("aaa", "/home/u/project/alpha", "
|
|
87
|
+
writeFileSync(file, sessionFileLines("aaa", "/home/u/project/alpha", "你好项目") + '{"type":"message","id":"a1","parentId":"u1","message":{"role":"assistant","content":"hi"}}\n');
|
|
84
88
|
const summary = await runSessionIndexScan(root);
|
|
85
89
|
assert.equal(summary.inserted, 0);
|
|
86
90
|
assert.equal(summary.updated, 1);
|
|
87
91
|
const after = getDb().prepare("SELECT modified FROM session_meta WHERE session_id='aaa'").get().modified;
|
|
88
92
|
assert.ok(after > before, "mtime 刷新");
|
|
93
|
+
const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='aaa'").get();
|
|
94
|
+
assert.equal(row.first_message, "你好项目", "mtime 分支回填 first_message");
|
|
95
|
+
} finally {
|
|
96
|
+
rmSync(root, { recursive: true, force: true });
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
test("runSessionIndexScan:first_message 历史欠账(NULL 且 mtime 未变)→ 补跑回填", async () => {
|
|
101
|
+
freshDb();
|
|
102
|
+
const { root, projDir } = makeRoot();
|
|
103
|
+
try {
|
|
104
|
+
const file = join(projDir, "2026-01-01T00-00-00-000Z_aaa.jsonl");
|
|
105
|
+
writeFileSync(file, sessionFileLines("aaa", "/home/u/project/alpha", "历史首条"));
|
|
106
|
+
await runSessionIndexScan(root);
|
|
107
|
+
// 模拟老行欠账:文件有消息但 first_message 被清成 NULL(persist 建行冻结场景)
|
|
108
|
+
getDb().prepare("UPDATE session_meta SET first_message = NULL WHERE session_id='aaa'").run();
|
|
109
|
+
|
|
110
|
+
const summary = await runSessionIndexScan(root);
|
|
111
|
+
assert.equal(summary.inserted, 0);
|
|
112
|
+
assert.equal(summary.updated, 1, "NULL 行补跑计入 updated");
|
|
113
|
+
const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='aaa'").get();
|
|
114
|
+
assert.equal(row.first_message, "历史首条", "mtime 未变也回填");
|
|
89
115
|
} finally {
|
|
90
116
|
rmSync(root, { recursive: true, force: true });
|
|
91
117
|
}
|
|
@@ -56,13 +56,14 @@ export async function runSessionIndexScan(
|
|
|
56
56
|
const summary: SessionIndexScanSummary = { scanned: diskFiles.length, inserted: 0, updated: 0, deleted: 0 };
|
|
57
57
|
|
|
58
58
|
const db = getDb();
|
|
59
|
-
const selectAll = db.prepare("SELECT session_id, modified, path, parent_id FROM session_meta").all() as Array<{
|
|
59
|
+
const selectAll = db.prepare("SELECT session_id, modified, path, parent_id, first_message FROM session_meta").all() as Array<{
|
|
60
60
|
session_id: string;
|
|
61
61
|
modified: number | null;
|
|
62
62
|
path: string | null;
|
|
63
63
|
parent_id: string | null;
|
|
64
|
+
first_message: string | null;
|
|
64
65
|
}>;
|
|
65
|
-
const dbRows = new Map(selectAll.map((r) => [r.session_id, { modified: r.modified, path: r.path, parent_id: r.parent_id }]));
|
|
66
|
+
const dbRows = new Map(selectAll.map((r) => [r.session_id, { modified: r.modified, path: r.path, parent_id: r.parent_id, first_message: r.first_message }]));
|
|
66
67
|
|
|
67
68
|
const seenOnDisk = new Set<string>();
|
|
68
69
|
|
|
@@ -88,6 +89,7 @@ export async function runSessionIndexScan(
|
|
|
88
89
|
);
|
|
89
90
|
const updateModifiedStmt = db.prepare("UPDATE session_meta SET modified = ? WHERE session_id = ?");
|
|
90
91
|
const updateParentStmt = db.prepare("UPDATE session_meta SET parent_id = ? WHERE session_id = ?");
|
|
92
|
+
const updateFirstMessageStmt = db.prepare("UPDATE session_meta SET first_message = ? WHERE session_id = ?");
|
|
91
93
|
const deleteStmt = db.prepare("DELETE FROM session_meta WHERE session_id = ?");
|
|
92
94
|
|
|
93
95
|
// 读 header + resolve project + upsert(新文件或老行补全索引列共用)。
|
|
@@ -116,6 +118,26 @@ export async function runSessionIndexScan(
|
|
|
116
118
|
);
|
|
117
119
|
};
|
|
118
120
|
|
|
121
|
+
// 从磁盘读 head 并收敛派生列(parent_id 重挂 + first_message 回填)。
|
|
122
|
+
// first_message 是内容派生列:文件写入了首条消息,索引必须跟随——
|
|
123
|
+
// 否则 persist 建行的会话(first_message 恒 NULL)标题永远冻结。
|
|
124
|
+
// 返回是否更新了 first_message(供 NULL 欠账补跑计数)。
|
|
125
|
+
const reconcileFromHead = (file: { path: string; id: string }, dbRow: { parent_id: string | null; first_message: string | null }): boolean => {
|
|
126
|
+
const head = scanOneSessionHead(file.path);
|
|
127
|
+
if (!head) return false;
|
|
128
|
+
const parentId = head.parentSessionPath
|
|
129
|
+
? (pathToId.get(sessionPathKey(head.parentSessionPath)) ?? null)
|
|
130
|
+
: null;
|
|
131
|
+
if (parentId !== dbRow.parent_id) {
|
|
132
|
+
updateParentStmt.run(parentId, file.id);
|
|
133
|
+
}
|
|
134
|
+
if (head.firstMessage && head.firstMessage !== dbRow.first_message) {
|
|
135
|
+
updateFirstMessageStmt.run(head.firstMessage, file.id);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
return false;
|
|
139
|
+
};
|
|
140
|
+
|
|
119
141
|
for (const file of diskFiles) {
|
|
120
142
|
seenOnDisk.add(file.id);
|
|
121
143
|
const dbRow = dbRows.get(file.id);
|
|
@@ -125,21 +147,18 @@ export async function runSessionIndexScan(
|
|
|
125
147
|
await upsertFromDisk(file);
|
|
126
148
|
summary.inserted += 1;
|
|
127
149
|
} else if (dbRow.modified === null || dbRow.modified !== file.modified.getTime()) {
|
|
128
|
-
// mtime
|
|
129
|
-
//
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
const head = scanOneSessionHead(file.path);
|
|
133
|
-
if (head) {
|
|
134
|
-
const parentId = head.parentSessionPath
|
|
135
|
-
? (pathToId.get(sessionPathKey(head.parentSessionPath)) ?? null)
|
|
136
|
-
: null;
|
|
137
|
-
if (parentId !== dbRow.parent_id) {
|
|
138
|
-
updateParentStmt.run(parentId, file.id);
|
|
139
|
-
}
|
|
140
|
-
}
|
|
150
|
+
// mtime 变化:刷新排序键;同时收敛派生列——parent 链(删除会话级联
|
|
151
|
+
// 重挂/外部重挂会改 header.parentSession)与 first_message(内容写入
|
|
152
|
+
// 后标题必须跟随)。磁盘 header 是父链唯一权威,文件头是标题事实源。
|
|
153
|
+
reconcileFromHead(file, dbRow);
|
|
141
154
|
updateModifiedStmt.run(file.modified.getTime(), file.id);
|
|
142
155
|
summary.updated += 1;
|
|
156
|
+
} else if (!dbRow.first_message) {
|
|
157
|
+
// first_message 欠账补跑:persist 建行的老会话(first_message 恒 NULL)
|
|
158
|
+
// 在 mtime 已同步后仍需回填一次;文件已有首条消息则写入,否则保持 NULL。
|
|
159
|
+
if (reconcileFromHead(file, dbRow)) {
|
|
160
|
+
summary.updated += 1;
|
|
161
|
+
}
|
|
143
162
|
}
|
|
144
163
|
}
|
|
145
164
|
|
|
@@ -118,3 +118,89 @@ test("loadSessionSummariesByIds:按 id 点查摘要(title/lastReply/自定
|
|
|
118
118
|
rmSync(root, { recursive: true, force: true });
|
|
119
119
|
}
|
|
120
120
|
});
|
|
121
|
+
|
|
122
|
+
test("loadTaskSessionsPageWithIndex:任务列表标题与聊天同源(session_meta,不依赖文件存在)", async () => {
|
|
123
|
+
freshDb();
|
|
124
|
+
markIndexReady();
|
|
125
|
+
// 文件都不存在(磁盘扫描快照外/已删):标题必须仍能从 session_meta 读出,
|
|
126
|
+
// 与聊天列表 loadProjectSessions 同源(旧实现 scanOneSessionFile 读文件 → 全跳过)。
|
|
127
|
+
seedSession({ id: "a", cwd: "/home/u/p", projectKey: "/home/u/p", taskId: "task-1", firstMessage: "任务首条", path: "/no/such/a.jsonl", modified: 300 });
|
|
128
|
+
seedSession({ id: "b", cwd: "/home/u/p", projectKey: "/home/u/p", taskId: "task-1", firstMessage: "另一条", path: "/no/such/b.jsonl", modified: 200 });
|
|
129
|
+
// 非任务会话 → 不返回
|
|
130
|
+
seedSession({ id: "c", cwd: "/home/u/p", projectKey: "/home/u/p", firstMessage: "游离", path: "/no/such/c.jsonl", modified: 100 });
|
|
131
|
+
|
|
132
|
+
const { loadTaskSessionsPageWithIndex } = await jiti.import("./session-reader.ts");
|
|
133
|
+
const index = {
|
|
134
|
+
metaById: new Map([
|
|
135
|
+
["a", { path: "/no/such/a.jsonl", id: "a", modified: new Date(300) }],
|
|
136
|
+
["b", { path: "/no/such/b.jsonl", id: "b", modified: new Date(200) }],
|
|
137
|
+
]),
|
|
138
|
+
childrenOf: new Map(),
|
|
139
|
+
};
|
|
140
|
+
const res = await loadTaskSessionsPageWithIndex("task-1", index, 0, 5);
|
|
141
|
+
assert.equal(res.sessions.length, 2, "文件不存在但 meta 有行 → 仍返回(与聊天列表同源)");
|
|
142
|
+
assert.equal(res.sessions[0].id, "a");
|
|
143
|
+
assert.equal(res.sessions[0].firstMessage, "任务首条", "标题来自 session_meta.first_message");
|
|
144
|
+
assert.equal(res.sessions[1].firstMessage, "另一条");
|
|
145
|
+
assert.equal(res.sessions[0].name, undefined);
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("loadProjectSessions:first_message 空且文件有首条消息 → lazy 读文件补标题并写回 meta", async () => {
|
|
149
|
+
freshDb();
|
|
150
|
+
markIndexReady();
|
|
151
|
+
const root = mkdtempSync(join(tmpdir(), "pi-lazy-"));
|
|
152
|
+
const projDir = join(root, "--home-u-p--");
|
|
153
|
+
mkdirSync(projDir, { recursive: true });
|
|
154
|
+
try {
|
|
155
|
+
const cwd = "/home/u/p";
|
|
156
|
+
// 有消息但 meta 未同步(persist 建行冻结场景)→ lazy 应补
|
|
157
|
+
const file = join(projDir, "2026-01-01T00-00-00-000Z_a.jsonl");
|
|
158
|
+
writeFileSync(file, [
|
|
159
|
+
`{"type":"session","version":3,"id":"a","timestamp":"2026-01-01T00:00:00.000Z","cwd":"${cwd}"}`,
|
|
160
|
+
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"lazy 首条"}}',
|
|
161
|
+
].join("\n") + "\n");
|
|
162
|
+
seedSession({ id: "a", cwd, projectKey: cwd, path: file, firstMessage: null });
|
|
163
|
+
// 真空会话:文件无消息 → 保持 no messages
|
|
164
|
+
const emptyFile = join(projDir, "2026-01-02T00-00-00-000Z_b.jsonl");
|
|
165
|
+
writeFileSync(emptyFile, [
|
|
166
|
+
`{"type":"session","version":3,"id":"b","timestamp":"2026-01-02T00:00:00.000Z","cwd":"${cwd}"}`,
|
|
167
|
+
].join("\n") + "\n");
|
|
168
|
+
seedSession({ id: "b", cwd, projectKey: cwd, path: emptyFile, firstMessage: null });
|
|
169
|
+
|
|
170
|
+
const { loadProjectSessions } = await jiti.import("./session-reader.ts");
|
|
171
|
+
const sessions = await loadProjectSessions(cwd);
|
|
172
|
+
const byId = Object.fromEntries(sessions.map((s) => [s.id, s]));
|
|
173
|
+
assert.equal(byId.a.firstMessage, "lazy 首条", "lazy 读文件补标题");
|
|
174
|
+
assert.equal(byId.b.firstMessage, "(no messages)", "真空会话保持 no messages");
|
|
175
|
+
const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='a'").get();
|
|
176
|
+
assert.equal(row.first_message, "lazy 首条", "lazy 补读后写回 meta");
|
|
177
|
+
} finally {
|
|
178
|
+
rmSync(root, { recursive: true, force: true });
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("loadSessionDetailsFromMeta:任务列表同样 lazy 补标题并写回", async () => {
|
|
183
|
+
freshDb();
|
|
184
|
+
markIndexReady();
|
|
185
|
+
const root = mkdtempSync(join(tmpdir(), "pi-lazy-task-"));
|
|
186
|
+
const projDir = join(root, "--home-u-p--");
|
|
187
|
+
mkdirSync(projDir, { recursive: true });
|
|
188
|
+
try {
|
|
189
|
+
const cwd = "/home/u/p";
|
|
190
|
+
const file = join(projDir, "2026-01-01T00-00-00-000Z_a.jsonl");
|
|
191
|
+
writeFileSync(file, [
|
|
192
|
+
`{"type":"session","version":3,"id":"a","timestamp":"2026-01-01T00:00:00.000Z","cwd":"${cwd}"}`,
|
|
193
|
+
'{"type":"message","id":"u1","parentId":null,"message":{"role":"user","content":"任务 lazy 首条"}}',
|
|
194
|
+
].join("\n") + "\n");
|
|
195
|
+
seedSession({ id: "a", cwd, projectKey: cwd, path: file, firstMessage: null, taskId: "task-1" });
|
|
196
|
+
|
|
197
|
+
const { loadSessionDetailsFromMeta } = await jiti.import("./session-reader.ts");
|
|
198
|
+
const sessions = await loadSessionDetailsFromMeta(["a"]);
|
|
199
|
+
assert.equal(sessions.length, 1);
|
|
200
|
+
assert.equal(sessions[0].firstMessage, "任务 lazy 首条", "任务列表 lazy 补标题");
|
|
201
|
+
const row = getDb().prepare("SELECT first_message FROM session_meta WHERE session_id='a'").get();
|
|
202
|
+
assert.equal(row.first_message, "任务 lazy 首条", "写回 meta");
|
|
203
|
+
} finally {
|
|
204
|
+
rmSync(root, { recursive: true, force: true });
|
|
205
|
+
}
|
|
206
|
+
});
|
package/lib/session-reader.ts
CHANGED
|
@@ -14,12 +14,81 @@ import { normalizeToolCalls } from "./normalize";
|
|
|
14
14
|
import { projectIdentityKey } from "./project-identity";
|
|
15
15
|
import { sessionPathKey } from "./session-path";
|
|
16
16
|
import { resolveProject, type ProjectInfo } from "./worktree";
|
|
17
|
-
import {
|
|
17
|
+
import { scanSessionFileMeta, scanOneSessionFile, scanOneSessionHead, sessionScanner } from "./session-scanner";
|
|
18
18
|
import { ensureSessionIndexReady } from "./session-index-scanner";
|
|
19
19
|
import type { TurnIndexItem } from "./api-types";
|
|
20
20
|
|
|
21
21
|
export { getAgentDir };
|
|
22
22
|
|
|
23
|
+
type SessionMetaRow = Record<string, unknown>;
|
|
24
|
+
|
|
25
|
+
/** 单个 session_meta 行 → SessionInfo(聊天列表与任务列表共用的唯一映射)。 */
|
|
26
|
+
function mapSessionMetaRow(r: SessionMetaRow): SessionInfo {
|
|
27
|
+
return {
|
|
28
|
+
path: (r.path as string) ?? "",
|
|
29
|
+
id: r.session_id as string,
|
|
30
|
+
cwd: (r.cwd as string) ?? "",
|
|
31
|
+
name: (r.title as string | null) ?? undefined,
|
|
32
|
+
created: new Date((r.created as number) ?? 0).toISOString(),
|
|
33
|
+
modified: new Date((r.modified as number) ?? 0).toISOString(),
|
|
34
|
+
messageCount: 0,
|
|
35
|
+
firstMessage: (r.first_message as string | null) ?? "(no messages)",
|
|
36
|
+
parentSessionId: (r.parent_id as string | null) ?? undefined,
|
|
37
|
+
pinned: Boolean((r.pinned as number) ?? 0),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** 按 id 集合查 session_meta 映射 SessionInfo(返回顺序与入参一致,无行 id 跳过)。
|
|
42
|
+
* 任务列表详情与聊天列表 loadProjectSessions 完全同源——标题/首条消息不再依赖
|
|
43
|
+
* 读文件(旧 readSessionDetails → scanOneSessionFile),统一由 session_meta 供给。 */
|
|
44
|
+
export async function loadSessionDetailsFromMeta(ids: string[]): Promise<SessionInfo[]> {
|
|
45
|
+
if (ids.length === 0) return [];
|
|
46
|
+
let rows: Array<Record<string, unknown>>;
|
|
47
|
+
try {
|
|
48
|
+
rows = getDb()
|
|
49
|
+
.prepare(
|
|
50
|
+
`SELECT session_id, path, cwd, title, first_message, parent_id, created, modified, pinned
|
|
51
|
+
FROM session_meta WHERE session_id IN (${ids.map(() => "?").join(",")})`,
|
|
52
|
+
)
|
|
53
|
+
.all(...ids) as Array<Record<string, unknown>>;
|
|
54
|
+
} catch {
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
const byId = new Map(rows.map((r) => [r.session_id as string, r]));
|
|
58
|
+
const sessions: SessionInfo[] = [];
|
|
59
|
+
for (const id of ids) {
|
|
60
|
+
const row = byId.get(id);
|
|
61
|
+
if (row) sessions.push(mapSessionMetaRow(row));
|
|
62
|
+
}
|
|
63
|
+
return fillFirstMessageFromFile(sessions);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* lazy 回填:列表读取时对 first_message 为空的会话读文件头补真实首条消息
|
|
68
|
+
* (文件是内容事实源),并批量写回 session_meta(后台收敛,下次免读文件)。
|
|
69
|
+
* 真·空会话(文件无消息)保持 NULL → 显示 no messages。
|
|
70
|
+
* 列表(聊天/任务/全量)共用同一逻辑,保证各处标题一致。
|
|
71
|
+
*/
|
|
72
|
+
function fillFirstMessageFromFile(sessions: SessionInfo[]): SessionInfo[] {
|
|
73
|
+
const pending = sessions.filter((s) => s.firstMessage === "(no messages)" && s.path);
|
|
74
|
+
if (pending.length === 0) return sessions;
|
|
75
|
+
const updateStmt = getDb().prepare("UPDATE session_meta SET first_message = ? WHERE session_id = ?");
|
|
76
|
+
return sessions.map((s) => {
|
|
77
|
+
if (s.firstMessage !== "(no messages)" || !s.path) return s;
|
|
78
|
+
let firstMessage = s.firstMessage;
|
|
79
|
+
try {
|
|
80
|
+
const head = scanOneSessionHead(s.path);
|
|
81
|
+
if (head?.firstMessage) {
|
|
82
|
+
firstMessage = head.firstMessage;
|
|
83
|
+
updateStmt.run(firstMessage, s.id);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// 文件不可读(丢失/权限)→ 保持 no messages
|
|
87
|
+
}
|
|
88
|
+
return firstMessage === s.firstMessage ? s : { ...s, firstMessage };
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
23
92
|
/**
|
|
24
93
|
* 当前项目聊天区会话(列表重构 v2 主读取路径)。
|
|
25
94
|
*
|
|
@@ -45,18 +114,7 @@ export async function loadProjectSessions(projectKey: string): Promise<SessionIn
|
|
|
45
114
|
return [];
|
|
46
115
|
}
|
|
47
116
|
|
|
48
|
-
const sessions
|
|
49
|
-
path: (r.path as string) ?? "",
|
|
50
|
-
id: r.session_id as string,
|
|
51
|
-
cwd: (r.cwd as string) ?? "",
|
|
52
|
-
name: (r.title as string | null) ?? undefined,
|
|
53
|
-
created: new Date((r.created as number) ?? 0).toISOString(),
|
|
54
|
-
modified: new Date((r.modified as number) ?? 0).toISOString(),
|
|
55
|
-
messageCount: 0,
|
|
56
|
-
firstMessage: (r.first_message as string | null) ?? "(no messages)",
|
|
57
|
-
parentSessionId: (r.parent_id as string | null) ?? undefined,
|
|
58
|
-
pinned: Boolean((r.pinned as number) ?? 0),
|
|
59
|
-
}));
|
|
117
|
+
const sessions = fillFirstMessageFromFile(rows.map(mapSessionMetaRow));
|
|
60
118
|
return attachSessionProjectInfo(sessions);
|
|
61
119
|
}
|
|
62
120
|
|
|
@@ -78,18 +136,7 @@ export async function loadAllSessionIndex(): Promise<SessionInfo[]> {
|
|
|
78
136
|
return [];
|
|
79
137
|
}
|
|
80
138
|
|
|
81
|
-
const sessions
|
|
82
|
-
path: (r.path as string) ?? "",
|
|
83
|
-
id: r.session_id as string,
|
|
84
|
-
cwd: (r.cwd as string) ?? "",
|
|
85
|
-
name: (r.title as string | null) ?? undefined,
|
|
86
|
-
created: new Date((r.created as number) ?? 0).toISOString(),
|
|
87
|
-
modified: new Date((r.modified as number) ?? 0).toISOString(),
|
|
88
|
-
messageCount: 0,
|
|
89
|
-
firstMessage: (r.first_message as string | null) ?? "(no messages)",
|
|
90
|
-
parentSessionId: (r.parent_id as string | null) ?? undefined,
|
|
91
|
-
pinned: Boolean((r.pinned as number) ?? 0),
|
|
92
|
-
}));
|
|
139
|
+
const sessions = fillFirstMessageFromFile(rows.map(mapSessionMetaRow));
|
|
93
140
|
return attachSessionProjectInfo(sessions);
|
|
94
141
|
}
|
|
95
142
|
|
|
@@ -166,33 +213,6 @@ async function loadAllSessions(): Promise<SessionInfo[]> {
|
|
|
166
213
|
return attachSessionProjectInfo(sessions);
|
|
167
214
|
}
|
|
168
215
|
|
|
169
|
-
/** 阶段二:批量读详情(只读需要的子集)。返回顺序与入参一致。 */
|
|
170
|
-
async function readSessionDetails(metas: Array<{ path: string; id: string; modified: Date }>): Promise<SessionInfo[]> {
|
|
171
|
-
const pathToId = new Map<string, string>();
|
|
172
|
-
for (const m of metas) pathToId.set(sessionPathKey(m.path), m.id);
|
|
173
|
-
const sessions: SessionInfo[] = [];
|
|
174
|
-
for (const meta of metas) {
|
|
175
|
-
const scanned = scanOneSessionFile(meta.path);
|
|
176
|
-
if (!scanned) continue;
|
|
177
|
-
cacheSessionPath(scanned.id, scanned.path);
|
|
178
|
-
sessions.push({
|
|
179
|
-
path: scanned.path,
|
|
180
|
-
id: scanned.id,
|
|
181
|
-
cwd: scanned.cwd,
|
|
182
|
-
name: scanned.name,
|
|
183
|
-
created: scanned.created.toISOString(),
|
|
184
|
-
modified: scanned.modified.toISOString(),
|
|
185
|
-
messageCount: 0,
|
|
186
|
-
firstMessage: scanned.firstMessage || "(no messages)",
|
|
187
|
-
lastReply: scanned.lastReply || "",
|
|
188
|
-
parentSessionId: scanned.parentSessionPath ? pathToId.get(sessionPathKey(scanned.parentSessionPath)) : undefined,
|
|
189
|
-
transient: false,
|
|
190
|
-
});
|
|
191
|
-
}
|
|
192
|
-
return sessions;
|
|
193
|
-
}
|
|
194
|
-
|
|
195
|
-
|
|
196
216
|
/** 任务会话详情按需分页(侧栏任务区)。
|
|
197
217
|
*
|
|
198
218
|
* 服务端分流:任务下的会话详情由 /api/tasks 直接下发,前端不再用
|
|
@@ -283,8 +303,9 @@ export async function loadTaskSessionsPageWithIndex(
|
|
|
283
303
|
for (const rid of pageRootIds) {
|
|
284
304
|
for (const id of collectSubtree(rid)) wantedIds.add(id);
|
|
285
305
|
}
|
|
286
|
-
|
|
287
|
-
|
|
306
|
+
// 详情走 session_meta(与聊天列表 loadProjectSessions 同源)——标题/首条消息
|
|
307
|
+
// 由索引供给,不再读文件;不存在/未入索引的 id 自然跳过。
|
|
308
|
+
const sessions = await attachSessionProjectInfo(await loadSessionDetailsFromMeta([...wantedIds]));
|
|
288
309
|
|
|
289
310
|
// sessionTotal:任务下全部根 + 子树节点数(删除确认文案)。
|
|
290
311
|
const allIds = new Set<string>();
|
package/lib/task-store.ts
CHANGED
|
@@ -33,7 +33,7 @@ const now = () => Date.now();
|
|
|
33
33
|
*/
|
|
34
34
|
function invalidateSessionListCache(): void {
|
|
35
35
|
try {
|
|
36
|
-
// eslint-disable-next-line @typescript-eslint/no-
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
37
|
require("./session-reader").invalidateSessionListCache();
|
|
38
38
|
} catch {
|
|
39
39
|
// 循环加载/不可用时忽略——下一次 force 刷新会重建缓存
|
package/lib/worktree.test.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import assert from "node:assert/strict";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
-
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { promisify } from "node:util";
|
|
@@ -48,3 +48,19 @@ test("main and linked worktrees share one canonical project root", async (t) =>
|
|
|
48
48
|
assert.ok(listedLinked);
|
|
49
49
|
assert.equal(findCurrentWorktreePath(worktrees, `${linked}${path.sep}`), listedLinked.path);
|
|
50
50
|
});
|
|
51
|
+
|
|
52
|
+
test("resolveCwdOrProjectRoot:已删除 worktree 目录回退主项目根;存在目录原样", async (t) => {
|
|
53
|
+
const tempRoot = await mkdtemp(path.join(os.tmpdir(), "pi-web-wt-rm-"));
|
|
54
|
+
t.after(() => rm(tempRoot, { recursive: true, force: true }));
|
|
55
|
+
|
|
56
|
+
const repo = path.join(tempRoot, "repo");
|
|
57
|
+
await execFileAsync("git", ["init", repo]);
|
|
58
|
+
// addWorktree 约定:worktree 落在 <repoRoot>-worktrees/<branch> 下
|
|
59
|
+
const removed = path.join(`${repo}-worktrees`, "feature-test");
|
|
60
|
+
await mkdir(removed, { recursive: true });
|
|
61
|
+
await rm(removed, { recursive: true, force: true }); // 模拟 worktree 目录已删除
|
|
62
|
+
|
|
63
|
+
const { resolveCwdOrProjectRoot } = await loadSubject();
|
|
64
|
+
assert.equal(await resolveCwdOrProjectRoot(removed), await realpath(repo), "已删 worktree 回退主根");
|
|
65
|
+
assert.equal(await resolveCwdOrProjectRoot(repo), repo, "存在目录原样");
|
|
66
|
+
});
|
package/lib/worktree.ts
CHANGED
|
@@ -49,6 +49,15 @@ export function invalidateProjectCache(): void {
|
|
|
49
49
|
globalThis.__piProjectCache?.clear();
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/** cwd 指向已删除的 worktree(目录不存在)时回退到主项目根;目录存在则原样返回。
|
|
53
|
+
* 会话 cwd 落盘即锁定,worktree 目录被删后,模型/信任/git/分支操作应继续按主
|
|
54
|
+
* checkout 可用(让用户能切回主分支),而不是 4xx 把页面卡死。 */
|
|
55
|
+
export async function resolveCwdOrProjectRoot(cwd: string): Promise<string> {
|
|
56
|
+
if (existsSync(cwd)) return cwd;
|
|
57
|
+
const project = await resolveProject(cwd);
|
|
58
|
+
return project.projectRoot || cwd;
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
async function git(cwd: string, args: string[]): Promise<string> {
|
|
53
62
|
const { stdout } = await execFileAsync("git", ["-C", cwd, ...args], {
|
|
54
63
|
timeout: 10_000,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@baique/pi-web-sky",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.45",
|
|
4
4
|
"description": "Web UI for the pi coding agent — Sky skin edition (fork of @agegr/pi-web / earendil-labs pi-web, MIT)",
|
|
5
5
|
"homepage": "https://github.com/baique/pi-web-sky#readme",
|
|
6
6
|
"repository": {
|