Skip to content

Commit 7710a28

Browse files
gaoyu06claude
andcommitted
feat(desktop): model-list cache, spawn loader, effort debounce; slider color polish & noise filtering
- Model picker opens instantly from a cached catalog (ChatState.modelCatalog), refreshing via /model in the background — no more click lag. - New sessions show a spawn loading spinner until the engine's first event (ChatState.booting). - Effort switch is debounced (350ms) and optimistic, so rapid drags don't race a burst of /model switches through the engine. - EffortSlider: non-max/ultra levels are gray; tier colors interpolate smoothly via @property-registered <color>s (no more gradient tearing); shimmer is an opacity overlay; "X-High" label no longer wraps. - Filter noise notifications: codex warning/deprecationNotice/configWarning are dropped (guardianWarning kept); claude stderr tracing-format log lines dropped. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 72eaa14 commit 7710a28

9 files changed

Lines changed: 157 additions & 38 deletions

File tree

src/lib/backends/claude.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1067,5 +1067,8 @@ describe('claude adapter: restarts and robustness', () => {
10671067
{ type: 'info', message: '[claude] node: warning' }
10681068
]);
10691069
expect(adapter.translate({ __stderr: ' ' })).toEqual([]);
1070+
// Tracing-formatted engine log lines are dropped as noise.
1071+
expect(adapter.translate({ __stderr: '2026-07-14T10:00:00.000Z DEBUG hitting cache' })).toEqual([]);
1072+
expect(adapter.translate({ __stderr: '2026-07-14T10:00:00Z ERROR boom' })).toEqual([]);
10701073
});
10711074
});

