@hienlh/ppm 0.8.92 → 0.8.94

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 (54) hide show
  1. package/.claude.bak/agent-memory/tester/MEMORY.md +3 -0
  2. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +32 -0
  3. package/CHANGELOG.md +3 -34
  4. package/dist/web/assets/api-settings-Dh4oFOpX.js +1 -0
  5. package/dist/web/assets/{browser-tab-CQojbRRg.js → browser-tab-DJLH0eDY.js} +1 -1
  6. package/dist/web/assets/chat-tab-C8HFXqGS.js +8 -0
  7. package/dist/web/assets/{code-editor-Dp3w7ZdH.js → code-editor-CaGdx-lS.js} +1 -1
  8. package/dist/web/assets/{database-viewer-DXEZ9XyO.js → database-viewer-i4Ddk6mO.js} +1 -1
  9. package/dist/web/assets/{diff-viewer-gjTmjJxA.js → diff-viewer-DQDS7yjv.js} +1 -1
  10. package/dist/web/assets/{git-graph-BPP0uvo6.js → git-graph-DUs-TN1u.js} +1 -1
  11. package/dist/web/assets/index-DhtLEnPD.css +2 -0
  12. package/dist/web/assets/{index-BiKAvKp1.js → index-Dm6RN1A1.js} +11 -11
  13. package/dist/web/assets/keybindings-store-qVLDZz97.js +1 -0
  14. package/dist/web/assets/{markdown-renderer-DJTeCvlY.js → markdown-renderer-L1NgC2Rw.js} +1 -1
  15. package/dist/web/assets/{postgres-viewer-D-_FH_ZH.js → postgres-viewer-_uDispGW.js} +1 -1
  16. package/dist/web/assets/{settings-tab-BQxPvO96.js → settings-tab-Bp4041i6.js} +1 -1
  17. package/dist/web/assets/{sqlite-viewer-DTZx5FY3.js → sqlite-viewer-GW-QCjHn.js} +1 -1
  18. package/dist/web/assets/{terminal-tab-BVllaZ_J.js → terminal-tab-E4cWujj4.js} +1 -1
  19. package/dist/web/assets/{use-monaco-theme-FpL5fLOV.js → use-monaco-theme-zABXAAla.js} +1 -1
  20. package/dist/web/index.html +3 -3
  21. package/dist/web/sw.js +1 -1
  22. package/package.json +1 -1
  23. package/src/providers/claude-agent-sdk.ts +9 -61
  24. package/src/providers/cli-provider-base.ts +0 -6
  25. package/src/server/routes/chat.ts +14 -33
  26. package/src/server/routes/settings.ts +0 -27
  27. package/src/server/ws/chat.ts +1 -7
  28. package/src/services/account.service.ts +2 -2
  29. package/src/services/claude-usage.service.ts +7 -2
  30. package/src/services/cloud-ws.service.ts +0 -1
  31. package/src/services/cloud.service.ts +0 -1
  32. package/src/services/db.service.ts +23 -11
  33. package/src/services/mcp-config.service.ts +6 -15
  34. package/src/services/supervisor.ts +2 -22
  35. package/src/types/api.ts +0 -1
  36. package/src/types/chat.ts +0 -2
  37. package/src/web/app.tsx +2 -3
  38. package/src/web/components/chat/chat-history-bar.tsx +7 -21
  39. package/src/web/components/chat/chat-tab.tsx +10 -15
  40. package/src/web/components/chat/message-list.tsx +3 -7
  41. package/src/web/components/chat/session-picker.tsx +0 -1
  42. package/src/web/components/chat/usage-badge.tsx +8 -58
  43. package/src/web/components/layout/upgrade-banner.tsx +5 -15
  44. package/src/web/components/settings/settings-tab.tsx +0 -4
  45. package/src/web/hooks/use-chat.ts +0 -17
  46. package/dist/web/assets/api-settings-Bid0NHuI.js +0 -1
  47. package/dist/web/assets/chat-tab-DLpVS21v.js +0 -8
  48. package/dist/web/assets/index-CqhIj4Ko.css +0 -2
  49. package/dist/web/assets/keybindings-store-CRWbpzzj.js +0 -1
  50. package/docs/streaming-input-guide.md +0 -267
  51. package/snapshot-state.md +0 -1526
  52. package/src/web/components/settings/change-password-section.tsx +0 -128
  53. package/test-session-ops.mjs +0 -444
  54. package/test-tokens.mjs +0 -212
