@hienlh/ppm 0.8.94 → 0.8.95

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/CHANGELOG.md +48 -0
  2. package/dist/web/assets/api-settings-Bid0NHuI.js +1 -0
  3. package/dist/web/assets/{browser-tab-DJLH0eDY.js → browser-tab-DNiBGn5p.js} +1 -1
  4. package/dist/web/assets/chat-tab-w7jsIjfo.js +8 -0
  5. package/dist/web/assets/{code-editor-CaGdx-lS.js → code-editor-COwuo1MZ.js} +1 -1
  6. package/dist/web/assets/{database-viewer-i4Ddk6mO.js → database-viewer-CL3kXoYN.js} +1 -1
  7. package/dist/web/assets/{diff-viewer-DQDS7yjv.js → diff-viewer-BCuKcGH5.js} +1 -1
  8. package/dist/web/assets/{git-graph-DUs-TN1u.js → git-graph-CmQb8T0E.js} +1 -1
  9. package/dist/web/assets/{index-Dm6RN1A1.js → index-BlDA3VoN.js} +11 -11
  10. package/dist/web/assets/index-CqhIj4Ko.css +2 -0
  11. package/dist/web/assets/keybindings-store-D44LPqNY.js +1 -0
  12. package/dist/web/assets/{markdown-renderer-L1NgC2Rw.js → markdown-renderer-PdMYiBSA.js} +1 -1
  13. package/dist/web/assets/{postgres-viewer-_uDispGW.js → postgres-viewer-9yUy5BZB.js} +1 -1
  14. package/dist/web/assets/{settings-tab-Bp4041i6.js → settings-tab-DSF87yix.js} +1 -1
  15. package/dist/web/assets/{sqlite-viewer-GW-QCjHn.js → sqlite-viewer-BaloRTBe.js} +1 -1
  16. package/dist/web/assets/{terminal-tab-E4cWujj4.js → terminal-tab-CM6G6XMO.js} +1 -1
  17. package/dist/web/assets/{use-monaco-theme-zABXAAla.js → use-monaco-theme-C8rXfYU9.js} +1 -1
  18. package/dist/web/index.html +3 -3
  19. package/dist/web/sw.js +1 -1
  20. package/docs/streaming-input-guide.md +267 -0
  21. package/package.json +1 -1
  22. package/snapshot-state.md +1526 -0
  23. package/src/providers/claude-agent-sdk.ts +78 -2
  24. package/src/providers/cli-provider-base.ts +6 -0
  25. package/src/server/routes/chat.ts +31 -10
  26. package/src/server/routes/settings.ts +27 -0
  27. package/src/server/ws/chat.ts +7 -1
  28. package/src/services/account.service.ts +2 -2
  29. package/src/services/claude-usage.service.ts +2 -7
  30. package/src/services/cloud-ws.service.ts +1 -0
  31. package/src/services/cloud.service.ts +1 -0
  32. package/src/services/db.service.ts +8 -0
  33. package/src/services/mcp-config.service.ts +15 -6
  34. package/src/services/supervisor.ts +22 -26
  35. package/src/types/api.ts +1 -0
  36. package/src/types/chat.ts +2 -0
  37. package/src/web/app.tsx +3 -2
  38. package/src/web/components/chat/chat-history-bar.tsx +39 -8
  39. package/src/web/components/chat/chat-tab.tsx +15 -10
  40. package/src/web/components/chat/message-list.tsx +7 -3
  41. package/src/web/components/chat/session-picker.tsx +1 -0
  42. package/src/web/components/chat/usage-badge.tsx +58 -8
  43. package/src/web/components/layout/upgrade-banner.tsx +15 -5
  44. package/src/web/components/settings/change-password-section.tsx +128 -0
  45. package/src/web/components/settings/settings-tab.tsx +4 -0
  46. package/src/web/hooks/use-chat.ts +17 -0
  47. package/test-session-ops.mjs +444 -0
  48. package/test-tokens.mjs +212 -0
  49. package/.claude.bak/agent-memory/tester/MEMORY.md +0 -3
  50. package/.claude.bak/agent-memory/tester/project-ppm-test-conventions.md +0 -32
  51. package/dist/web/assets/api-settings-Dh4oFOpX.js +0 -1
  52. package/dist/web/assets/chat-tab-C8HFXqGS.js +0 -8
  53. package/dist/web/assets/index-DhtLEnPD.css +0 -2
  54. package/dist/web/assets/keybindings-store-qVLDZz97.js +0 -1
