repoburg 1.3.199 → 1.3.202

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 (136) hide show
  1. package/agent-pane/backend/data.db +0 -0
  2. package/agent-pane/backend/data.db-shm +0 -0
  3. package/agent-pane/backend/data.db-wal +0 -0
  4. package/agent-pane/backend/dist/index.js +1658 -277
  5. package/agent-pane/backend/src/claude/claude-provider.test.ts +393 -3
  6. package/agent-pane/backend/src/claude/claude-provider.ts +53 -50
  7. package/agent-pane/backend/src/db/index.ts +12 -0
  8. package/agent-pane/backend/src/index.ts +5 -2
  9. package/agent-pane/backend/src/integration/threads-claude-fork.test.ts +241 -0
  10. package/agent-pane/backend/src/providers/index.ts +3 -1
  11. package/agent-pane/backend/src/providers/runtime-provider.ts +6 -0
  12. package/agent-pane/backend/src/repoburg/action-mapping.test.ts +122 -0
  13. package/agent-pane/backend/src/repoburg/action-mapping.ts +294 -0
  14. package/agent-pane/backend/src/repoburg/agent-runtime-client.test.ts +271 -0
  15. package/agent-pane/backend/src/repoburg/agent-runtime-client.ts +285 -0
  16. package/agent-pane/backend/src/repoburg/backend-resolver.test.ts +356 -0
  17. package/agent-pane/backend/src/repoburg/backend-resolver.ts +261 -0
  18. package/agent-pane/backend/src/repoburg/repoburg-client.test.ts +180 -0
  19. package/agent-pane/backend/src/repoburg/repoburg-client.ts +194 -0
  20. package/agent-pane/backend/src/repoburg/repoburg-provider.test.ts +780 -0
  21. package/agent-pane/backend/src/repoburg/repoburg-provider.ts +471 -0
  22. package/agent-pane/backend/src/repoburg/turn-projector.test.ts +621 -0
  23. package/agent-pane/backend/src/repoburg/turn-projector.ts +637 -0
  24. package/agent-pane/backend/src/routes/providers.test.ts +18 -1
  25. package/agent-pane/backend/src/routes/providers.ts +19 -1
  26. package/agent-pane/backend/src/routes/sessions.test.ts +215 -1
  27. package/agent-pane/backend/src/routes/sessions.ts +82 -9
  28. package/agent-pane/backend/src/routes/threads.ts +21 -6
  29. package/agent-pane/backend/src/routes/turns.test.ts +45 -1
  30. package/agent-pane/frontend/dist/assets/index-CJxD4YmJ.js +370 -0
  31. package/agent-pane/frontend/dist/assets/{index-Dwwhx7YN.css → index-r-xwfOKQ.css} +1 -1
  32. package/agent-pane/frontend/dist/index.html +2 -2
  33. package/agent-pane/frontend/src/api/sessions.test.ts +1 -0
  34. package/agent-pane/frontend/src/api/websocket.test.ts +2 -2
  35. package/agent-pane/frontend/src/api/websocket.ts +3 -1
  36. package/agent-pane/frontend/src/components/AskUserItem.test.tsx +143 -0
  37. package/agent-pane/frontend/src/components/AskUserItem.tsx +207 -0
  38. package/agent-pane/frontend/src/components/CommentableText.test.tsx +196 -0
  39. package/agent-pane/frontend/src/components/CommentableText.tsx +109 -21
  40. package/agent-pane/frontend/src/components/IdeWorkspace.test.tsx +39 -0
  41. package/agent-pane/frontend/src/components/IdeWorkspace.tsx +28 -2
  42. package/agent-pane/frontend/src/components/ResizeHandle.tsx +29 -0
  43. package/agent-pane/frontend/src/components/TurnContent.test.tsx +79 -0
  44. package/agent-pane/frontend/src/components/TurnContent.tsx +49 -3
  45. package/agent-pane/frontend/src/components/panels/ThreadsPanel.tsx +35 -3
  46. package/agent-pane/frontend/src/components/panels/TurnsPanel.test.tsx +48 -0
  47. package/agent-pane/frontend/src/components/panels/TurnsPanel.tsx +7 -2
  48. package/agent-pane/frontend/src/constants/models.ts +37 -0
  49. package/agent-pane/frontend/src/hooks/useResizable.test.ts +97 -0
  50. package/agent-pane/frontend/src/hooks/useResizable.ts +75 -0
  51. package/agent-pane/frontend/src/store/store.test.ts +54 -0
  52. package/agent-pane/frontend/src/store/uiSlice.ts +50 -0
  53. package/agent-pane/frontend/src/store/wsSlice.ts +10 -3
  54. package/agent-pane/frontend/src/styles/app.css +26 -1
  55. package/agent-pane/frontend/src/test/component-helpers.ts +2 -0
  56. package/agent-pane/frontend/src/test/fixtures.ts +1 -0
  57. package/agent-pane/frontend/src/types/index.ts +2 -0
  58. package/agent-pane/frontend/src/utils/duration.test.ts +1 -0
  59. package/agent-pane/shared/types.ts +17 -0
  60. package/agent-pane-bug-repro-no-agent-reply.png +0 -0
  61. package/askuser-after-step5-postgres-selected.png +0 -0
  62. package/askuser-after-step7-answer-sent.png +0 -0
  63. package/askuser-card-visible.png +0 -0
  64. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.d.ts +36 -0
  65. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js +199 -0
  66. package/backend/dist/src/agent-runtime/agent-runtime-event-emitter.js.map +1 -0
  67. package/backend/dist/src/agent-runtime/agent-runtime.controller.d.ts +11 -0
  68. package/backend/dist/src/agent-runtime/agent-runtime.controller.js +91 -0
  69. package/backend/dist/src/agent-runtime/agent-runtime.controller.js.map +1 -0
  70. package/backend/dist/src/agent-runtime/agent-runtime.module.d.ts +2 -0
  71. package/backend/dist/src/agent-runtime/agent-runtime.module.js +26 -0
  72. package/backend/dist/src/agent-runtime/agent-runtime.module.js.map +1 -0
  73. package/backend/dist/src/agent-runtime/agent-runtime.types.d.ts +92 -0
  74. package/backend/dist/src/agent-runtime/agent-runtime.types.js +3 -0
  75. package/backend/dist/src/agent-runtime/agent-runtime.types.js.map +1 -0
  76. package/backend/dist/src/app.controller.d.ts +1 -0
  77. package/backend/dist/src/app.module.js +4 -0
  78. package/backend/dist/src/app.module.js.map +1 -1
  79. package/backend/dist/src/app.service.d.ts +1 -0
  80. package/backend/dist/src/app.service.js +4 -1
  81. package/backend/dist/src/app.service.js.map +1 -1
  82. package/backend/dist/src/interactive-chat/chat.module.js +4 -0
  83. package/backend/dist/src/interactive-chat/chat.module.js.map +1 -1
  84. package/backend/dist/src/interactive-chat/chat.service.d.ts +7 -1
  85. package/backend/dist/src/interactive-chat/chat.service.js +54 -7
  86. package/backend/dist/src/interactive-chat/chat.service.js.map +1 -1
  87. package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js +12 -1
  88. package/backend/dist/src/llm-orchestration/action-handlers/invoke-subagent.handler.js.map +1 -1
  89. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.d.ts +3 -1
  90. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js +20 -2
  91. package/backend/dist/src/llm-orchestration/hooks/frontend-notification.hook.js.map +1 -1
  92. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.d.ts +1 -0
  93. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js +1 -0
  94. package/backend/dist/src/llm-orchestration/llm-orchestration.interfaces.js.map +1 -1
  95. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js +2 -0
  96. package/backend/dist/src/llm-orchestration/llm-orchestration.module.js.map +1 -1
  97. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.d.ts +4 -1
  98. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js +37 -2
  99. package/backend/dist/src/llm-orchestration/llm-turn-processor.service.js.map +1 -1
  100. package/backend/dist/src/llm-provider/ollama-llm.provider.d.ts +1 -0
  101. package/backend/dist/src/llm-provider/ollama-llm.provider.js +19 -1
  102. package/backend/dist/src/llm-provider/ollama-llm.provider.js.map +1 -1
  103. package/backend/dist/src/llm-responses/llm-responses.module.js +4 -0
  104. package/backend/dist/src/llm-responses/llm-responses.module.js.map +1 -1
  105. package/backend/dist/src/llm-responses/llm-responses.service.d.ts +4 -1
  106. package/backend/dist/src/llm-responses/llm-responses.service.js +52 -2
  107. package/backend/dist/src/llm-responses/llm-responses.service.js.map +1 -1
  108. package/backend/dist/tsconfig.build.tsbuildinfo +1 -1
  109. package/daemon/dist/api/services.js +4 -0
  110. package/daemon/dist/index.js +0 -0
  111. package/daemon/dist/serviceManager.js +30 -11
  112. package/daemon/dist/serviceManager.spec.js +26 -0
  113. package/daemon/dist/workspace-validation.js +63 -0
  114. package/daemon/dist/workspace-validation.spec.js +63 -0
  115. package/package.json +1 -1
  116. package/r2-step4-apple-banana.png +0 -0
  117. package/r2-step5-after-rewind-apple-last.png +0 -0
  118. package/r2-step7-apple-cherry.png +0 -0
  119. package/r2-step8-cherry-menu-no-restore.png +0 -0
  120. package/r3-step3-live-task-card-running.png +0 -0
  121. package/r3-step4-after-completion.png +0 -0
  122. package/r3-step5-persistence-after-reload.png +0 -0
  123. package/r3-step6-tasks-tab-card.png +0 -0
  124. package/reverify-answered-state-answer-sent.png +0 -0
  125. package/reverify-card-visible-after-completion.png +0 -0
  126. package/rewind-step3-apple-banana-turns.png +0 -0
  127. package/rewind-step5-ui-unchanged-after-confirmed-rewind.png +0 -0
  128. package/rewind-step6-apple-menu-no-restore.png +0 -0
  129. package/rewind-step7-final-turnlist-unchanged.png +0 -0
  130. package/rewind-step7-negative-error-toast.png +0 -0
  131. package/rewind-step7-negative-error-toast2.png +0 -0
  132. package/subagent-r2-step5-turn-after-reload-no-card.png +0 -0
  133. package/subagent-r2-step6-tasks-panel-card.png +0 -0
  134. package/subagent-step3-tasks-tab-done-card.png +0 -0
  135. package/subagent-step4-turn-stuck-no-cards.png +0 -0
  136. package/agent-pane/frontend/dist/assets/index-BYo-lnxm.js +0 -362
