@hienlh/ppm 0.12.11 → 0.13.0

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 (64) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +11 -0
  3. package/assets/skills/ppm/SKILL.md +74 -0
  4. package/assets/skills/ppm/references/cli-reference.md +728 -0
  5. package/assets/skills/ppm/references/common-tasks.md +139 -0
  6. package/assets/skills/ppm/references/http-api.md +204 -0
  7. package/bun.lock +2062 -0
  8. package/bunfig.toml +2 -0
  9. package/dist/web/assets/{audio-preview-DnQmf9fu.js → audio-preview-J5neETTY.js} +1 -1
  10. package/dist/web/assets/chat-tab-sVHRa1Fz.js +12 -0
  11. package/dist/web/assets/{code-editor-B-lU1fz3.js → code-editor-tMfcFaQ5.js} +2 -2
  12. package/dist/web/assets/{conflict-editor-BYzf3LuW.js → conflict-editor-FydCxWTC.js} +1 -1
  13. package/dist/web/assets/{database-viewer-DjvnIn8p.js → database-viewer-Celi1puH.js} +1 -1
  14. package/dist/web/assets/diff-viewer-NgDJLTk9.js +4 -0
  15. package/dist/web/assets/{extension-webview-4xMREn_x.js → extension-webview-xWAdCj3q.js} +1 -1
  16. package/dist/web/assets/{image-preview-CkS2PVdQ.js → image-preview-C6bFkdZD.js} +1 -1
  17. package/dist/web/assets/index-BMhiElt6.css +2 -0
  18. package/dist/web/assets/{index-FGlF8IWZ.js → index-DtbAoxyy.js} +2 -2
  19. package/dist/web/assets/{markdown-renderer-Bj2B05Km.js → markdown-renderer-BAnnk1pI.js} +1 -1
  20. package/dist/web/assets/{pdf-preview-CCyw5cuH.js → pdf-preview-BNuFTSOL.js} +1 -1
  21. package/dist/web/assets/{port-forwarding-tab-Cebb5Eix.js → port-forwarding-tab-BbDlGxAs.js} +1 -1
  22. package/dist/web/assets/{postgres-viewer-BrOiliEv.js → postgres-viewer-Cman1YRO.js} +1 -1
  23. package/dist/web/assets/{settings-tab-D0XjupJm.js → settings-tab-n5X_Dbu4.js} +1 -1
  24. package/dist/web/assets/{sqlite-viewer-OEVq_-Po.js → sqlite-viewer-D6JT11uu.js} +1 -1
  25. package/dist/web/assets/{terminal-tab-MjmJaQyA.js → terminal-tab-B4kMthYo.js} +1 -1
  26. package/dist/web/assets/{video-preview-B819qvlp.js → video-preview-BftQOOzF.js} +1 -1
  27. package/dist/web/index.html +2 -2
  28. package/dist/web/sw.js +1 -1
  29. package/docs/project-changelog.md +15 -1
  30. package/package.json +3 -3
  31. package/scripts/generate-ppm-skill.ts +23 -0
  32. package/scripts/lib/generate-cli-reference.ts +81 -0
  33. package/scripts/lib/generate-common-tasks.ts +14 -0
  34. package/scripts/lib/generate-http-api.ts +145 -0
  35. package/scripts/lib/generate-skill-md.ts +28 -0
  36. package/scripts/lib/write-output.ts +17 -0
  37. package/src/cli/commands/export-cmd.ts +85 -0
  38. package/src/index.ts +167 -153
  39. package/src/providers/claude-agent-sdk.ts +1 -135
  40. package/src/server/index.ts +2 -1
  41. package/src/server/routes/chat.ts +18 -0
  42. package/src/server/routes/git.ts +16 -0
  43. package/src/services/git.service.ts +34 -0
  44. package/src/services/jsonl-transcript-parser.ts +216 -0
  45. package/src/services/skill-export/backup-existing.ts +33 -0
  46. package/src/services/skill-export/copy-bundled-skill.ts +36 -0
  47. package/src/services/skill-export/generate-db-schema.ts +66 -0
  48. package/src/services/skill-export/index.ts +6 -0
  49. package/src/services/skill-export/resolve-assets-dir.ts +31 -0
  50. package/src/services/skill-export/resolve-target-dir.ts +17 -0
  51. package/src/services/supervisor.ts +2 -1
  52. package/src/web/components/chat/chat-tab.tsx +6 -1
  53. package/src/web/components/chat/message-list.tsx +101 -9
  54. package/src/web/components/chat/pre-compact-button.tsx +50 -0
  55. package/src/web/components/editor/diff-viewer.tsx +21 -5
  56. package/src/web/hooks/use-chat.ts +37 -1
  57. package/src/web/lib/flatten-expansions.ts +36 -0
  58. package/templates/skill/SKILL.md.tmpl +74 -0
  59. package/templates/skill/common-tasks.md +139 -0
  60. package/assets/skills/ppm-guide/SKILL.md +0 -61
  61. package/dist/web/assets/chat-tab-Cf6T3mGO.js +0 -12
  62. package/dist/web/assets/diff-viewer-CP2jcR5J.js +0 -4
  63. package/dist/web/assets/index-BTjuH4fn.css +0 -2
  64. package/scripts/generate-ppm-guide.ts +0 -92
