Skip to content

Commit 673444f

Browse files
gaoyu06claude
andcommitted
feat: account panel redesign, cross-provider model picker, dock multi-tabs
Account & providers (Settings + AccountPanel): - providers shown as independent login cards that coexist (not one global pick); per-card balance + a "default" badge / set-as-default action - DeepSeek balance query (api.deepseek.com/user/balance) + Rust command - jucode balance on the card; click expands full account details - a not-logged-in jucode card triggers OAuth directly (no expand) - tidier recent-calls list: two-line rows, relative time, compact tokens Model selection: - the default-model dropdown and the in-chat /model picker now list every provider's models (active provider from the engine's filtered view, the rest from config). Cross-provider picks rewrite config and restart the session, resuming the conversation; same-provider picks stay instant. Reliability: - fix "failed to resume … No such file": only /resume sessions the engine actually persisted (had a turn), and stop persisting empty tabs Right dock: - allow multiple instances of the same panel (e.g. two terminals), numbered - empty state shows clickable panel cards to create one directly Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 0bba112 commit 673444f

10 files changed

Lines changed: 576 additions & 160 deletions

File tree

src-tauri/src/lib.rs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,26 @@ fn fetch_usage_logs() -> Result<serde_json::Value, String> {
402402
jucode_get("/v1/oauth/usage-logs?limit=10")
403403
}
404404

