@hienlh/ppm 0.9.0-beta.8 → 0.9.1

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 (159) hide show
  1. package/CHANGELOG.md +238 -0
  2. package/bun.lock +17 -0
  3. package/dist/web/assets/api-settings-BUvk6Saw.js +1 -0
  4. package/dist/web/assets/arrow-up-BYhx9ckd.js +1 -0
  5. package/dist/web/assets/browser-tab-CrkhFCaw.js +1 -0
  6. package/dist/web/assets/chat-tab-C6jpiwh7.js +8 -0
  7. package/dist/web/assets/chevron-right-5HgK6l7K.js +1 -0
  8. package/dist/web/assets/code-editor-CBIPzlP2.js +2 -0
  9. package/dist/web/assets/columns-2-cEVJHYd7.js +1 -0
  10. package/dist/web/assets/createLucideIcon-PuMiQgHl.js +1 -0
  11. package/dist/web/assets/{csv-preview-DLqYtXxt.js → csv-preview-ncSOnJSC.js} +2 -2
  12. package/dist/web/assets/database-viewer-BqOJR_zi.js +1 -0
  13. package/dist/web/assets/diff-viewer-CcLyp4eY.js +4 -0
  14. package/dist/web/assets/{dist-CALwEtco.js → dist-DIV6WgAG.js} +1 -1
  15. package/dist/web/assets/{dist-DGDPTxs1.js → dist-ovWkrgO-.js} +1 -1
  16. package/dist/web/assets/extension-webview-NiZ7Ybvv.js +3 -0
  17. package/dist/web/assets/git-graph-CoTvMrIo.js +1 -0
  18. package/dist/web/assets/index-C8byznLO.js +37 -0
  19. package/dist/web/assets/index-KwC2YrG4.css +2 -0
  20. package/dist/web/assets/jsx-runtime-kMwlnEGE.js +1 -0
  21. package/dist/web/assets/keybindings-store-DPYzBe_M.js +1 -0
  22. package/dist/web/assets/{markdown-renderer-DklUd_Gv.js → markdown-renderer-DPLdR9xc.js} +4 -4
  23. package/dist/web/assets/postgres-viewer-BeiK4lCa.js +1 -0
  24. package/dist/web/assets/settings-tab-D3AvU4lu.js +1 -0
  25. package/dist/web/assets/sqlite-viewer-nA2sD4Yv.js +1 -0
  26. package/dist/web/assets/tab-store-BOgTrqRr.js +1 -0
  27. package/dist/web/assets/table-DFevCOMd.js +1 -0
  28. package/dist/web/assets/tag-CXMT0QB6.js +1 -0
  29. package/dist/web/assets/{terminal-tab-CqRuiIFn.js → terminal-tab-BBi0pEji.js} +2 -2
  30. package/dist/web/assets/{use-monaco-theme-Dcz3aLAE.js → use-monaco-theme-B5pG2d1w.js} +1 -1
  31. package/dist/web/index.html +8 -8
  32. package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
  33. package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
  34. package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
  35. package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
  36. package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
  37. package/dist/web/sw.js +1 -1
  38. package/docs/code-standards.md +128 -1
  39. package/docs/codebase-summary.md +79 -12
  40. package/docs/extension-development-guide.md +532 -0
  41. package/docs/project-changelog.md +51 -1
  42. package/docs/project-roadmap.md +9 -3
  43. package/docs/streaming-input-guide.md +267 -0
  44. package/docs/system-architecture.md +432 -3
  45. package/package.json +6 -3
  46. package/packages/ext-database/package.json +41 -0
  47. package/packages/ext-database/src/connection-tree.ts +142 -0
  48. package/packages/ext-database/src/extension.ts +346 -0
  49. package/packages/ext-database/src/query-panel.ts +120 -0
  50. package/packages/ext-database/src/table-viewer-panel.ts +410 -0
  51. package/packages/ext-database/tsconfig.json +8 -0
  52. package/packages/vscode-compat/package.json +16 -0
  53. package/packages/vscode-compat/src/commands.ts +39 -0
  54. package/packages/vscode-compat/src/context.ts +65 -0
  55. package/packages/vscode-compat/src/disposable.ts +21 -0
  56. package/packages/vscode-compat/src/env.ts +20 -0
  57. package/packages/vscode-compat/src/event-emitter.ts +28 -0
  58. package/packages/vscode-compat/src/index.ts +93 -0
  59. package/packages/vscode-compat/src/not-supported.ts +15 -0
  60. package/packages/vscode-compat/src/types.ts +167 -0
  61. package/packages/vscode-compat/src/uri.ts +65 -0
  62. package/packages/vscode-compat/src/window.ts +229 -0
  63. package/packages/vscode-compat/src/workspace.ts +76 -0
  64. package/packages/vscode-compat/tsconfig.json +10 -0
  65. package/snapshot-state.md +1526 -0
  66. package/src/cli/commands/autostart.ts +1 -1
  67. package/src/cli/commands/ext-cmd.ts +121 -0
  68. package/src/cli/commands/restart.ts +9 -1
  69. package/src/cli/commands/status.ts +19 -0
  70. package/src/index.ts +5 -3
  71. package/src/providers/claude-agent-sdk.ts +221 -17
  72. package/src/providers/cli-provider-base.ts +6 -0
  73. package/src/server/index.ts +55 -155
  74. package/src/server/routes/chat.ts +81 -11
  75. package/src/server/routes/extensions.ts +81 -0
  76. package/src/server/routes/project-scoped.ts +2 -0
  77. package/src/server/routes/settings.ts +27 -0
  78. package/src/server/routes/workspace.ts +35 -0
  79. package/src/server/ws/chat.ts +9 -3
  80. package/src/server/ws/extensions.ts +175 -0
  81. package/src/services/account-selector.service.ts +14 -5
  82. package/src/services/account.service.ts +20 -15
  83. package/src/services/claude-usage.service.ts +29 -24
  84. package/src/services/cloud-ws.service.ts +228 -0
  85. package/src/services/cloud.service.ts +11 -6
  86. package/src/services/contribution-registry.ts +110 -0
  87. package/src/services/db.service.ts +181 -4
  88. package/src/services/extension-host-worker.ts +160 -0
  89. package/src/services/extension-installer.ts +112 -0
  90. package/src/services/extension-manifest.ts +65 -0
  91. package/src/services/extension-rpc-handlers.ts +235 -0
  92. package/src/services/extension-rpc.ts +105 -0
  93. package/src/services/extension.service.ts +228 -0
  94. package/src/services/mcp-config.service.ts +15 -6
  95. package/src/services/supervisor.ts +271 -25
  96. package/src/types/api.ts +1 -0
  97. package/src/types/chat.ts +4 -0
  98. package/src/types/extension-messages.ts +64 -0
  99. package/src/types/extension.ts +131 -0
  100. package/src/web/app.tsx +69 -48
  101. package/src/web/components/chat/account-rotation-settings.tsx +163 -0
  102. package/src/web/components/chat/chat-history-bar.tsx +106 -10
  103. package/src/web/components/chat/chat-tab.tsx +15 -10
  104. package/src/web/components/chat/chat-welcome.tsx +148 -0
  105. package/src/web/components/chat/message-list.tsx +19 -6
  106. package/src/web/components/chat/session-picker.tsx +80 -32
  107. package/src/web/components/chat/usage-badge.tsx +68 -8
  108. package/src/web/components/editor/editor-breadcrumb.tsx +20 -29
  109. package/src/web/components/extensions/extension-inputbox.tsx +92 -0
  110. package/src/web/components/extensions/extension-quickpick.tsx +194 -0
  111. package/src/web/components/extensions/extension-tree-view.tsx +240 -0
  112. package/src/web/components/extensions/extension-webview.tsx +83 -0
  113. package/src/web/components/layout/command-palette.tsx +22 -2
  114. package/src/web/components/layout/editor-panel.tsx +163 -18
  115. package/src/web/components/layout/mobile-nav.tsx +2 -1
  116. package/src/web/components/layout/sidebar.tsx +21 -3
  117. package/src/web/components/layout/status-bar.tsx +64 -0
  118. package/src/web/components/layout/tab-bar.tsx +2 -0
  119. package/src/web/components/layout/tab-content.tsx +5 -0
  120. package/src/web/components/layout/upgrade-banner.tsx +15 -5
  121. package/src/web/components/settings/change-password-section.tsx +128 -0
  122. package/src/web/components/settings/extension-manager-section.tsx +214 -0
  123. package/src/web/components/settings/settings-tab.tsx +9 -2
  124. package/src/web/components/shared/connection-lost-overlay.tsx +89 -0
  125. package/src/web/hooks/use-chat.ts +28 -0
  126. package/src/web/hooks/use-extension-ws.ts +181 -0
  127. package/src/web/hooks/use-global-keybindings.ts +18 -2
  128. package/src/web/hooks/use-server-reload.ts +9 -0
  129. package/src/web/hooks/use-url-sync.ts +173 -21
  130. package/src/web/stores/connection-store.ts +39 -0
  131. package/src/web/stores/extension-store.ts +204 -0
  132. package/src/web/stores/panel-store.ts +63 -9
  133. package/src/web/stores/panel-utils.ts +145 -3
  134. package/src/web/stores/settings-store.ts +7 -2
  135. package/src/web/stores/tab-store.ts +2 -1
  136. package/test-session-ops.mjs +444 -0
  137. package/test-tokens.mjs +212 -0
  138. package/tsconfig.json +3 -1
  139. package/dist/web/assets/api-settings-D21InCnR.js +0 -1
  140. package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
  141. package/dist/web/assets/browser-tab-BEe89aSD.js +0 -1
  142. package/dist/web/assets/chat-tab-9lqvWozA.js +0 -7
  143. package/dist/web/assets/chevron-right-CHnjJt4E.js +0 -1
  144. package/dist/web/assets/code-editor-COAIZx-B.js +0 -2
  145. package/dist/web/assets/columns-2-DbesTfa7.js +0 -1
  146. package/dist/web/assets/database-viewer-aRR9n_Ui.js +0 -1
  147. package/dist/web/assets/diff-viewer-C4KMvpHr.js +0 -4
  148. package/dist/web/assets/dist-CVTST7Gc.js +0 -1
  149. package/dist/web/assets/git-graph-CfJjl4E3.js +0 -1
  150. package/dist/web/assets/index-Db8uky1a.css +0 -2
  151. package/dist/web/assets/index-DxZuwBDe.js +0 -37
  152. package/dist/web/assets/jsx-runtime-BRW_vwa9.js +0 -1
  153. package/dist/web/assets/keybindings-store-_uWVCZMv.js +0 -1
  154. package/dist/web/assets/postgres-viewer-DEAvAyaX.js +0 -1
  155. package/dist/web/assets/settings-tab-BQedc-No.js +0 -1
  156. package/dist/web/assets/sqlite-viewer-BPA5idzT.js +0 -1
  157. package/dist/web/assets/tab-store-DhK6EpBT.js +0 -1
  158. package/dist/web/assets/table-CQVQM2SB.js +0 -1
  159. package/dist/web/assets/tag-Q2dZiSPX.js +0 -1
