Skip to content

Commit 236e36d

Browse files
committed
Refine chat session controls
1 parent 100957b commit 236e36d

11 files changed

Lines changed: 159 additions & 282 deletions

File tree

src-tauri/Cargo.toml

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ crate-type = ["staticlib", "cdylib", "rlib"]
2121
tauri-build = { version = "2", features = [] }
2222

2323
[dependencies]
24-
tauri = { version = "2", features = ["protocol-asset", "tray-icon"] }
24+
tauri = { version = "2", features = ["macos-private-api", "protocol-asset", "tray-icon"] }
2525
base64 = "0.22"
2626
tauri-plugin-opener = "2"
2727
tauri-plugin-dialog = "2"
@@ -53,4 +53,3 @@ tauri-plugin-single-instance = { version = "2", features = ["deep-link"] }
5353
[target."cfg(target_os = \"macos\")".dependencies]
5454
tauri = { version = "2", features = ["macos-private-api"] }
5555
window-vibrancy = "0.5"
56-

src/lib/ChatPane.svelte

Lines changed: 11 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,6 @@
3838
type Op
3939
} from '$lib/protocol';
4040
import { buildModelRows } from '$lib/composer/modelRows';
41-
import { canHandOffToTui, isValidResumeSessionId } from '$lib/tuiHandoff';
4241
import { dispatch } from '$lib/backends/router';
4342
import { browser } from '$lib/browser.svelte';
4443
import { prefs } from '$lib/prefs.svelte';
@@ -121,9 +120,7 @@
121120
// Picker filter (history / long lists)
122121
let pickerQuery = $state('');
123122
let selIdx = $state(0);
124-
// True once the user arrow-keys through the picker — the model popover only
125-
// shows effort chips for a keyboard-focused row (not the default selIdx).
126-
let pickerKeyNav = $state(false);
123+
let pendingModel = $state('');
127124
128125
// Ops flow through this session's backend adapter; an unsupported op
129126
// (non-jucode stub backends) surfaces as an inline system notice.
@@ -177,14 +174,6 @@
177174
// yet (an optimistic push counts) and not a resumed conversation.
178175
const backendLocked = $derived(!!session.restored || chat.userTurns > 0);
179176
180-
// GUI → TUI handoff: offered for the native CLIs only (never ACP), enabled
181-
// once the engine holds a resumable conversation under a valid session id
182-
// (same gate as SessionStore.openInTui).
183-
const tuiCapable = $derived(canHandOffToTui(session.backendId));
184-
const tuiReady = $derived(
185-
isValidResumeSessionId(chat.sessionId) && (chat.resumable || !!session.restored)
186-
);
187-
188177
// Current git branch for the composer's footer strip. A detached HEAD reads
189178
// "detached"; a failed probe (not a git repo) hides the chip.
190179
let gitBranch = $state('');
@@ -307,8 +296,7 @@
307296
return p.items.map((it) => ({ id: it.id, label: it.label, detail: it.detail, active: it.active, command: `/rewind ${it.id}`, depth: nil }));
308297
// Model picker rows (pure packing in $lib/composer/modelRows): the active
309298
// provider's models from the engine's model_view plus, for jucode
310-
// sessions, every other configured provider's catalog — each row carrying
311-
// its model's reasoning-effort options for the popover's hover chips.
299+
// sessions, every other configured provider's catalog.
312300
return buildModelRows({
313301
models: p.models,
314302
backendId: chat.backendId,
@@ -339,10 +327,10 @@
339327
pickerQuery = '';
340328
});
341329
$effect(() => {
330+
if (pendingModel && chat.model === pendingModel) pendingModel = '';
342331
if (chat.picker) {
343332
const i = filteredRows.findIndex((r) => r.active);
344333
selIdx = i >= 0 ? i : 0;
345-
pickerKeyNav = false;
346334
}
347335
});
348336
$effect(() => {
@@ -473,6 +461,10 @@
473461
}
474462
475463
function selectRow(command: string) {
464+
if (command.startsWith('/model ')) {
465+
const target = command.slice('/model '.length).trim().split(/\s+/)[0] || '';
466+
if (target && target !== chat.model) pendingModel = target;
467+
}
476468
// Cross-provider model pick: rewrite config + restart this session (resumes
477469
// the conversation) since the engine can't change provider at runtime.
478470
// `@switch <provider> <model> [effort]` — the effort chip appends its value.
@@ -508,7 +500,7 @@
508500
chat.closePicker();
509501
}
510502
function setEffort(effort: string) {
511-
if (activeModel) selectRow(`/model ${activeModel.model} ${effort}`);
503+
if (chat.model && !pendingModel && !chat.switching) selectRow(`/model ${chat.model} ${effort}`);
512504
}
513505
function pickerKey(e: KeyboardEvent) {
514506
if (!chat.picker) return;
@@ -518,11 +510,9 @@
518510
} else if (e.key === 'ArrowDown') {
519511
e.preventDefault();
520512
selIdx = Math.min(selIdx + 1, filteredRows.length - 1);
521-
pickerKeyNav = true;
522513
} else if (e.key === 'ArrowUp') {
523514
e.preventDefault();
524515
selIdx = Math.max(selIdx - 1, 0);
525-
pickerKeyNav = true;
526516
} else if (e.key === 'Enter') {
527517
e.preventDefault();
528518
const r = filteredRows[selIdx];
@@ -785,12 +775,11 @@
785775
modelSearch={showPickerSearch}
786776
{backendLocked}
787777
{gitBranch}
788-
{tuiReady}
789-
onOpenTui={tuiCapable ? () => store.openInTui(session.id) : undefined}
790778
onBackend={(b, acpAgent) => store.switchBackend(session.id, b, acpAgent)}
791779
bind:pickerQuery
792-
bind:pickerSelIdx={selIdx}
793-
bind:pickerKeyNav
780+
bind:pickerSelIdx={selIdx}
781+
onEffort={setEffort}
782+
effortDisabled={!!pendingModel || chat.switching}
794783
onApproval={setApprovalMode}
795784
/>
796785
</div>

src/lib/Composer.svelte

Lines changed: 62 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { Send, Square, Paperclip, FastForward, ShieldCheck, CircleStop, Mic, LoaderCircle, GitBranch, SquareTerminal } from 'lucide-svelte';
2+
import { Send, Square, Paperclip, FastForward, ShieldCheck, CircleStop, Mic, LoaderCircle, GitBranch, Brain } from 'lucide-svelte';
33
import { message } from '@tauri-apps/plugin-dialog';
44
import IconButton from '$lib/ui/IconButton.svelte';
55
import BackendIcon from '$lib/BackendIcon.svelte';
@@ -26,14 +26,11 @@
2626
el = $bindable(),
2727
pickerQuery = $bindable(''),
2828
pickerSelIdx = $bindable(0),
29-
pickerKeyNav = $bindable(false),
3029
modelRows = [],
3130
modelTitle = '',
3231
modelSearch = false,
3332
backendLocked = true,
3433
gitBranch = '',
35-
tuiReady = false,
36-
onOpenTui,
3734
onBackend,
3835
onSubmit,
3936
onStop,
@@ -42,6 +39,8 @@
4239
onModel,
4340
onModelSelect,
4441
onModelClose,
42+
onEffort,
43+
effortDisabled = false,
4544
onApproval
4645
}: {
4746
chat: ChatState;
@@ -51,8 +50,6 @@
5150
el: HTMLElement | null;
5251
pickerQuery?: string;
5352
pickerSelIdx?: number;
54-
/** Arrow keys moved the picker selection (effort chips follow it then). */
55-
pickerKeyNav?: boolean;
5653
modelRows?: ModelRow[];
5754
modelTitle?: string;
5855
modelSearch?: boolean;
@@ -61,12 +58,6 @@
6158
backendLocked?: boolean;
6259
/** Current git branch for the footer strip ('' hides the chip). */
6360
gitBranch?: string;
64-
/** The session holds a resumable engine conversation the native TUI
65-
* can continue (gates the "continue in TUI" chip). */
66-
tuiReady?: boolean;
67-
/** Hand the conversation to the native TUI. Absent (e.g. ACP) hides
68-
* the chip entirely. */
69-
onOpenTui?: () => void;
7061
onBackend?: (b: BackendId, acpAgent?: { id: string; name: string }) => void | Promise<void>;
7162
onSubmit: () => void;
7263
onStop: () => void;
@@ -75,25 +66,28 @@
7566
onModel: () => void;
7667
onModelSelect?: (command: string) => void;
7768
onModelClose?: () => void;
69+
onEffort: (effort: string) => void;
70+
effortDisabled?: boolean;
7871
onApproval: (mode: ApprovalMode) => void;
7972
} = $props();
8073
8174
let slashIdx = $state(0);
75+
let showEffort = $state(false);
8276
let showApproval = $state(false);
8377
8478
// The model popover holds its own open flag so it can outlive an agent
8579
// switch (the new session ChatState starts with no picker) and open for
8680
// agents without a model catalog (ACP) — the rail inside it is the only
8781
// way to pick a coding agent.
8882
let modelOpen = $state(false);
89-
const modelPopoverVisible = $derived(modelOpen || chat.picker?.kind === 'model');
83+
const modelPopoverVisible = $derived(modelOpen);
9084
function toggleModelPopover() {
9185
if (modelPopoverVisible) {
9286
closeModelPopover();
9387
return;
9488
}
89+
showEffort = false;
9590
modelOpen = true;
96-
pickerKeyNav = false;
9791
if (bcaps.modelPicker) onModel();
9892
}
9993
function closeModelPopover() {
@@ -104,16 +98,22 @@
10498
// (ACP agents — `modelOpen` is ours, not chat.picker). Capture phase so the
10599
// key never reaches the pane's window handler or the editor.
106100
function onWindowKeyCapture(e: KeyboardEvent) {
107-
if (e.key === 'Escape' && modelPopoverVisible) {
101+
if (e.key === 'Escape' && (modelPopoverVisible || showEffort)) {
108102
e.preventDefault();
109103
e.stopPropagation();
110-
closeModelPopover();
104+
if (modelPopoverVisible) closeModelPopover();
105+
showEffort = false;
111106
}
112107
}
113108
function selectFromPopover(command: string) {
114109
modelOpen = false;
115110
onModelSelect?.(command);
116111
}
112+
function setEffort(effort: string) {
113+
if (effortDisabled) return;
114+
onEffort(effort);
115+
showEffort = false;
116+
}
117117
118118
// The fallback label on the model button before the engine reports a model:
119119
// the ACP agent's registered name, else the engine's brand name.
@@ -254,6 +254,7 @@
254254
]
255255
);
256256
const approvalLabel = $derived(APPROVAL.find((a) => a.value === chat.approvalMode)?.label ?? t('chat.approvalAsk'));
257+
const effortOptions = $derived(chat.efforts.map((effort) => ({ value: effort })));
257258
// Persisting + pushing the mode to the engine lives with the page (it owns
258259
// the session id); the picker only reports the choice.
259260
function setApproval(m: string) {
@@ -549,7 +550,6 @@
549550
{backendLocked}
550551
bind:query={pickerQuery}
551552
bind:selIdx={pickerSelIdx}
552-
bind:keyNav={pickerKeyNav}
553553
onClose={closeModelPopover}
554554
onSelect={selectFromPopover}
555555
{onBackend}
@@ -560,6 +560,29 @@
560560
{:else if chat.model}
561561
<span class="flatbtn model static"><BackendIcon backend={chat.backendId} size={15} /><span>{chat.modelLabel || chat.model}</span></span>
562562
{/if}
563+
{#if chat.efforts.length}
564+
<div class="effortsel">
565+
<button
566+
class="flatbtn effort"
567+
disabled={effortDisabled}
568+
class:pending={effortDisabled}
569+
onclick={() => {
570+
if (modelPopoverVisible) closeModelPopover();
571+
showEffort = !showEffort;
572+
}}
573+
title={t('chat.effortTitle')}
574+
aria-label={t('chat.effortTitle')}
575+
>
576+
<Brain size={15} /><span>{chat.effort || t('chat.effortTitle')}</span>
577+
</button>
578+
{#if showEffort}
579+
<button class="pop-backdrop" aria-label="close" onclick={() => (showEffort = false)}></button>
580+
<div class="effort-pop">
581+
<Segmented value={chat.effort} options={effortOptions} onChange={setEffort} />
582+
</div>
583+
{/if}
584+
</div>
585+
{/if}
563586
<div class="cspace"></div>
564587
<button
565588
class="cact voice"
@@ -596,15 +619,6 @@
596619
{/if}
597620
</div>
598621
{/if}
599-
{#if onOpenTui && tuiReady}
600-
<button
601-
class="foot-chip"
602-
onclick={onOpenTui}
603-
title={t('chat.tuiContinueTitle')}
604-
>
605-
<SquareTerminal size={12} /><span>{t('chat.tuiContinue')}</span>
606-
</button>
607-
{/if}
608622
<div class="fspace"></div>
609623
{#if bcaps.contextUsage && ctxLimit > 0}
610624
<div class="foot-ctx">
@@ -705,11 +719,16 @@
705719
.flatbtn.model span {
706720
font-family: var(--font-mono);
707721
font-size: 12px;
722+
min-width: 0;
708723
max-width: 220px;
709724
white-space: nowrap;
710725
overflow: hidden;
711726
text-overflow: ellipsis;
712727
}
728+
.flatbtn.effort span {
729+
font-family: var(--font-mono);
730+
font-size: 12px;
731+
}
713732
/* read-only model label for backends without an in-chat model picker */
714733
.flatbtn.static {
715734
cursor: default;
@@ -720,12 +739,20 @@
720739
.flatbtn.static:active {
721740
transform: none;
722741
}
723-
/* position:relative anchors for the model popover / the approval popover */
742+
/* position:relative anchors for the compact composer popovers */
724743
.modelsel,
744+
.effortsel,
725745
.footsel {
726746
position: relative;
727747
display: inline-flex;
728748
}
749+
.modelsel,
750+
.flatbtn.model {
751+
min-width: 0;
752+
}
753+
.effortsel {
754+
flex-shrink: 0;
755+
}
729756
.pop-backdrop {
730757
position: fixed;
731758
inset: 0;
@@ -735,9 +762,10 @@
735762
cursor: default;
736763
}
737764
.effort-pop {
738-
position: absolute;
739-
bottom: calc(100% + 8px);
740-
left: 0;
765+
position: fixed;
766+
bottom: 72px;
767+
left: 18px;
768+
max-width: calc(100vw - 36px);
741769
z-index: 21;
742770
padding: 6px;
743771
background: var(--panel);
@@ -747,6 +775,10 @@
747775
transform-origin: bottom left;
748776
animation: pop-in var(--t-med) var(--ease-spring);
749777
}
778+
.effort-pop :global(.seg) {
779+
max-width: 100%;
780+
overflow-x: auto;
781+
}
750782
.cspace {
751783
flex: 1;
752784
}

0 commit comments

Comments
 (0)