405+
/// DeepSeek account balance (https://api.deepseek.com/user/balance), using the
406+
/// API key stored under providers.deepseek in auth.json.
407+
#[tauri::command(async)]
408+
fn fetch_deepseek_balance() -> Result<serde_json::Value, String> {
409+
let key = read_json(&jucode_dir().join("auth.json"))
410+
.get("providers")
411+
.and_then(|p| p.get("deepseek"))
412+
.and_then(|v| v.as_str())
413+
.map(|s| s.trim().to_string())
414+
.filter(|k| !k.is_empty())
415+
.ok_or_else(|| "未配置 DeepSeek API key".to_string())?;
416+
ureq::get("https://api.deepseek.com/user/balance")
417+
.timeout(std::time::Duration::from_secs(30))
418+
.set("Authorization", &format!("Bearer {key}"))
419+
.call()
420+
.map_err(|e| e.to_string())?
421+
.into_json::<serde_json::Value>()
422+
.map_err(|e| e.to_string())
423+
}
424+
405425
#[tauri::command]
406426
fn close_session(session: String, engines: tauri::State<Engines>) -> Result<(), String> {
407427
if let Some(target) = engines.sessions.lock().unwrap().remove(&session) {
@@ -830,6 +850,7 @@ pub fn run() {
830850
fetch_account_info,
831851
fetch_usage,
832852
fetch_usage_logs,
853+
fetch_deepseek_balance,
833854
project_root,
834855
list_providers,
835856
list_dir,

src/lib/AccountPanel.svelte

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -35,10 +35,19 @@
3535
}
3636
}
3737
38-
function fmtTime(v?: string): string {
38+
const fmtNum = (n: number) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
39+
function relTime(v?: string): string {
3940
if (!v) return '';
40-
const d = new Date(v);
41-
return Number.isNaN(d.getTime()) ? '' : d.toLocaleString();
41+
const t = new Date(v).getTime();
42+
if (Number.isNaN(t)) return '';
43+
const s = Math.max(0, Math.floor((Date.now() - t) / 1000));
44+
if (s < 60) return '刚刚';
45+
const m = Math.floor(s / 60);
46+
if (m < 60) return `${m} 分钟前`;
47+
const h = Math.floor(m / 60);
48+
if (h < 24) return `${h} 小时前`;
49+
const d = Math.floor(h / 24);
50+
return d < 7 ? `${d} 天前` : new Date(v).toLocaleDateString();
4251
}
4352
4453
function pct(used?: string, quota?: string): string {
@@ -100,9 +109,9 @@
100109
{#each logs.slice(0, 8) as l, i (l.created_at ?? i)}
101110
<div class="logrow">
102111
<span class="lm">{l.model ?? '-'}</span>
103-
<span class="lt">in {l.tokens_in ?? 0} · out {l.tokens_out ?? 0}</span>
112+
<span class="lt">↑{fmtNum(l.tokens_in ?? 0)} ↓{fmtNum(l.tokens_out ?? 0)}</span>
104113
<span class="lc">{l.cost_final ?? '0'}</span>
105-
<span class="ld">{fmtTime(l.created_at)}</span>
114+
<span class="ld">{relTime(l.created_at)}</span>
106115
</div>
107116
{/each}
108117
{/if}
@@ -214,28 +223,38 @@
214223
}
215224
.logrow {
216225
display: grid;
217-
grid-template-columns: 1fr auto auto auto;
218-
gap: 10px;
219-
align-items: center;
220-
padding: 6px 0;
226+
grid-template-columns: 1fr auto;
227+
gap: 1px 10px;
228+
align-items: baseline;
229+
padding: 7px 0;
221230
border-top: 1px solid var(--border);
222-
font-size: 12px;
223231
}
232+
/* Two rows: model + cost on top, tokens + time below (via grid order). */
224233
.lm {
234+
order: 0;
225235
color: var(--text);
226236
font-weight: 500;
237+
font-size: 13px;
227238
overflow: hidden;
228239
text-overflow: ellipsis;
229240
white-space: nowrap;
230241
}
231-
.lt {
232-
color: var(--dim);
233-
}
234242
.lc {
243+
order: 1;
235244
color: var(--accent);
245+
text-align: right;
246+
font-variant-numeric: tabular-nums;
236247
}
237-
.ld {
248+
.lt {
249+
order: 2;
238250
color: var(--dim);
251+
font-size: 11px;
239252
font-variant-numeric: tabular-nums;
240253
}
254+
.ld {
255+
order: 3;
256+
color: var(--dim2);
257+
font-size: 11px;
258+
text-align: right;
259+
}
241260
</style>

src/lib/RightDock.svelte

Lines changed: 110 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { X, Plus } from 'lucide-svelte';
2+
import { X, Plus, ListTodo, Target, FileDiff, FolderTree, GitBranch, Terminal } from 'lucide-svelte';
33
import IconButton from '$lib/ui/IconButton.svelte';
44
import GoalPanel from './GoalPanel.svelte';
55
import PlanPanel from './PlanPanel.svelte';
@@ -18,74 +18,95 @@
1818
}: { goal: Goal | null; plan?: PlanStep[]; cwd?: string; changed?: string[]; onRevertFile?: (p: string) => void } = $props();
1919
2020
const PANELS = [
21-
{ key: 'plan', label: '计划' },
22-
{ key: 'goal', label: '目标' },
23-
{ key: 'changes', label: '改动' },
24-
{ key: 'files', label: '文件' },
25-
{ key: 'git', label: 'Git' },
26-
{ key: 'term', label: '终端' }
21+
{ key: 'plan', label: '计划', icon: ListTodo },
22+
{ key: 'goal', label: '目标', icon: Target },
23+
{ key: 'changes', label: '改动', icon: FileDiff },
24+
{ key: 'files', label: '文件', icon: FolderTree },
25+
{ key: 'git', label: 'Git', icon: GitBranch },
26+
{ key: 'term', label: '终端', icon: Terminal }
2727
];
2828
const labelOf = (key: string) => PANELS.find((p) => p.key === key)?.label ?? key;
2929
30-
function loadTabs(): string[] {
30+
// A tab is an *instance* of a panel, so several tabs can share a panel type
31+
// (e.g. two terminals); each carries its own id.
32+
type Tab = { id: string; panel: string };
33+
let counter = 0;
34+
const newId = () => `t${Date.now().toString(36)}-${(counter++).toString(36)}`;
35+
36+
function loadTabs(): Tab[] {
3137
try {
32-
const t = JSON.parse(localStorage.getItem('jucode-dock-tabs') || 'null');
33-
if (Array.isArray(t)) return t.filter((k) => PANELS.some((p) => p.key === k));
38+
const raw = JSON.parse(localStorage.getItem('jucode-dock-tabs') || 'null');
39+
if (Array.isArray(raw)) {
40+
const tabs = raw
41+
.map((t): Tab | null => {
42+
if (typeof t === 'string') return { id: newId(), panel: t }; // migrate old format
43+
if (t && typeof t.id === 'string' && typeof t.panel === 'string') return { id: t.id, panel: t.panel };
44+
return null;
45+
})
46+
.filter((t): t is Tab => t !== null && PANELS.some((p) => p.key === t.panel));
47+
if (tabs.length) return tabs;
48+
}
3449
} catch {
3550
/* ignore */
3651
}
37-
return ['goal'];
52+
return [{ id: newId(), panel: 'goal' }];
3853
}
3954
40-
let openTabs = $state<string[]>(loadTabs());
55+
let openTabs = $state<Tab[]>(loadTabs());
4156
let active = $state(
4257
(() => {
4358
const saved = localStorage.getItem('jucode-dock-active');
44-
return saved && openTabs.includes(saved) ? saved : (openTabs[0] ?? '');
59+
return saved && openTabs.some((t) => t.id === saved) ? saved : (openTabs[0]?.id ?? '');
4560
})()
4661
);
4762
let addOpen = $state(false);
48-
let dragKey = $state<string | null>(null);
63+
let dragId = $state<string | null>(null);
4964
let bar = $state<HTMLElement | null>(null);
5065
51-
const available = $derived(PANELS.filter((p) => !openTabs.includes(p.key)));
66+
// Number repeated panels so duplicates are distinguishable (终端 1 / 终端 2).
67+
function tabLabel(tab: Tab): string {
68+
const base = labelOf(tab.panel);
69+
const same = openTabs.filter((t) => t.panel === tab.panel);
70+
return same.length < 2 ? base : `${base} ${same.indexOf(tab) + 1}`;
71+
}
5272
5373
$effect(() => {
5474
localStorage.setItem('jucode-dock-tabs', JSON.stringify(openTabs));
5575
localStorage.setItem('jucode-dock-active', active);
5676
});
5777
58-
function openPanel(key: string) {
59-
if (!openTabs.includes(key)) openTabs = [...openTabs, key];
60-
active = key;
78+
function openPanel(panel: string) {
79+
const id = newId();
80+
openTabs = [...openTabs, { id, panel }];
81+
active = id;
6182
addOpen = false;
6283
}
63-
function closeTab(key: string) {
64-
const idx = openTabs.indexOf(key);
65-
openTabs = openTabs.filter((k) => k !== key);
66-
if (active === key) active = openTabs[Math.min(idx, openTabs.length - 1)] ?? '';
84+
function closeTab(id: string) {
85+
const idx = openTabs.findIndex((t) => t.id === id);
86+
openTabs = openTabs.filter((t) => t.id !== id);
87+
if (active === id) active = openTabs[Math.min(idx, openTabs.length - 1)]?.id ?? '';
6788
}
68-
function startDrag(e: PointerEvent, key: string) {
89+
function startDrag(e: PointerEvent, id: string) {
6990
if (e.button !== 0) return;
70-
dragKey = key;
91+
dragId = id;
7192
const move = (ev: PointerEvent) => {
7293
if (!bar) return;
7394
const tabs = [...bar.querySelectorAll<HTMLElement>('[data-tab]')];
7495
const over = tabs.find((el) => {
7596
const r = el.getBoundingClientRect();
7697
return ev.clientX >= r.left && ev.clientX <= r.right;
7798
});
78-
const overKey = over?.dataset.tab;
79-
if (overKey && overKey !== dragKey) {
80-
const from = openTabs.indexOf(dragKey!);
81-
const to = openTabs.indexOf(overKey);
99+
const overId = over?.dataset.tab;
100+
if (overId && overId !== dragId) {
101+
const from = openTabs.findIndex((t) => t.id === dragId);
102+
const to = openTabs.findIndex((t) => t.id === overId);
82103
const arr = [...openTabs];
83104
arr.splice(to, 0, arr.splice(from, 1)[0]);
84105
openTabs = arr;
85106
}
86107
};
87108
const up = () => {
88-
dragKey = null;
109+
dragId = null;
89110
window.removeEventListener('pointermove', move);
90111
window.removeEventListener('pointerup', up);
91112
};
@@ -97,38 +118,38 @@
97118
<div class="dock">
98119
<div class="tabbar">
99120
<div class="tabs" bind:this={bar}>
100-
{#each openTabs as key (key)}
121+
{#each openTabs as tab (tab.id)}
101122
<div
102123
class="tab"
103-
class:on={key === active}
104-
class:dragging={key === dragKey}
105-
data-tab={key}
124+
class:on={tab.id === active}
125+
class:dragging={tab.id === dragId}
126+
data-tab={tab.id}
106127
role="tab"
107128
tabindex="0"
108-
aria-selected={key === active}
109-
onpointerdown={(e) => startDrag(e, key)}
110-
onclick={() => (active = key)}
111-
onkeydown={(e) => e.key === 'Enter' && (active = key)}
129+
aria-selected={tab.id === active}
130+
onpointerdown={(e) => startDrag(e, tab.id)}
131+
onclick={() => (active = tab.id)}
132+
onkeydown={(e) => e.key === 'Enter' && (active = tab.id)}
112133
>
113-
<span class="tdot" class:on={key === active}></span>
114-
<span class="tlabel">{labelOf(key)}</span>
134+
<span class="tdot" class:on={tab.id === active}></span>
135+
<span class="tlabel">{tabLabel(tab)}</span>
115136
<IconButton
116137
size="sm"
117138
label="close tab"
118139
onpointerdown={(e: PointerEvent) => e.stopPropagation()}
119140
onclick={(e: MouseEvent) => {
120141
e.stopPropagation();
121-
closeTab(key);
142+
closeTab(tab.id);
122143
}}><X size={12} /></IconButton>
123144
</div>
124145
{/each}
125146
</div>
126147
<div class="add">
127-
<IconButton onclick={() => (addOpen = !addOpen)} label="add panel" disabled={available.length === 0}><Plus size={15} /></IconButton>
148+
<IconButton onclick={() => (addOpen = !addOpen)} label="add panel"><Plus size={15} /></IconButton>
128149
{#if addOpen}
129150
<button class="add-backdrop" aria-label="close" onclick={() => (addOpen = false)}></button>
130151
<div class="add-menu">
131-
{#each available as p (p.key)}
152+
{#each PANELS as p (p.key)}
132153
<button class="add-item" onclick={() => openPanel(p.key)}>{p.label}</button>
133154
{/each}
134155
</div>
@@ -137,20 +158,28 @@
137158
</div>
138159

139160
<div class="content">
140-
{#each openTabs as key (key)}
141-
<div class="pane" class:hidden={key !== active}>
142-
{#if key === 'plan'}<PlanPanel {plan} />
143-
{:else if key === 'goal'}<GoalPanel {goal} />
144-
{:else if key === 'changes'}<ChangesPanel {cwd} files={changed} onRevert={onRevertFile} />
145-
{:else if key === 'files'}<FilesPanel rootDir={cwd} />
146-
{:else if key === 'git'}<GitPanel {cwd} />
147-
{:else if key === 'term'}<TerminalPanel {cwd} />{/if}
161+
{#each openTabs as tab (tab.id)}
162+
<div class="pane" class:hidden={tab.id !== active}>
163+
{#if tab.panel === 'plan'}<PlanPanel {plan} />
164+
{:else if tab.panel === 'goal'}<GoalPanel {goal} />
165+
{:else if tab.panel === 'changes'}<ChangesPanel {cwd} files={changed} onRevert={onRevertFile} />
166+
{:else if tab.panel === 'files'}<FilesPanel rootDir={cwd} />
167+
{:else if tab.panel === 'git'}<GitPanel {cwd} />
168+
{:else if tab.panel === 'term'}<TerminalPanel {cwd} />{/if}
148169
</div>
149170
{/each}
150171
{#if openTabs.length === 0}
151172
<div class="empty">
152173
<p>没有打开的面板</p>
153-
<span>点 <b>+</b> 打开一个</span>
174+
<span>选一个面板打开,或点右上角 <b>+</b></span>
175+
<div class="empty-grid">
176+
{#each PANELS as p (p.key)}
177+
<button class="pcardbtn" onclick={() => openPanel(p.key)}>
178+
<p.icon size={18} />
179+
<span>{p.label}</span>
180+
</button>
181+
{/each}
182+
</div>
154183
</div>
155184
{/if}
156185
</div>
@@ -302,4 +331,34 @@
302331
.empty b {
303332
color: var(--accent-bright);
304333
}
334+
.empty {
335+
padding: 24px 18px;
336+
}
337+
.empty-grid {
338+
display: grid;
339+
grid-template-columns: 1fr 1fr;
340+
gap: 8px;
341+
width: 100%;
342+
max-width: 260px;
343+
margin-top: 14px;
344+
}
345+
.pcardbtn {
346+
display: flex;
347+
flex-direction: column;
348+
align-items: center;
349+
gap: 8px;
350+
padding: 15px 10px;
351+
border: 1px solid var(--border);
352+
border-radius: var(--r-md);
353+
background: var(--surface);
354+
color: var(--dim);
355+
font-size: 12.5px;
356+
cursor: pointer;
357+
transition: border-color 0.12s, color 0.12s, background 0.12s;
358+
}
359+
.pcardbtn:hover {
360+
border-color: color-mix(in oklab, var(--accent) 45%, var(--border));
361+
color: var(--text);
362+
background: var(--surface2);
363+
}
305364
</style>

0 commit comments

Comments
 (0)