@@ -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({
@@ -90,13 +90,17 @@ 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) => (
93
+ {filtered.map((msg, idx) => (
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 ? () => onFork(msg.content) : undefined}
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}
100
104
  />
101
105
  ))}
102
106
 
@@ -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(
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useRef } from "react";
2
- import { Activity, RefreshCw, Eye, Download, Upload, Plus, X, Settings } from "lucide-react";
2
+ import { Activity, RefreshCw, Eye, Download, Upload, Plus, X, Settings, Trash2 } from "lucide-react";
3
3
  import { Switch } from "@/components/ui/switch";
4
4
  import type { UsageInfo, LimitBucket } from "../../../types/chat";
5
5
  import {
@@ -7,6 +7,7 @@ import {
7
7
  getActiveAccount,
8
8
  getAllAccountUsages,
9
9
  patchAccount,
10
+ deleteAccount,
10
11
  type AccountInfo,
11
12
  type AccountUsageEntry,
12
13
  type OAuthProfileData,
@@ -152,11 +153,12 @@ function formatLastUpdated(ts: number | null | undefined): string | null {
152
153
  return `${days}d ago`;
153
154
  }
154
155
 
155
- function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, onViewProfile, flash }: {
156
+ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onDelete, onExport, onViewProfile, flash }: {
156
157
  entry: AccountUsageEntry;
157
158
  isActive: boolean;
158
159
  accountInfo?: AccountInfo;
159
160
  onToggle?: (id: string, status: string) => void;
161
+ onDelete?: (id: string, display: string) => void;
160
162
  onExport?: (id: string) => void;
161
163
  onViewProfile?: (profile: OAuthProfileData) => void;
162
164
  flash?: boolean;
@@ -164,19 +166,24 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
164
166
  const { usage } = entry;
165
167
  const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
166
168
  const status = accountInfo?.status ?? entry.accountStatus;
169
+ // Expired: has expiresAt in the past AND no refresh token to auto-renew
170
+ const isExpired = !!(accountInfo && !accountInfo.hasRefreshToken && accountInfo.expiresAt && accountInfo.expiresAt < Math.floor(Date.now() / 1000));
167
171
 
168
172
  return (
169
- <div className={`rounded-md border p-2 space-y-1.5 transition-colors duration-500 min-w-[200px] shrink-0 snap-start ${flash ? "bg-primary/10 border-primary/40" : ""} ${isActive ? "border-primary/30 bg-primary/5" : "border-border/50"}`}>
173
+ <div className={`rounded-md border p-2 space-y-1.5 transition-colors duration-500 min-w-[200px] shrink-0 snap-start ${isExpired ? "opacity-50" : ""} ${flash ? "bg-primary/10 border-primary/40" : ""} ${isActive ? "border-primary/30 bg-primary/5" : "border-border/50"}`}>
170
174
  <div className="flex items-center gap-1.5">
171
175
  <span className="text-xs font-medium truncate flex-1 min-w-0">
172
176
  {entry.accountLabel ?? entry.accountId.slice(0, 8)}
173
177
  </span>
174
- {!entry.isOAuth && (
178
+ {isExpired && (
179
+ <span className="text-[9px] text-red-500 shrink-0 font-medium">Expired</span>
180
+ )}
181
+ {!entry.isOAuth && !isExpired && (
175
182
  <span className="text-[9px] text-text-subtle shrink-0">API key</span>
176
183
  )}
177
184
  {/* Account controls */}
178
185
  <div className="flex items-center gap-0.5 shrink-0">
179
- {onViewProfile && accountInfo?.profileData && (
186
+ {!isExpired && onViewProfile && accountInfo?.profileData && (
180
187
  <button
181
188
  className="p-1 rounded cursor-pointer text-text-subtle hover:text-foreground hover:bg-surface-elevated transition-colors"
182
189
  onClick={() => onViewProfile(accountInfo.profileData!)}
@@ -185,7 +192,7 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
185
192
  <Eye className="size-3" />
186
193
  </button>
187
194
  )}
188
- {onExport && entry.isOAuth && (
195
+ {!isExpired && onExport && entry.isOAuth && (
189
196
  <button
190
197
  className="p-1 rounded cursor-pointer text-text-subtle hover:text-blue-500 hover:bg-surface-elevated transition-colors"
191
198
  onClick={() => onExport(entry.accountId)}
@@ -194,7 +201,7 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
194
201
  <Download className="size-3" />
195
202
  </button>
196
203
  )}
197
- {onToggle && (
204
+ {!isExpired && onToggle && (
198
205
  <Switch
199
206
  checked={status !== "disabled"}
200
207
  onCheckedChange={() => onToggle(entry.accountId, status)}
@@ -202,6 +209,15 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
202
209
  className="scale-[0.6] cursor-pointer"
203
210
  />
204
211
  )}
212
+ {onDelete && (
213
+ <button
214
+ className="p-1 rounded cursor-pointer text-text-subtle hover:text-red-500 hover:bg-surface-elevated transition-colors"
215
+ onClick={() => onDelete(entry.accountId, entry.accountLabel ?? entry.accountId.slice(0, 8))}
216
+ title="Remove account"
217
+ >
218
+ <Trash2 className="size-3" />
219
+ </button>
220
+ )}
205
221
  </div>
206
222
  </div>
207
223
  {hasBuckets ? (
@@ -247,6 +263,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
247
263
  const [showExportDialog, setShowExportDialog] = useState(false);
248
264
  const [showImportDialog, setShowImportDialog] = useState(false);
249
265
  const [showRotationSettings, setShowRotationSettings] = useState(false);
266
+ const [deleteTarget, setDeleteTarget] = useState<{ id: string; display: string } | null>(null);
250
267
  const [exportPreselect, setExportPreselect] = useState<string | null>(null);
251
268
  const [message, setMessage] = useState<string | null>(null);
252
269
  const msgTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -325,13 +342,26 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
325
342
  onReload?.();
326
343
  }
327
344
 
345
+ async function confirmDeleteAccount() {
346
+ if (!deleteTarget) return;
347
+ try {
348
+ await deleteAccount(deleteTarget.id);
349
+ showMessage(`Account "${deleteTarget.display}" removed.`);
350
+ loadAll();
351
+ onReload?.();
352
+ } catch (e) {
353
+ showMessage(`Failed to remove: ${(e as Error).message}`);
354
+ }
355
+ setDeleteTarget(null);
356
+ }
357
+
328
358
  function openExportAll() {
329
359
  setExportPreselect(null);
330
360
  setShowExportDialog(true);
331
361
  }
332
362
 
333
363
  return (
334
- <div className="border-b border-border bg-surface px-3 py-2.5 space-y-2.5 max-h-[350px] overflow-y-auto">
364
+ <div className="relative border-b border-border bg-surface px-3 py-2.5 space-y-2.5 max-h-[350px] overflow-y-auto">
335
365
  <div className="flex items-center justify-between">
336
366
  <div className="flex items-center gap-2">
337
367
  <span className="text-xs font-semibold text-text-primary">Usage & Accounts</span>
@@ -384,6 +414,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
384
414
  isActive={entry.accountId === (activeAccountId ?? usage.activeAccountId)}
385
415
  accountInfo={accountMap.get(entry.accountId)}
386
416
  onToggle={handleToggle}
417
+ onDelete={(id, display) => setDeleteTarget({ id, display })}
387
418
  onExport={(id) => { setExportPreselect(id); setShowExportDialog(true); }}
388
419
  onViewProfile={setProfileView}
389
420
  flash={flashIds.has(entry.accountId)}
@@ -460,6 +491,25 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
460
491
  </button>
461
492
  </div>
462
493
 
494
+ {/* Delete confirmation overlay */}
495
+ {deleteTarget && (
496
+ <div className="absolute inset-0 z-10 flex items-center justify-center bg-background/80 backdrop-blur-sm rounded-md">
497
+ <div className="bg-surface border border-border rounded-lg shadow-lg p-4 mx-4 max-w-[280px] w-full space-y-3">
498
+ <p className="text-xs text-text-primary text-center">
499
+ Remove <strong className="text-foreground">{deleteTarget.display}</strong>?
500
+ </p>
501
+ <div className="flex gap-2">
502
+ <button onClick={() => setDeleteTarget(null)} className="flex-1 px-3 py-1.5 rounded-md text-xs border border-border text-text-secondary hover:bg-surface-hover cursor-pointer transition-colors">
503
+ Cancel
504
+ </button>
505
+ <button onClick={confirmDeleteAccount} className="flex-1 px-3 py-1.5 rounded-md text-xs bg-red-500 text-white hover:bg-red-600 cursor-pointer transition-colors">
506
+ Remove
507
+ </button>
508
+ </div>
509
+ </div>
510
+ </div>
511
+ )}
512
+
463
513
  {/* Account dialogs */}
464
514
  <AddAccountDialog open={showAddDialog} onOpenChange={setShowAddDialog} onSuccess={handleSuccess} />
465
515
  <ExportAccountsDialog open={showExportDialog} onOpenChange={(v) => { setShowExportDialog(v); if (!v) setExportPreselect(null); }} accounts={accounts} preselectId={exportPreselect} onMessage={showMessage} />
@@ -12,7 +12,11 @@ interface UpgradeStatus {
12
12
  installMethod: string;
13
13
  }
14
14
 
15
- export function UpgradeBanner() {
15
+ interface UpgradeBannerProps {
16
+ onVisibilityChange?: (visible: boolean) => void;
17
+ }
18
+
19
+ export function UpgradeBanner({ onVisibilityChange }: UpgradeBannerProps) {
16
20
  const [availableVersion, setAvailableVersion] = useState<string | null>(null);
17
21
  const [upgrading, setUpgrading] = useState(false);
18
22
  const [dismissed, setDismissed] = useState(false);
@@ -61,10 +65,16 @@ export function UpgradeBanner() {
61
65
  setDismissed(true);
62
66
  }, [availableVersion]);
63
67
 
64
- if (!availableVersion || dismissed) return null;
68
+ const visible = !!availableVersion && !dismissed;
69
+
70
+ useEffect(() => {
71
+ onVisibilityChange?.(visible);
72
+ }, [visible, onVisibilityChange]);
73
+
74
+ if (!visible) return null;
65
75
 
66
76
  return (
67
- <div className="w-full bg-blue-600 dark:bg-blue-700 text-white px-3 py-2 flex items-center justify-between gap-2 z-50 text-sm shrink-0">
77
+ <div className="w-full bg-blue-600 dark:bg-blue-700 text-white px-3 py-1 flex items-center justify-between gap-2 z-50 text-sm shrink-0">
68
78
  {upgrading ? (
69
79
  <div className="flex items-center gap-2 flex-1 min-w-0">
70
80
  <Loader2 className="size-4 animate-spin shrink-0" />
@@ -83,13 +93,13 @@ export function UpgradeBanner() {
83
93
  <div className="flex items-center gap-1 shrink-0">
84
94
  <button
85
95
  onClick={handleUpgrade}
86
- className="bg-white text-blue-600 font-medium rounded-full px-3 min-h-[44px] min-w-[44px] flex items-center justify-center hover:bg-blue-50 active:bg-blue-100 transition-colors"
96
+ className="bg-white text-blue-600 font-medium rounded-full px-3 py-0.5 text-xs min-h-[28px] min-w-[28px] flex items-center justify-center hover:bg-blue-50 active:bg-blue-100 transition-colors"
87
97
  >
88
98
  Upgrade
89
99
  </button>
90
100
  <button
91
101
  onClick={handleDismiss}
92
- className="min-h-[44px] min-w-[44px] flex items-center justify-center rounded-full hover:bg-blue-500 active:bg-blue-800 transition-colors"
102
+ className="min-h-[28px] min-w-[28px] flex items-center justify-center rounded-full hover:bg-blue-500 active:bg-blue-800 transition-colors"
93
103
  aria-label="Dismiss upgrade notification"
94
104
  >
95
105
  <X className="size-4" />
@@ -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,