ompchamber 0.8.0 → 1.1.0
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/dist/client/index.html +1 -1
- package/dist/client/static/css/index.b859cc1cc5.css +2 -0
- package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
- package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
- package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
- package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
- package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
- package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
- package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
- package/dist/client/static/js/index.6e065017eb.js +33 -0
- package/package.json +2 -1
- package/src/client/components/common/ComposerPicker.tsx +16 -4
- package/src/client/components/common/ComposerTextarea.tsx +1 -0
- package/src/client/components/layout/RightActivityBar.tsx +1 -1
- package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
- package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
- package/src/client/components/layout/desktop-layout/index.tsx +25 -96
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
- package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
- package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
- package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
- package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
- package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
- package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
- package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
- package/src/client/components/workspace/git-panel/index.tsx +5 -1
- package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
- package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
- package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
- package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
- package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
- package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
- package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
- package/src/client/data/theme/terminal.ts +27 -29
- package/src/client/hooks/chat/composer/index.ts +28 -8
- package/src/client/hooks/chat/omp/session-list.tsx +19 -2
- package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
- package/src/client/hooks/chat/timeline/actions.ts +47 -28
- package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
- package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
- package/src/client/hooks/chat/timeline/index.ts +25 -21
- package/src/client/hooks/chat/timeline/queue.ts +110 -46
- package/src/client/hooks/chat/timeline/send.ts +16 -5
- package/src/client/hooks/workspace/available-width.ts +33 -0
- package/src/client/hooks/workspace/panel-widths.ts +34 -37
- package/src/client/hooks/workspace/terminal/index.ts +217 -0
- package/src/client/hooks/workspace/terminal/socket.ts +227 -0
- package/src/server/db.server.ts +19 -0
- package/src/server/lib/fs/git-sync.ts +191 -0
- package/src/server/lib/models/preferences.server.ts +110 -0
- package/src/server/lib/models/registry.server.ts +206 -0
- package/src/server/lib/omp/rpc/manager.ts +58 -3
- package/src/server/lib/omp/rpc/session-commands.ts +6 -0
- package/src/server/lib/omp/rpc/utility.ts +7 -1
- package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
- package/src/server/lib/queue/delivery.server.test.ts +203 -0
- package/src/server/lib/queue/delivery.server.ts +144 -0
- package/src/server/lib/queue/schema-migration.server.ts +50 -0
- package/src/server/lib/queue/store.server.ts +185 -0
- package/src/server/lib/terminal/foreground.ts +73 -0
- package/src/server/lib/terminal/history.test.ts +118 -0
- package/src/server/lib/terminal/history.ts +205 -0
- package/src/server/lib/terminal/runtime.server.ts +338 -0
- package/src/server/lib/terminal/session.server.ts +215 -0
- package/src/server/lib/terminal/shell.test.ts +97 -0
- package/src/server/lib/terminal/shell.ts +103 -0
- package/src/server/routes/fs/git.ts +22 -12
- package/src/server/routes/index.ts +2 -1
- package/src/server/routes/models/root.ts +88 -215
- package/src/server/routes/sessions/index.ts +11 -3
- package/src/server/routes/sessions/queue.ts +121 -0
- package/src/server/routes/sessions/session.ts +1 -132
- package/src/server/routes/settings/commands.ts +59 -18
- package/src/server/routes/terminal/index.ts +2 -2
- package/src/server/routes/terminal/run.ts +8 -100
- package/src/server/routes/terminal/sessions.ts +24 -0
- package/src/server/routes/terminal/ws.ts +149 -0
- package/src/shared/lib/chat/composer/client.ts +17 -8
- package/src/shared/lib/chat/composer/commands.ts +290 -0
- package/src/shared/lib/chat/composer/composer.test.ts +157 -0
- package/src/shared/lib/chat/composer/filter.ts +21 -7
- package/src/shared/lib/chat/composer/trigger.ts +109 -20
- package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
- package/src/shared/lib/chat/omp/agent-events.ts +30 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
- package/src/shared/lib/models/preferences.test.ts +131 -0
- package/src/shared/lib/models/preferences.ts +88 -0
- package/src/shared/lib/models/provider-models.ts +8 -1
- package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
- package/src/shared/lib/omp/subagent/label.test.ts +94 -0
- package/src/shared/lib/omp/subagent/label.ts +84 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
- package/src/shared/lib/workspace/panel-widths.ts +161 -30
- package/src/shared/lib/workspace/right-panels.ts +50 -11
- package/src/shared/lib/workspace/session-state/store.ts +4 -4
- package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
- package/src/shared/types/chat.ts +38 -65
- package/src/shared/types/index.ts +1 -0
- package/src/shared/types/models.ts +101 -0
- package/src/shared/types/omp/agent.ts +12 -0
- package/src/shared/types/omp/subagent.ts +4 -0
- package/src/shared/types/settings/command.ts +13 -0
- package/src/shared/types/terminal.ts +17 -15
- package/src/shared/types/workspace.ts +2 -2
- package/dist/client/static/css/index.5c0447cefe.css +0 -2
- package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
- package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
- package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
- package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
- package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
- package/src/client/hooks/workspace/terminal-output.ts +0 -66
- package/src/client/hooks/workspace/terminal.ts +0 -283
- package/src/server/lib/fs/terminal-cwd.ts +0 -49
- package/src/server/routes/terminal/stream.ts +0 -100
- /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
- /package/dist/client/static/js/{index.dc5f5fabe8.js.LICENSE.txt → index.6e065017eb.js.LICENSE.txt} +0 -0
|
@@ -13,7 +13,8 @@ interface ComposerDockProps {
|
|
|
13
13
|
provider?: string;
|
|
14
14
|
providerNames?: Record<string, string>;
|
|
15
15
|
messageQueue: QueuedMessage[];
|
|
16
|
-
|
|
16
|
+
onRemoveQueueItem: (id: string) => void;
|
|
17
|
+
onReorderQueue: (orderedIds: string[]) => void;
|
|
17
18
|
onEditQueueItem: (item: QueuedMessage) => void;
|
|
18
19
|
onSendNowQueueItem: (item: QueuedMessage) => void;
|
|
19
20
|
steeringQueue: QueuedMessage[];
|
|
@@ -31,6 +32,7 @@ interface ComposerDockProps {
|
|
|
31
32
|
accessMode: ApprovalMode;
|
|
32
33
|
onAccessModeChange: (mode: ApprovalMode) => void;
|
|
33
34
|
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
35
|
+
deferredComposerPickRef: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
|
|
34
36
|
sessionModel: { provider: string; modelId: string } | null;
|
|
35
37
|
sessionThinkingLevel?: string | null;
|
|
36
38
|
variant: 'desktop' | 'mobile';
|
|
@@ -49,7 +51,8 @@ export function ComposerDock({
|
|
|
49
51
|
provider,
|
|
50
52
|
providerNames,
|
|
51
53
|
messageQueue,
|
|
52
|
-
|
|
54
|
+
onRemoveQueueItem,
|
|
55
|
+
onReorderQueue,
|
|
53
56
|
onEditQueueItem,
|
|
54
57
|
onSendNowQueueItem,
|
|
55
58
|
steeringQueue,
|
|
@@ -67,6 +70,7 @@ export function ComposerDock({
|
|
|
67
70
|
accessMode,
|
|
68
71
|
onAccessModeChange,
|
|
69
72
|
composerModelRef,
|
|
73
|
+
deferredComposerPickRef,
|
|
70
74
|
sessionModel,
|
|
71
75
|
sessionThinkingLevel,
|
|
72
76
|
variant,
|
|
@@ -87,13 +91,18 @@ export function ComposerDock({
|
|
|
87
91
|
)}
|
|
88
92
|
<QueueList
|
|
89
93
|
queue={messageQueue}
|
|
90
|
-
|
|
94
|
+
onRemove={onRemoveQueueItem}
|
|
95
|
+
onReorder={onReorderQueue}
|
|
91
96
|
onEdit={onEditQueueItem}
|
|
92
97
|
onSendNow={onSendNowQueueItem}
|
|
93
98
|
/>
|
|
94
99
|
<QueueList
|
|
95
100
|
queue={steeringQueue}
|
|
96
|
-
|
|
101
|
+
onRemove={(id) => setSteeringQueue((q) => q.filter((i) => i.id !== id))}
|
|
102
|
+
onReorder={(orderedIds) => setSteeringQueue((prev) => {
|
|
103
|
+
const byId = new Map(prev.map((i) => [i.id, i]));
|
|
104
|
+
return orderedIds.map((id) => byId.get(id)).filter((i): i is QueuedMessage => Boolean(i));
|
|
105
|
+
})}
|
|
97
106
|
isSteering
|
|
98
107
|
/>
|
|
99
108
|
<ChatInput
|
|
@@ -111,6 +120,7 @@ export function ComposerDock({
|
|
|
111
120
|
accessMode={accessMode}
|
|
112
121
|
onAccessModeChange={onAccessModeChange}
|
|
113
122
|
composerModelRef={composerModelRef}
|
|
123
|
+
deferredComposerPickRef={deferredComposerPickRef}
|
|
114
124
|
sessionModel={sessionModel}
|
|
115
125
|
sessionThinkingLevel={sessionThinkingLevel}
|
|
116
126
|
variant={variant}
|
|
@@ -40,6 +40,7 @@ interface EmptyWorkspacePromptProps {
|
|
|
40
40
|
accessMode: ApprovalMode;
|
|
41
41
|
onAccessModeChange: (mode: ApprovalMode) => void;
|
|
42
42
|
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
43
|
+
deferredComposerPickRef: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
|
|
43
44
|
generatingVerb?: string;
|
|
44
45
|
variant?: 'desktop' | 'mobile';
|
|
45
46
|
}
|
|
@@ -69,6 +70,7 @@ export function EmptyWorkspacePrompt({
|
|
|
69
70
|
accessMode,
|
|
70
71
|
onAccessModeChange,
|
|
71
72
|
composerModelRef,
|
|
73
|
+
deferredComposerPickRef,
|
|
72
74
|
generatingVerb,
|
|
73
75
|
variant = 'desktop',
|
|
74
76
|
}: EmptyWorkspacePromptProps) {
|
|
@@ -226,6 +228,7 @@ export function EmptyWorkspacePrompt({
|
|
|
226
228
|
accessMode={accessMode}
|
|
227
229
|
onAccessModeChange={onAccessModeChange}
|
|
228
230
|
composerModelRef={composerModelRef}
|
|
231
|
+
deferredComposerPickRef={deferredComposerPickRef}
|
|
229
232
|
variant={variant}
|
|
230
233
|
/>
|
|
231
234
|
</div>
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { Dispatch, SetStateAction } from 'preact/compat';
|
|
2
1
|
import { useRef } from 'preact/hooks';
|
|
3
2
|
import { GripVertical, Pencil, Send, X } from 'lucide-preact';
|
|
4
3
|
import type { QueuedMessage } from '@/shared/types';
|
|
@@ -7,7 +6,10 @@ export type { QueuedMessage };
|
|
|
7
6
|
|
|
8
7
|
interface QueueListProps {
|
|
9
8
|
queue: QueuedMessage[];
|
|
10
|
-
|
|
9
|
+
/** Reorder handler (drag-and-drop): receives the new id order. */
|
|
10
|
+
onReorder?: (orderedIds: string[]) => void;
|
|
11
|
+
/** Remove one row by id. */
|
|
12
|
+
onRemove?: (id: string) => void;
|
|
11
13
|
/** True when every row in this panel is a steering delivery. */
|
|
12
14
|
isSteering?: boolean;
|
|
13
15
|
onEdit?: (item: QueuedMessage) => void;
|
|
@@ -20,18 +22,18 @@ const KIND_LABEL: Record<string, string> = {
|
|
|
20
22
|
followup: 'follow-up',
|
|
21
23
|
};
|
|
22
24
|
|
|
23
|
-
export function QueueList({ queue,
|
|
25
|
+
export function QueueList({ queue, onReorder, onRemove, isSteering = false, onEdit, onSendNow }: QueueListProps) {
|
|
24
26
|
const dragItem = useRef<number | null>(null);
|
|
25
27
|
const dragOverItem = useRef<number | null>(null);
|
|
26
28
|
|
|
27
29
|
const handleSort = () => {
|
|
28
30
|
if (dragItem.current === null || dragOverItem.current === null) return;
|
|
29
|
-
const
|
|
30
|
-
const
|
|
31
|
-
|
|
31
|
+
const ids = queue.map((i) => i.id);
|
|
32
|
+
const draggedId = ids.splice(dragItem.current, 1)[0];
|
|
33
|
+
ids.splice(dragOverItem.current, 0, draggedId);
|
|
32
34
|
dragItem.current = null;
|
|
33
35
|
dragOverItem.current = null;
|
|
34
|
-
|
|
36
|
+
onReorder?.(ids);
|
|
35
37
|
};
|
|
36
38
|
|
|
37
39
|
if (queue.length === 0) return null;
|
|
@@ -80,13 +82,15 @@ export function QueueList({ queue, setQueue, isSteering = false, onEdit, onSendN
|
|
|
80
82
|
<Pencil size={12} />
|
|
81
83
|
</button>
|
|
82
84
|
)}
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
85
|
+
{onRemove && (
|
|
86
|
+
<button
|
|
87
|
+
onClick={() => onRemove(item.id)}
|
|
88
|
+
className="text-ink/40 hover:text-error opacity-0 group-hover:opacity-100 transition-opacity"
|
|
89
|
+
title="Remove"
|
|
90
|
+
>
|
|
91
|
+
<X size={14} />
|
|
92
|
+
</button>
|
|
93
|
+
)}
|
|
90
94
|
</div>
|
|
91
95
|
))}
|
|
92
96
|
</div>
|
|
@@ -8,6 +8,7 @@ import { RunFooter } from '@/client/components/workspace/chat-timeline/RunFooter
|
|
|
8
8
|
import { useSubagentTranscript } from '@/client/hooks/chat/subagent';
|
|
9
9
|
import { useModelNames } from '@/client/hooks/models/use-model-names';
|
|
10
10
|
import { isNoticeRow } from '@/shared/lib/chat/notice-row';
|
|
11
|
+
import { subagentHeaderTitle } from '@/shared/lib/omp/subagent/label';
|
|
11
12
|
import { previousNonNoticeIndex, resolveRunFooters, streamingRowIndex } from '@/shared/lib/chat/timeline/run-footer';
|
|
12
13
|
import { formatCompactTokens } from '@/shared/lib/format/number';
|
|
13
14
|
|
|
@@ -62,6 +63,7 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
|
|
|
62
63
|
|
|
63
64
|
const progress = status?.progress ?? subagent.progress;
|
|
64
65
|
const modelName = progress?.resolvedModel ?? subagent.agent;
|
|
66
|
+
const headerTitle = subagentHeaderTitle(status ?? subagent);
|
|
65
67
|
const compactTokens = formatCompactTokens(progress?.tokens);
|
|
66
68
|
const meta = compactTokens ? `${compactTokens} tok` : '';
|
|
67
69
|
const rawTask = status?.task ?? status?.assignment ?? status?.description
|
|
@@ -85,7 +87,7 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
|
|
|
85
87
|
<div className="min-w-0 flex-1">
|
|
86
88
|
<div className="flex items-center gap-2 min-w-0">
|
|
87
89
|
<span className="font-mono text-[12px] font-semibold text-ink truncate">
|
|
88
|
-
{
|
|
90
|
+
{headerTitle}
|
|
89
91
|
</span>
|
|
90
92
|
<span className="shrink-0 rounded bg-ink/5 px-1.5 py-0.5 font-mono text-[9px] uppercase tracking-wider text-ink/50">
|
|
91
93
|
{STATUS_LABEL[effectiveStatus]}
|
|
@@ -8,6 +8,7 @@ import { AttachmentToolbar } from '@/client/components/workspace/chat-timeline/c
|
|
|
8
8
|
import { selectableThinkingLevels } from '@/shared/lib/models/thinking-levels';
|
|
9
9
|
import { fetchModelsData, subscribeModelsUpdated } from '@/shared/lib/models/client';
|
|
10
10
|
import { resolveThinkingLevel, selectionFor } from '@/client/components/workspace/chat-timeline/chat-input/selection';
|
|
11
|
+
import { NO_PENDING_PICK } from '@/client/hooks/chat/timeline/deferred-model';
|
|
11
12
|
|
|
12
13
|
export function ChatInput({
|
|
13
14
|
value,
|
|
@@ -31,6 +32,12 @@ export function ChatInput({
|
|
|
31
32
|
/** Written on every model/thinking pick so the send path can snapshot the
|
|
32
33
|
* selection into queued items without lifting ChatInput state. */
|
|
33
34
|
composerModelRef,
|
|
35
|
+
/** A pick the user made while a turn was streaming, still waiting for the
|
|
36
|
+
* next prompt. While set, the session's own model must NOT be adopted over
|
|
37
|
+
* it — the composer shows what the next prompt will actually run. Omitted
|
|
38
|
+
* by composers that never stream (the New Chat modal), where there is
|
|
39
|
+
* nothing to defer and adoption must behave as before. */
|
|
40
|
+
deferredComposerPickRef = NO_PENDING_PICK,
|
|
34
41
|
}: {
|
|
35
42
|
value: string;
|
|
36
43
|
onChange: (v: string) => void;
|
|
@@ -58,6 +65,7 @@ export function ChatInput({
|
|
|
58
65
|
*/
|
|
59
66
|
variant?: 'desktop' | 'mobile';
|
|
60
67
|
composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
|
|
68
|
+
deferredComposerPickRef?: { current: { provider?: string; modelId?: string; thinkingLevel?: string } | null };
|
|
61
69
|
}) {
|
|
62
70
|
const [internalAttachments, setInternalAttachments] = useState<Attachment[]>([]);
|
|
63
71
|
|
|
@@ -102,12 +110,18 @@ export function ChatInput({
|
|
|
102
110
|
// this effect re-runs right after a spawn (sessionModel changes while the
|
|
103
111
|
// session's own thinking_level_change entry is not readable yet), and any
|
|
104
112
|
// concrete fallback here claims a level the run never used.
|
|
113
|
+
//
|
|
114
|
+
// A pick still waiting for the next prompt wins: the session's model is what
|
|
115
|
+
// the RUNNING turn uses, and adopting it would erase the user's choice from
|
|
116
|
+
// the composer even though it is what the next prompt will run with.
|
|
105
117
|
useEffect(() => {
|
|
106
118
|
if (!sessionModel?.provider || !sessionModel.modelId) return;
|
|
119
|
+
if (deferredComposerPickRef.current) return;
|
|
107
120
|
let active = true;
|
|
108
121
|
fetchModelsData()
|
|
109
122
|
.then((data) => {
|
|
110
123
|
if (!active || !sessionModel) return;
|
|
124
|
+
if (deferredComposerPickRef.current) return;
|
|
111
125
|
const match = (data.modelList || []).find(
|
|
112
126
|
(m) => m.id === sessionModel.modelId && m.provider === sessionModel.provider
|
|
113
127
|
);
|
|
@@ -119,18 +133,21 @@ export function ChatInput({
|
|
|
119
133
|
return () => {
|
|
120
134
|
active = false;
|
|
121
135
|
};
|
|
122
|
-
}, [sessionModel?.provider, sessionModel?.modelId]);
|
|
136
|
+
}, [sessionModel?.provider, sessionModel?.modelId, deferredComposerPickRef]);
|
|
123
137
|
|
|
124
138
|
// Adopt the active session's last-used thinking level. Runs after the model
|
|
125
139
|
// sync effect above so it overrides the catalog default for this session.
|
|
140
|
+
// Same rule as the model: a pick waiting for the next prompt is not
|
|
141
|
+
// overridden by the level the running turn recorded.
|
|
126
142
|
useEffect(() => {
|
|
127
143
|
if (!sessionThinkingLevel) return;
|
|
144
|
+
if (deferredComposerPickRef.current?.thinkingLevel) return;
|
|
128
145
|
setSelectedModel(prev => {
|
|
129
146
|
if (!prev) return prev;
|
|
130
147
|
const level = resolveThinkingLevel(prev.thinkingLevels, sessionThinkingLevel, prev.thinkingLevel);
|
|
131
148
|
return level === prev.thinkingLevel ? prev : { ...prev, thinkingLevel: level };
|
|
132
149
|
});
|
|
133
|
-
}, [sessionThinkingLevel]);
|
|
150
|
+
}, [sessionThinkingLevel, deferredComposerPickRef]);
|
|
134
151
|
|
|
135
152
|
// Sync selected model from server and event listener
|
|
136
153
|
useEffect(() => {
|
|
@@ -139,6 +156,9 @@ export function ChatInput({
|
|
|
139
156
|
try {
|
|
140
157
|
const data = await fetchModelsData();
|
|
141
158
|
if (!active) return;
|
|
159
|
+
// A pick waiting for the next prompt owns the composer's selection:
|
|
160
|
+
// every branch below would replace it with a session/catalog default.
|
|
161
|
+
if (deferredComposerPickRef.current) return;
|
|
142
162
|
if (Array.isArray(data.modelList) && data.modelList.length > 0) {
|
|
143
163
|
// 1. The active session's last-used model is authoritative; even when
|
|
144
164
|
// it is missing from the catalog, never replace it with the default.
|
|
@@ -183,7 +203,7 @@ export function ChatInput({
|
|
|
183
203
|
active = false;
|
|
184
204
|
unsubscribe();
|
|
185
205
|
};
|
|
186
|
-
}, []);
|
|
206
|
+
}, [deferredComposerPickRef]);
|
|
187
207
|
|
|
188
208
|
// Thinking Dropdown State - Reactive with selectedModel
|
|
189
209
|
const thinkingLevels = useMemo(() => {
|
|
@@ -49,7 +49,8 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
49
49
|
loadOlder,
|
|
50
50
|
generatingVerb,
|
|
51
51
|
messageQueue,
|
|
52
|
-
|
|
52
|
+
removeMessage,
|
|
53
|
+
reorderMessages,
|
|
53
54
|
steeringQueue,
|
|
54
55
|
setSteeringQueue,
|
|
55
56
|
inputValue,
|
|
@@ -74,6 +75,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
74
75
|
accessMode,
|
|
75
76
|
handleAccessModeChange,
|
|
76
77
|
composerModelRef,
|
|
78
|
+
deferredComposerPickRef,
|
|
77
79
|
extensionDialogs,
|
|
78
80
|
resolveExtensionDialog,
|
|
79
81
|
respondToExtensionUi,
|
|
@@ -179,6 +181,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
179
181
|
accessMode={accessMode}
|
|
180
182
|
onAccessModeChange={handleAccessModeChange}
|
|
181
183
|
composerModelRef={composerModelRef}
|
|
184
|
+
deferredComposerPickRef={deferredComposerPickRef}
|
|
182
185
|
sessionModel={typeof sessionData?.model === 'object' ? sessionData.model : null}
|
|
183
186
|
sessionThinkingLevel={sessionData?.thinkingLevel}
|
|
184
187
|
generatingVerb={generatingVerb}
|
|
@@ -243,7 +246,8 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
243
246
|
provider={sessionProvider}
|
|
244
247
|
providerNames={providerNames}
|
|
245
248
|
messageQueue={messageQueue}
|
|
246
|
-
|
|
249
|
+
onRemoveQueueItem={removeMessage}
|
|
250
|
+
onReorderQueue={reorderMessages}
|
|
247
251
|
onEditQueueItem={handleEditQueueItem}
|
|
248
252
|
onSendNowQueueItem={handleSendNowQueueItem}
|
|
249
253
|
steeringQueue={steeringQueue}
|
|
@@ -261,6 +265,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
|
|
|
261
265
|
accessMode={accessMode}
|
|
262
266
|
onAccessModeChange={handleAccessModeChange}
|
|
263
267
|
composerModelRef={composerModelRef}
|
|
268
|
+
deferredComposerPickRef={deferredComposerPickRef}
|
|
264
269
|
sessionModel={typeof sessionData?.model === 'object' ? sessionData.model : null}
|
|
265
270
|
sessionThinkingLevel={sessionData?.thinkingLevel}
|
|
266
271
|
variant={variant}
|
|
@@ -124,9 +124,9 @@ export function Goal({ tool }: { tool: ToolCallData }) {
|
|
|
124
124
|
{payload.summary && (
|
|
125
125
|
<div>
|
|
126
126
|
<span className="text-[10px] font-mono text-ink/45 uppercase tracking-wider">Summary</span>
|
|
127
|
-
<
|
|
128
|
-
{payload.summary}
|
|
129
|
-
</
|
|
127
|
+
<div className="mt-0.5 rounded border border-ink/6 bg-ink/[0.03] p-2">
|
|
128
|
+
<MarkdownRenderer content={payload.summary} className="font-medium" />
|
|
129
|
+
</div>
|
|
130
130
|
</div>
|
|
131
131
|
)}
|
|
132
132
|
|
|
@@ -113,7 +113,7 @@ export function GitBranchToolbar({
|
|
|
113
113
|
<button
|
|
114
114
|
type="button"
|
|
115
115
|
onClick={onSync}
|
|
116
|
-
disabled={isBusy
|
|
116
|
+
disabled={isBusy}
|
|
117
117
|
title={
|
|
118
118
|
syncCount.ahead > 0 && syncCount.behind > 0
|
|
119
119
|
? `Pull ${syncCount.behind} then push ${syncCount.ahead} commit(s)`
|
|
@@ -121,7 +121,7 @@ export function GitBranchToolbar({
|
|
|
121
121
|
? `Push ${syncCount.ahead} commit(s)`
|
|
122
122
|
: syncCount.behind > 0
|
|
123
123
|
? `Pull ${syncCount.behind} commit(s)`
|
|
124
|
-
: 'Up to date'
|
|
124
|
+
: 'Up to date — sync anyway'
|
|
125
125
|
}
|
|
126
126
|
className="flex items-center gap-1 p-1 rounded hover:text-ink hover:bg-ink/5 cursor-pointer transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
|
127
127
|
>
|
|
@@ -34,6 +34,10 @@ export function GitPanel({ className = '', enabled = true, rootPath, refreshKey
|
|
|
34
34
|
const params = new URLSearchParams();
|
|
35
35
|
if (rootPath) params.set('root', rootPath);
|
|
36
36
|
if (targetRepo && targetRepo !== '.') params.set('repo', targetRepo);
|
|
37
|
+
// The toolbar's ↑ahead ↓behind badge is what this asks for; it makes the
|
|
38
|
+
// loader refresh the branch's remote-tracking ref before counting, so the
|
|
39
|
+
// numbers describe origin rather than the last fetch.
|
|
40
|
+
params.set('sync', '1');
|
|
37
41
|
params.set('t', String(Date.now()));
|
|
38
42
|
fetcher.load(`/api/fs/git?${params.toString()}`);
|
|
39
43
|
};
|
|
@@ -200,7 +204,7 @@ export function GitPanel({ className = '', enabled = true, rootPath, refreshKey
|
|
|
200
204
|
if (!enabled || pollingRepos || rescanningRepos) return;
|
|
201
205
|
setExtraRepos(repos);
|
|
202
206
|
setRescanningRepos(true);
|
|
203
|
-
const params = new URLSearchParams({ rescan: '1' });
|
|
207
|
+
const params = new URLSearchParams({ rescan: '1', sync: '1' });
|
|
204
208
|
if (rootPath) params.set('root', rootPath);
|
|
205
209
|
if (activeRepo !== '.') params.set('repo', activeRepo);
|
|
206
210
|
params.set('t', String(Date.now()));
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
1
6
|
import type { AIModelOption } from '@/shared/types';
|
|
2
7
|
import { modelKey } from '@/shared/lib/models/identity';
|
|
3
8
|
|
|
@@ -13,6 +18,25 @@ export interface ModelPickerGroups {
|
|
|
13
18
|
index: Record<string, number>;
|
|
14
19
|
}
|
|
15
20
|
|
|
21
|
+
/**
|
|
22
|
+
* Models named by an ordered key list, in that order.
|
|
23
|
+
*
|
|
24
|
+
* The order is the point: RECENT means "most recently used first", and the
|
|
25
|
+
* catalog's own order is alphabetical by name — so reading recents off a flag
|
|
26
|
+
* (the previous `isRecent`) could not express the ranking at all, and a
|
|
27
|
+
* re-picked model never moved back to the top.
|
|
28
|
+
*/
|
|
29
|
+
function modelsInKeyOrder(models: AIModelOption[], keys: readonly string[]): AIModelOption[] {
|
|
30
|
+
if (keys.length === 0) return [];
|
|
31
|
+
const byKey = new Map(models.map((model) => [modelKey(model), model]));
|
|
32
|
+
const ordered: AIModelOption[] = [];
|
|
33
|
+
for (const key of keys) {
|
|
34
|
+
const match = byKey.get(key);
|
|
35
|
+
if (match) ordered.push(match);
|
|
36
|
+
}
|
|
37
|
+
return ordered;
|
|
38
|
+
}
|
|
39
|
+
|
|
16
40
|
/**
|
|
17
41
|
* All derived picker state in one place: search filtering, the favorites/recent
|
|
18
42
|
* rails, the per-provider grouping, and the flattened key→index map the
|
|
@@ -21,11 +45,16 @@ export interface ModelPickerGroups {
|
|
|
21
45
|
* Keying `index` on the composite identity matters — the same model id is served
|
|
22
46
|
* by several providers, so an id-only key collapsed their rows onto one index
|
|
23
47
|
* and hovering one highlighted every other provider's row.
|
|
48
|
+
*
|
|
49
|
+
* A row never appears twice: a favorite that is also recent is only listed under
|
|
50
|
+
* FAVORITES, and neither rail repeats the row in its provider section.
|
|
24
51
|
*/
|
|
25
52
|
export function buildPickerGroups(
|
|
26
53
|
models: AIModelOption[],
|
|
27
54
|
search: string,
|
|
28
55
|
collapsedSections: Record<string, boolean>,
|
|
56
|
+
favoriteKeys: readonly string[],
|
|
57
|
+
recentKeys: readonly string[],
|
|
29
58
|
): ModelPickerGroups {
|
|
30
59
|
const query = search.trim().toLowerCase();
|
|
31
60
|
const filtered = query
|
|
@@ -37,8 +66,12 @@ export function buildPickerGroups(
|
|
|
37
66
|
)
|
|
38
67
|
: models;
|
|
39
68
|
|
|
40
|
-
|
|
41
|
-
|
|
69
|
+
// The rails are ordered by the stored key lists, not by the catalog order.
|
|
70
|
+
// A search narrows them like every other section — a starred model that does
|
|
71
|
+
// not match the query must not sit above the results.
|
|
72
|
+
const favorites = modelsInKeyOrder(filtered, favoriteKeys);
|
|
73
|
+
const favoriteSet = new Set(favoriteKeys);
|
|
74
|
+
const recent = modelsInKeyOrder(filtered, recentKeys).filter(m => !favoriteSet.has(modelKey(m)));
|
|
42
75
|
|
|
43
76
|
const byProvider: Record<string, AIModelOption[]> = {};
|
|
44
77
|
for (const model of filtered) {
|
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type { TargetedMouseEvent } from 'preact';
|
|
2
2
|
import { useMemo, useRef, useState } from 'preact/hooks';
|
|
3
3
|
import { ChevronDown, Sparkles } from 'lucide-preact';
|
|
4
|
-
import type { AIModelOption } from '@/shared/types';
|
|
4
|
+
import type { AIModelOption, ModelPreferences } from '@/shared/types';
|
|
5
5
|
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
6
6
|
import { invalidateModelsCache } from '@/shared/lib/models/client';
|
|
7
7
|
import { ModelDropdownPanel } from '@/client/components/workspace/model-dropdown/Panel';
|
|
8
8
|
import { useModelCatalog } from '@/client/components/workspace/model-dropdown/use-catalog';
|
|
9
9
|
import { buildPickerGroups } from '@/client/components/workspace/model-dropdown/groups';
|
|
10
10
|
import { modelKey } from '@/shared/lib/models/identity';
|
|
11
|
+
import { RECENT_MODELS_LIMIT, filterKnownKeys, readModelPreferences, recordRecentKey, toggleFavoriteKey } from '@/shared/lib/models/preferences';
|
|
11
12
|
|
|
12
13
|
interface ModelDropdownProps {
|
|
13
14
|
selectedModel?: AIModelOption;
|
|
@@ -29,7 +30,15 @@ export function ModelDropdown({
|
|
|
29
30
|
const [focusedIndex, setFocusedIndex] = useState<number>(-1);
|
|
30
31
|
const [hoveredModel, setHoveredModel] = useState<AIModelOption | null>(null);
|
|
31
32
|
const [collapsedSections, setCollapsedSections] = useState<Record<string, boolean>>({});
|
|
32
|
-
const {
|
|
33
|
+
const {
|
|
34
|
+
models,
|
|
35
|
+
isLoading,
|
|
36
|
+
setModels,
|
|
37
|
+
selectedModel,
|
|
38
|
+
setSelectedModel,
|
|
39
|
+
preferences,
|
|
40
|
+
applyPreferences,
|
|
41
|
+
} = useModelCatalog(externalSelectedModel);
|
|
33
42
|
|
|
34
43
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
35
44
|
useOnClickOutside(containerRef, () => {
|
|
@@ -46,8 +55,8 @@ export function ModelDropdown({
|
|
|
46
55
|
visibleFlatList,
|
|
47
56
|
index: modelIndex,
|
|
48
57
|
} = useMemo(
|
|
49
|
-
() => buildPickerGroups(models, search, collapsedSections),
|
|
50
|
-
[models, search, collapsedSections],
|
|
58
|
+
() => buildPickerGroups(models, search, collapsedSections, preferences.favorites, preferences.recentKeys),
|
|
59
|
+
[models, search, collapsedSections, preferences],
|
|
51
60
|
);
|
|
52
61
|
|
|
53
62
|
const selectedModelKey = modelKey(selectedModel);
|
|
@@ -70,17 +79,40 @@ export function ModelDropdown({
|
|
|
70
79
|
setCollapsedSections(prev => ({ ...prev, [section]: !prev[section] }));
|
|
71
80
|
};
|
|
72
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Flip one row's star. The optimistic stamp and the rollback both go through
|
|
84
|
+
* the preference pair — the flag on a row is derived state, so patching the
|
|
85
|
+
* row alone would be undone by the next `applyModelPreferences` (every reload
|
|
86
|
+
* re-stamps rows from the store).
|
|
87
|
+
*/
|
|
73
88
|
const handleToggleFavorite = async (model: AIModelOption, e: TargetedMouseEvent<HTMLElement>) => {
|
|
74
89
|
e.stopPropagation();
|
|
75
90
|
const key = modelKey(model);
|
|
76
|
-
|
|
91
|
+
const previous = preferences;
|
|
92
|
+
const optimistic: ModelPreferences = {
|
|
93
|
+
...previous,
|
|
94
|
+
favorites: toggleFavoriteKey(previous.favorites, key),
|
|
95
|
+
};
|
|
96
|
+
applyPreferences(optimistic);
|
|
77
97
|
try {
|
|
78
|
-
await fetch('/api/models', {
|
|
98
|
+
const response = await fetch('/api/models', {
|
|
79
99
|
method: 'POST',
|
|
80
100
|
headers: { 'Content-Type': 'application/json' },
|
|
81
101
|
body: JSON.stringify({ actionType: 'toggleFavorite', provider: model.provider, modelId: model.id }),
|
|
82
102
|
});
|
|
83
|
-
|
|
103
|
+
if (!response.ok) throw new Error(`toggleFavorite failed: ${response.status}`);
|
|
104
|
+
const data = await response.json() as { modelPreferences?: unknown };
|
|
105
|
+
// The server is the authority on the resulting list — it also drops keys
|
|
106
|
+
// the registry no longer serves. An absent/malformed payload keeps the
|
|
107
|
+
// optimistic list rather than blanking the rail.
|
|
108
|
+
if (data.modelPreferences) {
|
|
109
|
+
applyPreferences(readModelPreferences(data.modelPreferences));
|
|
110
|
+
}
|
|
111
|
+
} catch {
|
|
112
|
+
// A failed write must not leave the star lit: the next reload would show
|
|
113
|
+
// it unlit anyway, so the rollback keeps the panel honest immediately.
|
|
114
|
+
applyPreferences(previous);
|
|
115
|
+
}
|
|
84
116
|
};
|
|
85
117
|
|
|
86
118
|
const cycleThinkingLevel = (model: AIModelOption) => {
|
|
@@ -125,6 +157,15 @@ export function ModelDropdown({
|
|
|
125
157
|
onSelectModel?.(model);
|
|
126
158
|
setIsOpen(false);
|
|
127
159
|
setHoveredModel(null);
|
|
160
|
+
// Move the pick to the top of RECENT straight away — the server records it
|
|
161
|
+
// alongside the selection, and waiting for the round trip would leave the
|
|
162
|
+
// rail stale until the next full reload. Same rules as the server's write:
|
|
163
|
+
// pruned to the models the registry serves, de-duplicated, capped.
|
|
164
|
+
const known = new Set(models.map(modelKey));
|
|
165
|
+
applyPreferences({
|
|
166
|
+
favorites: filterKnownKeys(preferences.favorites, known),
|
|
167
|
+
recentKeys: recordRecentKey(filterKnownKeys(preferences.recentKeys, known), modelKey(model), RECENT_MODELS_LIMIT),
|
|
168
|
+
});
|
|
128
169
|
try {
|
|
129
170
|
await fetch('/api/models', {
|
|
130
171
|
method: 'POST',
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks';
|
|
2
|
-
import type { AIModelOption, ModelEntry } from '@/shared/types';
|
|
2
|
+
import type { AIModelOption, ModelEntry, ModelPreferences } from '@/shared/types';
|
|
3
3
|
import { fetchModelsData, subscribeModelsUpdated } from '@/shared/lib/models/client';
|
|
4
|
+
import {
|
|
5
|
+
EMPTY_MODEL_PREFERENCES,
|
|
6
|
+
applyModelPreferences,
|
|
7
|
+
readModelPreferences,
|
|
8
|
+
} from '@/shared/lib/models/preferences';
|
|
4
9
|
|
|
5
10
|
function toOption(m: ModelEntry): AIModelOption {
|
|
6
11
|
const ladder = Array.isArray(m.thinkingLevels) ? m.thinkingLevels : [];
|
|
@@ -28,6 +33,10 @@ export interface ModelCatalogState {
|
|
|
28
33
|
setModels: (updater: (prev: AIModelOption[]) => AIModelOption[]) => void;
|
|
29
34
|
selectedModel: AIModelOption | null;
|
|
30
35
|
setSelectedModel: (model: AIModelOption | null) => void;
|
|
36
|
+
/** Favorite + recent rails, ordered by the stored key lists. */
|
|
37
|
+
preferences: ModelPreferences;
|
|
38
|
+
/** Merge a server-returned preference pair into the rails. */
|
|
39
|
+
applyPreferences: (preferences: ModelPreferences) => void;
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/**
|
|
@@ -42,11 +51,17 @@ export interface ModelCatalogState {
|
|
|
42
51
|
* An externally-driven selection (the composer's session model) always wins over
|
|
43
52
|
* the API's own default — it is read through a ref so a thinking-level change
|
|
44
53
|
* flowing back through the prop does not re-arm the fetch.
|
|
54
|
+
*
|
|
55
|
+
* Favorites arrive as a key list, never as a flag on the registry rows: the
|
|
56
|
+
* registry is a read-only view of omp's config, so the only place a star can
|
|
57
|
+
* live is the preference store. The rows are stamped from it here, which is why
|
|
58
|
+
* a toggle must go through {@link applyPreferences} rather than patching a row.
|
|
45
59
|
*/
|
|
46
60
|
export function useModelCatalog(externalSelectedModel?: AIModelOption): ModelCatalogState {
|
|
47
61
|
const [models, setModelsState] = useState<AIModelOption[]>([]);
|
|
48
62
|
const [isLoading, setIsLoading] = useState(true);
|
|
49
63
|
const [selectedModel, setSelectedModel] = useState<AIModelOption | null>(externalSelectedModel ?? null);
|
|
64
|
+
const [preferences, setPreferences] = useState<ModelPreferences>(EMPTY_MODEL_PREFERENCES);
|
|
50
65
|
|
|
51
66
|
const externalSelectedRef = useRef(externalSelectedModel);
|
|
52
67
|
externalSelectedRef.current = externalSelectedModel;
|
|
@@ -56,11 +71,18 @@ export function useModelCatalog(externalSelectedModel?: AIModelOption): ModelCat
|
|
|
56
71
|
setModelsState(updater);
|
|
57
72
|
}, []);
|
|
58
73
|
|
|
74
|
+
const applyPreferences = useCallback((next: ModelPreferences) => {
|
|
75
|
+
setPreferences(next);
|
|
76
|
+
setModelsState(prev => applyModelPreferences(prev, next));
|
|
77
|
+
}, []);
|
|
78
|
+
|
|
59
79
|
const load = useCallback(async () => {
|
|
60
80
|
try {
|
|
61
81
|
const data = await fetchModelsData();
|
|
62
82
|
if (Array.isArray(data.modelList) && data.modelList.length > 0) {
|
|
63
|
-
const
|
|
83
|
+
const prefs = readModelPreferences(data.modelPreferences);
|
|
84
|
+
const realModels = applyModelPreferences(data.modelList.map(toOption), prefs);
|
|
85
|
+
setPreferences(prefs);
|
|
64
86
|
// Keep a thinking level the user already picked, but only when it is
|
|
65
87
|
// still a real level of this model's ladder (legacy mock values like
|
|
66
88
|
// 'Default'/'High' must not be preserved).
|
|
@@ -76,7 +98,9 @@ export function useModelCatalog(externalSelectedModel?: AIModelOption): ModelCat
|
|
|
76
98
|
if (match) setSelectedModel(match);
|
|
77
99
|
}
|
|
78
100
|
} else if (Array.isArray(data.models) && data.models.length > 0) {
|
|
79
|
-
|
|
101
|
+
const prefs = readModelPreferences(data.modelPreferences);
|
|
102
|
+
setPreferences(prefs);
|
|
103
|
+
setModelsState(applyModelPreferences(data.models, prefs));
|
|
80
104
|
}
|
|
81
105
|
if (data.selectedModel && !externalSelectedRef.current) {
|
|
82
106
|
setSelectedModel(data.selectedModel);
|
|
@@ -107,5 +131,5 @@ export function useModelCatalog(externalSelectedModel?: AIModelOption): ModelCat
|
|
|
107
131
|
));
|
|
108
132
|
}, [externalSelectedModel]);
|
|
109
133
|
|
|
110
|
-
return { models, isLoading, setModels, selectedModel, setSelectedModel };
|
|
134
|
+
return { models, isLoading, setModels, selectedModel, setSelectedModel, preferences, applyPreferences };
|
|
111
135
|
}
|