@@ -0,0 +1,216 @@
1
+ /**
2
+ * Parses a Claude Code JSONL transcript file into ChatMessage[].
3
+ * Reusable across live SDK session history (claude-agent-sdk.ts) and
4
+ * pre-compact transcript loading (chat route /pre-compact-messages).
5
+ */
6
+ import { existsSync, realpathSync, statSync } from "node:fs";
7
+ import { resolve } from "node:path";
8
+ import { homedir } from "node:os";
9
+ import type { ChatEvent, ChatMessage } from "../types/chat.ts";
10
+
11
+ const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
12
+ const TEAMMATE_MSG_RE = /<teammate-message[^>]*>[\s\S]*?<\/teammate-message>/g;
13
+
14
+ /** Strip SDK teammate-message XML tags from assistant text */
15
+ export function stripTeammateXml(text: string): string {
16
+ if (!text.includes("<teammate-message")) return text;
17
+ return text.replace(TEAMMATE_MSG_RE, "").replace(/\n{3,}/g, "\n\n").trim();
18
+ }
19
+
20
+ /** Extract plain text from message payload */
21
+ export function extractText(message: unknown): string {
22
+ if (!message || typeof message !== "object") return "";
23
+ const msg = message as Record<string, unknown>;
24
+ if (typeof msg.content === "string") return msg.content;
25
+ if (Array.isArray(msg.content)) {
26
+ return (msg.content as Array<Record<string, unknown>>)
27
+ .filter((b) => b.type === "text" && typeof b.text === "string")
28
+ .map((b) => b.text as string)
29
+ .join("");
30
+ }
31
+ return "";
32
+ }
33
+
34
+ /** Parse SDK SessionMessage into ChatMessage with events for tool_use blocks */
35
+ export function parseSessionMessage(
36
+ msg: { uuid: string; type: string; message: unknown; parent_tool_use_id?: string | null },
37
+ ): ChatMessage {
38
+ const message = msg.message as Record<string, unknown> | undefined;
39
+ const role = msg.type as "user" | "assistant";
40
+ const parentId = (msg as any).parent_tool_use_id as string | undefined;
41
+
42
+ // Filter synthetic SDK-generated error messages (auth failures, rate limits, etc.)
43
+ const isSdkErrorMessage =
44
+ (msg as any).isApiErrorMessage === true ||
45
+ typeof (msg as any).error === "string" ||
46
+ (message && (message as any).model === "<synthetic>" &&
47
+ Array.isArray(message.content) &&
48
+ (message.content as Array<Record<string, unknown>>).some(
49
+ (b) => b.type === "text" && typeof b.text === "string" &&
50
+ /Failed to authenticate|API Error: 40[13]|hit your limit|rate.?limit/i.test(b.text as string),
51
+ ));
52
+ if (isSdkErrorMessage) {
53
+ return {
54
+ id: msg.uuid,
55
+ role,
56
+ content: "",
57
+ timestamp: new Date().toISOString(),
58
+ sdkUuid: msg.uuid,
59
+ };
60
+ }
61
+
62
+ const events: ChatEvent[] = [];
63
+ let textContent = "";
64
+
65
+ if (message && Array.isArray(message.content)) {
66
+ for (const block of message.content as Array<Record<string, unknown>>) {
67
+ if (block.type === "text" && typeof block.text === "string") {
68
+ const cleaned = role === "assistant" ? stripTeammateXml(block.text) : block.text;
69
+ textContent += cleaned;
70
+ if (role === "assistant" && cleaned) {
71
+ events.push({ type: "text", content: cleaned, ...(parentId && { parentToolUseId: parentId }) });
72
+ }
73
+ } else if (block.type === "tool_use") {
74
+ events.push({
75
+ type: "tool_use",
76
+ tool: (block.name as string) ?? "unknown",
77
+ input: block.input ?? {},
78
+ toolUseId: block.id as string | undefined,
79
+ ...(parentId && { parentToolUseId: parentId }),
80
+ });
81
+ } else if (block.type === "tool_result") {
82
+ const output = block.content ?? block.output ?? "";
83
+ events.push({
84
+ type: "tool_result",
85
+ output: typeof output === "string" ? output : JSON.stringify(output),
86
+ isError: !!(block as Record<string, unknown>).is_error,
87
+ toolUseId: block.tool_use_id as string | undefined,
88
+ ...(parentId && { parentToolUseId: parentId }),
89
+ });
90
+ }
91
+ }
92
+ } else {
93
+ textContent = extractText(message);
94
+ }
95
+
96
+ // SDK-generated user messages carry system text (tool_result blocks, teammate XML) —
97
+ // clear so they don't render as user bubbles.
98
+ if (role === "user" && (events.some((e) => e.type === "tool_result") || textContent.includes("<teammate-message"))) {
99
+ textContent = "";
100
+ }
101
+
102
+ return {
103
+ id: msg.uuid,
104
+ role,
105
+ content: textContent,
106
+ events: events.length > 0 ? events : undefined,
107
+ timestamp: new Date().toISOString(),
108
+ sdkUuid: msg.uuid,
109
+ };
110
+ }
111
+
112
+ /**
113
+ * Move events with parentToolUseId into their parent Agent/Task tool_use's children array.
114
+ * Mutates the array in-place.
115
+ */
116
+ export function nestChildEvents(events: ChatEvent[]): void {
117
+ const parentMap = new Map<string, ChatEvent & { type: "tool_use" }>();
118
+ for (const ev of events) {
119
+ if (ev.type === "tool_use" && (ev.tool === "Agent" || ev.tool === "Task") && ev.toolUseId) {
120
+ parentMap.set(ev.toolUseId, ev);
121
+ }
122
+ }
123
+ if (parentMap.size === 0) return;
124
+
125
+ const childIndices: number[] = [];
126
+ for (let i = 0; i < events.length; i++) {
127
+ const ev = events[i]!;
128
+ const pid = (ev as any).parentToolUseId as string | undefined;
129
+ if (!pid) continue;
130
+ const parent = parentMap.get(pid);
131
+ if (parent) {
132
+ if (!parent.children) parent.children = [];
133
+ parent.children.push(ev);
134
+ childIndices.push(i);
135
+ }
136
+ }
137
+ for (let i = childIndices.length - 1; i >= 0; i--) {
138
+ events.splice(childIndices[i]!, 1);
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Validate JSONL path — must be under ~/.claude/ (prevents arbitrary file reads).
144
+ * Throws Error with descriptive message. Returns resolved realpath on success.
145
+ */
146
+ export function validateJsonlPath(inputPath: string): string {
147
+ if (!inputPath) throw new Error("jsonlPath is required");
148
+ // Reject obvious traversal attempts before resolution
149
+ if (inputPath.includes("\0")) throw new Error("Invalid path: denied");
150
+ if (!inputPath.endsWith(".jsonl")) throw new Error("Invalid path: must be a .jsonl file");
151
+
152
+ const resolved = resolve(inputPath);
153
+ if (!existsSync(resolved)) throw new Error("File not found");
154
+
155
+ let real: string;
156
+ try {
157
+ real = realpathSync(resolved);
158
+ } catch {
159
+ throw new Error("File not found");
160
+ }
161
+
162
+ const claudeDir = resolve(homedir(), ".claude") + "/";
163
+ if (!(real + "/").startsWith(claudeDir)) {
164
+ throw new Error("Access denied: path traversal detected");
165
+ }
166
+
167
+ const stat = statSync(real);
168
+ if (!stat.isFile()) throw new Error("Not a regular file");
169
+ if (stat.size > MAX_FILE_SIZE) {
170
+ throw new Error(`File too large: ${Math.round(stat.size / 1024 / 1024)}MB exceeds 50MB limit`);
171
+ }
172
+ return real;
173
+ }
174
+
175
+ /**
176
+ * Read a JSONL transcript file, parse entries, apply merge/nest pipeline, return ChatMessage[].
177
+ * Applies the same logic as ClaudeAgentSdkProvider.getMessages() but reads from file directly.
178
+ */
179
+ export async function parseJsonlTranscript(filePath: string): Promise<ChatMessage[]> {
180
+ const text = await Bun.file(filePath).text();
181
+ const parsed: ChatMessage[] = [];
182
+ for (const line of text.split("\n")) {
183
+ const trimmed = line.trim();
184
+ if (!trimmed) continue;
185
+ let entry: any;
186
+ try {
187
+ entry = JSON.parse(trimmed);
188
+ } catch {
189
+ continue; // skip malformed lines defensively
190
+ }
191
+ if (entry.type !== "user" && entry.type !== "assistant") continue;
192
+ if (!entry.uuid || !entry.message) continue;
193
+ parsed.push(parseSessionMessage(entry));
194
+ }
195
+
196
+ // Merge tool_result-only user messages into preceding assistant
197
+ const merged: ChatMessage[] = [];
198
+ for (const msg of parsed) {
199
+ if (msg.events?.length && msg.events.every((e) => e.type === "tool_result")) {
200
+ const lastAssistant = [...merged].reverse().find((m) => m.role === "assistant");
201
+ if (lastAssistant?.events) {
202
+ lastAssistant.events.push(...msg.events);
203
+ continue;
204
+ }
205
+ }
206
+ merged.push(msg);
207
+ }
208
+
209
+ for (const msg of merged) {
210
+ if (msg.events) nestChildEvents(msg.events);
211
+ }
212
+
213
+ return merged.filter(
214
+ (msg) => msg.content.trim().length > 0 || (msg.events && msg.events.length > 0),
215
+ );
216
+ }
@@ -0,0 +1,33 @@
1
+ // Rename existing skill files to `.bak-<timestamp>` before overwriting.
2
+ // Preserves earlier backups by embedding a UTC timestamp in the suffix.
3
+ import { existsSync, renameSync, readdirSync, statSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+
6
+ /** Produce a compact UTC timestamp like `202604211733` (YYYYMMDDHHmm). */
7
+ export function makeTimestamp(d: Date = new Date()): string {
8
+ return d.toISOString().replace(/[-:T]/g, "").slice(0, 12);
9
+ }
10
+
11
+ export function backupExisting(targetDir: string, ts: string = makeTimestamp()): string[] {
12
+ if (!existsSync(targetDir)) return [];
13
+ const backedUp: string[] = [];
14
+ walkAndBackup(targetDir, ts, backedUp);
15
+ return backedUp;
16
+ }
17
+
18
+ function walkAndBackup(dir: string, ts: string, collected: string[]): void {
19
+ const entries = readdirSync(dir);
20
+ for (const name of entries) {
21
+ // Skip already-backed-up files to avoid `.md.bak-X.bak-Y` chains.
22
+ if (name.includes(".bak-")) continue;
23
+ const abs = resolve(dir, name);
24
+ const st = statSync(abs);
25
+ if (st.isDirectory()) {
26
+ walkAndBackup(abs, ts, collected);
27
+ } else if (st.isFile() && name.endsWith(".md")) {
28
+ const dest = `${abs}.bak-${ts}`;
29
+ renameSync(abs, dest);
30
+ collected.push(dest);
31
+ }
32
+ }
33
+ }
@@ -0,0 +1,36 @@
1
+ // Recursive copy of the bundled skill package to a target directory.
2
+ // Skips any stale `.bak-*` files in source (defensive, should not occur in bundle).
3
+ import { cpSync, mkdirSync, existsSync, readdirSync, statSync, copyFileSync } from "node:fs";
4
+ import { resolve, relative } from "node:path";
5
+
6
+ export function copyBundledSkill(sourceDir: string, targetDir: string): string[] {
7
+ if (!existsSync(sourceDir)) {
8
+ throw new Error(`Source skill dir not found: ${sourceDir}`);
9
+ }
10
+ mkdirSync(targetDir, { recursive: true });
11
+ const copied: string[] = [];
12
+ walk(sourceDir, sourceDir, targetDir, copied);
13
+ return copied;
14
+ }
15
+
16
+ function walk(rootSrc: string, dir: string, targetRoot: string, collected: string[]): void {
17
+ for (const name of readdirSync(dir)) {
18
+ if (name.includes(".bak-")) continue;
19
+ const abs = resolve(dir, name);
20
+ const rel = relative(rootSrc, abs);
21
+ const dest = resolve(targetRoot, rel);
22
+ const st = statSync(abs);
23
+ if (st.isDirectory()) {
24
+ mkdirSync(dest, { recursive: true });
25
+ walk(rootSrc, abs, targetRoot, collected);
26
+ } else if (st.isFile()) {
27
+ copyFileSync(abs, dest);
28
+ collected.push(dest);
29
+ }
30
+ }
31
+ }
32
+
33
+ // Fallback single-shot copy using Node 16.7+ cpSync (kept in case walk is bypassed).
34
+ export function copyTree(src: string, dest: string): void {
35
+ cpSync(src, dest, { recursive: true });
36
+ }
@@ -0,0 +1,66 @@
1
+ // Runtime: read the user's PPM SQLite config DB (readonly) and render a markdown schema doc.
2
+ // Never opens the DB read-write. Gracefully handles missing DB.
3
+ import { existsSync } from "node:fs";
4
+ import { resolve } from "node:path";
5
+ import { Database } from "bun:sqlite";
6
+ import { getPpmDir } from "../ppm-dir.ts";
7
+
8
+ interface ColumnInfo {
9
+ cid: number;
10
+ name: string;
11
+ type: string;
12
+ notnull: number;
13
+ dflt_value: string | null;
14
+ pk: number;
15
+ }
16
+
17
+ interface TableRow {
18
+ name: string;
19
+ }
20
+
21
+ export function generateDbSchemaMarkdown(dbPath?: string): string {
22
+ const path = dbPath ?? resolve(getPpmDir(), "ppm.db");
23
+ const header = "# PPM Database Schema\n\n_Auto-generated at install time from your local config DB._\n";
24
+
25
+ if (!existsSync(path)) {
26
+ return `${header}\n_Database not found at \`${path}\`. Run \`ppm init\` to create it._\n`;
27
+ }
28
+
29
+ let db: Database | null = null;
30
+ try {
31
+ db = new Database(path, { readonly: true });
32
+ const tables = db
33
+ .query<TableRow, []>("SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' ORDER BY name")
34
+ .all();
35
+
36
+ if (tables.length === 0) {
37
+ return `${header}\n_No tables found in \`${path}\`._\n`;
38
+ }
39
+
40
+ const parts: string[] = [header, ""];
41
+ parts.push(`Source: \`${path}\``);
42
+ parts.push("");
43
+
44
+ for (const t of tables) {
45
+ parts.push(`## ${t.name}`);
46
+ parts.push("");
47
+ const cols = db.query<ColumnInfo, []>(`PRAGMA table_info("${t.name}")`).all();
48
+ parts.push("| Column | Type | Nullable | PK | Default |");
49
+ parts.push("|---|---|---|---|---|");
50
+ for (const c of cols) {
51
+ const nullable = c.notnull === 0 ? "yes" : "no";
52
+ const pk = c.pk > 0 ? "yes" : "";
53
+ const def = c.dflt_value !== null ? `\`${c.dflt_value}\`` : "";
54
+ parts.push(`| \`${c.name}\` | \`${c.type || "—"}\` | ${nullable} | ${pk} | ${def} |`);
55
+ }
56
+ parts.push("");
57
+ }
58
+
59
+ return parts.join("\n") + "\n";
60
+ } catch (e) {
61
+ const msg = e instanceof Error ? e.message : String(e);
62
+ return `${header}\n_Failed to read database at \`${path}\`: ${msg}_\n`;
63
+ } finally {
64
+ db?.close();
65
+ }
66
+ }
@@ -0,0 +1,6 @@
1
+ // Barrel re-exports for the skill-export service.
2
+ export { resolveTargetDir, type SkillScope, type ResolveTargetOpts } from "./resolve-target-dir.ts";
3
+ export { resolveAssetsDir } from "./resolve-assets-dir.ts";
4
+ export { backupExisting, makeTimestamp } from "./backup-existing.ts";
5
+ export { copyBundledSkill } from "./copy-bundled-skill.ts";
6
+ export { generateDbSchemaMarkdown } from "./generate-db-schema.ts";
@@ -0,0 +1,31 @@
1
+ // Resolve the bundled skill assets dir. Works both in dev (`bun src/index.ts`)
2
+ // and installed npm package. Throws a clear error if assets are missing.
3
+ import { fileURLToPath } from "node:url";
4
+ import { resolve, dirname } from "node:path";
5
+ import { existsSync } from "node:fs";
6
+
7
+ const ASSETS_REL = "assets/skills/ppm";
8
+
9
+ /**
10
+ * Walks up from this file to find the repo/package root that contains
11
+ * `assets/skills/ppm/SKILL.md`. Covers both:
12
+ * - dev: src/services/skill-export/*.ts → repo root is ../../..
13
+ * - bundled: compiled binary walks up similarly when `bun build --compile` preserves structure
14
+ */
15
+ export function resolveAssetsDir(): string {
16
+ const here = dirname(fileURLToPath(import.meta.url));
17
+ const candidates = [
18
+ resolve(here, "../../..", ASSETS_REL),
19
+ resolve(here, "../..", ASSETS_REL),
20
+ resolve(here, "..", ASSETS_REL),
21
+ resolve(process.cwd(), ASSETS_REL),
22
+ ];
23
+ for (const c of candidates) {
24
+ if (existsSync(resolve(c, "SKILL.md"))) return c;
25
+ }
26
+ throw new Error(
27
+ `Bundled PPM skill assets not found. Searched:\n ${candidates.join(
28
+ "\n ",
29
+ )}\nRun \`bun run generate:skill\` (dev) or reinstall PPM.`,
30
+ );
31
+ }
@@ -0,0 +1,17 @@
1
+ // Resolve the install target directory based on scope/output flags.
2
+ // Precedence: --output > --scope project > --scope user (default).
3
+ import { resolve } from "node:path";
4
+ import { homedir } from "node:os";
5
+
6
+ export type SkillScope = "user" | "project";
7
+
8
+ export interface ResolveTargetOpts {
9
+ scope?: SkillScope;
10
+ output?: string;
11
+ }
12
+
13
+ export function resolveTargetDir(opts: ResolveTargetOpts): string {
14
+ if (opts.output) return resolve(opts.output);
15
+ if (opts.scope === "project") return resolve(process.cwd(), ".claude/skills/ppm");
16
+ return resolve(homedir(), ".claude/skills/ppm");
17
+ }
@@ -949,7 +949,8 @@ if (process.argv.includes("__supervise__")) {
949
949
  const idx = process.argv.indexOf("__supervise__");
950
950
  const port = parseInt(process.argv[idx + 1] ?? "8080", 10);
951
951
  const host = process.argv[idx + 2] ?? "0.0.0.0";
952
- const profile = process.argv[idx + 3] && process.argv[idx + 3] !== "_" ? process.argv[idx + 3] : undefined;
952
+ const profileRaw = process.argv[idx + 3];
953
+ const profile = profileRaw && profileRaw !== "_" && !profileRaw.startsWith("--") ? profileRaw : undefined;
953
954
  const share = process.argv.includes("--share");
954
955
 
955
956
  // Set DB profile for supervisor (needed to read config)
@@ -88,6 +88,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
88
88
 
89
89
  const {
90
90
  messages,
91
+ renderedMessages,
92
+ expandCompact,
93
+ isCompactExpanded,
91
94
  messagesLoading,
92
95
  isStreaming,
93
96
  phase,
@@ -384,7 +387,9 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
384
387
 
385
388
  {/* Messages */}
386
389
  <MessageList
387
- messages={messages}
390
+ messages={renderedMessages}
391
+ onExpandCompact={expandCompact}
392
+ isCompactExpanded={isCompactExpanded}
388
393
  messagesLoading={messagesLoading}
389
394
  pendingApproval={pendingApproval}
390
395
  onApprovalResponse={respondToApproval}
@@ -5,6 +5,7 @@ import type { ChatMessage, ChatEvent } from "../../../types/chat";
5
5
  import type { SessionPhase } from "../../../types/api";
6
6
  import type { BashPartialEntry } from "../../hooks/use-chat";
7
7
  import { ToolCard } from "./tool-cards";
8
+ import { extractJsonlPath, PreCompactButton, type PreCompactStatus } from "./pre-compact-button";
8
9
  const MarkdownRenderer = lazy(() =>
9
10
  import("@/components/shared/markdown-renderer").then((m) => ({ default: m.MarkdownRenderer }))
10
11
  );
@@ -55,6 +56,10 @@ interface MessageListProps {
55
56
  onSelectSession?: (session: import("../../../types/chat").SessionInfo) => void;
56
57
  /** Partial bash output ref from useChat for real-time streaming */
57
58
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
59
+ /** Fetches pre-compact transcript and prepends messages. Returns loaded count. */
60
+ onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
61
+ /** Whether a given compact message has already been expanded. */
62
+ isCompactExpanded?: (compactMessageId: string) => boolean;
58
63
  }
59
64
 
60
65
  export function MessageList({
@@ -71,6 +76,8 @@ export function MessageList({
71
76
  projectName,
72
77
  onFork,
73
78
  bashPartialOutput,
79
+ onExpandCompact,
80
+ isCompactExpanded,
74
81
  }: MessageListProps) {
75
82
  // Scroll handled by StickToBottom wrapper — no manual scroll logic needed
76
83
 
@@ -102,6 +109,15 @@ export function MessageList({
102
109
  onFork?.(msgContent, msgId);
103
110
  }, [onFork]);
104
111
 
112
+ // Wrap expandCompact: bump visibleCount by loaded count so expansion is immediately visible
113
+ // in the paginated view (pre-compact messages land at top of flattened array, above pagination window).
114
+ const handleExpandCompact = useCallback(async (compactId: string, jsonlPath: string): Promise<number> => {
115
+ if (!onExpandCompact) throw new Error("Expansion not wired");
116
+ const count = await onExpandCompact(compactId, jsonlPath);
117
+ setVisibleCount((c) => c + count);
118
+ return count;
119
+ }, [onExpandCompact]);
120
+
105
121
  if (messagesLoading) {
106
122
  return (
107
123
  <div className="flex flex-col items-center justify-center h-full gap-3 text-text-secondary">
@@ -122,8 +138,8 @@ export function MessageList({
122
138
 
123
139
  return (
124
140
  <div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
125
- <StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict]" resize="smooth" initial="instant">
126
- <StickToBottom.Content className="p-4 space-y-4 select-none">
141
+ <StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden [contain:strict] [overflow-anchor:auto]" resize="smooth" initial="instant">
142
+ <StickToBottom.Content className="p-4 space-y-4 select-none [&>*]:[overflow-anchor:auto]">
127
143
  {hasMore && (
128
144
  <button onClick={() => setVisibleCount((c) => c + PAGE_SIZE)}
129
145
  className="w-full py-2 text-xs text-text-secondary hover:text-text-primary bg-surface-elevated/50 hover:bg-surface-elevated rounded-md border border-border/50 transition-colors">
@@ -142,6 +158,8 @@ export function MessageList({
142
158
  onFork={msg.role === "user" && onFork ? handleFork : undefined}
143
159
  prevMsgId={prevMsg?.sdkUuid ?? prevMsg?.id}
144
160
  bashPartialOutput={bashPartialOutput}
161
+ onExpandCompact={handleExpandCompact}
162
+ isCompactExpanded={isCompactExpanded}
145
163
  />
146
164
  );
147
165
  })}
@@ -176,16 +194,25 @@ function ScrollToBottomButton() {
176
194
  );
177
195
  }
178
196
 
179
- const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput }: {
197
+ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projectName, onFork, prevMsgId, bashPartialOutput, onExpandCompact, isCompactExpanded }: {
180
198
  message: ChatMessage; isStreaming: boolean; projectName?: string;
181
199
  onFork?: (content: string, messageId: string | undefined) => void;
182
200
  prevMsgId?: string;
183
201
  bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
202
+ onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
203
+ isCompactExpanded?: (compactMessageId: string) => boolean;
184
204
  }) {
185
205
  if (message.role === "user") {
186
206
  const handleFork = onFork ? () => onFork(message.content, prevMsgId) : undefined;
187
207
  return (
188
- <UserBubble content={message.content} projectName={projectName} onFork={handleFork} />
208
+ <UserBubble
209
+ content={message.content}
210
+ messageId={message.id}
211
+ projectName={projectName}
212
+ onFork={handleFork}
213
+ onExpandCompact={onExpandCompact}
214
+ isCompactExpanded={isCompactExpanded}
215
+ />
189
216
  );
190
217
  }
191
218
 
@@ -202,7 +229,7 @@ const MessageBubble = memo(function MessageBubble({ message, isStreaming, projec
202
229
  return (
203
230
  <div className="flex flex-col gap-2">
204
231
  {message.events && message.events.length > 0
205
- ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} />
232
+ ? <InterleavedEvents events={message.events} isStreaming={isStreaming} projectName={projectName} bashPartialOutput={bashPartialOutput} messageId={message.id} onExpandCompact={onExpandCompact} isCompactExpanded={isCompactExpanded} />
206
233
  : message.content && (
207
234
  <div className="text-sm text-text-primary select-text">
208
235
  <MarkdownContent content={message.content} projectName={projectName} />
@@ -309,14 +336,38 @@ function isPdfPath(path: string): boolean {
309
336
  const SYSTEM_TAG_NAMES = new Set(["task-notification", "environment_details"]);
310
337
 
311
338
  /** User message bubble — full width, collapsible, with system tag badges */
312
- function UserBubble({ content, projectName, onFork }: { content: string; projectName?: string; onFork?: () => void }) {
313
- const { files, text, tags, command } = useMemo(() => {
339
+ function UserBubble({ content, messageId, projectName, onFork, onExpandCompact, isCompactExpanded }: {
340
+ content: string;
341
+ messageId?: string;
342
+ projectName?: string;
343
+ onFork?: () => void;
344
+ onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
345
+ isCompactExpanded?: (compactMessageId: string) => boolean;
346
+ }) {
347
+ const { files, text, tags, command, jsonlPath } = useMemo(() => {
314
348
  const parsed = parseUserAttachments(content);
315
349
  const { cleanText: noSysTags, tags } = extractSystemTags(parsed.text);
316
350
  const { command, cleanText } = parseCommandTags(noSysTags);
317
- return { files: parsed.files, text: cleanText, tags, command };
351
+ return { files: parsed.files, text: cleanText, tags, command, jsonlPath: extractJsonlPath(cleanText) };
318
352
  }, [content]);
319
353
 
354
+ // Pre-compact expansion state — local per button instance
355
+ const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
356
+ messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
357
+ );
358
+ const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
359
+ const handleExpand = useCallback(async () => {
360
+ if (!jsonlPath || !messageId || !onExpandCompact) return;
361
+ setPreCompactStatus("loading");
362
+ try {
363
+ const count = await onExpandCompact(messageId, jsonlPath);
364
+ setPreCompactCount(count);
365
+ setPreCompactStatus("loaded");
366
+ } catch {
367
+ setPreCompactStatus("error");
368
+ }
369
+ }, [jsonlPath, messageId, onExpandCompact]);
370
+
320
371
  const isSystemContext = tags.some((t) => SYSTEM_TAG_NAMES.has(t.name));
321
372
 
322
373
  const [expanded, setExpanded] = useState(false);
@@ -399,6 +450,15 @@ function UserBubble({ content, projectName, onFork }: { content: string; project
399
450
  {expanded ? <><ChevronUp className="size-3" />Show less</> : <><ChevronDown className="size-3" />Show more</>}
400
451
  </button>
401
452
  )}
453
+ {/* Expand compacted conversation: detect JSONL path in compact summary user message.
454
+ Prepends pre-compact messages into main flattened list (see useChat.expandCompact). */}
455
+ {jsonlPath && messageId && onExpandCompact && (
456
+ <PreCompactButton
457
+ status={preCompactStatus}
458
+ onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? handleExpand : undefined}
459
+ count={preCompactCount}
460
+ />
461
+ )}
402
462
  {/* Fork/Rewind button — only for real user messages */}
403
463
  {!isSystemContext && onFork && (
404
464
  <button
@@ -673,7 +733,31 @@ type EventGroup =
673
733
  | { kind: "thinking"; content: string }
674
734
  | { kind: "tool"; tool: ChatEvent; result?: ChatEvent; completed?: boolean };
675
735
 
676
- function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput }: { events: ChatEvent[]; isStreaming: boolean; projectName?: string; bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>> }) {
736
+ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput, messageId, onExpandCompact, isCompactExpanded }: {
737
+ events: ChatEvent[];
738
+ isStreaming: boolean;
739
+ projectName?: string;
740
+ bashPartialOutput?: React.RefObject<Map<string, BashPartialEntry>>;
741
+ messageId?: string;
742
+ onExpandCompact?: (compactMessageId: string, jsonlPath: string) => Promise<number>;
743
+ isCompactExpanded?: (compactMessageId: string) => boolean;
744
+ }) {
745
+ // Local state for the /compact slash-command path (assistant-authored summary)
746
+ const [preCompactStatus, setPreCompactStatus] = useState<PreCompactStatus>(() =>
747
+ messageId && isCompactExpanded?.(messageId) ? "loaded" : "idle",
748
+ );
749
+ const [preCompactCount, setPreCompactCount] = useState<number | undefined>();
750
+ const handleExpand = useCallback(async (jsonlPath: string) => {
751
+ if (!messageId || !onExpandCompact) return;
752
+ setPreCompactStatus("loading");
753
+ try {
754
+ const count = await onExpandCompact(messageId, jsonlPath);
755
+ setPreCompactCount(count);
756
+ setPreCompactStatus("loaded");
757
+ } catch {
758
+ setPreCompactStatus("error");
759
+ }
760
+ }, [messageId, onExpandCompact]);
677
761
  // Group: consecutive text → merged text block; tool_use + tool_result paired by toolUseId
678
762
  const groups: EventGroup[] = [];
679
763
  let textBuffer = "";
@@ -788,9 +872,17 @@ function InterleavedEvents({ events, isStreaming, projectName, bashPartialOutput
788
872
  }
789
873
  if (group.kind === "text") {
790
874
  const isLast = isStreaming && i === groups.length - 1;
875
+ const jsonlPath = extractJsonlPath(group.content);
791
876
  return (
792
877
  <div key={`text-${i}`} className="text-sm text-text-primary select-text">
793
878
  <StreamingText content={group.content} animate={isLast} projectName={projectName} />
879
+ {jsonlPath && messageId && onExpandCompact && (
880
+ <PreCompactButton
881
+ status={preCompactStatus}
882
+ onLoad={preCompactStatus === "idle" || preCompactStatus === "error" ? () => handleExpand(jsonlPath) : undefined}
883
+ count={preCompactCount}
884
+ />
885
+ )}
794
886
  </div>
795
887
  );
796
888
  }