package/src/web/app.tsx CHANGED
@@ -10,12 +10,18 @@ import { ProjectBottomSheet } from "@/components/layout/project-bottom-sheet";
10
10
  import { LoginScreen } from "@/components/auth/login-screen";
11
11
  import { useProjectStore, resolveOrder } from "@/stores/project-store";
12
12
  import { useTabStore } from "@/stores/tab-store";
13
+ import { usePanelStore } from "@/stores/panel-store";
14
+ import {
15
+ fetchWorkspaceFromServer,
16
+ resolveWorkspaceConflict,
17
+ savePanelLayout,
18
+ } from "@/stores/panel-utils";
13
19
  import {
14
20
  useSettingsStore,
15
21
  applyThemeClass,
16
22
  } from "@/stores/settings-store";
17
23
  import { getAuthToken } from "@/lib/api-client";
18
- import { useUrlSync, parseUrlState } from "@/hooks/use-url-sync";
24
+ import { useUrlSync, parseUrlState, autoOpenFromUrl } from "@/hooks/use-url-sync";
19
25
  import { useGlobalKeybindings } from "@/hooks/use-global-keybindings";
20
26
  import { useNotificationBadge } from "@/hooks/use-notification-badge";
21
27
  import { useServerReload } from "@/hooks/use-server-reload";