@@ -9,6 +9,7 @@ import type { Turn, Item, DiscussionThread, Attachment } from '../types/index.ts
9
9
  import { runtimeIcon } from '../types/index.ts';
10
10
  import CollapsibleToolCalls, { isToolItem } from './CollapsibleToolCalls.tsx';
11
11
  import CommentableText from './CommentableText.tsx';
12
+ import AskUserItem from './AskUserItem.tsx';
12
13
 
13
14
  marked.setOptions({ breaks: true, gfm: true });
14
15
 
@@ -306,6 +307,10 @@ function ItemCard({ item, runtimeType, turn, threads }: { item: Item; runtimeTyp
306
307
  );
307
308
  }
308
309
 
310
+ case 'ask_user': {
311
+ return <AskUserItem item={item} turn={turn} />;
312
+ }
313
+
309
314
  case 'background_task': {
310
315
  const data = item.data || {};
311
316
  const taskType = (data.task_type as string) || 'unknown';
@@ -427,15 +432,35 @@ function TurnContentBase({ turn, runtimeType, threads = [] }: Props) {
427
432
  // Intermediate = everything before final agent_message (or ALL items if running)
428
433
  const intermediateItems = hasFinalResponse ? turn.items.slice(0, lastAgentIdx) : turn.items;
429
434
 
435
+ // Fallback final response — some runtime turns (repoburg final-action
436
+ // rounds) never produce an agent_message item; their final text lives on
437
+ // turn.agent_response instead. Synthesize the final card from it.
438
+ const responseFallback: Item | null =
439
+ !hasFinalResponse && turn.status === 'completed' && turn.agent_response
440
+ ? {
441
+ id: `response-${turn.id}`,
442
+ turn_id: turn.id,
443
+ external_item_id: `response-${turn.id}`,
444
+ type: 'agent_message',
445
+ data: { text: turn.agent_response },
446
+ status: 'completed',
447
+ created_at: turn.created_at,
448
+ updated_at: turn.updated_at,
449
+ }
450
+ : null;
451
+
430
452
  // Filter out empty reasoning items (text is "" — signed-empty thinking blocks from Claude SDK).
431
453
  // These inflate the intermediate step count and render as empty cards.
432
454
  const visibleIntermediate = intermediateItems.filter(
433
455
  (item) => !(item.type === 'reasoning' && (!item.data?.text || item.data.text === '')),
434
456
  );
435
457
 
436
- // Filter main flow: remove background_task items (they belong in Tasks panel).
458
+ // Filter main flow: remove background_task items (they belong in Tasks panel)
459
+ // and ask_user items (always-visible section below — the answer flow must
460
+ // never hide behind the collapsed intermediate section).
437
461
  const mainIntermediate = visibleIntermediate.filter((item) => {
438
462
  if (item.type === 'background_task') return false;
463
+ if (item.type === 'ask_user') return false;
439
464
  return true;
440
465
  });
441
466
 
@@ -517,11 +542,32 @@ function TurnContentBase({ turn, runtimeType, threads = [] }: Props) {
517
542
  </div>
518
543
  )}
519
544
 
545
+ {/* background_task cards — always visible in the turn flow (live sub-agent
546
+ / monitor lifecycle), outside the collapsible intermediate section.
547
+ Rendered before the final answer to match the chronological flow
548
+ (task ran, then the agent responded). Mirrors the Tasks panel entries
549
+ but inline with the conversation. */}
550
+ {turn.items.filter((i) => i.type === 'background_task').map((item) => (
551
+ <MemoItemCard key={`task-${item.id}`} item={item} runtimeType={runtimeType} turn={turn} threads={threads} />
552
+ ))}
553
+
520
554
  {/* Final agent message — always visible */}
521
555
  {hasFinalResponse && <MemoItemCard key={turn.items[lastAgentIdx].id} item={turn.items[lastAgentIdx]} runtimeType={runtimeType} turn={turn} threads={threads} />}
522
556
 
523
- {/* Items after final agent message (rare — errors, etc.) */}
524
- {hasFinalResponse && turn.items.slice(lastAgentIdx + 1).map((item) => <MemoItemCard key={item.id} item={item} runtimeType={runtimeType} turn={turn} threads={threads} />)}
557
+ {/* Fallback final response (turn.agent_response with no agent_message item). */}
558
+ {responseFallback && (
559
+ <MemoItemCard key={responseFallback.id} item={responseFallback} runtimeType={runtimeType} turn={turn} threads={threads} />
560
+ )}
561
+
562
+ {/* Items after final agent message (rare — errors, etc.; ask_user and
563
+ background_task render in the dedicated always-visible sections below). */}
564
+ {hasFinalResponse && turn.items.slice(lastAgentIdx + 1).filter((i) => i.type !== 'ask_user' && i.type !== 'background_task').map((item) => <MemoItemCard key={item.id} item={item} runtimeType={runtimeType} turn={turn} threads={threads} />)}
565
+
566
+ {/* ask_user cards — always visible + interactive, outside the collapsible
567
+ intermediate section (works even for ask_user-only turns). */}
568
+ {turn.items.filter((i) => i.type === 'ask_user').map((item) => (
569
+ <MemoItemCard key={`ask-${item.id}`} item={item} runtimeType={runtimeType} turn={turn} threads={threads} />
570
+ ))}
525
571
 
526
572
  {/* Pending state (no items at all) */}
527
573
  {turn.items.length === 0 && !turn.error && (turn.status === 'pending' || turn.status === 'running') && (
@@ -98,7 +98,7 @@ function ThreadCard({
98
98
  setShowQuoteBtn(false);
99
99
  }, []);
100
100
 
101
- const handleConversationMouseUp = useCallback(() => {
101
+ const checkSelection = useCallback(() => {
102
102
  const container = conversationRef.current;
103
103
  if (!container) return;
104
104
  const sel = window.getSelection();
@@ -128,13 +128,46 @@ function ThreadCard({
128
128
  const selRect = range.getBoundingClientRect();
129
129
  const BUTTON_HEIGHT = 28;
130
130
  const GAP = 6;
131
+ const BUTTON_WIDTH_EST = 170;
132
+
133
+ // Detect backward selection via anchor (press) vs focus (release) order.
134
+ let backward = false;
135
+ if (sel.anchorNode && sel.focusNode) {
136
+ try {
137
+ const fr = document.createRange();
138
+ fr.setStart(sel.focusNode, sel.focusOffset);
139
+ fr.setEnd(sel.focusNode, sel.focusOffset);
140
+ const ar = document.createRange();
141
+ ar.setStart(sel.anchorNode, sel.anchorOffset);
142
+ ar.setEnd(sel.anchorNode, sel.anchorOffset);
143
+ backward = ar.compareBoundaryPoints(Range.START_TO_START, fr) > 0;
144
+ } catch {
145
+ backward = false;
146
+ }
147
+ }
148
+
131
149
  let btnY = selRect.top - BUTTON_HEIGHT - GAP;
132
150
  if (btnY < 8) btnY = selRect.bottom + GAP;
133
- setQuoteBtnPos({ x: selRect.right, y: btnY });
151
+ let btnX = backward ? selRect.left - BUTTON_WIDTH_EST : selRect.right;
152
+ btnX = Math.max(8, Math.min(btnX, window.innerWidth - BUTTON_WIDTH_EST - 8));
153
+ setQuoteBtnPos({ x: btnX, y: btnY });
134
154
  setSelectedText(text);
135
155
  setShowQuoteBtn(true);
136
156
  }, []);
137
157
 
158
+ // Detect selections on mouseup anywhere in the document so that releasing
159
+ // the mouse in the surrounding padding (common with backward drags) still
160
+ // registers the quote button.
161
+ useEffect(() => {
162
+ function onDocMouseUp(e: MouseEvent) {
163
+ const t = e.target as HTMLElement | null;
164
+ if (t && t.closest('.selection-actions')) return;
165
+ checkSelection();
166
+ }
167
+ document.addEventListener('mouseup', onDocMouseUp);
168
+ return () => document.removeEventListener('mouseup', onDocMouseUp);
169
+ }, [checkSelection]);
170
+
138
171
  const handleQuoteClick = useCallback(() => {
139
172
  const formatted = `\n\`\`\`QUOTE\n${selectedText}\n\`\`\`\n\n`;
140
173
  setFollowup((prev) => prev + formatted);
@@ -180,7 +213,6 @@ function ThreadCard({
180
213
  className="thread-card-conversation"
181
214
  ref={conversationRef}
182
215
  onMouseDown={handleConversationMouseDown}
183
- onMouseUp={handleConversationMouseUp}
184
216
  >
185
217
  {threadTurns.map((turn) => (
186
218
  <ThreadTurnView key={turn.id} turn={turn} />
@@ -80,4 +80,52 @@ describe('TurnsPanel', () => {
80
80
  fireEvent.click(screen.getByText('Branch from Here'));
81
81
  expect(branchFromTurn).toHaveBeenCalledWith('s1', 't1');
82
82
  });
83
+
84
+ it('shows Restore Rewind for rewound non-repoburg sessions', () => {
85
+ const detail = makeDetail('s1', [makeTurn('t1', 's1'), makeTurn('t2', 's1')], {
86
+ runtime_type: 'codex',
87
+ rewound_at_turn_id: 't1',
88
+ });
89
+ resetStore({ activeSessionId: 's1', sessionDetails: { s1: detail } });
90
+ render(<TurnsPanel />);
91
+ fireEvent.contextMenu(document.querySelector('.turn-item')!);
92
+ expect(screen.getByText('Restore Rewind')).toBeInTheDocument();
93
+ });
94
+
95
+ it('hides Restore Rewind for rewound repoburg sessions (hard delete — no restore)', () => {
96
+ const detail = makeDetail('s1', [makeTurn('t1', 's1'), makeTurn('t2', 's1')], {
97
+ runtime_type: 'repoburg',
98
+ rewound_at_turn_id: 't1',
99
+ });
100
+ resetStore({ activeSessionId: 's1', sessionDetails: { s1: detail } });
101
+ render(<TurnsPanel />);
102
+ fireEvent.contextMenu(document.querySelector('.turn-item')!);
103
+ expect(screen.queryByText('Restore Rewind')).not.toBeInTheDocument();
104
+ });
105
+
106
+ it('repoburg rewind confirm says the runtime history is permanently deleted', () => {
107
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
108
+ const rewindToTurn = vi.fn();
109
+ const detail = makeDetail('s1', [makeTurn('t1', 's1'), makeTurn('t2', 's1')], {
110
+ runtime_type: 'repoburg',
111
+ });
112
+ resetStore({ activeSessionId: 's1', sessionDetails: { s1: detail }, rewindToTurn });
113
+ render(<TurnsPanel />);
114
+ fireEvent.contextMenu(document.querySelectorAll('.turn-item')[0]);
115
+ fireEvent.click(screen.getByText('Rewind to Here'));
116
+ expect(confirmSpy).toHaveBeenCalledWith(
117
+ 'Rewind to this turn? Runtime history after this point is PERMANENTLY deleted and cannot be restored.',
118
+ );
119
+ expect(rewindToTurn).not.toHaveBeenCalled();
120
+ });
121
+
122
+ it('non-repoburg rewind confirm keeps the restore-later copy', () => {
123
+ const confirmSpy = vi.spyOn(window, 'confirm').mockReturnValue(false);
124
+ const detail = makeDetail('s1', [makeTurn('t1', 's1'), makeTurn('t2', 's1')]);
125
+ resetStore({ activeSessionId: 's1', sessionDetails: { s1: detail } });
126
+ render(<TurnsPanel />);
127
+ fireEvent.contextMenu(document.querySelectorAll('.turn-item')[0]);
128
+ fireEvent.click(screen.getByText('Rewind to Here'));
129
+ expect(confirmSpy).toHaveBeenCalledWith('Rewind to this turn? Hidden turns can be restored later.');
130
+ });
83
131
  });
@@ -26,6 +26,11 @@ export default function TurnsPanel() {
26
26
  const turns = detail?.turns ?? [];
27
27
  const isRunning = detail?.is_running === 1;
28
28
  const isRewound = !!detail?.rewound_at_turn_id;
29
+ // Repoburg rewind = platform-parity hard delete — no restore, permanent copy.
30
+ const isRepoburg = detail?.runtime_type === 'repoburg';
31
+ const rewindConfirm = isRepoburg
32
+ ? 'Rewind to this turn? Runtime history after this point is PERMANENTLY deleted and cannot be restored.'
33
+ : 'Rewind to this turn? Hidden turns can be restored later.';
29
34
 
30
35
  const [ctxMenu, setCtxMenu] = useState<TurnContextMenu | null>(null);
31
36
  const ctxRef = useRef<HTMLDivElement>(null);
@@ -91,7 +96,7 @@ export default function TurnsPanel() {
91
96
  className={`ctx-menu-item ${ctxMenu.isLast || isRunning || ctxMenu.isSlashCommand ? 'ctx-disabled' : ''}`}
92
97
  onClick={() => {
93
98
  if (ctxMenu.isLast || isRunning || ctxMenu.isSlashCommand) return;
94
- if (window.confirm('Rewind to this turn? Hidden turns can be restored later.')) {
99
+ if (window.confirm(rewindConfirm)) {
95
100
  rewindToTurn(activeSessionId!, ctxMenu.turnId);
96
101
  }
97
102
  setCtxMenu(null);
@@ -99,7 +104,7 @@ export default function TurnsPanel() {
99
104
  >
100
105
  <Rewind size={12} /> Rewind to Here{ctxMenu.isSlashCommand ? ' (slash command)' : ''}
101
106
  </div>
102
- {isRewound && (
107
+ {isRewound && !isRepoburg && (
103
108
  <div
104
109
  className="ctx-menu-item"
105
110
  onClick={() => {
@@ -94,6 +94,43 @@ export const PROVIDERS: ProviderDef[] = [
94
94
  },
95
95
  ],
96
96
  },
97
+ {
98
+ id: 'repoburg',
99
+ label: 'Repoburg',
100
+ available: true,
101
+ // Curated fallback list — live sessions load the full catalog dynamically
102
+ // from GET /api/providers/repoburg/models (repoburg backend model registry).
103
+ models: [
104
+ {
105
+ slug: 'ollama/glm-5.3:cloud',
106
+ displayName: 'GLM-5.3',
107
+ description: 'Ollama Cloud',
108
+ defaultReasoning: 'medium',
109
+ reasoningLevels: [LEVELS.low, LEVELS.medium, LEVELS.high],
110
+ },
111
+ {
112
+ slug: 'ollama/glm-5.3-flash:cloud',
113
+ displayName: 'GLM-5.3 Flash',
114
+ description: 'Ollama Cloud — fast',
115
+ defaultReasoning: 'medium',
116
+ reasoningLevels: [LEVELS.low, LEVELS.medium, LEVELS.high],
117
+ },
118
+ {
119
+ slug: 'ollama/minimax-m3:cloud',
120
+ displayName: 'MiniMax M3',
121
+ description: 'Ollama Cloud',
122
+ defaultReasoning: 'medium',
123
+ reasoningLevels: [LEVELS.low, LEVELS.medium, LEVELS.high],
124
+ },
125
+ {
126
+ slug: 'ollama/deepseek-v4-pro:cloud',
127
+ displayName: 'DeepSeek V4 Pro',
128
+ description: 'Ollama Cloud',
129
+ defaultReasoning: 'medium',
130
+ reasoningLevels: [LEVELS.low, LEVELS.medium, LEVELS.high],
131
+ },
132
+ ],
133
+ },
97
134
  ];
98
135
 
99
136
  /** Get provider by ID. */
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
2
+ import { renderHook, act } from '@testing-library/react';
3
+ import { useResizable } from './useResizable.ts';
4
+
5
+ /** Helper: simulate a native pointer event on window with a clientX.
6
+ * jsdom doesn't implement PointerEvent; MouseEvent carries clientX which is
7
+ * all the hook reads, so we use it as a stand-in. */
8
+ function fireWindowPointer(type: 'pointermove' | 'pointerup', clientX: number): void {
9
+ window.dispatchEvent(new MouseEvent(type, { clientX, bubbles: true }));
10
+ }
11
+
12
+ /** Build a minimal React.PointerEvent-like object for onPointerDown. */
13
+ function pointerDown(clientX: number) {
14
+ return {
15
+ clientX,
16
+ preventDefault: vi.fn(),
17
+ stopPropagation: vi.fn(),
18
+ } as unknown as React.PointerEvent;
19
+ }
20
+
21
+ describe('useResizable', () => {
22
+ beforeEach(() => {
23
+ document.body.classList.remove('is-resizing');
24
+ });
25
+
26
+ it('grows left-side width when dragging right (+delta)', () => {
27
+ const getWidth = vi.fn(() => 240);
28
+ const setWidth = vi.fn();
29
+ const { result } = renderHook(() =>
30
+ useResizable({ side: 'left', minWidth: 160, maxWidth: 480, getWidth, setWidth }),
31
+ );
32
+
33
+ act(() => {
34
+ result.current.onPointerDown(pointerDown(100));
35
+ });
36
+ expect(result.current.dragging).toBe(true);
37
+ expect(document.body.classList.contains('is-resizing')).toBe(true);
38
+
39
+ act(() => fireWindowPointer('pointermove', 150)); // +50
40
+ expect(setWidth).toHaveBeenLastCalledWith(290);
41
+
42
+ act(() => fireWindowPointer('pointerup', 150));
43
+ expect(result.current.dragging).toBe(false);
44
+ expect(document.body.classList.contains('is-resizing')).toBe(false);
45
+ });
46
+
47
+ it('shrinks right-side width when dragging right (-delta)', () => {
48
+ const getWidth = vi.fn(() => 300);
49
+ const setWidth = vi.fn();
50
+ const { result } = renderHook(() =>
51
+ useResizable({ side: 'right', minWidth: 220, maxWidth: 600, getWidth, setWidth }),
52
+ );
53
+
54
+ act(() => {
55
+ result.current.onPointerDown(pointerDown(200));
56
+ });
57
+ act(() => fireWindowPointer('pointermove', 260)); // +60
58
+ expect(setWidth).toHaveBeenLastCalledWith(240);
59
+ act(() => fireWindowPointer('pointerup', 260));
60
+ });
61
+
62
+ it('clamps to maxWidth on left side', () => {
63
+ const getWidth = vi.fn(() => 460);
64
+ const setWidth = vi.fn();
65
+ const { result } = renderHook(() =>
66
+ useResizable({ side: 'left', minWidth: 160, maxWidth: 480, getWidth, setWidth }),
67
+ );
68
+ act(() => result.current.onPointerDown(pointerDown(0)));
69
+ act(() => fireWindowPointer('pointermove', 1000));
70
+ expect(setWidth).toHaveBeenLastCalledWith(480);
71
+ act(() => fireWindowPointer('pointerup', 1000));
72
+ });
73
+
74
+ it('clamps to minWidth on right side', () => {
75
+ const getWidth = vi.fn(() => 230);
76
+ const setWidth = vi.fn();
77
+ const { result } = renderHook(() =>
78
+ useResizable({ side: 'right', minWidth: 220, maxWidth: 600, getWidth, setWidth }),
79
+ );
80
+ act(() => result.current.onPointerDown(pointerDown(0)));
81
+ act(() => fireWindowPointer('pointermove', 5000)); // dragging far right shrinks right panel
82
+ expect(setWidth).toHaveBeenLastCalledWith(220);
83
+ act(() => fireWindowPointer('pointerup', 5000));
84
+ });
85
+
86
+ it('stops listening after pointerup', () => {
87
+ const getWidth = vi.fn(() => 240);
88
+ const setWidth = vi.fn();
89
+ const { result } = renderHook(() =>
90
+ useResizable({ side: 'left', minWidth: 160, maxWidth: 480, getWidth, setWidth }),
91
+ );
92
+ act(() => result.current.onPointerDown(pointerDown(0)));
93
+ act(() => fireWindowPointer('pointerup', 0));
94
+ act(() => fireWindowPointer('pointermove', 300));
95
+ expect(setWidth).not.toHaveBeenCalled();
96
+ });
97
+ });
@@ -0,0 +1,75 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+
3
+ export type ResizeSide = 'left' | 'right';
4
+
5
+ interface UseResizableOptions {
6
+ /** Which edge is being dragged: 'left' grows when dragging right, 'right' grows when dragging left. */
7
+ side: ResizeSide;
8
+ minWidth: number;
9
+ maxWidth: number;
10
+ /** Current width (px) — read live inside the pointer handlers via a ref. */
11
+ getWidth: () => number;
12
+ /** Commit a new width (already clamped by the hook). */
13
+ setWidth: (w: number) => void;
14
+ }
15
+
16
+ export interface UseResizableResult {
17
+ /** Attach to the drag handle element. */
18
+ onPointerDown: (e: React.PointerEvent) => void;
19
+ /** True while a drag for this handle is in progress. */
20
+ dragging: boolean;
21
+ }
22
+
23
+ /**
24
+ * Dependency-free horizontal resize handle. Returns a pointerdown handler to
25
+ * attach to a thin handle element; while dragging it updates the store width
26
+ * (clamped to [min, max]) on every pointermove and cleans up on pointerup.
27
+ *
28
+ * Uses window listeners so the drag continues even when the cursor leaves the
29
+ * handle (standard desktop-app behavior).
30
+ */
31
+ export function useResizable({
32
+ side,
33
+ minWidth,
34
+ maxWidth,
35
+ getWidth,
36
+ setWidth,
37
+ }: UseResizableOptions): UseResizableResult {
38
+ const [dragging, setDragging] = useState(false);
39
+ // Captured at drag start; the listeners close over these refs.
40
+ const startX = useRef(0);
41
+ const startWidth = useRef(0);
42
+
43
+ const onPointerMove = useCallback(
44
+ (e: PointerEvent) => {
45
+ const delta = e.clientX - startX.current;
46
+ const next = side === 'left' ? startWidth.current + delta : startWidth.current - delta;
47
+ const clamped = Math.min(maxWidth, Math.max(minWidth, next));
48
+ setWidth(clamped);
49
+ },
50
+ [side, minWidth, maxWidth, setWidth],
51
+ );
52
+
53
+ const onPointerUp = useCallback(() => {
54
+ window.removeEventListener('pointermove', onPointerMove);
55
+ window.removeEventListener('pointerup', onPointerUp);
56
+ document.body.classList.remove('is-resizing');
57
+ setDragging(false);
58
+ }, [onPointerMove]);
59
+
60
+ const onPointerDown = useCallback(
61
+ (e: React.PointerEvent) => {
62
+ e.preventDefault();
63
+ e.stopPropagation();
64
+ startX.current = e.clientX;
65
+ startWidth.current = getWidth();
66
+ setDragging(true);
67
+ document.body.classList.add('is-resizing');
68
+ window.addEventListener('pointermove', onPointerMove);
69
+ window.addEventListener('pointerup', onPointerUp);
70
+ },
71
+ [getWidth, onPointerMove, onPointerUp],
72
+ );
73
+
74
+ return { onPointerDown, dragging };
75
+ }
@@ -438,6 +438,25 @@ describe('onTurnCompleted', () => {
438
438
  expect(useStore.getState().sessionDetails['s1'].has_unreviewed).toBe(1);
439
439
  expect(useStore.getState().sessions[0].has_unreviewed).toBe(1);
440
440
  });
441
+ it('stores the response text when provided and not already streamed', () => {
442
+ const turn = makeTurn('t1', 's1', { status: 'running' });
443
+ const detail = makeDetail('s1', [turn], { is_running: 1 });
444
+ resetStore({ sessionDetails: { s1: detail }, sessions: [makeSession('s1', { is_running: 1 })] });
445
+
446
+ useStore.getState().onTurnCompleted('s1', 't1', makeUsage(), true, 'Confirmed: done.');
447
+
448
+ expect(useStore.getState().sessionDetails['s1'].turns[0].agent_response).toBe('Confirmed: done.');
449
+ });
450
+
451
+ it('keeps streamed agent_response over the event response', () => {
452
+ const turn = makeTurn('t1', 's1', { status: 'running', agent_response: 'streamed text' });
453
+ const detail = makeDetail('s1', [turn], { is_running: 1 });
454
+ resetStore({ sessionDetails: { s1: detail }, sessions: [makeSession('s1', { is_running: 1 })] });
455
+
456
+ useStore.getState().onTurnCompleted('s1', 't1', makeUsage(), true, 'event text');
457
+
458
+ expect(useStore.getState().sessionDetails['s1'].turns[0].agent_response).toBe('streamed text');
459
+ });
441
460
 
442
461
  it('does not change has_unreviewed when hasUnreviewed is falsy', () => {
443
462
  const turn = makeTurn('t1', 's1', { status: 'running' });
@@ -1226,6 +1245,41 @@ describe('simple setters', () => {
1226
1245
 
1227
1246
  // === New tests: init ===
1228
1247
 
1248
+ describe('setSidebarWidth / setRightPanelWidth', () => {
1249
+ beforeEach(() => {
1250
+ vi.clearAllMocks();
1251
+ resetStore();
1252
+ vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => {});
1253
+ });
1254
+
1255
+ it('clamps sidebar width to [SIDEBAR_MIN, SIDEBAR_MAX] and persists', () => {
1256
+ const setItem = vi.mocked(Storage.prototype.setItem);
1257
+ useStore.getState().setSidebarWidth(900);
1258
+ expect(useStore.getState().sidebarWidth).toBe(480);
1259
+ expect(setItem).toHaveBeenCalledWith('agentpane:sidebar-width', '480');
1260
+
1261
+ useStore.getState().setSidebarWidth(50);
1262
+ expect(useStore.getState().sidebarWidth).toBe(160);
1263
+ expect(setItem).toHaveBeenCalledWith('agentpane:sidebar-width', '160');
1264
+ });
1265
+
1266
+ it('clamps right panel width to [RIGHT_PANEL_MIN, RIGHT_PANEL_MAX] and persists', () => {
1267
+ const setItem = vi.mocked(Storage.prototype.setItem);
1268
+ useStore.getState().setRightPanelWidth(9999);
1269
+ expect(useStore.getState().rightPanelWidth).toBe(600);
1270
+ expect(setItem).toHaveBeenCalledWith('agentpane:right-panel-width', '600');
1271
+
1272
+ useStore.getState().setRightPanelWidth(10);
1273
+ expect(useStore.getState().rightPanelWidth).toBe(220);
1274
+ expect(setItem).toHaveBeenCalledWith('agentpane:right-panel-width', '220');
1275
+ });
1276
+
1277
+ it('rounds fractional widths', () => {
1278
+ useStore.getState().setSidebarWidth(240.7);
1279
+ expect(useStore.getState().sidebarWidth).toBe(241);
1280
+ });
1281
+ });
1282
+
1229
1283
  describe('init', () => {
1230
1284
  beforeEach(() => {
1231
1285
  vi.clearAllMocks();
@@ -3,6 +3,30 @@ import type { LogEntry, RightTab, ChangesScope, ModelInfo } from '../types/index
3
3
  import type { StoreState, Toast } from './store.ts';
4
4
  import { fetchModels } from '../api/models.ts';
5
5
 
6
+ /** Default + clamp bounds for resizable side panels (px). */
7
+ export const SIDEBAR_MIN = 160;
8
+ export const SIDEBAR_MAX = 480;
9
+ export const SIDEBAR_DEFAULT = 240;
10
+ export const RIGHT_PANEL_MIN = 220;
11
+ export const RIGHT_PANEL_MAX = 600;
12
+ export const RIGHT_PANEL_DEFAULT = 300;
13
+
14
+ const SIDEBAR_KEY = 'agentpane:sidebar-width';
15
+ const RIGHT_PANEL_KEY = 'agentpane:right-panel-width';
16
+
17
+ /** Read a stored px width, falling back to `fallback` on missing/invalid data. */
18
+ function readWidth(key: string, min: number, max: number, fallback: number): number {
19
+ try {
20
+ const raw = localStorage.getItem(key);
21
+ if (raw == null) return fallback;
22
+ const n = Number(raw);
23
+ if (!Number.isFinite(n)) return fallback;
24
+ return Math.min(max, Math.max(min, n));
25
+ } catch {
26
+ return fallback;
27
+ }
28
+ }
29
+
6
30
  export type UiSlice = {
7
31
  activeRightTab: RightTab;
8
32
  changesScope: ChangesScope;
@@ -17,6 +41,8 @@ export type UiSlice = {
17
41
  models: ModelInfo[];
18
42
  modelsLoading: boolean;
19
43
  showModelSelector: boolean;
44
+ sidebarWidth: number;
45
+ rightPanelWidth: number;
20
46
 
21
47
  setActiveTurnIndex: (idx: number) => void;
22
48
  switchRightTab: (tab: RightTab) => void;
@@ -25,6 +51,8 @@ export type UiSlice = {
25
51
  setShowCommandPalette: (show: boolean) => void;
26
52
  setShowModelSelector: (show: boolean) => void;
27
53
  setSelectedChangePath: (path: string | null) => void;
54
+ setSidebarWidth: (w: number) => void;
55
+ setRightPanelWidth: (w: number) => void;
28
56
  addToast: (type: Toast['type'], message: string, undoAction?: () => void, undoLabel?: string) => void;
29
57
  removeToast: (id: string) => void;
30
58
  addLogEntry: (level: LogEntry['level'], message: string) => void;
@@ -45,6 +73,8 @@ export const createUiSlice: StateCreator<StoreState, [], [], UiSlice> = (set, ge
45
73
  models: [],
46
74
  modelsLoading: false,
47
75
  showModelSelector: false,
76
+ sidebarWidth: readWidth(SIDEBAR_KEY, SIDEBAR_MIN, SIDEBAR_MAX, SIDEBAR_DEFAULT),
77
+ rightPanelWidth: readWidth(RIGHT_PANEL_KEY, RIGHT_PANEL_MIN, RIGHT_PANEL_MAX, RIGHT_PANEL_DEFAULT),
48
78
 
49
79
  setActiveTurnIndex: (idx) => set({ activeTurnIndex: idx }),
50
80
  switchRightTab: (tab) => set({ activeRightTab: tab }),
@@ -54,6 +84,26 @@ export const createUiSlice: StateCreator<StoreState, [], [], UiSlice> = (set, ge
54
84
  setShowModelSelector: (show) => set({ showModelSelector: show }),
55
85
  setSelectedChangePath: (path) => set({ selectedChangePath: path }),
56
86
 
87
+ setSidebarWidth: (w) => {
88
+ const clamped = Math.min(SIDEBAR_MAX, Math.max(SIDEBAR_MIN, Math.round(w)));
89
+ set({ sidebarWidth: clamped });
90
+ try {
91
+ localStorage.setItem(SIDEBAR_KEY, String(clamped));
92
+ } catch {
93
+ /* ignore quota / disabled storage */
94
+ }
95
+ },
96
+
97
+ setRightPanelWidth: (w) => {
98
+ const clamped = Math.min(RIGHT_PANEL_MAX, Math.max(RIGHT_PANEL_MIN, Math.round(w)));
99
+ set({ rightPanelWidth: clamped });
100
+ try {
101
+ localStorage.setItem(RIGHT_PANEL_KEY, String(clamped));
102
+ } catch {
103
+ /* ignore quota / disabled storage */
104
+ }
105
+ },
106
+
57
107
  addToast: (type, message, undoAction, undoLabel) => {
58
108
  const id = Math.random().toString(36).slice(2, 9);
59
109
  set((state) => ({ toasts: [...state.toasts, { id, type, message, undoAction, undoLabel }] }));
@@ -16,7 +16,7 @@ export type WsSlice = {
16
16
  onItemUpdated: (sessionId: string, turnId: string, item: Item) => void;
17
17
  onItemCompleted: (sessionId: string, turnId: string, item: Item) => void;
18
18
  onItemDelta: (sessionId: string, turnId: string, itemId: string, delta: string) => void;
19
- onTurnCompleted: (sessionId: string, turnId: string, usage?: TokenUsage | null, hasUnreviewed?: boolean) => void;
19
+ onTurnCompleted: (sessionId: string, turnId: string, usage?: TokenUsage | null, hasUnreviewed?: boolean, response?: string) => void;
20
20
  onTokenUsageUpdated: (sessionId: string, turnId: string, usage: TokenUsage) => void;
21
21
  onContextUsageUpdated: (sessionId: string, ctx: ContextUsage) => void;
22
22
  onTurnFailed: (sessionId: string, turnId: string, error: string) => void;
@@ -141,7 +141,7 @@ export const createWsSlice: StateCreator<StoreState, [], [], WsSlice> = (set, ge
141
141
  });
142
142
  },
143
143
 
144
- onTurnCompleted: (sessionId, turnId, usage, hasUnreviewed) => {
144
+ onTurnCompleted: (sessionId, turnId, usage, hasUnreviewed, response) => {
145
145
  const inTok = usage?.input_tokens ?? 0;
146
146
  const outTok = usage?.output_tokens ?? 0;
147
147
  get().addLogEntry('INFO', `turn:completed — ${inTok} in / ${outTok} out`);
@@ -160,7 +160,14 @@ export const createWsSlice: StateCreator<StoreState, [], [], WsSlice> = (set, ge
160
160
  has_unreviewed: hasUnreviewed ? 1 : detail.has_unreviewed,
161
161
  turns: detail.turns.map((t) =>
162
162
  t.id === turnId
163
- ? { ...t, status: 'completed', token_usage: usage ?? t.token_usage ?? null }
163
+ ? {
164
+ ...t,
165
+ status: 'completed',
166
+ token_usage: usage ?? t.token_usage ?? null,
167
+ // Final text for turns that produce no agent_message item
168
+ // (repoburg final-action rounds) — keep any streamed text.
169
+ ...(response && !t.agent_response ? { agent_response: response } : {}),
170
+ }
164
171
  : t,
165
172
  ),
166
173
  },