@hienlh/ppm 0.8.90 → 0.8.91

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 (38) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dist/web/assets/{browser-tab-SHBc1OCK.js → browser-tab-Bt91e0v_.js} +1 -1
  3. package/dist/web/assets/chat-tab-BY1ovPns.js +8 -0
  4. package/dist/web/assets/{code-editor-BomcTYQ4.js → code-editor-CAHcH0N-.js} +1 -1
  5. package/dist/web/assets/{database-viewer-B47ck-1v.js → database-viewer-DzEoA-r6.js} +1 -1
  6. package/dist/web/assets/{diff-viewer-Dw2v2RU2.js → diff-viewer-Co7JUnvw.js} +1 -1
  7. package/dist/web/assets/{git-graph-Co7fcau-.js → git-graph-B139k04F.js} +1 -1
  8. package/dist/web/assets/{index-CQu4iIvy.js → index-CXJneRo7.js} +10 -10
  9. package/dist/web/assets/keybindings-store-C8WA_lZu.js +1 -0
  10. package/dist/web/assets/{markdown-renderer-C0n-Ucfa.js → markdown-renderer-C6phS0NU.js} +1 -1
  11. package/dist/web/assets/{postgres-viewer-D4vyH--N.js → postgres-viewer-Bb1N6-J2.js} +1 -1
  12. package/dist/web/assets/{settings-tab-CisRYqMl.js → settings-tab-CZp_PyJ9.js} +1 -1
  13. package/dist/web/assets/{sqlite-viewer-D3t4nApY.js → sqlite-viewer-Bzgj_M05.js} +1 -1
  14. package/dist/web/assets/{terminal-tab-DFz3Bd_N.js → terminal-tab-Bi4qWzTP.js} +1 -1
  15. package/dist/web/assets/{use-monaco-theme-Dopv6S2i.js → use-monaco-theme-D7s2hmIL.js} +1 -1
  16. package/dist/web/index.html +1 -1
  17. package/dist/web/sw.js +1 -1
  18. package/package.json +1 -1
  19. package/src/providers/claude-agent-sdk.ts +59 -1
  20. package/src/providers/cli-provider-base.ts +6 -0
  21. package/src/server/routes/chat.ts +31 -10
  22. package/src/server/routes/settings.ts +27 -0
  23. package/src/server/ws/chat.ts +7 -1
  24. package/src/services/cloud-ws.service.ts +1 -0
  25. package/src/services/cloud.service.ts +1 -0
  26. package/src/services/db.service.ts +8 -0
  27. package/src/services/supervisor.ts +3 -0
  28. package/src/types/api.ts +1 -0
  29. package/src/types/chat.ts +2 -0
  30. package/src/web/components/chat/chat-history-bar.tsx +21 -7
  31. package/src/web/components/chat/chat-tab.tsx +4 -1
  32. package/src/web/components/chat/message-list.tsx +2 -2
  33. package/src/web/components/chat/session-picker.tsx +1 -0
  34. package/src/web/components/settings/change-password-section.tsx +128 -0
  35. package/src/web/components/settings/settings-tab.tsx +4 -0
  36. package/src/web/hooks/use-chat.ts +17 -0
  37. package/dist/web/assets/chat-tab-dssvQaJN.js +0 -8
  38. package/dist/web/assets/keybindings-store-OkhvRBpn.js +0 -1
@@ -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, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession } from "../../services/db.service.ts";
11
+ import { getSessionMapping, setSessionMapping, setSessionTitle, getPinnedSessionIds, pinSession, unpinSession, deleteSessionMapping, deleteSessionTitle } 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,7 +125,13 @@ 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.)
128
130
  await chatService.deleteSession(providerId, id);
131
+ // Shared DB cleanup
132
+ deleteSessionMapping(id);
133
+ deleteSessionTitle(sdkId);
134
+ unpinSession(sdkId);
129
135
  return c.json(ok({ deleted: id }));
