@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
@@ -0,0 +1,148 @@
1
+ import { useState, useEffect, useCallback } from "react";
2
+ import { Bot, ChevronDown, ChevronUp, MessageSquare, Pin, PinOff } from "lucide-react";
3
+ import { api, projectUrl } from "@/lib/api-client";
4
+ import type { SessionInfo } from "../../../types/chat";
5
+
6
+ const MAX_RECENT_SESSIONS = 5;
7
+ const FETCH_SESSIONS_LIMIT = 20;
8
+
9
+ function formatRelativeDate(iso: string): string {
10
+ try {
11
+ const date = new Date(iso);
12
+ const now = new Date();
13
+ const diffMs = now.getTime() - date.getTime();
14
+ const diffMin = Math.floor(diffMs / 60_000);
15
+ if (diffMin < 1) return "just now";
16
+ if (diffMin < 60) return `${diffMin}m ago`;
17
+ const diffHr = Math.floor(diffMin / 60);
18
+ if (diffHr < 24) return `${diffHr}h ago`;
19
+ const diffDay = Math.floor(diffHr / 24);
20
+ if (diffDay < 7) return `${diffDay}d ago`;
21
+ return date.toLocaleDateString(undefined, { month: "short", day: "numeric" });
22
+ } catch {
23
+ return "";
24
+ }
25
+ }
26
+
27
+ interface ChatWelcomeProps {
28
+ projectName: string;
29
+ onSelectSession: (session: SessionInfo) => void;
30
+ }
31
+
32
+ export function ChatWelcome({ projectName, onSelectSession }: ChatWelcomeProps) {
33
+ const [sessions, setSessions] = useState<SessionInfo[]>([]);
34
+ const [loading, setLoading] = useState(false);
35
+ const [showAll, setShowAll] = useState(false);
36
+
37
+ const loadSessions = useCallback(async () => {
38
+ if (!projectName) return;
39
+ setLoading(true);
40
+ try {
41
+ const data = await api.get<SessionInfo[]>(`${projectUrl(projectName)}/chat/sessions`);
42
+ setSessions(data.slice(0, FETCH_SESSIONS_LIMIT));
43
+ } catch {
44
+ // silently ignore
45
+ } finally {
46
+ setLoading(false);
47
+ }
48
+ }, [projectName]);
49
+
50
+ useEffect(() => { loadSessions(); }, [loadSessions]);
51
+
52
+ const togglePin = useCallback(async (e: React.MouseEvent, session: SessionInfo) => {
53
+ e.stopPropagation();
54
+ if (!projectName) return;
55
+ const url = `${projectUrl(projectName)}/chat/sessions/${session.id}/pin`;
56
+ try {
57
+ if (session.pinned) {
58
+ await api.del(url);
59
+ } else {
60
+ await api.put(url);
61
+ }
62
+ setSessions((prev) => {
63
+ const updated = prev.map((s) => s.id === session.id ? { ...s, pinned: !s.pinned } : s);
64
+ return updated.sort((a, b) => {
65
+ if (a.pinned && !b.pinned) return -1;
66
+ if (!a.pinned && b.pinned) return 1;
67
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
68
+ });
69
+ });
70
+ } catch {
71
+ // silently ignore
72
+ }
73
+ }, [projectName]);
74
+
75
+ const pinnedSessions = sessions.filter((s) => s.pinned);
76
+ const allRecentSessions = sessions.filter((s) => !s.pinned);
77
+ const recentSessions = showAll ? allRecentSessions : allRecentSessions.slice(0, MAX_RECENT_SESSIONS);
78
+ const hasMore = allRecentSessions.length > MAX_RECENT_SESSIONS;
79
+
80
+ function renderSessionRow(session: SessionInfo) {
81
+ return (
82
+ <button
83
+ key={session.id}
84
+ onClick={() => onSelectSession(session)}
85
+ className="group flex items-center gap-2.5 w-full px-3 py-2.5 text-left hover:bg-surface-elevated active:bg-surface-elevated transition-colors border-b border-border/50 last:border-0"
86
+ >
87
+ <MessageSquare className="size-3.5 shrink-0 text-text-subtle" />
88
+ <span className="flex-1 min-w-0 text-xs font-medium truncate text-text-primary">
89
+ {session.title || "Untitled"}
90
+ </span>
91
+ {session.updatedAt && (
92
+ <span className="text-[10px] text-text-subtle shrink-0">
93
+ {formatRelativeDate(session.updatedAt)}
94
+ </span>
95
+ )}
96
+ <span
97
+ role="button"
98
+ tabIndex={0}
99
+ onClick={(e) => togglePin(e, session)}
100
+ className={`p-1 rounded transition-colors shrink-0 ${
101
+ session.pinned
102
+ ? "text-primary hover:text-primary/70"
103
+ : "text-text-subtle md:opacity-0 md:group-hover:opacity-100 hover:text-text-primary"
104
+ }`}
105
+ aria-label={session.pinned ? "Unpin session" : "Pin session"}
106
+ >
107
+ {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
108
+ </span>
109
+ </button>
110
+ );
111
+ }
112
+
113
+ return (
114
+ <div className="flex flex-col items-center justify-center h-full gap-6 text-text-secondary overflow-y-auto">
115
+ <div className="flex flex-col items-center gap-3">
116
+ <Bot className="size-10 text-text-subtle" />
117
+ <p className="text-sm">Send a message to start a new conversation</p>
118
+ </div>
119
+
120
+ {!loading && pinnedSessions.length > 0 && (
121
+ <div className="flex flex-col gap-2 w-full max-w-sm px-4">
122
+ <p className="text-xs text-text-subtle text-center">Pinned</p>
123
+ <div className="w-full rounded-md border border-border bg-surface overflow-hidden">
124
+ {pinnedSessions.map(renderSessionRow)}
125
+ </div>
126
+ </div>
127
+ )}
128
+
129
+ {!loading && recentSessions.length > 0 && (
130
+ <div className="flex flex-col gap-2 w-full max-w-sm px-4">
131
+ <p className="text-xs text-text-subtle text-center">Recent chats</p>
132
+ <div className="w-full rounded-md border border-border bg-surface overflow-hidden">
133
+ {recentSessions.map(renderSessionRow)}
134
+ </div>
135
+ {hasMore && (
136
+ <button
137
+ onClick={() => setShowAll(!showAll)}
138
+ className="flex items-center justify-center gap-1 text-[11px] text-text-subtle hover:text-text-primary transition-colors py-1"
139
+ >
140
+ {showAll ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
141
+ {showAll ? "Show less" : `Show more (${allRecentSessions.length - MAX_RECENT_SESSIONS})`}
142
+ </button>
143
+ )}
144
+ </div>
145
+ )}
146
+ </div>
147
+ );
148
+ }
@@ -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
 
@@ -610,6 +614,13 @@ function InterleavedEvents({ events, isStreaming, projectName }: { events: ChatE
610
614
  groups.push({ kind: "thinking", content: thinkingBuffer });
611
615
  thinkingBuffer = "";
612
616
  }
617
+ if (event.type === "account_retry") {
618
+ if (textBuffer) { groups.push({ kind: "text", content: textBuffer }); textBuffer = ""; }
619
+ const label = (event as any).accountLabel ?? "another account";
620
+ const reason = (event as any).reason ?? "Auth failed";
621
+ groups.push({ kind: "text", content: `\n\n> ↻ ${reason} — retrying with **${label}**...\n\n` });
622
+ continue;
623
+ }
613
624
  if (event.type === "text") {
614
625
  textBuffer += event.content;
615
626
  } else if (event.type === "tool_use") {
@@ -719,9 +730,11 @@ function ThinkingBlock({ content, isStreaming }: { content: string; isStreaming:
719
730
  {!isStreaming && <span className="text-text-subtle/50 ml-auto">{content.length > 100 ? `${Math.round(content.length / 4)} tokens` : ""}</span>}
720
731
  </button>
721
732
  {expanded && (
722
- <div className="px-2 pb-2 text-text-subtle/80 whitespace-pre-wrap max-h-60 overflow-y-auto text-[11px] leading-relaxed">
723
- {content}
724
- </div>
733
+ <StickToBottom className="max-h-60 overflow-y-auto" resize="smooth" initial="instant">
734
+ <StickToBottom.Content className="px-2 pb-2 text-text-subtle/80 whitespace-pre-wrap text-[11px] leading-relaxed">
735
+ {content}
736
+ </StickToBottom.Content>
737
+ </StickToBottom>
725
738
  )}
726
739
  </div>
727
740
  );
@@ -1,6 +1,6 @@
1
1
  import { useState, useEffect, useCallback } from "react";
2
2
  import { api, projectUrl } from "@/lib/api-client";
3
- import { Plus, Trash2, MessageSquare, ChevronDown } from "lucide-react";
3
+ import { Plus, Trash2, MessageSquare, ChevronDown, Pin, PinOff } from "lucide-react";
4
4
  import { ProviderBadge } from "./provider-selector";
5
5
  import type { SessionInfo } from "../../../types/chat";
6
6
 
@@ -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(
@@ -58,6 +59,76 @@ export function SessionPicker({
58
59
  }
59
60
  };
60
61
 
62
+ const handleTogglePin = async (e: React.MouseEvent, session: SessionInfo) => {
63
+ e.stopPropagation();
64
+ if (!projectName) return;
65
+ const url = `${projectUrl(projectName)}/chat/sessions/${session.id}/pin`;
66
+ try {
67
+ if (session.pinned) {
68
+ await api.del(url);
69
+ } else {
70
+ await api.put(url);
71
+ }
72
+ setSessions((prev) => {
73
+ const updated = prev.map((s) => s.id === session.id ? { ...s, pinned: !s.pinned } : s);
74
+ return updated.sort((a, b) => {
75
+ if (a.pinned && !b.pinned) return -1;
76
+ if (!a.pinned && b.pinned) return 1;
77
+ return new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime();
78
+ });
79
+ });
80
+ } catch {
81
+ // Silently fail
82
+ }
83
+ };
84
+
85
+ function renderSessionRow(session: SessionInfo) {
86
+ return (
87
+ <div
88
+ key={session.id}
89
+ onClick={() => {
90
+ onSelectSession(session);
91
+ setOpen(false);
92
+ }}
93
+ className={`group flex items-center justify-between px-3 py-2 text-sm cursor-pointer hover:bg-surface-elevated transition-colors ${
94
+ session.id === currentSessionId
95
+ ? "bg-surface-elevated text-text-primary"
96
+ : "text-text-secondary"
97
+ }`}
98
+ >
99
+ <div className="flex flex-col min-w-0 flex-1">
100
+ <span className="flex items-center gap-1.5 truncate text-xs font-medium">
101
+ <ProviderBadge providerId={session.providerId} />
102
+ {session.title}
103
+ </span>
104
+ <span className="text-xs text-text-subtle">
105
+ {new Date(session.createdAt).toLocaleDateString()}
106
+ </span>
107
+ </div>
108
+ <div className="flex items-center gap-0.5 shrink-0">
109
+ <button
110
+ onClick={(e) => handleTogglePin(e, session)}
111
+ className={`p-1 rounded transition-colors ${
112
+ session.pinned
113
+ ? "text-primary hover:text-primary/70"
114
+ : "text-text-subtle md:opacity-0 md:group-hover:opacity-100 hover:text-text-primary"
115
+ }`}
116
+ aria-label={session.pinned ? "Unpin session" : "Pin session"}
117
+ >
118
+ {session.pinned ? <PinOff className="size-3" /> : <Pin className="size-3" />}
119
+ </button>
120
+ <button
121
+ onClick={(e) => handleDelete(e, session)}
122
+ className="p-1 rounded hover:bg-red-500/20 text-text-subtle hover:text-red-400 transition-colors md:opacity-0 md:group-hover:opacity-100"
123
+ aria-label="Delete session"
124
+ >
125
+ <Trash2 className="size-3" />
126
+ </button>
127
+ </div>
128
+ </div>
129
+ );
130
+ }
131
+
61
132
  return (
62
133
  <div className="relative">
63
134
  <button
@@ -101,37 +172,14 @@ export function SessionPicker({
101
172
  No sessions yet
102
173
  </p>
103
174
  )}
104
- {sessions.map((session) => (
105
- <div
106
- key={session.id}
107
- onClick={() => {
108
- onSelectSession(session);
109
- setOpen(false);
110
- }}
111
- className={`flex items-center justify-between px-3 py-2 text-sm cursor-pointer hover:bg-surface-elevated transition-colors ${
112
- session.id === currentSessionId
113
- ? "bg-surface-elevated text-text-primary"
114
- : "text-text-secondary"
115
- }`}
116
- >
117
- <div className="flex flex-col min-w-0 flex-1">
118
- <span className="flex items-center gap-1.5 truncate text-xs font-medium">
119
- <ProviderBadge providerId={session.providerId} />
120
- {session.title}
121
- </span>
122
- <span className="text-xs text-text-subtle">
123
- {new Date(session.createdAt).toLocaleDateString()}
124
- </span>
125
- </div>
126
- <button
127
- onClick={(e) => handleDelete(e, session)}
128
- className="p-1 rounded hover:bg-red-500/20 text-text-subtle hover:text-red-400 transition-colors shrink-0"
129
- aria-label="Delete session"
130
- >
131
- <Trash2 className="size-3" />
132
- </button>
133
- </div>
134
- ))}
175
+ {sessions.filter((s) => s.pinned).length > 0 && (
176
+ <p className="px-3 py-1 text-[10px] text-text-subtle uppercase tracking-wider bg-surface">Pinned</p>
177
+ )}
178
+ {sessions.filter((s) => s.pinned).map((session) => renderSessionRow(session))}
179
+ {sessions.filter((s) => s.pinned).length > 0 && sessions.filter((s) => !s.pinned).length > 0 && (
180
+ <div className="border-t border-border" />
181
+ )}
182
+ {sessions.filter((s) => !s.pinned).map((session) => renderSessionRow(session))}
135
183
  </div>
136
184
  </div>
137
185
  </>
@@ -1,5 +1,5 @@
1
1
  import { useState, useEffect, useRef } from "react";
2
- import { Activity, RefreshCw, Eye, Download, Upload, Plus, X } 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,11 +7,13 @@ import {
7
7
  getActiveAccount,
8
8
  getAllAccountUsages,
9
9
  patchAccount,
10
+ deleteAccount,
10
11
  type AccountInfo,
11
12
  type AccountUsageEntry,
12
13
  type OAuthProfileData,
13
14
  } from "../../lib/api-settings";
14
15
  import { AddAccountDialog, ExportAccountsDialog, ImportAccountsDialog } from "./account-dialogs";
16
+ import { AccountRotationSettings } from "./account-rotation-settings";
15
17
 
16
18
  interface UsageBadgeProps {
17
19
  usage: UsageInfo;
@@ -151,11 +153,12 @@ function formatLastUpdated(ts: number | null | undefined): string | null {
151
153
  return `${days}d ago`;
152
154
  }
153
155
 
154
- function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, onViewProfile, flash }: {
156
+ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onDelete, onExport, onViewProfile, flash }: {
155
157
  entry: AccountUsageEntry;
156
158
  isActive: boolean;
157
159
  accountInfo?: AccountInfo;
158
160
  onToggle?: (id: string, status: string) => void;
161
+ onDelete?: (id: string, display: string) => void;
159
162
  onExport?: (id: string) => void;
160
163
  onViewProfile?: (profile: OAuthProfileData) => void;
161
164
  flash?: boolean;
@@ -163,19 +166,24 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
163
166
  const { usage } = entry;
164
167
  const hasBuckets = usage.session || usage.weekly || usage.weeklyOpus || usage.weeklySonnet;
165
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));
166
171
 
167
172
  return (
168
- <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"}`}>
169
174
  <div className="flex items-center gap-1.5">
170
175
  <span className="text-xs font-medium truncate flex-1 min-w-0">
171
176
  {entry.accountLabel ?? entry.accountId.slice(0, 8)}
172
177
  </span>
173
- {!entry.isOAuth && (
178
+ {isExpired && (
179
+ <span className="text-[9px] text-red-500 shrink-0 font-medium">Expired</span>
180
+ )}
181
+ {!entry.isOAuth && !isExpired && (
174
182
  <span className="text-[9px] text-text-subtle shrink-0">API key</span>
175
183
  )}
176
184
  {/* Account controls */}
177
185
  <div className="flex items-center gap-0.5 shrink-0">
178
- {onViewProfile && accountInfo?.profileData && (
186
+ {!isExpired && onViewProfile && accountInfo?.profileData && (
179
187
  <button
180
188
  className="p-1 rounded cursor-pointer text-text-subtle hover:text-foreground hover:bg-surface-elevated transition-colors"
181
189
  onClick={() => onViewProfile(accountInfo.profileData!)}
@@ -184,7 +192,7 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
184
192
  <Eye className="size-3" />
185
193
  </button>
186
194
  )}
187
- {onExport && entry.isOAuth && (
195
+ {!isExpired && onExport && entry.isOAuth && (
188
196
  <button
189
197
  className="p-1 rounded cursor-pointer text-text-subtle hover:text-blue-500 hover:bg-surface-elevated transition-colors"
190
198
  onClick={() => onExport(entry.accountId)}
@@ -193,7 +201,7 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
193
201
  <Download className="size-3" />
194
202
  </button>
195
203
  )}
196
- {onToggle && (
204
+ {!isExpired && onToggle && (
197
205
  <Switch
198
206
  checked={status !== "disabled"}
199
207
  onCheckedChange={() => onToggle(entry.accountId, status)}
@@ -201,6 +209,15 @@ function AccountUsageCard({ entry, isActive, accountInfo, onToggle, onExport, on
201
209
  className="scale-[0.6] cursor-pointer"
202
210
  />
203
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
+ )}
204
221
  </div>
205
222
  </div>
206
223
  {hasBuckets ? (
@@ -245,6 +262,8 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
245
262
  const [showAddDialog, setShowAddDialog] = useState(false);
246
263
  const [showExportDialog, setShowExportDialog] = useState(false);
247
264
  const [showImportDialog, setShowImportDialog] = useState(false);
265
+ const [showRotationSettings, setShowRotationSettings] = useState(false);
266
+ const [deleteTarget, setDeleteTarget] = useState<{ id: string; display: string } | null>(null);
248
267
  const [exportPreselect, setExportPreselect] = useState<string | null>(null);
249
268
  const [message, setMessage] = useState<string | null>(null);
250
269
  const msgTimer = useRef<ReturnType<typeof setTimeout>>(undefined);
@@ -323,13 +342,26 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
323
342
  onReload?.();
324
343
  }
325
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
+
326
358
  function openExportAll() {
327
359
  setExportPreselect(null);
328
360
  setShowExportDialog(true);
329
361
  }
330
362
 
331
363
  return (
332
- <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">
333
365
  <div className="flex items-center justify-between">
334
366
  <div className="flex items-center gap-2">
335
367
  <span className="text-xs font-semibold text-text-primary">Usage & Accounts</span>
@@ -338,6 +370,13 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
338
370
  )}
339
371
  </div>
340
372
  <div className="flex items-center gap-1">
373
+ <button
374
+ onClick={() => setShowRotationSettings(true)}
375
+ className="text-xs text-text-subtle hover:text-text-primary px-1 cursor-pointer"
376
+ title="Rotation & retry settings"
377
+ >
378
+ <Settings className="size-3" />
379
+ </button>
341
380
  {onReload && (
342
381
  <button
343
382
  onClick={() => { onReload(); loadAll(); }}
@@ -375,6 +414,7 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
375
414
  isActive={entry.accountId === (activeAccountId ?? usage.activeAccountId)}
376
415
  accountInfo={accountMap.get(entry.accountId)}
377
416
  onToggle={handleToggle}
417
+ onDelete={(id, display) => setDeleteTarget({ id, display })}
378
418
  onExport={(id) => { setExportPreselect(id); setShowExportDialog(true); }}
379
419
  onViewProfile={setProfileView}
380
420
  flash={flashIds.has(entry.accountId)}
@@ -451,10 +491,30 @@ export function UsageDetailPanel({ usage, visible, onClose, onReload, loading, l
451
491
  </button>
452
492
  </div>
453
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
+
454
513
  {/* Account dialogs */}
455
514
  <AddAccountDialog open={showAddDialog} onOpenChange={setShowAddDialog} onSuccess={handleSuccess} />
456
515
  <ExportAccountsDialog open={showExportDialog} onOpenChange={(v) => { setShowExportDialog(v); if (!v) setExportPreselect(null); }} accounts={accounts} preselectId={exportPreselect} onMessage={showMessage} />
457
516
  <ImportAccountsDialog open={showImportDialog} onOpenChange={setShowImportDialog} onSuccess={handleSuccess} />
517
+ <AccountRotationSettings open={showRotationSettings} onOpenChange={setShowRotationSettings} />
458
518
  </div>
459
519
  );
460
520
  }
@@ -9,7 +9,6 @@ import {
9
9
  DropdownMenuSubTrigger,
10
10
  DropdownMenuSubContent,
11
11
  } from "@/components/ui/dropdown-menu";
12
- import { ScrollArea } from "@/components/ui/scroll-area";
13
12
  import { useFileStore, type FileNode } from "@/stores/file-store";
14
13
  import { useTabStore } from "@/stores/tab-store";
15
14
  import { basename } from "@/lib/utils";
@@ -151,20 +150,16 @@ function SegmentDropdown({ segment, isLast, projectName, onFileClick }: SegmentD
151
150
  {segment.name}
152
151
  </button>
153
152
  </DropdownMenuTrigger>
154
- <DropdownMenuContent align="start" className="max-h-[300px] overflow-hidden p-0">
155
- <ScrollArea className="max-h-[300px]">
156
- <div className="p-1">
157
- {sorted.map((node) => (
158
- <NodeMenuItem
159
- key={node.path}
160
- node={node}
161
- projectName={projectName}
162
- activePath={segment.fullPath}
163
- onFileClick={onFileClick}
164
- />
165
- ))}
166
- </div>
167
- </ScrollArea>
153
+ <DropdownMenuContent align="start" className="max-h-[300px] p-1">
154
+ {sorted.map((node) => (
155
+ <NodeMenuItem
156
+ key={node.path}
157
+ node={node}
158
+ projectName={projectName}
159
+ activePath={segment.fullPath}
160
+ onFileClick={onFileClick}
161
+ />
162
+ ))}
168
163
  </DropdownMenuContent>
169
164
  </DropdownMenu>
170
165
  );
@@ -188,20 +183,16 @@ function NodeMenuItem({ node, projectName, activePath, onFileClick }: NodeMenuIt
188
183
  <Icon className="size-3.5 shrink-0 text-muted-foreground" />
189
184
  <span className="truncate">{node.name}</span>
190
185
  </DropdownMenuSubTrigger>
191
- <DropdownMenuSubContent className="max-h-[300px] overflow-hidden p-0">
192
- <ScrollArea className="max-h-[300px]">
193
- <div className="p-1">
194
- {sortNodes(node.children).map((child) => (
195
- <NodeMenuItem
196
- key={child.path}
197
- node={child}
198
- projectName={projectName}
199
- activePath={activePath}
200
- onFileClick={onFileClick}
201
- />
202
- ))}
203
- </div>
204
- </ScrollArea>
186
+ <DropdownMenuSubContent className="max-h-[300px] overflow-y-auto p-1">
187
+ {sortNodes(node.children).map((child) => (
188
+ <NodeMenuItem
189
+ key={child.path}
190
+ node={child}
191
+ projectName={projectName}
192
+ activePath={activePath}
193
+ onFileClick={onFileClick}
194
+ />
195
+ ))}
205
196
  </DropdownMenuSubContent>
206
197
  </DropdownMenuSub>
207
198
  );