src/lib/backends/claude.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -873,8 +873,13 @@ export function createClaudeAdapter(): EngineAdapter {
873873
},
874874
translate(raw: unknown): NormalizedEvent[] {
875875
if (isStderrPayload(raw)) {
876-
const line = raw.__stderr.trim();
877-
return line ? [{ type: 'info', message: `[claude] ${line}` }] : [];
876+
// Strip ANSI, drop tracing-formatted log lines (`2026-…Z ERROR …`) —
877+
// routine engine noise that would otherwise spam the transcript.
878+
// eslint-disable-next-line no-control-regex
879+
const line = raw.__stderr.replace(/\[[0-9;]*m/g, '').trim();
880+
if (!line) return [];
881+
if (/^\d{4}-\d{2}-\d{2}T\S+\s+(ERROR|WARN|INFO|DEBUG|TRACE)\b/.test(line)) return [];
882+
return [{ type: 'info', message: `[claude] ${line}` }];
878883
}
879884
const msg = rec(raw);
880885
if (!msg) return [];

src/lib/backends/codex.test.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,16 @@ describe('codex adapter: turns', () => {
256256
).toEqual([{ type: 'status', message: 'ready' }]);
257257
});
258258

259+
it('drops dev/config noise notifications but keeps guardian warnings', () => {
260+
const adapter = createCodexAdapter();
261+
for (const method of ['warning', 'deprecationNotice', 'configWarning']) {
262+
expect(adapter.translate({ method, params: { message: 'noise' } })).toEqual([]);
263+
}
264+
expect(adapter.translate({ method: 'guardianWarning', params: { message: 'unsafe op' } })).toEqual([
265+
{ type: 'info', message: '[codex] unsafe op' }
266+
]);
267+
});
268+
259269
it('reports cumulative usage as per-update deltas', () => {
260270
const { lines } = makeIo();
261271
const adapter = createCodexAdapter();

src/lib/backends/codex.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -756,9 +756,13 @@ export function createCodexAdapter(): EngineAdapter {
756756
return [errorEvent(message, e.error?.codexErrorInfo)];
757757
}
758758
case 'warning':
759-
case 'guardianWarning':
760759
case 'deprecationNotice':
761-
case 'configWarning': {
760+
case 'configWarning':
761+
// Internal / config / deprecation chatter — pure noise in the chat
762+
// transcript, so we drop it rather than surface a system bubble.
763+
return [];
764+
case 'guardianWarning': {
765+
// Safety-relevant — keep it visible.
762766
const message = str(p.message);
763767
return message ? [{ type: 'info', message: `[codex] ${message}` }] : [];
764768
}

src/lib/chat.svelte.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ export class ChatState {
101101
effort = $state('');
102102
efforts = $state<string[]>([]);
103103
engineState = $state('starting');
104+
// True from session creation until the engine emits its first event — i.e.
105+
// while the claude/codex/jucode child is still booting. Drives the spawn
106+
// loading animation in the empty chat area.
107+
booting = $state(true);
108+
// Last model catalog seen (from a `model_view`), so the picker popover can open
109+
// instantly from cache while a fresh `/model` round-trip refreshes it.
110+
modelCatalog = $state<ModelOption[]>([]);
111+
modelCatalogEffort = $state('');
104112
contextTokens = $state(0);
105113
contextWindow = $state(0);
106114
contextLimit = $state(0);
@@ -277,6 +285,8 @@ export class ChatState {
277285
}
278286

279287
handle(ev: AgentEvent) {
288+
// The engine has spoken — the child is up, so the boot animation ends.
289+
this.booting = false;
280290
switch (ev.type) {
281291
case 'startup':
282292
this.model = str(ev.model);
@@ -434,6 +444,8 @@ export class ChatState {
434444
this.picker = { kind: 'tree', nodes: arr<TreeNode>(ev.nodes) };
435445
break;
436446
case 'model_view':
447+
this.modelCatalog = arr<ModelOption>(ev.models);
448+
this.modelCatalogEffort = str(ev.active_effort);
437449
this.picker = {
438450
kind: 'model',
439451
models: arr<ModelOption>(ev.models),

src/lib/chat.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,20 @@ describe('ChatState.handle', () => {
128128
expect(c.mcpServers).toEqual([]);
129129
});
130130

131+
it('clears booting on the first engine event and caches the model catalog', () => {
132+
const c = new ChatState();
133+
expect(c.booting).toBe(true);
134+
c.handle({ type: 'model_status', state: 'idle' });
135+
expect(c.booting).toBe(false);
136+
c.handle({
137+
type: 'model_view',
138+
models: [{ model: 'opus', active: true, reasoning_efforts: ['high', 'max'] }],
139+
active_effort: 'high'
140+
});
141+
expect(c.modelCatalog).toEqual([{ model: 'opus', active: true, reasoning_efforts: ['high', 'max'] }]);
142+
expect(c.modelCatalogEffort).toBe('high');
143+
});
144+
131145
it('tracks busy state from engine status', () => {
132146
const c = new ChatState();
133147
c.handle({ type: 'model_status', state: 'streaming' });

src/lib/i18n/messages/shell.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ const shell = {
4949

5050
// engine
5151
engineDown: '引擎已停止运行',
52+
spawning: '正在启动引擎…',
5253
restartEngine: '重启引擎',
5354

5455
// approval
@@ -238,6 +239,7 @@ const shell = {
238239

239240
// engine
240241
engineDown: 'Engine has stopped',
242+
spawning: 'Starting engine…',
241243
restartEngine: 'Restart engine',
242244

243245
// approval

src/lib/ui/EffortSlider.svelte

Lines changed: 57 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,9 @@
107107
onkeydown={onKey}
108108
>
109109
<div class="es-inner" bind:this={inner}>
110-
<div class="es-fill" class:flowing={tier !== ''} style="width: calc({pct}% + 22px)"></div>
110+
<div class="es-fill" style="width: calc({pct}% + 22px)">
111+
<div class="es-flow" class:on={tier !== ''}></div>
112+
</div>
111113
{#each options as opt, i (opt)}
112114
<span
113115
class="es-dot"
@@ -123,17 +125,39 @@
123125
</div>
124126

125127
<style>
128+
/* Register the palette slots as animatable <color>s so swapping tiers morphs
129+
the gradient smoothly instead of hard-cutting (the old `transition: background`
130+
couldn't interpolate a gradient and tore on every change). */
131+
@property --eff-a {
132+
syntax: '<color>';
133+
inherits: true;
134+
initial-value: #808791;
135+
}
136+
@property --eff-b {
137+
syntax: '<color>';
138+
inherits: true;
139+
initial-value: #6b7280;
140+
}
141+
@property --eff-solid {
142+
syntax: '<color>';
143+
inherits: true;
144+
initial-value: #6b7280;
145+
}
126146
.es {
127147
display: flex;
128148
flex-direction: column;
129149
gap: 12px;
130150
padding: 6px 6px 10px;
131151
min-width: 248px;
132152
user-select: none;
133-
/* Tier palette — default accent; overridden per data-tier below. */
134-
--eff-a: color-mix(in oklab, var(--accent) 82%, #fff);
135-
--eff-b: var(--accent);
136-
--eff-solid: var(--accent);
153+
/* Default (every level except the special top tiers): plain gray. */
154+
--eff-a: color-mix(in oklab, var(--dim) 78%, var(--text));
155+
--eff-b: var(--dim);
156+
--eff-solid: var(--dim);
157+
transition:
158+
--eff-a 0.42s ease,
159+
--eff-b 0.42s ease,
160+
--eff-solid 0.42s ease;
137161
}
138162
.es[data-tier='max'] {
139163
--eff-a: #ffb24d;
@@ -153,6 +177,7 @@
153177
}
154178
.es-head .edge {
155179
color: var(--dim2);
180+
white-space: nowrap;
156181
transition: color 0.18s ease;
157182
}
158183
.es-head .edge:last-child {
@@ -168,6 +193,7 @@
168193
color: var(--eff-solid);
169194
text-align: center;
170195
padding: 0 10px;
196+
white-space: nowrap;
171197
transition: color 0.35s ease;
172198
}
173199
/* Thick capsule track. */
@@ -195,40 +221,41 @@
195221
top: 0;
196222
bottom: 0;
197223
border-radius: 999px;
224+
overflow: hidden;
225+
/* Colors are the registered --eff-* props, so this gradient morphs via the
226+
transition on .es — only width snaps here. No gradient in `transition`. */
198227
background: linear-gradient(90deg, var(--eff-a), var(--eff-b));
199-
/* Animate width (snap between levels) and the color transition (tier swap). */
200-
transition:
201-
width 0.16s ease,
202-
background 0.4s ease;
228+
transition: width 0.16s ease;
203229
}
204-
/* Flowing shimmer for the special tiers: a moving highlight band scrolls
205-
across the fill. */
206-
.es-fill.flowing {
207-
background:
208-
linear-gradient(
209-
100deg,
210-
transparent 20%,
211-
color-mix(in oklab, #fff 45%, transparent) 42%,
212-
color-mix(in oklab, #fff 45%, transparent) 50%,
213-
transparent 72%
214-
),
215-
linear-gradient(90deg, var(--eff-a), var(--eff-b));
216-
background-size:
217-
220% 100%,
218-
100% 100%;
230+
/* Flowing shimmer for the special tiers: a separate highlight layer that fades
231+
in (opacity) so entering a tier never swaps the fill's background structure. */
232+
.es-flow {
233+
position: absolute;
234+
inset: 0;
235+
border-radius: inherit;
236+
opacity: 0;
237+
background: linear-gradient(
238+
100deg,
239+
transparent 20%,
240+
color-mix(in oklab, #fff 45%, transparent) 42%,
241+
color-mix(in oklab, #fff 45%, transparent) 50%,
242+
transparent 72%
243+
);
244+
background-size: 220% 100%;
219245
background-repeat: no-repeat;
220246
animation: eff-flow 1.6s linear infinite;
247+
transition: opacity 0.42s ease;
248+
pointer-events: none;
249+
}
250+
.es-flow.on {
251+
opacity: 1;
221252
}
222253
@keyframes eff-flow {
223254
from {
224-
background-position:
225-
160% 0,
226-
0 0;
255+
background-position: 160% 0;
227256
}
228257
to {
229-
background-position:
230-
-120% 0,
231-
0 0;
258+
background-position: -120% 0;
232259
}
233260
}
234261
.es-dot {

src/routes/+page.svelte

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { onMount, tick, untrack } from 'svelte';
33
import { listen } from '@tauri-apps/api/event';
44
import { getCurrentWebview } from '@tauri-apps/api/webview';
5-
import { X, Check, PanelRight, ChevronDown, ChevronUp, Search } from 'lucide-svelte';
5+
import { X, Check, PanelRight, ChevronDown, ChevronUp, Search, LoaderCircle } from 'lucide-svelte';
66
import { open, ask, message } from '@tauri-apps/plugin-dialog';
77
import { cycleTheme } from '$lib/theme.svelte';
88
import {
@@ -361,8 +361,35 @@
361361
return true;
362362
}
363363
364+
// Effort switch is debounced: reflect the pick immediately on the slider
365+
// (optimistic chat.effort) so the handle stays put, but only send the actual
366+
// `/model` command once the user settles — rapid drags/clicks don't race a
367+
// half-dozen switches through the engine.
368+
let effortTimer: ReturnType<typeof setTimeout> | undefined;
364369
function chooseEffort(ef: string) {
365-
if (chat) send({ op: 'command', input: `/model ${chat.model} ${ef}` });
370+
if (!chat) return;
371+
chat.effort = ef;
372+
const model = chat.model;
373+
clearTimeout(effortTimer);
374+
effortTimer = setTimeout(() => {
375+
if (chat) send({ op: 'command', input: `/model ${model} ${ef}` });
376+
}, 350);
377+
}
378+
379+
// Open the model picker as a popover. If we already have a cached catalog,
380+
// show it instantly and refresh in the background; otherwise fetch first.
381+
function openModelPicker() {
382+
if (!chat) return;
383+
if (chat.modelCatalog.length) {
384+
chat.picker = {
385+
kind: 'model',
386+
models: chat.modelCatalog,
387+
activeEffort: chat.modelCatalogEffort || chat.effort
388+
};
389+
const act = chat.modelCatalog.findIndex((m) => m.active);
390+
selIdx = act >= 0 ? act : 0;
391+
}
392+
nav('/model');
366393
}
367394
368395
// The assistant message that's still streaming: render it as plain text and
@@ -1119,7 +1146,12 @@
11191146
<div bind:this={contentEl}>
11201147
<MessageList messages={chat.messages} {streamingMsg} {streamingReasoning} phase={chat.phase} compactionTokens={chat.compactionTokens} {findActive} {scroller} onEdit={editMessage} onRewind={rewindToMessage} />
11211148
</div>
1122-
{#if chat.messages.length === 0 && !chat.busy}
1149+
{#if chat.booting && chat.engineState !== 'exited'}
1150+
<div class="welcome spawning">
1151+
<span class="spawn-spin"><LoaderCircle size={26} /></span>
1152+
<p class="welcome-tip">{t('shell.spawning')}</p>
1153+
</div>
1154+
{:else if chat.messages.length === 0 && !chat.busy}
11231155
<div class="welcome">
11241156
<span class="welcome-mark">JuCode</span>
11251157
<p class="welcome-tip">{t('shell.welcomeTip')}</p>
@@ -1167,7 +1199,7 @@
11671199
onPick={pickFiles}
11681200
onScreenshot={screenshot}
11691201
onRecord={toggleRecord}
1170-
onModel={() => nav('/model')}
1202+
onModel={openModelPicker}
11711203
onModelSelect={selectRow}
11721204
onModelEffort={setEffort}
11731205
onModelClose={() => chat?.closePicker()}
@@ -1476,6 +1508,16 @@
14761508
text-align: center;
14771509
animation: rise 0.3s ease both;
14781510
}
1511+
.spawn-spin {
1512+
display: inline-flex;
1513+
color: var(--accent);
1514+
animation: spawn-spin 0.8s linear infinite;
1515+
}
1516+
@keyframes spawn-spin {
1517+
to {
1518+
transform: rotate(360deg);
1519+
}
1520+
}
14791521
.welcome-mark {
14801522
font-family: var(--font-display);
14811523
font-weight: 800;

0 commit comments

Comments
 (0)