130
136
  } catch (e) {
131
137
  return c.json(err((e as Error).message), 404);
@@ -184,16 +190,31 @@ chatRoutes.post("/sessions/:id/fork", async (c) => {
184
190
  const projectName = c.get("projectName");
185
191
  const projectPath = c.get("projectPath");
186
192
  const providerId = c.req.query("providerId") ?? "claude";
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
193
+ const body = await c.req.json<{ messageId?: string }>().catch(() => ({} as { messageId?: string }));
194
194
  const provider = providerRegistry.get(providerId);
195
- provider?.setForkSource?.(session.id, sourceId);
196
- return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
195
+ if (!provider) return c.json(err("Provider not found"), 404);
196
+
197
+ if (body.messageId && provider.forkAtMessage) {
198
+ // Mid-fork: SDK fork first, then create PPM session only on success
199
+ const result = await provider.forkAtMessage(sourceId, body.messageId, {
200
+ title: "Forked Chat", dir: projectPath,
201
+ });
202
+ const session = await chatService.createSession(providerId, {
203
+ projectName, projectPath, title: "Forked Chat",
204
+ });
205
+ setSessionMapping(session.id, result.sessionId);
206
+ provider.markAsResumed?.(session.id);
207
+ return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
208
+ } else if (provider.setForkSource) {
209
+ // Deferred fork from end (full history copy on first msg)
210
+ const session = await chatService.createSession(providerId, {
211
+ projectName, projectPath, title: "Forked Chat",
212
+ });
213
+ provider.setForkSource(session.id, sourceId);
214
+ return c.json(ok({ ...session, forkedFrom: sourceId }), 201);
215
+ } else {
216
+ return c.json(err("Provider does not support forking"), 400);
217
+ }
197
218
  } catch (e) {
198
219
  return c.json(err((e as Error).message), 500);
199
220
  }
@@ -252,6 +252,33 @@ 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
+
255
282
  // ── Proxy ────────────────────────────────────────────────────────────
256
283
 
257
284
  /** GET /settings/proxy — proxy status */
@@ -218,8 +218,14 @@ async function startSessionConsumer(sessionId: string, providerId: string, conte
218
218
  continue;
219
219
  }
220
220
 
221
- // System events → transition connecting → thinking
221
+ // System events → transition connecting → thinking, forward compact events
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
+ }
223
229
  if (!firstEventReceived) {
224
230
  if (heartbeat) clearInterval(heartbeat);
225
231
  setPhase(sessionId, "thinking");
@@ -21,6 +21,7 @@ interface HeartbeatMsg extends WsMessage {
21
21
  availableVersion: string | null;
22
22
  serverPid: number | null;
23
23
  uptime: number;
24
+ deviceName?: string;
24
25
  }
25
26
 
26
27
  interface StateChangeMsg extends WsMessage {
@@ -354,6 +354,7 @@ 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,
357
358
  }),
358
359
  });
359
360
  return res.ok;
@@ -424,6 +424,14 @@ export function getPinnedSessionIds(): Set<string> {
424
424
  return new Set(rows.map((r) => r.session_id));
425
425
  }
426
426
 
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
+
427
435
  // ---------------------------------------------------------------------------
428
436
  // Push subscription helpers
429
437
  // ---------------------------------------------------------------------------
@@ -506,6 +506,8 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
506
506
  secretKey: device.secret_key,
507
507
  heartbeatFn: () => {
508
508
  const status = readStatus();
509
+ // Re-read device file each heartbeat to pick up name changes
510
+ const currentDevice = getCloudDevice();
509
511
  return {
510
512
  type: "heartbeat" as const,
511
513
  tunnelUrl,
@@ -515,6 +517,7 @@ async function connectCloud(opts: { port: number }, serverArgs: string[], logFd:
515
517
  availableVersion: (status.availableVersion as string) || null,
516
518
  serverPid: serverChild?.pid ?? null,
517
519
  uptime: Math.floor((Date.now() - startTime) / 1000),
520
+ deviceName: currentDevice?.name ?? device.name,
518
521
  timestamp: new Date().toISOString(),
519
522
  };
520
523
  },
package/src/types/api.ts CHANGED
@@ -44,4 +44,5 @@ 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" }
47
48
  | { type: "ping" };
package/src/types/chat.ts CHANGED
@@ -29,6 +29,8 @@ 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;
32
34
  isAvailable?(): Promise<boolean>;
33
35
  listModels?(): Promise<ModelOption[]>;
34
36
  }
