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.
Files changed (130) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/static/css/index.b859cc1cc5.css +2 -0
  3. package/dist/client/static/js/async/{5b.0d6ae50fe8.js → 5b.a5ccbc19dc.js} +2 -2
  4. package/dist/client/static/js/async/g.a06ca3b3e9.js +2 -0
  5. package/dist/client/static/js/async/{m.0896dab823.js → m.c038c71b9f.js} +2 -2
  6. package/dist/client/static/js/async/ms.98960af4eb.js +67 -0
  7. package/dist/client/static/js/async/ms.98960af4eb.js.LICENSE.txt +12 -0
  8. package/dist/client/static/js/async/ot.84c71b308f.js +2 -0
  9. package/dist/client/static/js/async/{x4.69ec69c61b.js.LICENSE.txt → ot.84c71b308f.js.LICENSE.txt} +5 -0
  10. package/dist/client/static/js/index.6e065017eb.js +33 -0
  11. package/package.json +2 -1
  12. package/src/client/components/common/ComposerPicker.tsx +16 -4
  13. package/src/client/components/common/ComposerTextarea.tsx +1 -0
  14. package/src/client/components/layout/RightActivityBar.tsx +1 -1
  15. package/src/client/components/layout/desktop-layout/ResizeHandle.tsx +2 -2
  16. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +13 -2
  17. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +107 -56
  18. package/src/client/components/layout/desktop-layout/index.tsx +25 -96
  19. package/src/client/components/layout/desktop-layout/resizer/index.tsx +79 -118
  20. package/src/client/components/layout/desktop-layout/resizer/separator-drag.ts +140 -0
  21. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +1 -1
  22. package/src/client/components/layout/session-sidebar/Header.tsx +1 -1
  23. package/src/client/components/layout/session-sidebar/SessionItem.tsx +27 -13
  24. package/src/client/components/layout/session-sidebar/SessionList.tsx +1 -1
  25. package/src/client/components/layout/session-sidebar/SubagentList.tsx +6 -4
  26. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +1 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +1 -1
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +2 -2
  29. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +14 -4
  30. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +3 -0
  31. package/src/client/components/workspace/chat-timeline/QueueList.tsx +18 -14
  32. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +3 -1
  33. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +23 -3
  34. package/src/client/components/workspace/chat-timeline/index.tsx +7 -2
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Goal.tsx +3 -3
  36. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +2 -2
  37. package/src/client/components/workspace/git-panel/index.tsx +5 -1
  38. package/src/client/components/workspace/model-dropdown/groups.ts +35 -2
  39. package/src/client/components/workspace/model-dropdown/index.tsx +48 -7
  40. package/src/client/components/workspace/model-dropdown/use-catalog.ts +28 -4
  41. package/src/client/components/workspace/terminal-panel/Header.tsx +51 -19
  42. package/src/client/components/workspace/terminal-panel/RealtimeXtermView.tsx +86 -282
  43. package/src/client/components/workspace/terminal-panel/index.tsx +76 -50
  44. package/src/client/components/workspace/terminal-panel/mount.ts +275 -0
  45. package/src/client/data/theme/terminal.ts +27 -29
  46. package/src/client/hooks/chat/composer/index.ts +28 -8
  47. package/src/client/hooks/chat/omp/session-list.tsx +19 -2
  48. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +1 -1
  49. package/src/client/hooks/chat/timeline/actions.ts +47 -28
  50. package/src/client/hooks/chat/timeline/deferred-model.test.ts +219 -0
  51. package/src/client/hooks/chat/timeline/deferred-model.ts +92 -0
  52. package/src/client/hooks/chat/timeline/index.ts +25 -21
  53. package/src/client/hooks/chat/timeline/queue.ts +110 -46
  54. package/src/client/hooks/chat/timeline/send.ts +16 -5
  55. package/src/client/hooks/workspace/available-width.ts +33 -0
  56. package/src/client/hooks/workspace/panel-widths.ts +34 -37
  57. package/src/client/hooks/workspace/terminal/index.ts +217 -0
  58. package/src/client/hooks/workspace/terminal/socket.ts +227 -0
  59. package/src/server/db.server.ts +19 -0
  60. package/src/server/lib/fs/git-sync.ts +191 -0
  61. package/src/server/lib/models/preferences.server.ts +110 -0
  62. package/src/server/lib/models/registry.server.ts +206 -0
  63. package/src/server/lib/omp/rpc/manager.ts +58 -3
  64. package/src/server/lib/omp/rpc/session-commands.ts +6 -0
  65. package/src/server/lib/omp/rpc/utility.ts +7 -1
  66. package/src/server/lib/omp/session/sidebar-data.server.ts +3 -3
  67. package/src/server/lib/queue/delivery.server.test.ts +203 -0
  68. package/src/server/lib/queue/delivery.server.ts +144 -0
  69. package/src/server/lib/queue/schema-migration.server.ts +50 -0
  70. package/src/server/lib/queue/store.server.ts +185 -0
  71. package/src/server/lib/terminal/foreground.ts +73 -0
  72. package/src/server/lib/terminal/history.test.ts +118 -0
  73. package/src/server/lib/terminal/history.ts +205 -0
  74. package/src/server/lib/terminal/runtime.server.ts +338 -0
  75. package/src/server/lib/terminal/session.server.ts +215 -0
  76. package/src/server/lib/terminal/shell.test.ts +97 -0
  77. package/src/server/lib/terminal/shell.ts +103 -0
  78. package/src/server/routes/fs/git.ts +22 -12
  79. package/src/server/routes/index.ts +2 -1
  80. package/src/server/routes/models/root.ts +88 -215
  81. package/src/server/routes/sessions/index.ts +11 -3
  82. package/src/server/routes/sessions/queue.ts +121 -0
  83. package/src/server/routes/sessions/session.ts +1 -132
  84. package/src/server/routes/settings/commands.ts +59 -18
  85. package/src/server/routes/terminal/index.ts +2 -2
  86. package/src/server/routes/terminal/run.ts +8 -100
  87. package/src/server/routes/terminal/sessions.ts +24 -0
  88. package/src/server/routes/terminal/ws.ts +149 -0
  89. package/src/shared/lib/chat/composer/client.ts +17 -8
  90. package/src/shared/lib/chat/composer/commands.ts +290 -0
  91. package/src/shared/lib/chat/composer/composer.test.ts +157 -0
  92. package/src/shared/lib/chat/composer/filter.ts +21 -7
  93. package/src/shared/lib/chat/composer/trigger.ts +109 -20
  94. package/src/shared/lib/chat/omp/agent-events.test.ts +37 -1
  95. package/src/shared/lib/chat/omp/agent-events.ts +30 -0
  96. package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +9 -1
  97. package/src/shared/lib/chat/timeline/omp-callbacks.ts +45 -8
  98. package/src/shared/lib/models/preferences.test.ts +131 -0
  99. package/src/shared/lib/models/preferences.ts +88 -0
  100. package/src/shared/lib/models/provider-models.ts +8 -1
  101. package/src/shared/lib/omp/session/stream-state.server.ts +2 -3
  102. package/src/shared/lib/omp/subagent/label.test.ts +94 -0
  103. package/src/shared/lib/omp/subagent/label.ts +84 -0
  104. package/src/shared/lib/workspace/panel-widths.test.ts +113 -18
  105. package/src/shared/lib/workspace/panel-widths.ts +161 -30
  106. package/src/shared/lib/workspace/right-panels.ts +50 -11
  107. package/src/shared/lib/workspace/session-state/store.ts +4 -4
  108. package/src/shared/lib/workspace/terminal/protocol.ts +222 -0
  109. package/src/shared/types/chat.ts +38 -65
  110. package/src/shared/types/index.ts +1 -0
  111. package/src/shared/types/models.ts +101 -0
  112. package/src/shared/types/omp/agent.ts +12 -0
  113. package/src/shared/types/omp/subagent.ts +4 -0
  114. package/src/shared/types/settings/command.ts +13 -0
  115. package/src/shared/types/terminal.ts +17 -15
  116. package/src/shared/types/workspace.ts +2 -2
  117. package/dist/client/static/css/index.5c0447cefe.css +0 -2
  118. package/dist/client/static/js/async/g.d1c2fe09aa.js +0 -2
  119. package/dist/client/static/js/async/x4.69ec69c61b.js +0 -6
  120. package/dist/client/static/js/index.dc5f5fabe8.js +0 -33
  121. package/src/client/components/workspace/terminal-panel/InputBar.tsx +0 -82
  122. package/src/client/components/workspace/terminal-panel/LogEntry.tsx +0 -70
  123. package/src/client/hooks/workspace/terminal-output.ts +0 -66
  124. package/src/client/hooks/workspace/terminal.ts +0 -283
  125. package/src/server/lib/fs/terminal-cwd.ts +0 -49
  126. package/src/server/routes/terminal/stream.ts +0 -100
  127. /package/dist/client/static/js/async/{5b.0d6ae50fe8.js.LICENSE.txt → 5b.a5ccbc19dc.js.LICENSE.txt} +0 -0
  128. /package/dist/client/static/js/async/{g.d1c2fe09aa.js.LICENSE.txt → g.a06ca3b3e9.js.LICENSE.txt} +0 -0
  129. /package/dist/client/static/js/async/{m.0896dab823.js.LICENSE.txt → m.c038c71b9f.js.LICENSE.txt} +0 -0
  130. /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