@@ -84,13 +84,7 @@ export abstract class CliProvider implements AIProvider {
84
84
  }));
85
85
  }
86
86
 
87
- markAsResumed(sessionId: string): void {
88
- this.messageCount.set(sessionId, 1);
89
- }
90
-
91
87
  async deleteSession(sessionId: string): Promise<void> {
92
- const proc = this.activeProcesses.get(sessionId);
93
- if (proc) { proc.kill(); this.activeProcesses.delete(sessionId); }
94
88
  this.sessions.delete(sessionId);
95
89
  this.messageCount.delete(sessionId);
96
90
  }
@@ -8,7 +8,7 @@ import { renameSession as sdkRenameSession } from "@anthropic-ai/claude-agent-sd
8
8
  import { listSlashItems } from "../../services/slash-items.service.ts";
9
9
  import { getCachedUsage, refreshUsageNow } from "../../services/claude-usage.service.ts";
10
10
  import { getSessionLog } from "../../services/session-log.service.ts";
11
- import { getSessionMapping, setSessionMapping, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession, deleteSessionMapping, deleteSessionTitle } from "../../services/db.service.ts";
11
+ import { getSessionMapping, getSessionProjectPath, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession } from "../../services/db.service.ts";
12
12
  import { ok, err } from "../../types/api.ts";
13
13
 
14
14
  type Env = { Variables: { projectPath: string; projectName: string } };