@@ -16,6 +16,7 @@ interface ChatHistoryBarProps {
16
16
  projectName: string;
17
17
  usageInfo: UsageInfo;
18
18
  contextWindowPct?: number | null;
19
+ compactStatus?: "compacting" | null;
19
20
  usageLoading?: boolean;
20
21
  refreshUsage?: () => void;
21
22
  lastFetchedAt?: string | null;
@@ -79,7 +80,7 @@ function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projec
79
80
  }
80
81
 
81
82
  export function ChatHistoryBar({
82
- projectName, usageInfo, contextWindowPct, usageLoading, refreshUsage, lastFetchedAt,
83
+ projectName, usageInfo, contextWindowPct, compactStatus, usageLoading, refreshUsage, lastFetchedAt,
83
84
  sessionId, providerId, onSelectSession, onBugReport, isConnected, onReconnect,
84
85
  }: ChatHistoryBarProps) {
85
86
  const [activePanel, setActivePanel] = useState<PanelType>(null);
@@ -240,14 +241,27 @@ export function ChatHistoryBar({
240
241
  <span className={pctColor(contextWindowPct)}>Ctx:{contextWindowPct}%</span>
241
242
  </>
242
243
  )}
244
+ {compactStatus === "compacting" && (
245
+ <>
246
+ <span className="text-text-subtle">·</span>
247
+ <span className="text-blue-400 animate-pulse">compacting...</span>
248
+ </>
249
+ )}
243
250
  </button>
244
251
  ) : (
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
- )
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
+ </>
251
265
  )}
252
266
 
253
267
  {/* Spacer */}
@@ -89,6 +89,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
89
89
  connectingElapsed,
90
90
  pendingApproval,
91
91
  contextWindowPct,
92
+ compactStatus,
92
93
  sessionTitle,
93
94
  migratedSessionId,
94
95
  sendMessage,
@@ -162,12 +163,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
162
163
  }, [tabId, updateTab]);
163
164
 
164
165
  /** Fork current session and open new tab with the forked session, resending userMessage */
