@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.
- package/CHANGELOG.md +238 -0
- package/bun.lock +17 -0
- package/dist/web/assets/api-settings-BUvk6Saw.js +1 -0
- package/dist/web/assets/arrow-up-BYhx9ckd.js +1 -0
- package/dist/web/assets/browser-tab-CrkhFCaw.js +1 -0
- package/dist/web/assets/chat-tab-C6jpiwh7.js +8 -0
- package/dist/web/assets/chevron-right-5HgK6l7K.js +1 -0
- package/dist/web/assets/code-editor-CBIPzlP2.js +2 -0
- package/dist/web/assets/columns-2-cEVJHYd7.js +1 -0
- package/dist/web/assets/createLucideIcon-PuMiQgHl.js +1 -0
- package/dist/web/assets/{csv-preview-DLqYtXxt.js → csv-preview-ncSOnJSC.js} +2 -2
- package/dist/web/assets/database-viewer-BqOJR_zi.js +1 -0
- package/dist/web/assets/diff-viewer-CcLyp4eY.js +4 -0
- package/dist/web/assets/{dist-CALwEtco.js → dist-DIV6WgAG.js} +1 -1
- package/dist/web/assets/{dist-DGDPTxs1.js → dist-ovWkrgO-.js} +1 -1
- package/dist/web/assets/extension-webview-NiZ7Ybvv.js +3 -0
- package/dist/web/assets/git-graph-CoTvMrIo.js +1 -0
- package/dist/web/assets/index-C8byznLO.js +37 -0
- package/dist/web/assets/index-KwC2YrG4.css +2 -0
- package/dist/web/assets/jsx-runtime-kMwlnEGE.js +1 -0
- package/dist/web/assets/keybindings-store-DPYzBe_M.js +1 -0
- package/dist/web/assets/{markdown-renderer-DklUd_Gv.js → markdown-renderer-DPLdR9xc.js} +4 -4
- package/dist/web/assets/postgres-viewer-BeiK4lCa.js +1 -0
- package/dist/web/assets/settings-tab-D3AvU4lu.js +1 -0
- package/dist/web/assets/sqlite-viewer-nA2sD4Yv.js +1 -0
- package/dist/web/assets/tab-store-BOgTrqRr.js +1 -0
- package/dist/web/assets/table-DFevCOMd.js +1 -0
- package/dist/web/assets/tag-CXMT0QB6.js +1 -0
- package/dist/web/assets/{terminal-tab-CqRuiIFn.js → terminal-tab-BBi0pEji.js} +2 -2
- package/dist/web/assets/{use-monaco-theme-Dcz3aLAE.js → use-monaco-theme-B5pG2d1w.js} +1 -1
- package/dist/web/index.html +8 -8
- package/dist/web/monacoeditorwork/css.worker.bundle.js +122 -122
- package/dist/web/monacoeditorwork/editor.worker.bundle.js +78 -78
- package/dist/web/monacoeditorwork/html.worker.bundle.js +110 -110
- package/dist/web/monacoeditorwork/json.worker.bundle.js +108 -108
- package/dist/web/monacoeditorwork/ts.worker.bundle.js +81 -81
- package/dist/web/sw.js +1 -1
- package/docs/code-standards.md +128 -1
- package/docs/codebase-summary.md +79 -12
- package/docs/extension-development-guide.md +532 -0
- package/docs/project-changelog.md +51 -1
- package/docs/project-roadmap.md +9 -3
- package/docs/streaming-input-guide.md +267 -0
- package/docs/system-architecture.md +432 -3
- package/package.json +6 -3
- package/packages/ext-database/package.json +41 -0
- package/packages/ext-database/src/connection-tree.ts +142 -0
- package/packages/ext-database/src/extension.ts +346 -0
- package/packages/ext-database/src/query-panel.ts +120 -0
- package/packages/ext-database/src/table-viewer-panel.ts +410 -0
- package/packages/ext-database/tsconfig.json +8 -0
- package/packages/vscode-compat/package.json +16 -0
- package/packages/vscode-compat/src/commands.ts +39 -0
- package/packages/vscode-compat/src/context.ts +65 -0
- package/packages/vscode-compat/src/disposable.ts +21 -0
- package/packages/vscode-compat/src/env.ts +20 -0
- package/packages/vscode-compat/src/event-emitter.ts +28 -0
- package/packages/vscode-compat/src/index.ts +93 -0
- package/packages/vscode-compat/src/not-supported.ts +15 -0
- package/packages/vscode-compat/src/types.ts +167 -0
- package/packages/vscode-compat/src/uri.ts +65 -0
- package/packages/vscode-compat/src/window.ts +229 -0
- package/packages/vscode-compat/src/workspace.ts +76 -0
- package/packages/vscode-compat/tsconfig.json +10 -0
- package/snapshot-state.md +1526 -0
- package/src/cli/commands/autostart.ts +1 -1
- package/src/cli/commands/ext-cmd.ts +121 -0
- package/src/cli/commands/restart.ts +9 -1
- package/src/cli/commands/status.ts +19 -0
- package/src/index.ts +5 -3
- package/src/providers/claude-agent-sdk.ts +221 -17
- package/src/providers/cli-provider-base.ts +6 -0
- package/src/server/index.ts +55 -155
- package/src/server/routes/chat.ts +81 -11
- package/src/server/routes/extensions.ts +81 -0
- package/src/server/routes/project-scoped.ts +2 -0
- package/src/server/routes/settings.ts +27 -0
- package/src/server/routes/workspace.ts +35 -0
- package/src/server/ws/chat.ts +9 -3
- package/src/server/ws/extensions.ts +175 -0
- package/src/services/account-selector.service.ts +14 -5
- package/src/services/account.service.ts +20 -15
- package/src/services/claude-usage.service.ts +29 -24
- package/src/services/cloud-ws.service.ts +228 -0
- package/src/services/cloud.service.ts +11 -6
- package/src/services/contribution-registry.ts +110 -0
- package/src/services/db.service.ts +181 -4
- package/src/services/extension-host-worker.ts +160 -0
- package/src/services/extension-installer.ts +112 -0
- package/src/services/extension-manifest.ts +65 -0
- package/src/services/extension-rpc-handlers.ts +235 -0
- package/src/services/extension-rpc.ts +105 -0
- package/src/services/extension.service.ts +228 -0
- package/src/services/mcp-config.service.ts +15 -6
- package/src/services/supervisor.ts +271 -25
- package/src/types/api.ts +1 -0
- package/src/types/chat.ts +4 -0
- package/src/types/extension-messages.ts +64 -0
- package/src/types/extension.ts +131 -0
- package/src/web/app.tsx +69 -48
- package/src/web/components/chat/account-rotation-settings.tsx +163 -0
- package/src/web/components/chat/chat-history-bar.tsx +106 -10
- package/src/web/components/chat/chat-tab.tsx +15 -10
- package/src/web/components/chat/chat-welcome.tsx +148 -0
- package/src/web/components/chat/message-list.tsx +19 -6
- package/src/web/components/chat/session-picker.tsx +80 -32
- package/src/web/components/chat/usage-badge.tsx +68 -8
- package/src/web/components/editor/editor-breadcrumb.tsx +20 -29
- package/src/web/components/extensions/extension-inputbox.tsx +92 -0
- package/src/web/components/extensions/extension-quickpick.tsx +194 -0
- package/src/web/components/extensions/extension-tree-view.tsx +240 -0
- package/src/web/components/extensions/extension-webview.tsx +83 -0
- package/src/web/components/layout/command-palette.tsx +22 -2
- package/src/web/components/layout/editor-panel.tsx +163 -18
- package/src/web/components/layout/mobile-nav.tsx +2 -1
- package/src/web/components/layout/sidebar.tsx +21 -3
- package/src/web/components/layout/status-bar.tsx +64 -0
- package/src/web/components/layout/tab-bar.tsx +2 -0
- package/src/web/components/layout/tab-content.tsx +5 -0
- package/src/web/components/layout/upgrade-banner.tsx +15 -5
- package/src/web/components/settings/change-password-section.tsx +128 -0
- package/src/web/components/settings/extension-manager-section.tsx +214 -0
- package/src/web/components/settings/settings-tab.tsx +9 -2
- package/src/web/components/shared/connection-lost-overlay.tsx +89 -0
- package/src/web/hooks/use-chat.ts +28 -0
- package/src/web/hooks/use-extension-ws.ts +181 -0
- package/src/web/hooks/use-global-keybindings.ts +18 -2
- package/src/web/hooks/use-server-reload.ts +9 -0
- package/src/web/hooks/use-url-sync.ts +173 -21
- package/src/web/stores/connection-store.ts +39 -0
- package/src/web/stores/extension-store.ts +204 -0
- package/src/web/stores/panel-store.ts +63 -9
- package/src/web/stores/panel-utils.ts +145 -3
- package/src/web/stores/settings-store.ts +7 -2
- package/src/web/stores/tab-store.ts +2 -1
- package/test-session-ops.mjs +444 -0
- package/test-tokens.mjs +212 -0
- package/tsconfig.json +3 -1
- package/dist/web/assets/api-settings-D21InCnR.js +0 -1
- package/dist/web/assets/arrow-up--LjUXLEt.js +0 -1
- package/dist/web/assets/browser-tab-BEe89aSD.js +0 -1
- package/dist/web/assets/chat-tab-9lqvWozA.js +0 -7
- package/dist/web/assets/chevron-right-CHnjJt4E.js +0 -1
- package/dist/web/assets/code-editor-COAIZx-B.js +0 -2
- package/dist/web/assets/columns-2-DbesTfa7.js +0 -1
- package/dist/web/assets/database-viewer-aRR9n_Ui.js +0 -1
- package/dist/web/assets/diff-viewer-C4KMvpHr.js +0 -4
- package/dist/web/assets/dist-CVTST7Gc.js +0 -1
- package/dist/web/assets/git-graph-CfJjl4E3.js +0 -1
- package/dist/web/assets/index-Db8uky1a.css +0 -2
- package/dist/web/assets/index-DxZuwBDe.js +0 -37
- package/dist/web/assets/jsx-runtime-BRW_vwa9.js +0 -1
- package/dist/web/assets/keybindings-store-_uWVCZMv.js +0 -1
- package/dist/web/assets/postgres-viewer-DEAvAyaX.js +0 -1
- package/dist/web/assets/settings-tab-BQedc-No.js +0 -1
- package/dist/web/assets/sqlite-viewer-BPA5idzT.js +0 -1
- package/dist/web/assets/tab-store-DhK6EpBT.js +0 -1
- package/dist/web/assets/table-CQVQM2SB.js +0 -1
- 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 ? () =>
|
|
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
|
-
<
|
|
723
|
-
|
|
724
|
-
|
|
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.
|
|
105
|
-
<
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
{
|
|
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]
|
|
155
|
-
|
|
156
|
-
<
|
|
157
|
-
{
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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-
|
|
192
|
-
|
|
193
|
-
<
|
|
194
|
-
{
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
);
|