- setMessageQueue: Dispatch<SetStateAction<QueuedMessage[]>>;
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
- setMessageQueue,
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
- setQueue={setMessageQueue}
94
+ onRemove={onRemoveQueueItem}
95
+ onReorder={onReorderQueue}
91
96
  onEdit={onEditQueueItem}
92
97
  onSendNow={onSendNowQueueItem}
93
98
  />
94
99
  <QueueList
95
100
  queue={steeringQueue}
96
- setQueue={setSteeringQueue}
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
- setQueue: Dispatch<SetStateAction<QueuedMessage[]>>;
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, setQueue, isSteering = false, onEdit, onSendNow }: QueueListProps) {
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 _queue = [...queue];
30
- const draggedItemContent = _queue.splice(dragItem.current, 1)[0];
31
- _queue.splice(dragOverItem.current, 0, draggedItemContent);
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
- setQueue(_queue);
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
- <button
84
- onClick={() => setQueue(q => q.filter(i => i.id !== item.id))}
85
- className="text-ink/40 hover:text-error opacity-0 group-hover:opacity-100 transition-opacity"
86
- title="Remove"
87
- >
88
- <X size={14} />
89
- </button>
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
- {status?.agent ?? subagent.agent}
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
- setMessageQueue,
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
- setMessageQueue={setMessageQueue}
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
- <p className="mt-0.5 font-sans text-[12px] text-ink font-medium leading-relaxed">
128
- {payload.summary}
129
- </p>
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 || (syncCount.ahead === 0 && syncCount.behind === 0)}
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
- const favorites = filtered.filter(m => m.isFavorite);
41
- const recent = filtered.filter(m => m.isRecent && !m.isFavorite);
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 { models, isLoading, setModels, selectedModel, setSelectedModel } = useModelCatalog(externalSelectedModel);
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
- setModels(prev => prev.map(m => modelKey(m) === key ? { ...m, isFavorite: !m.isFavorite } : m));
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
- } catch {}
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 realModels = data.modelList.map(toOption);
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
- setModelsState(data.models);
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
  }