@@ -125,13 +125,7 @@ chatRoutes.delete("/sessions/:id", async (c) => {
125
125
  try {
126
126
  const id = c.req.param("id");
127
127
  const providerId = c.req.query("providerId") ?? "claude";
128
- const sdkId = getSessionMapping(id) ?? id;
129
- // Provider-specific cleanup (JSONL, process, etc.)
130
128
  await chatService.deleteSession(providerId, id);
131
- // Shared DB cleanup
132
- deleteSessionMapping(id);
133
- deleteSessionTitle(sdkId);
134
- unpinSession(sdkId);
135
129
  return c.json(ok({ deleted: id }));
136
130
  } catch (e) {
137
131
  return c.json(err((e as Error).message), 404);
@@ -190,31 +184,16 @@ chatRoutes.post("/sessions/:id/fork", async (c) => {
190
184
  const projectName = c.get("projectName");
191
185
  const projectPath = c.get("projectPath");
192
186
  const providerId = c.req.query("providerId") ?? "claude";
193
- const body = await c.req.json<{ messageId?: string }>().catch(() => ({} as { messageId?: string }));
187
+ // Create a new PPM session that will fork from sourceId on first message
188
+ const session = await chatService.createSession(providerId, {
189
+ projectName,
190
+ projectPath,
191
+ title: "Forked Chat",
192
+ });
193
+ // Store fork source so WS handler knows to use forkSession on first message
194
194
  const provider = providerRegistry.get(providerId);
195
- if (!provider) return c.json(err("Provider not found"), 404);
196
-
197
- if (body.messageId) {
198
- // Mid-fork at a specific message
199
- if (!provider.forkAtMessage) {
200
- return c.json(err("Provider does not support forking"), 400);
201
- }
202
- const result = await provider.forkAtMessage(sourceId, body.messageId, {
203
- title: "Forked Chat", dir: projectPath,
204
- });
205
- const session = await chatService.createSession(providerId, {
206
- projectName, projectPath, title: "Forked Chat",
207
- });
208
- setSessionMapping(session.id, result.sessionId);
209
- provider.markAsResumed?.(session.id);
210
- return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
211
- } else {
212
- // No messageId (fork at first message) — create a fresh empty session
213
- const session = await chatService.createSession(providerId, {
214
- projectName, projectPath, title: "Forked Chat",
215
- });
216
- return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
217
- }
195
+ provider?.setForkSource?.(session.id, sourceId);
196
+ return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
218
197
  } catch (e) {
219
198
  return c.json(err((e as Error).message), 500);
220
199
  }
@@ -236,11 +215,13 @@ chatRoutes.get("/sessions/:id/logs", (c) => {
236
215
  chatRoutes.get("/sessions/:id/debug", (c) => {
237
216
  const ppmId = c.req.param("id");
238
217
  const sdkId = getSessionMapping(ppmId) ?? ppmId;
239
- const projectName = c.req.query("project") ?? "";
240
218
  // Resolve JSONL path: ~/.claude/projects/<encoded-cwd>/<sdkId>.jsonl
241
219
  const homedir = process.env.HOME ?? process.env.USERPROFILE ?? "";
242
220
  const provider = providerRegistry.get("claude") as any;
243
- const projectPath = provider?.activeSessions?.get(ppmId)?.projectPath ?? "";
221
+ // Try in-memory first, fall back to DB-persisted project_path
222
+ const projectPath = provider?.activeSessions?.get(ppmId)?.projectPath
223
+ ?? getSessionProjectPath(ppmId)
224
+ ?? "";
244
225
  const encodedCwd = projectPath ? projectPath.replace(/\//g, "-") : "";
245
226
  const jsonlDir = encodedCwd ? resolve(homedir, ".claude", "projects", encodedCwd) : "";
246
227
  const jsonlPath = jsonlDir ? resolve(jsonlDir, `${sdkId}.jsonl`) : "";
@@ -252,33 +252,6 @@ settingsRoutes.post("/telegram/test", async (c) => {
252
252
  }
253
253
  });
254
254
 
255
- // ── Auth / Password ──────────────────────────────────────────────────
256
-
257
- /** PUT /settings/auth/password — change the access password (token) */
258
- settingsRoutes.put("/auth/password", async (c) => {
259
- try {
260
- const { password, confirm } = await c.req.json<{ password: string; confirm: string }>();
261
- if (typeof password !== "string" || !password.trim()) {
262
- return c.json(err("Password is required"), 400);
263
- }
264
- if (password !== confirm) {
265
- return c.json(err("Passwords do not match"), 400);
266
- }
267
- const trimmed = password.trim();
268
- if (trimmed.length < 4) {
269
- return c.json(err("Password must be at least 4 characters"), 400);
270
- }
271
-
272
- const auth = configService.get("auth");
273
- configService.set("auth", { ...auth, token: trimmed });
274
- configService.save();
275
-
276
- return c.json(ok({ token: trimmed }));
277
- } catch (e) {
278
- return c.json(err((e as Error).message), 400);
279
- }
280
- });
281
-
282
255
  // ── Proxy ────────────────────────────────────────────────────────────
283
256
 
284
257
  /** GET /settings/proxy — proxy status */
@@ -218,14 +218,8 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
218
218
  continue;
219
219
  }
220
220
 
221
- // System events → transition connecting → thinking, forward compact events
221
+ // System events → transition connecting → thinking
222
222
  if (evType === "system") {
223
- const sub = (ev as any).subtype;
224
- if (sub === "compacting") {
225
- broadcast(sessionId, { type: "compact_status", status: "compacting" });
226
- } else if (sub === "compact_done") {
227
- broadcast(sessionId, { type: "compact_status", status: "done" });
228
- }
229
223
  if (!firstEventReceived) {
230
224
  if (heartbeat) clearInterval(heartbeat);
231
225
  setPhase(sessionId, "thinking");
@@ -139,7 +139,7 @@ class AccountService {
139
139
  await this.refreshAccessToken(id, false);
140
140
  return this.getWithTokens(id);
141
141
  } catch (e) {
142
- console.error(`[accounts] Pre-flight refresh failed for ${id}: ${(e as Error).message ?? e}`);
142
+ console.error(`[accounts] Pre-flight refresh failed for ${id}:`, e);
143
143
  return null;
144
144
  }
145
145
  }
@@ -709,7 +709,7 @@ class AccountService {
709
709
  try {
710
710
  await this.refreshAccessToken(acc.id, false);
711
711
  } catch (e) {
712
- console.error(`[accounts] Auto-refresh failed for ${acc.id}: ${(e as Error).message ?? e}`);
712
+ console.error(`[accounts] Auto-refresh failed for ${acc.id}:`, e);
713
713
  }
714
714
  }
715
715
  };
@@ -273,9 +273,14 @@ export function getUsageForAccount(accountId: string): ClaudeUsage {
273
273
  return row ? snapshotToUsage(row) : {};
274
274
  }
275
275
 
276
- /** Get usage for all accounts */
276
+ /** Get usage for all accounts (excludes expired temporary accounts) */
277
277
  export function getAllAccountUsages(): AccountUsageEntry[] {
278
- const accounts = accountService.list();
278
+ const nowS = Math.floor(Date.now() / 1000);
279
+ const accounts = accountService.list().filter(acc => {
280
+ // Exclude expired accounts without refresh token (temporary/invalid)
281
+ if (!accountService.hasRefreshToken(acc.id) && acc.expiresAt && acc.expiresAt < nowS) return false;
282
+ return true;
283
+ });
279
284
  const snapshots = getAllLatestSnapshots();
280
285
  const snapshotMap = new Map(snapshots.map(s => [s.account_id, s]));
281
286
  return accounts.map(acc => {
@@ -21,7 +21,6 @@ interface HeartbeatMsg extends WsMessage {
21
21
  availableVersion: string | null;
22
22
  serverPid: number | null;
23
23
  uptime: number;
24
- deviceName?: string;
25
24
  }
26
25
 
27
26
  interface StateChangeMsg extends WsMessage {
@@ -354,7 +354,6 @@ export async function sendHeartbeat(tunnelUrl: string): Promise<boolean> {
354
354
  secret_key: device.secret_key,
355
355
  tunnel_url: tunnelUrl,
356
356
  status: "online",
357
- name: device.name,
358
357
  }),
359
358
  });
360
359
  return res.ok;
@@ -271,6 +271,21 @@ function runMigrations(database: Database): void {
271
271
  PRAGMA user_version = 10;
272
272
  `);
273
273
  }
274
+
275
+ if (current < 11) {
276
+ try {
277
+ database.exec(`ALTER TABLE session_map ADD COLUMN project_path TEXT`);
278
+ } catch {
279
+ // Column may already exist
280
+ }
281
+ // Backfill project_path from projects table where project_name matches
282
+ database.exec(`
283
+ UPDATE session_map SET project_path = (
284
+ SELECT path FROM projects WHERE projects.name = session_map.project_name
285
+ ) WHERE project_path IS NULL AND project_name IS NOT NULL
286
+ `);
287
+ database.exec(`PRAGMA user_version = 11`);
288
+ }
274
289
  }
275
290
 
276
291
  // ---------------------------------------------------------------------------
@@ -365,10 +380,15 @@ export function getSessionMapping(ppmId: string): string | null {
365
380
  return row?.sdk_id ?? null;
366
381
  }
367
382
 
368
- export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string): void {
383
+ export function getSessionProjectPath(ppmId: string): string | null {
384
+ const row = getDb().query("SELECT project_path FROM session_map WHERE ppm_id = ?").get(ppmId) as { project_path: string } | null;
385
+ return row?.project_path ?? null;
386
+ }
387
+
388
+ export function setSessionMapping(ppmId: string, sdkId: string, projectName?: string, projectPath?: string): void {
369
389
  getDb().query(
370
- "INSERT INTO session_map (ppm_id, sdk_id, project_name) VALUES (?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = excluded.project_name",
371
- ).run(ppmId, sdkId, projectName ?? null);
390
+ "INSERT INTO session_map (ppm_id, sdk_id, project_name, project_path) VALUES (?, ?, ?, ?) ON CONFLICT(ppm_id) DO UPDATE SET sdk_id = excluded.sdk_id, project_name = COALESCE(excluded.project_name, session_map.project_name), project_path = COALESCE(excluded.project_path, session_map.project_path)",
391
+ ).run(ppmId, sdkId, projectName ?? null, projectPath ?? null);
372
392
  }
373
393
 
374
394
  export function getAllSessionMappings(): Record<string, string> {
@@ -424,14 +444,6 @@ export function getPinnedSessionIds(): Set<string> {
424
444
  return new Set(rows.map((r) => r.session_id));
425
445
  }
426
446
 
427
- export function deleteSessionMapping(ppmId: string): void {
428
- getDb().query("DELETE FROM session_map WHERE ppm_id = ?").run(ppmId);
429
- }
430
-
431
- export function deleteSessionTitle(sessionId: string): void {
432
- getDb().query("DELETE FROM session_titles WHERE session_id = ?").run(sessionId);
433
- }
434
-
435
447
  // ---------------------------------------------------------------------------
436
448
  // Push subscription helpers
437
449
  // ---------------------------------------------------------------------------
@@ -27,22 +27,13 @@ export class McpConfigService {
27
27
 
28
28
  /** List all MCP servers as Record (SDK-compatible format) */
29
29
  list(): Record<string, McpServerConfig> {
30
- try {
31
- const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
32
- const result: Record<string, McpServerConfig> = {};
33
- for (const row of rows) {
34
- const parsed = safeParse(row.config, row.name);
35
- if (parsed) result[row.name] = parsed;
36
- }
37
- return result;
38
- } catch (e) {
39
- const msg = (e as Error).message ?? String(e);
40
- if (msg.includes("no such table")) {
41
- console.warn("[mcp] mcp_servers table not found — returning empty list");
42
- return {};
43
- }
44
- throw e;
30
+ const rows = this.db.query("SELECT name, config FROM mcp_servers ORDER BY name").all() as { name: string; config: string }[];
31
+ const result: Record<string, McpServerConfig> = {};
32
+ for (const row of rows) {
33
+ const parsed = safeParse(row.config, row.name);
34
+ if (parsed) result[row.name] = parsed;
45
35
  }
36
+ return result;
46
37
  }
47
38
 
48
39
  /** List as array with metadata (for UI) */
@@ -378,33 +378,17 @@ function adoptTunnel(): boolean {
378
378
  const status = readStatus();
379
379
  const pid = status.tunnelPid as number;
380
380
  const url = status.shareUrl as string;
381
- if (!pid || !url) {
382
- log("DEBUG", `adoptTunnel: missing tunnelPid(${pid}) or shareUrl(${url}) in status`);
383
- return false;
384
- }
381
+ if (!pid || !url) return false;
385
382
  process.kill(pid, 0); // throws if process is dead
386
383
  adoptedTunnelPid = pid;
387
384
  tunnelUrl = url;
388
385
  log("INFO", `Adopted existing tunnel (PID: ${pid}, URL: ${url})`);
389
386
  return true;
390
- } catch (e) {
391
- log("WARN", `adoptTunnel: tunnel PID ${(readStatus().tunnelPid)} unreachable: ${e}`);
387
+ } catch {
392
388
  return false;
393
389
  }
394
390
  }
395
391
 
396
- /** Kill stale tunnel PID from status.json (cleanup after failed adoption) */
397
- function killStaleTunnel() {
398
- try {
399
- const status = readStatus();
400
- const pid = status.tunnelPid as number;
401
- if (!pid) return;
402
- try { process.kill(pid, "SIGTERM"); } catch {}
403
- log("INFO", `Killed stale tunnel (PID: ${pid})`);
404
- } catch {}
405
- updateStatus({ tunnelPid: null, shareUrl: null });
406
- }
407
-
408
392
  /** Spawn new supervisor from updated code, wait for it to be healthy, then exit */
409
393
  async function selfReplace(): Promise<{ success: boolean; error?: string }> {
410
394
  log("INFO", "Starting self-replace for upgrade");
@@ -506,8 +490,6 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
506
490
  secretKey: device.secret_key,
507
491
  heartbeatFn: () => {
508
492
  const status = readStatus();
509
- // Re-read device file each heartbeat to pick up name changes
510
- const currentDevice = getCloudDevice();
511
493
  return {
512
494
  type: "heartbeat" as const,
513
495
  tunnelUrl,
@@ -517,7 +499,6 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
517
499
  availableVersion: (status.availableVersion as string) || null,
518
500
  serverPid: serverChild?.pid ?? null,
519
501
  uptime: Math.floor((Date.now() - startTime) / 1000),
520
- deviceName: currentDevice?.name ?? device.name,
521
502
  timestamp: new Date().toISOString(),
522
503
  };
523
504
  },
@@ -725,7 +706,6 @@ export async function runSupervisor(opts: {
725
706
  startTunnelProbe(opts.port);
726
707
  // Try adopting tunnel kept alive from previous upgrade; spawn new if dead
727
708
  if (!adoptTunnel()) {
728
- killStaleTunnel(); // kill orphaned tunnel before spawning new one
729
709
  promises.push(spawnTunnel(opts.port));
730
710
  }
731
711
  }
package/src/types/api.ts CHANGED
@@ -44,5 +44,4 @@ export type ChatWsServerMessage =
44
44
  | { type: "session_state"; sessionId: string; phase: SessionPhase; pendingApproval: { requestId: string; tool: string; input: unknown } | null; sessionTitle: string | null }
45
45
  | { type: "turn_events"; events: unknown[] }
46
46
  | { type: "title_updated"; title: string }
47
- | { type: "compact_status"; status: "compacting" | "done" }
48
47
  | { type: "ping" };
package/src/types/chat.ts CHANGED
@@ -29,8 +29,6 @@ export interface AIProvider {
29
29
  listSessionsByDir?(dir: string): Promise<SessionInfo[]>;
30
30
  ensureProjectPath?(sessionId: string, path: string): void;
31
31
  setForkSource?(sessionId: string, sourceSessionId: string): void;
32
- forkAtMessage?(sessionId: string, messageId: string, opts?: { title?: string; dir?: string }): Promise<{ sessionId: string }>;
33
- markAsResumed?(sessionId: string): void;
34
32
  isAvailable?(): Promise<boolean>;
35
33
  listModels?(): Promise<ModelOption[]>;
36
34
  }
package/src/web/app.tsx CHANGED
@@ -37,7 +37,6 @@ type AuthState = "checking" | "authenticated" | "unauthenticated";
37
37
 
38
38
  export function App() {
39
39
  const [authState, setAuthState] = useState<AuthState>("checking");
40
- const [upgradeBannerVisible, setUpgradeBannerVisible] = useState(false);
41
40
  const [drawerOpen, setDrawerOpen] = useState(false);
42
41
  const [drawerTab, setDrawerTab] = useState<"explorer" | "git" | "settings" | undefined>();
43
42
  const [projectSheetOpen, setProjectSheetOpen] = useState(false);
@@ -230,11 +229,11 @@ export function App() {
230
229
  <TooltipProvider>
231
230
  <div className="h-dvh flex flex-col bg-background text-foreground overflow-hidden relative">
232
231
  {/* Upgrade banner — shown when new version available */}
233
- <UpgradeBanner onVisibilityChange={setUpgradeBannerVisible} />
232
+ <UpgradeBanner />
234
233
 
235
234
  {/* Mobile device name badge — floating top-left */}
236
235
  {deviceName && (
237
- <div className={cn("md:hidden fixed left-0 z-50 px-2 py-0.5 bg-primary/80 text-primary-foreground text-[10px] font-medium rounded-br transition-[top]", upgradeBannerVisible ? "top-7" : "top-0")}>
236
+ <div className="md:hidden fixed top-0 left-0 z-50 px-2 py-0.5 bg-primary/80 text-primary-foreground text-[10px] font-medium rounded-br">
238
237
  {deviceName}
239
238
  </div>
240
239
  )}
@@ -16,7 +16,6 @@ interface ChatHistoryBarProps {
16
16
  projectName: string;
17
17
  usageInfo: UsageInfo;
18
18
  contextWindowPct?: number | null;
19
- compactStatus?: "compacting" | null;
20
19
  usageLoading?: boolean;
21
20
  refreshUsage?: () => void;
22
21
  lastFetchedAt?: string | null;
@@ -80,7 +79,7 @@ function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projec
80
79
  }
81
80
 
82
81
  export function ChatHistoryBar({
83
- projectName, usageInfo, contextWindowPct, compactStatus, usageLoading, refreshUsage, lastFetchedAt,
82
+ projectName, usageInfo, contextWindowPct, usageLoading, refreshUsage, lastFetchedAt,
84
83
  sessionId, providerId, onSelectSession, onBugReport, isConnected, onReconnect,
85
84
  }: ChatHistoryBarProps) {
86
85
  const [activePanel, setActivePanel] = useState<PanelType>(null);
@@ -241,27 +240,14 @@ export function ChatHistoryBar({
241
240
  <span className={pctColor(contextWindowPct)}>Ctx:{contextWindowPct}%</span>
242
241
  </>
243
242
  )}
244
- {compactStatus === "compacting" && (
245
- <>
246
- <span className="text-text-subtle">·</span>
247
- <span className="text-blue-400 animate-pulse">compacting...</span>
248
- </>
249
- )}
250
243
  </button>
251
244
  ) : (
252
- <>
253
- {contextWindowPct != null && (
254
- <span className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] font-medium tabular-nums ${pctColor(contextWindowPct)}`}>
255
- <Activity className="size-3" />
256
- <span>Ctx:{contextWindowPct}%</span>
257
- </span>
258
- )}
259
- {compactStatus === "compacting" && (
260
- <span className="text-[11px] px-1.5 py-0.5 text-blue-400 animate-pulse">
261
- compacting...
262
- </span>
263
- )}
264
- </>
245
+ contextWindowPct != null && (
246
+ <span className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] font-medium tabular-nums ${pctColor(contextWindowPct)}`}>
247
+ <Activity className="size-3" />
248
+ <span>Ctx:{contextWindowPct}%</span>
249
+ </span>
250
+ )
265
251
  )}
266
252
 
267
253
  {/* Spacer */}
@@ -89,7 +89,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
89
89
  connectingElapsed,
90
90
  pendingApproval,
91
91
  contextWindowPct,
92
- compactStatus,
93
92
  sessionTitle,
94
93
  migratedSessionId,
95
94
  sendMessage,
@@ -135,12 +134,14 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
135
134
  }
136
135
  }, [sessionTitle]); // eslint-disable-line react-hooks/exhaustive-deps
137
136
 
138
- // Pending fork message — show in input for user to edit, not auto-send
139
- const [forkDraft, setForkDraft] = useState<string | undefined>(metadata?.pendingMessage as string | undefined);
137
+ // Auto-send pending message for forked sessions (set by handleFork)
138
+ const pendingForkMsgRef = useRef(metadata?.pendingMessage as string | undefined);
140
139
  useEffect(() => {
141
- if (forkDraft && isConnected && sessionId && tabId) {
142
- // Clear from tab metadata once consumed
143
- updateTab(tabId, { metadata: { ...metadata, pendingMessage: undefined } });
140
+ if (pendingForkMsgRef.current && isConnected && sessionId) {
141
+ const msg = pendingForkMsgRef.current;
142
+ pendingForkMsgRef.current = undefined;
143
+ if (tabId) updateTab(tabId, { metadata: { ...metadata, pendingMessage: undefined } });
144
+ setTimeout(() => sendMessage(msg, { permissionMode }), 100);
144
145
  }
145
146
  }, [isConnected, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
146
147
 
@@ -161,13 +162,12 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
161
162
  }, [tabId, updateTab]);
162
163
 
163
164
  /** Fork current session and open new tab with the forked session, resending userMessage */
164
- const handleFork = useCallback(async (userMessage: string, messageId?: string) => {
165
+ const handleFork = useCallback(async (userMessage: string) => {
165
166
  if (!sessionId || !projectName) return;
166
167
  try {
167
168
  const { api, projectUrl } = await import("@/lib/api-client");
168
169
  const forked = await api.post<{ id: string; forkedFrom: string }>(
169
170
  `${projectUrl(projectName)}/chat/sessions/${sessionId}/fork?providerId=${providerId}`,
170
- { messageId },
171
171
  );
172
172
  // Open new chat tab with forked session — it will send userMessage on connect
173
173
  useTabStore.getState().openTab({
@@ -350,7 +350,6 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
350
350
  projectName={projectName}
351
351
  usageInfo={usageInfo}
352
352
  contextWindowPct={contextWindowPct}
353
- compactStatus={compactStatus}
354
353
  usageLoading={usageLoading}
355
354
  refreshUsage={refreshUsage}
356
355
  lastFetchedAt={lastFetchedAt}
@@ -383,14 +382,10 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
383
382
 
384
383
  {/* Input */}
385
384
  <MessageInput
386
- onSend={(content, attachments, priority) => {
387
- if (forkDraft) setForkDraft(undefined);
388
- handleSend(content, attachments, priority);
389
- }}
385
+ onSend={handleSend}
390
386
  isStreaming={isStreaming}
391
387
  onCancel={cancelStreaming}
392
- autoFocus={!(metadata?.sessionId) || !!forkDraft}
393
- initialValue={forkDraft}
388
+ autoFocus={!(metadata?.sessionId)}
394
389
  projectName={projectName}
395
390
  onSlashStateChange={handleSlashStateChange}
396
391
  onSlashItemsLoaded={setSlashItems}
@@ -43,7 +43,7 @@ interface MessageListProps {
43
43
  connectingElapsed?: number;
44
44
  projectName?: string;
45
45
  /** Called when user clicks Fork/Rewind — opens new forked chat tab */
46
- onFork?: (userMessage: string, messageId?: string) => void;
46
+ onFork?: (userMessage: string) => void;
47
47
  }
48
48
 
49
49
  export function MessageList({
@@ -90,17 +90,13 @@ export function MessageList({
90
90
  <div className="relative flex-1 overflow-hidden flex flex-col min-h-0">
91
91
  <StickToBottom className="flex-1 overflow-y-auto overflow-x-hidden" resize="smooth" initial="instant">
92
92
  <StickToBottom.Content className="p-4 space-y-4">
93
- {filtered.map((msg, idx) => (
93
+ {filtered.map((msg) => (
94
94
  <MessageBubble
95
95
  key={msg.id}
96
96
  message={msg}
97
97
  isStreaming={isStreaming && msg.id.startsWith("streaming-")}
98
98
  projectName={projectName}
99
- onFork={msg.role === "user" && onFork ? () => {
100
- // Pass the previous message ID so the fork includes history up to (but not including) this user message
101
- const prevMsg = idx > 0 ? filtered[idx - 1] : undefined;
102
- onFork(msg.content, prevMsg?.id);
103
- } : undefined}
99
+ onFork={msg.role === "user" && onFork ? () => onFork(msg.content) : undefined}
104
100
  />
105
101
  ))}
106
102
 
@@ -47,7 +47,6 @@ export function SessionPicker({
47
47
 
48
48
  const handleDelete = async (e: React.MouseEvent, session: SessionInfo) => {
49
49
  e.stopPropagation();
50
- if (!window.confirm("Delete this session? This cannot be undone.")) return;
51
50
  try {
52
51
  if (!projectName) return;
53
52
  await api.del(