@@ -24,12 +30,18 @@ import { BugReportPopup } from "@/components/shared/bug-report-popup";
24
30
  import { UpgradeBanner } from "@/components/layout/upgrade-banner";
25
31
  import { ImageOverlay } from "@/components/shared/image-overlay";
26
32
  import { DiagramOverlay } from "@/components/shared/diagram-overlay";
33
+ import { ConnectionLostOverlay } from "@/components/shared/connection-lost-overlay";
34
+ import { StatusBar } from "@/components/layout/status-bar";
35
+ import { ExtensionQuickPick } from "@/components/extensions/extension-quickpick";
36
+ import { ExtensionInputBox } from "@/components/extensions/extension-inputbox";
37
+ import { useExtensionWs } from "@/hooks/use-extension-ws";
27
38
  import { cn } from "@/lib/utils";
28
39
 
29
40
  type AuthState = "checking" | "authenticated" | "unauthenticated";
30
41
 
31
42
  export function App() {
32
43
  const [authState, setAuthState] = useState<AuthState>("checking");
44
+ const [upgradeBannerVisible, setUpgradeBannerVisible] = useState(false);
33
45
  const [drawerOpen, setDrawerOpen] = useState(false);
34
46
  const [drawerTab, setDrawerTab] = useState<"explorer" | "git" | "settings" | undefined>();
35
47
  const [projectSheetOpen, setProjectSheetOpen] = useState(false);
@@ -109,6 +121,9 @@ export function App() {
109
121
  // Auto-reload when server restarts (clears SW cache first)
110
122
  useServerReload();
111
123
 
124
+ // Extension WS bridge — connects to /ws/extensions for UI updates (only after auth)
125
+ useExtensionWs(authState === "authenticated");
126
+
112
127
  // Warn before closing browser tab (prevents accidental Ctrl+W)
113
128
  useEffect(() => {
114
129
  if (authState !== "authenticated") return;
@@ -127,56 +142,52 @@ export function App() {
127
142
  });
128
143
  }, [authState]);
129
144
 
130
- // Fetch projects after auth, then restore from URL if applicable
145
+ // Fetch projects after auth, then restore workspace + URL
131
146
  useEffect(() => {
132
147
  if (authState !== "authenticated") return;
133
148
 
134
- fetchProjects().then(() => {
135
- const { projectName: urlProject, tabId: urlTab, openChat } = initialUrlRef.current;
149
+ fetchProjects().then(async () => {
150
+ const urlState = initialUrlRef.current;
136
151
  const { projects, customOrder } = useProjectStore.getState();
137
152
  if (projects.length === 0) return;
138
153
 
139
154
  // URL project takes priority, then fall back to first sorted project
140
- let target = urlProject ? projects.find((p) => p.name === urlProject) : undefined;
155
+ let target = urlState.projectName
156
+ ? projects.find((p) => p.name === urlState.projectName)
157
+ : undefined;
141
158
  if (!target) {
142
159
  target = resolveOrder(projects, customOrder)[0];
143
160
  }
144
- if (target) {
145
- useProjectStore.getState().setActiveProject(target);
146
- if (urlProject && urlTab) {
147
- queueMicrotask(() => {
148
- const { tabs } = useTabStore.getState();
149
- if (tabs.some((t) => t.id === urlTab)) {
150
- useTabStore.getState().setActiveTab(urlTab);
151
- }
152
- });
161
+ if (!target) return;
162
+
163
+ useProjectStore.getState().setActiveProject(target);
164
+
165
+ // Fetch server workspace + compare with localStorage (latest-wins)
166
+ const serverLayout = await fetchWorkspaceFromServer(target.name);
167
+ if (serverLayout) {
168
+ const localRaw = localStorage.getItem(`ppm-panels-${target.name}`);
169
+ const localLayout = localRaw ? JSON.parse(localRaw) : null;
170
+ const resolved = resolveWorkspaceConflict(localLayout, serverLayout);
171
+ if (resolved && resolved === serverLayout) {
172
+ // Server wins — overwrite localStorage and reload panels
173
+ savePanelLayout(target.name, resolved);
174
+ usePanelStore.getState().reloadProject(target.name);
153
175
  }
154
176
  }
155
177
 
156
- // Deep link: ?openChat=sessionId open/focus the chat tab
157
- if (openChat) {
158
- queueMicrotask(() => {
159
- const { tabs, setActiveTab, openTab } = useTabStore.getState();
160
- const existing = tabs.find(
161
- (t) => t.type === "chat" && t.metadata?.sessionId === openChat,
162
- );
163
- if (existing) {
164
- setActiveTab(existing.id);
165
- } else {
166
- openTab({
167
- type: "chat",
168
- title: "Chat",
169
- projectId: target?.name ?? null,
170
- closable: true,
171
- metadata: { sessionId: openChat },
172
- });
173
- }
174
- // Clean up query param
178
+ // Auto-open target tab from URL (type-based)
179
+ queueMicrotask(() => {
180
+ if (urlState.tabType) {
181
+ autoOpenFromUrl(urlState.tabType, urlState.tabIdentifier, target!.name);
182
+ }
183
+ // Legacy: ?openChat= query param
184
+ if (urlState.openChat) {
185
+ autoOpenFromUrl("chat", urlState.openChat, target!.name);
175
186
  const url = new URL(window.location.href);
176
187
  url.searchParams.delete("openChat");
177
188
  window.history.replaceState(null, "", url.pathname);
178
- });
179
- }
189
+ }
190
+ });
180
191
  });
181
192
  }, [authState, fetchProjects]);
182
193
 
@@ -226,11 +237,11 @@ export function App() {
226
237
  <TooltipProvider>
227
238
  <div className="h-dvh flex flex-col bg-background text-foreground overflow-hidden relative">
228
239
  {/* Upgrade banner — shown when new version available */}
229
- <UpgradeBanner />
240
+ <UpgradeBanner onVisibilityChange={setUpgradeBannerVisible} />
230
241
 
231
242
  {/* Mobile device name badge — floating top-left */}
232
243
  {deviceName && (
233
- <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">
244
+ <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")}>
234
245
  {deviceName}
235
246
  </div>
236
247
  )}
@@ -244,17 +255,20 @@ export function App() {
244
255
  <Sidebar />
245
256
 
246
257
  {/* Content area — keep-alive per project */}
247
- {[...mountedProjects].map((projectName) => (
248
- <div
249
- key={projectName}
250
- className={cn(
251
- "flex-1 overflow-hidden pb-12 md:pb-0",
252
- activeProjectName !== projectName && "hidden",
253
- )}
254
- >
255
- <PanelLayout projectName={projectName} />
256
- </div>
257
- ))}
258
+ <div className="flex-1 flex flex-col overflow-hidden">
259
+ {[...mountedProjects].map((projectName) => (
260
+ <div
261
+ key={projectName}
262
+ className={cn(
263
+ "flex-1 overflow-hidden pb-12 md:pb-0",
264
+ activeProjectName !== projectName && "hidden",
265
+ )}
266
+ >
267
+ <PanelLayout projectName={projectName} />
268
+ </div>
269
+ ))}
270
+ <StatusBar />
271
+ </div>
258
272
  </div>
259
273
 
260
274
  {/* Mobile bottom nav */}
@@ -288,6 +302,13 @@ export function App() {
288
302
  {/* Global diagram lightbox (mermaid) */}
289
303
  <DiagramOverlay />
290
304
 
305
+ {/* Extension modals (QuickPick, InputBox) */}
306
+ <ExtensionQuickPick />
307
+ <ExtensionInputBox />
308
+
309
+ {/* Connection lost overlay — shown when API unreachable for >15s */}
310
+ <ConnectionLostOverlay />
311
+
291
312
  {/* Toast notifications */}
292
313
  <Toaster
293
314
  position="bottom-left"
@@ -0,0 +1,163 @@
1
+ import { useState, useEffect, useSyncExternalStore } from "react";
2
+ import { Settings, X } from "lucide-react";
3
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
4
+ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
5
+ import { cn } from "@/lib/utils";
6
+ import {
7
+ getAccountSettings,
8
+ updateAccountSettings,
9
+ type AccountSettings,
10
+ } from "../../lib/api-settings";
11
+
12
+ interface AccountRotationSettingsProps {
13
+ open: boolean;
14
+ onOpenChange: (open: boolean) => void;
15
+ }
16
+
17
+ const mdQuery = typeof window !== "undefined" ? window.matchMedia("(min-width: 768px)") : null;
18
+ function subscribeMedia(cb: () => void) {
19
+ mdQuery?.addEventListener("change", cb);
20
+ return () => mdQuery?.removeEventListener("change", cb);
21
+ }
22
+ function getIsDesktop() {
23
+ return mdQuery?.matches ?? true;
24
+ }
25
+
26
+ function SettingsContent() {
27
+ const [settings, setSettings] = useState<AccountSettings | null>(null);
28
+ const [loading, setLoading] = useState(true);
29
+
30
+ useEffect(() => {
31
+ setLoading(true);
32
+ getAccountSettings()
33
+ .then(setSettings)
34
+ .finally(() => setLoading(false));
35
+ }, []);
36
+
37
+ if (loading) {
38
+ return <p className="text-xs text-text-subtle py-4 text-center">Loading...</p>;
39
+ }
40
+ if (!settings) {
41
+ return <p className="text-xs text-text-subtle py-4 text-center">Failed to load settings</p>;
42
+ }
43
+
44
+ return (
45
+ <div className="space-y-4">
46
+ {/* Strategy */}
47
+ <div className="space-y-1.5">
48
+ <label className="text-xs font-medium text-text-primary">Rotation Strategy</label>
49
+ <Select
50
+ value={settings.strategy}
51
+ onValueChange={async (v) => {
52
+ const updated = await updateAccountSettings({ strategy: v as AccountSettings["strategy"] });
53
+ setSettings(updated);
54
+ }}
55
+ >
56
+ <SelectTrigger className="w-full h-9 text-xs">
57
+ <SelectValue />
58
+ </SelectTrigger>
59
+ <SelectContent>
60
+ <SelectItem value="round-robin">Round-robin</SelectItem>
61
+ <SelectItem value="fill-first">Fill-first</SelectItem>
62
+ <SelectItem value="lowest-usage">Lowest usage</SelectItem>
63
+ </SelectContent>
64
+ </Select>
65
+ <p className="text-[10px] text-text-subtle">
66
+ {settings.strategy === "round-robin" && "Cycles through accounts evenly"}
67
+ {settings.strategy === "fill-first" && "Uses one account until its limit, then moves on"}
68
+ {settings.strategy === "lowest-usage" && "Picks the account with the lowest current usage"}
69
+ </p>
70
+ </div>
71
+
72
+ {/* Max Retry */}
73
+ <div className="space-y-1.5">
74
+ <label className="text-xs font-medium text-text-primary">Max Retry</label>
75
+ <input
76
+ type="number"
77
+ min={0}
78
+ value={settings.maxRetry}
79
+ className="w-full h-9 text-xs border rounded-md px-3 bg-background"
80
+ onChange={async (e) => {
81
+ const v = parseInt(e.target.value, 10);
82
+ if (!isNaN(v) && v >= 0) {
83
+ const updated = await updateAccountSettings({ maxRetry: v });
84
+ setSettings(updated);
85
+ }
86
+ }}
87
+ />
88
+ <p className="text-[10px] text-text-subtle">
89
+ How many accounts to try on failure. 0 = try all available accounts.
90
+ </p>
91
+ </div>
92
+
93
+ {/* Active accounts */}
94
+ <div className="flex items-center justify-between text-xs border-t border-border pt-3">
95
+ <span className="text-text-subtle">Active accounts</span>
96
+ <span className="font-medium text-text-primary">{settings.activeCount}</span>
97
+ </div>
98
+ </div>
99
+ );
100
+ }
101
+
102
+ export function AccountRotationSettings({ open, onOpenChange }: AccountRotationSettingsProps) {
103
+ const isDesktop = useSyncExternalStore(subscribeMedia, getIsDesktop);
104
+
105
+ if (!open) return null;
106
+
107
+ // Desktop: Dialog
108
+ if (isDesktop) {
109
+ return (
110
+ <Dialog open={open} onOpenChange={onOpenChange}>
111
+ <DialogContent className="sm:max-w-sm">
112
+ <DialogHeader>
113
+ <DialogTitle className="text-sm flex items-center gap-2">
114
+ <Settings className="size-4" /> Rotation & Retry
115
+ </DialogTitle>
116
+ </DialogHeader>
117
+ <SettingsContent />
118
+ </DialogContent>
119
+ </Dialog>
120
+ );
121
+ }
122
+
123
+ // Mobile: Bottom sheet
124
+ return (
125
+ <>
126
+ <div
127
+ className="fixed inset-0 z-50 transition-opacity duration-200 opacity-100"
128
+ onClick={() => onOpenChange(false)}
129
+ style={{ backgroundColor: "rgba(0,0,0,0.5)" }}
130
+ />
131
+ <div
132
+ className={cn(
133
+ "fixed bottom-0 left-0 right-0 z-50 bg-background rounded-t-2xl border-t border-border shadow-2xl",
134
+ "transition-transform duration-300 ease-out max-h-[85vh] overflow-y-auto",
135
+ "translate-y-0",
136
+ )}
137
+ >
138
+ {/* Drag handle */}
139
+ <div className="flex justify-center pt-3 pb-1">
140
+ <div className="w-10 h-1 rounded-full bg-border" />
141
+ </div>
142
+
143
+ {/* Header */}
144
+ <div className="flex items-center justify-between px-4 py-2 border-b border-border">
145
+ <span className="text-sm font-semibold flex items-center gap-2">
146
+ <Settings className="size-4" /> Rotation & Retry
147
+ </span>
148
+ <button
149
+ onClick={() => onOpenChange(false)}
150
+ className="flex items-center justify-center size-7 rounded-md hover:bg-surface-elevated transition-colors"
151
+ >
152
+ <X className="size-4" />
153
+ </button>
154
+ </div>
155
+
156
+ {/* Content */}
157
+ <div className="px-4 py-4 pb-8">
158
+ <SettingsContent />
159
+ </div>
160
+ </div>
161
+ </>
162
+ );
163
+ }
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useCallback, useRef } from "react";
2
- import { History, Settings2, Loader2, RefreshCw, Search, Pencil, Check, X, BellOff } from "lucide-react";
2
+ import { History, Settings2, Loader2, MessageSquare, RefreshCw, Search, Pencil, Check, X, BellOff, Bug, ClipboardCheck, Pin, PinOff, Trash2 } from "lucide-react";
3
3
  import { Activity } from "lucide-react";
4
4
  import { api, projectUrl } from "@/lib/api-client";
5
5
  import { useTabStore } from "@/stores/tab-store";
@@ -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;
@@ -50,8 +51,36 @@ function pctColor(pct: number): string {
50
51
  return "text-green-500";
51
52
  }
52
53
 
54
+ function DebugCopyButton({ sessionId, projectName }: { sessionId: string; projectName: string }) {
55
+ const [copied, setCopied] = useState(false);
56
+ return (
57
+ <button
58
+ onClick={async () => {
59
+ try {
60
+ const data = await api.get<{ ppmSessionId: string; sdkSessionId: string; jsonlPath: string | null; projectPath: string }>(
61
+ `${projectUrl(projectName)}/chat/sessions/${sessionId}/debug?project=${encodeURIComponent(projectName)}`,
62
+ );
63
+ const info = [
64
+ `PPM Session: ${data.ppmSessionId}`,
65
+ `SDK Session: ${data.sdkSessionId}`,
66
+ data.jsonlPath ? `JSONL: ${data.jsonlPath}` : `JSONL: not found`,
67
+ data.projectPath ? `Project: ${data.projectPath}` : null,
68
+ ].filter(Boolean).join("\n");
69
+ await navigator.clipboard.writeText(info);
70
+ setCopied(true);
71
+ setTimeout(() => setCopied(false), 1500);
72
+ } catch { /* silent */ }
73
+ }}
74
+ className={`p-1 rounded transition-colors ${copied ? "text-green-500 bg-green-500/10" : "text-text-subtle hover:text-text-secondary hover:bg-surface-elevated"}`}
75
+ title={copied ? "Copied!" : "Copy session debug info"}
76
+ >
77
+ {copied ? <ClipboardCheck className="size-3" /> : <Bug className="size-3" />}
78
+ </button>
79
+ );
80
+ }
81
+
53
82
  export function ChatHistoryBar({
54
- projectName, usageInfo, contextWindowPct, usageLoading, refreshUsage, lastFetchedAt,
83
+ projectName, usageInfo, contextWindowPct, compactStatus, usageLoading, refreshUsage, lastFetchedAt,
55
84
  sessionId, providerId, onSelectSession, onBugReport, isConnected, onReconnect,
56
85
  }: ChatHistoryBarProps) {
57
86
  const [activePanel, setActivePanel] = useState<PanelType>(null);
@@ -123,6 +152,37 @@ export function ChatHistoryBar({
123
152
 
124
153
  const cancelEditing = useCallback(() => setEditingId(null), []);
125
154
 
155
+ const togglePin = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
156
+ e.stopPropagation();
157
+ if (!projectName) return;
158
+ const url = `${projectUrl(projectName)}/chat/sessions/${session.id}/pin`;
159
+ try {
160
+ if (session.pinned) {
161
+ await api.del(url);
162
+ } else {
163
+ await api.put(url);
164
+ }
165
+ setSessions((prev) => {
166
+ const updated = prev.map((s) => s.id === session.id ? { ...s, pinned: !s.pinned } : s);
167
+ return updated.sort((a, b) => {
168
+ if (a.pinned && !b.pinned) return -1;
169
+ if (!a.pinned && b.pinned) return 1;
170
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
171
+ });
172
+ });
173
+ } catch { /* silent */ }
174
+ }, [projectName]);
175
+
176
+ const deleteSession = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
177
+ e.stopPropagation();
178
+ if (!projectName) return;
179
+ if (!window.confirm("Delete this session? This cannot be undone.")) return;
180
+ try {
181
+ await api.del(`${projectUrl(projectName)}/chat/sessions/${session.id}?providerId=${session.providerId}`);
182
+ setSessions((prev) => prev.filter((s) => s.id !== session.id));
183
+ } catch { /* silent */ }
184
+ }, [projectName]);
185
+
126
186
  // Filter sessions by search query
127
187
  const filteredSessions = searchQuery.trim()
128
188
  ? sessions.filter((s) => (s.title || "").toLowerCase().includes(searchQuery.toLowerCase()))
@@ -191,14 +251,27 @@ export function ChatHistoryBar({
191
251
  <span className={pctColor(contextWindowPct)}>Ctx:{contextWindowPct}%</span>
192
252
  </>
193
253
  )}
254
+ {compactStatus === "compacting" && (
255
+ <>
256
+ <span className="text-text-subtle">·</span>
257
+ <span className="text-blue-400 animate-pulse">compacting...</span>
258
+ </>
259
+ )}
194
260
  </button>
195
261
  ) : (
196
- contextWindowPct != null && (
197
- <span className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] font-medium tabular-nums ${pctColor(contextWindowPct)}`}>
198
- <Activity className="size-3" />
199
- <span>Ctx:{contextWindowPct}%</span>
200
- </span>
201
- )
262
+ <>
263
+ {contextWindowPct != null && (
264
+ <span className={`flex items-center gap-1 px-1.5 py-0.5 text-[11px] font-medium tabular-nums ${pctColor(contextWindowPct)}`}>
265
+ <Activity className="size-3" />
266
+ <span>Ctx:{contextWindowPct}%</span>
267
+ </span>
268
+ )}
269
+ {compactStatus === "compacting" && (
270
+ <span className="text-[11px] px-1.5 py-0.5 text-blue-400 animate-pulse">
271
+ compacting...
272
+ </span>
273
+ )}
274
+ </>
202
275
  )}
203
276
 
204
277
  {/* Spacer */}
@@ -215,6 +288,11 @@ export function ChatHistoryBar({
215
288
  </button>
216
289
  )}
217
290
 
291
+ {/* Debug info — copy session IDs + JSONL path */}
292
+ {sessionId && (
293
+ <DebugCopyButton sessionId={sessionId} projectName={projectName} />
294
+ )}
295
+
218
296
  {/* Connection indicator */}
219
297
  {onReconnect && (
220
298
  <button
@@ -297,17 +375,35 @@ export function ChatHistoryBar({
297
375
  >
298
376
  {session.title || "Untitled"}
299
377
  </button>
378
+ <button
379
+ onClick={(e) => togglePin(e, session)}
380
+ className={`p-0.5 rounded transition-all ${
381
+ session.pinned
382
+ ? "text-primary hover:text-primary/70"
383
+ : "text-text-subtle hover:text-text-secondary md:opacity-0 md:group-hover:opacity-100"
384
+ }`}
385
+ title={session.pinned ? "Unpin session" : "Pin session"}
386
+ >
387
+ {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
388
+ </button>
300
389
  <button
301
390
  onClick={(e) => startEditing(session, e)}
302
- className="p-0.5 rounded text-text-subtle hover:text-text-secondary opacity-0 group-hover:opacity-100 transition-opacity"
391
+ className="p-0.5 rounded text-text-subtle hover:text-text-secondary md:opacity-0 md:group-hover:opacity-100 transition-opacity"
303
392
  title="Rename session"
304
393
  >
305
394
  <Pencil className="size-3" />
306
395
  </button>
396
+ <button
397
+ onClick={(e) => deleteSession(e, session)}
398
+ className="p-0.5 rounded text-text-subtle hover:text-red-400 hover:bg-red-500/20 md:opacity-0 md:group-hover:opacity-100 transition-opacity"
399
+ title="Delete session"
400
+ >
401
+ <Trash2 className="size-3" />
402
+ </button>
307
403
  </>
308
404
  )}
309
405
  {editingId !== session.id && session.updatedAt && (
310
- <span className="text-[10px] text-text-subtle shrink-0">{formatDate(session.updatedAt)}</span>
406
+ <span className="text-[10px] text-text-subtle shrink-0 w-10 text-right">{formatDate(session.updatedAt)}</span>
311
407
  )}
312
408
  </div>
313
409
  ))
@@ -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,
@@ -134,14 +135,12 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
134
135
  }
135
136
  }, [sessionTitle]); // eslint-disable-line react-hooks/exhaustive-deps
136
137
 
137
- // Auto-send pending message for forked sessions (set by handleFork)
138
- const pendingForkMsgRef = useRef(metadata?.pendingMessage as string | undefined);
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);
139
140
  useEffect(() => {
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);
141
+ if (forkDraft && isConnected && sessionId && tabId) {
142
+ // Clear from tab metadata once consumed
143
+ updateTab(tabId, { metadata: { ...metadata, pendingMessage: undefined } });
145
144
  }
146
145
  }, [isConnected, sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
147
146
 
@@ -162,12 +161,13 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
162
161
  }, [tabId, updateTab]);
163
162
 
164
163
  /** Fork current session and open new tab with the forked session, resending userMessage */
165
- const handleFork = useCallback(async (userMessage: string) => {
164
+ const handleFork = useCallback(async (userMessage: string, messageId?: string) => {
166
165
  if (!sessionId || !projectName) return;
167
166
  try {
168
167
  const { api, projectUrl } = await import("@/lib/api-client");
169
168
  const forked = await api.post<{ id: string; forkedFrom: string }>(
170
169
  `${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,6 +350,7 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
350
350
  projectName={projectName}
351
351
  usageInfo={usageInfo}
352
352
  contextWindowPct={contextWindowPct}
353
+ compactStatus={compactStatus}
353
354
  usageLoading={usageLoading}
354
355
  refreshUsage={refreshUsage}
355
356
  lastFetchedAt={lastFetchedAt}
@@ -382,10 +383,14 @@ export function ChatTab({ metadata, tabId }: ChatTabProps) {
382
383
 
383
384
  {/* Input */}
384
385
  <MessageInput
385
- onSend={handleSend}
386
+ onSend={(content, attachments, priority) => {
387
+ if (forkDraft) setForkDraft(undefined);
388
+ handleSend(content, attachments, priority);
389
+ }}
386
390
  isStreaming={isStreaming}
387
391
  onCancel={cancelStreaming}
388
- autoFocus={!(metadata?.sessionId)}
392
+ autoFocus={!(metadata?.sessionId) || !!forkDraft}
393
+ initialValue={forkDraft}
389
394
  projectName={projectName}
390
395
  onSlashStateChange={handleSlashStateChange}
391
396
  onSlashItemsLoaded={setSlashItems}