165
- const handleFork = useCallback(async (userMessage: string) => {
166
+ const handleFork = useCallback(async (userMessage: string, messageId?: string) => {
166
167
  if (!sessionId || !projectName) return;
167
168
  try {
168
169
  const { api, projectUrl } = await import("@/lib/api-client");
169
170
  const forked = await api.post<{ id: string; forkedFrom: string }>(
170
171
  `${projectUrl(projectName)}/chat/sessions/${sessionId}/fork?providerId=${providerId}`,
172
+ { messageId },
171
173
  );
172
174
  // Open new chat tab with forked session — it will send userMessage on connect
173
175
  useTabStore.getState().openTab({
@@ -350,6 +352,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
350
352
  projectName={projectName}
351
353
  usageInfo={usageInfo}
352
354
  contextWindowPct={contextWindowPct}
355
+ compactStatus={compactStatus}
353
356
  usageLoading={usageLoading}
354
357
  refreshUsage={refreshUsage}
355
358
  lastFetchedAt={lastFetchedAt}
@@ -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) => void;
46
+ onFork?: (userMessage: string, messageId?: string) => void;
47
47
  }
48
48
 
49
49
  export function MessageList({
@@ -96,7 +96,7 @@ export function MessageList({
96
96
  message={msg}
97
97
  isStreaming={isStreaming && msg.id.startsWith("streaming-")}
98
98
  projectName={projectName}
99
- onFork={msg.role === "user" && onFork ? () => onFork(msg.content) : undefined}
99
+ onFork={msg.role === "user" && onFork ? () => onFork(msg.content, msg.id) : undefined}
100
100
  />
101
101
  ))}
102
102
 
@@ -47,6 +47,7 @@ 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;
50
51
  try {
51
52
  if (!projectName) return;
52
53
  await api.del(
@@ -0,0 +1,128 @@
1
+ import { useState, useCallback } from "react";
2
+ import { KeyRound, Check, Eye, EyeOff } from "lucide-react";
3
+ import { Button } from "@/components/ui/button";
4
+ import { Input } from "@/components/ui/input";
5
+ import { api } from "@/lib/api-client";
6
+ import { setAuthToken } from "@/lib/api-client";
7
+
8
+ export function ChangePasswordSection() {
9
+ const [open, setOpen] = useState(false);
10
+ const [password, setPassword] = useState("");
11
+ const [confirm, setConfirm] = useState("");
12
+ const [showPw, setShowPw] = useState(false);
13
+ const [saving, setSaving] = useState(false);
14
+ const [saved, setSaved] = useState(false);
15
+ const [error, setError] = useState<string | null>(null);
16
+
17
+ const mismatch = confirm.length > 0 && password !== confirm;
18
+ const canSubmit = password.trim().length >= 4 && password === confirm && !saving;
19
+
20
+ const handleSubmit = useCallback(async () => {
21
+ if (!canSubmit) return;
22
+ setSaving(true);
23
+ setError(null);
24
+ try {
25
+ const { token } = await api.put<{ token: string }>("/api/settings/auth/password", {
26
+ password: password.trim(),
27
+ confirm: confirm.trim(),
28
+ });
29
+ // Update localStorage so current session stays authenticated
30
+ setAuthToken(token);
31
+ setSaved(true);
32
+ setPassword("");
33
+ setConfirm("");
34
+ setTimeout(() => {
35
+ setSaved(false);
36
+ setOpen(false);
37
+ }, 1500);
38
+ } catch (e) {
39
+ setError(e instanceof Error ? e.message : "Failed to change password");
40
+ } finally {
41
+ setSaving(false);
42
+ }
43
+ }, [canSubmit, password, confirm]);
44
+
45
+ if (!open) {
46
+ return (
47
+ <section className="space-y-2">
48
+ <h3 className="text-xs font-medium text-muted-foreground">Security</h3>
49
+ <Button
50
+ variant="outline"
51
+ size="sm"
52
+ className="h-8 text-xs gap-1.5 cursor-pointer"
53
+ onClick={() => setOpen(true)}
54
+ >
55
+ <KeyRound className="size-3.5" />
56
+ Change Password
57
+ </Button>
58
+ </section>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <section className="space-y-2">
64
+ <h3 className="text-xs font-medium text-muted-foreground">Change Password</h3>
65
+ <div className="space-y-2">
66
+ <div className="relative">
67
+ <Input
68
+ type={showPw ? "text" : "password"}
69
+ placeholder="New password"
70
+ value={password}
71
+ onChange={(e) => setPassword(e.target.value)}
72
+ className="h-8 text-xs pr-8"
73
+ autoFocus
74
+ />
75
+ <button
76
+ type="button"
77
+ className="absolute right-2 top-1/2 -translate-y-1/2 text-muted-foreground cursor-pointer"
78
+ onClick={() => setShowPw(!showPw)}
79
+ tabIndex={-1}
80
+ >
81
+ {showPw ? <EyeOff className="size-3.5" /> : <Eye className="size-3.5" />}
82
+ </button>
83
+ </div>
84
+ <Input
85
+ type={showPw ? "text" : "password"}
86
+ placeholder="Confirm password"
87
+ value={confirm}
88
+ onChange={(e) => setConfirm(e.target.value)}
89
+ onKeyDown={(e) => { if (e.key === "Enter") handleSubmit(); }}
90
+ className="h-8 text-xs"
91
+ />
92
+ {mismatch && (
93
+ <p className="text-[11px] text-destructive">Passwords do not match</p>
94
+ )}
95
+ {error && (
96
+ <p className="text-[11px] text-destructive">{error}</p>
97
+ )}
98
+ <div className="flex gap-1.5">
99
+ <Button
100
+ variant="outline"
101
+ size="sm"
102
+ className="h-8 text-xs flex-1 cursor-pointer"
103
+ onClick={() => {
104
+ setOpen(false);
105
+ setPassword("");
106
+ setConfirm("");
107
+ setError(null);
108
+ }}
109
+ >
110
+ Cancel
111
+ </Button>
112
+ <Button
113
+ variant={saved ? "default" : "outline"}
114
+ size="sm"
115
+ className="h-8 text-xs flex-1 cursor-pointer"
116
+ disabled={!canSubmit}
117
+ onClick={handleSubmit}
118
+ >
119
+ {saving ? "..." : saved ? <Check className="size-3.5" /> : "Save"}
120
+ </Button>
121
+ </div>
122
+ <p className="text-[11px] text-muted-foreground">
123
+ Min 4 characters. You'll stay logged in on this device.
124
+ </p>
125
+ </div>
126
+ </section>
127
+ );
128
+ }
@@ -14,6 +14,7 @@ import { KeyboardShortcutsSection } from "./keyboard-shortcuts-section";
14
14
  import { TelegramSettingsSection } from "./telegram-settings-section";
15
15
  import { ProxySettingsSection } from "./proxy-settings-section";
16
16
  import { McpSettingsSection } from "./mcp-settings-section";
17
+ import { ChangePasswordSection } from "./change-password-section";
17
18
  import { usePushNotification } from "@/hooks/use-push-notification";
18
19
 
19
20
  const THEME_OPTIONS: { value: Theme; label: string; icon: React.ElementType }[] = [
@@ -128,6 +129,9 @@ export function SettingsTab() {
128
129
  </p>
129
130
  </section>
130
131
 
132
+ {/* Security: Change Password */}
133
+ <ChangePasswordSection />
134
+
131
135
  {/* Quick: Theme */}
132
136
  <section className="space-y-2">
133
137
  <h3 className="text-xs font-medium text-muted-foreground">Theme</h3>
@@ -22,6 +22,7 @@ interface UseChatReturn {
22
22
  connectingElapsed: number;
23
23
  pendingApproval: ApprovalRequest | null;
24
24
  contextWindowPct: number | null;
25
+ compactStatus: "compacting" | null;
25
26
  sessionTitle: string | null;
26
27
  /** When CLI provider assigns a different session ID, this holds the new ID */
27
28
  migratedSessionId: string | null;
@@ -51,6 +52,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
51
52
  const [connectingElapsed, setConnectingElapsed] = useState(0);
52
53
  const [pendingApproval, setPendingApproval] = useState<ApprovalRequest | null>(null);
53
54
  const [contextWindowPct, setContextWindowPct] = useState<number | null>(null);
55
+ const [compactStatus, setCompactStatus] = useState<"compacting" | null>(null);
54
56
  const [sessionTitle, setSessionTitle] = useState<string | null>(null);
55
57
  const [isConnected, setIsConnected] = useState(false);
56
58
  const [migratedSessionId, setMigratedSessionId] = useState<string | null>(null);
@@ -270,6 +272,19 @@ export function useChat(sessionId: string | null, providerId = "claude", project
270
272
  return;
271
273
  }
272
274
 
275
+ // Handle compact status events
276
+ if ((data as any).type === "compact_status") {
277
+ const status = (data as any).status;
278
+ if (status === "compacting") {
279
+ setCompactStatus("compacting");
280
+ } else if (status === "done") {
281
+ setCompactStatus(null);
282
+ // Refresh messages to show compacted history
283
+ refetchRef.current?.();
284
+ }
285
+ return;
286
+ }
287
+
273
288
  // Handle phase transitions from BE
274
289
  if ((data as any).type === "phase_changed") {
275
290
  const p = (data as any).phase as SessionPhase;
@@ -362,6 +377,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
362
377
  setPhase("idle");
363
378
  phaseRef.current = "idle";
364
379
  setPendingApproval(null);
380
+ setCompactStatus(null);
365
381
  streamingContentRef.current = "";
366
382
  streamingEventsRef.current = [];
367
383
  setIsConnected(false);
@@ -550,6 +566,7 @@ export function useChat(sessionId: string | null, providerId = "claude", project
550
566
  connectingElapsed,
551
567
  pendingApproval,
552
568
  contextWindowPct,
569
+ compactStatus,
553
570
  sessionTitle,
554
571
  migratedSessionId,
555
572
  sendMessage,