@kabmo-labs/kabmo 0.1.0 → 0.2.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 (79) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +285 -285
  3. package/dist/client/static/js/index-pdp5dwc8.css +1 -0
  4. package/dist/client/static/js/{index-enkbbffs.js → index-zsskckw7.js} +671 -671
  5. package/package.json +1 -1
  6. package/src/client/bootstrap.d.ts +8 -0
  7. package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
  8. package/src/client/components/common/header-controls/index.test.tsx +77 -11
  9. package/src/client/components/common/header-controls/index.tsx +53 -4
  10. package/src/client/components/common/session-state-provider/index.test.ts +22 -1
  11. package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
  12. package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
  13. package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
  14. package/src/client/components/layout/session-sidebar/index.tsx +5 -0
  15. package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
  16. package/src/client/components/mobile/SessionSidebar.tsx +5 -0
  17. package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
  18. package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
  19. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
  20. package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
  21. package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
  22. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
  23. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
  24. package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
  25. package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
  26. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
  27. package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
  28. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
  29. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
  30. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
  31. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
  32. package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
  33. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
  34. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
  35. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
  36. package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
  37. package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
  38. package/src/client/components/workspace/git-panel/index.test.ts +15 -0
  39. package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
  40. package/src/client/components/workspace/search-panel/Body.tsx +53 -9
  41. package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
  42. package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
  43. package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
  44. package/src/client/hooks/chat/timeline/index.ts +2 -1
  45. package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
  46. package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
  47. package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
  48. package/src/client/hooks/chat/timeline/scroll.ts +34 -1
  49. package/src/client/hooks/ui/media-query.ts +41 -0
  50. package/src/client/hooks/ui/updates/android-download.test.ts +73 -0
  51. package/src/client/hooks/ui/updates/android-download.ts +106 -0
  52. package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
  53. package/src/client/hooks/workspace/file-tabs.ts +24 -11
  54. package/src/client/hooks/workspace/session-state/index.ts +24 -1
  55. package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
  56. package/src/client/lib/native/back.test.ts +108 -0
  57. package/src/client/lib/native/back.ts +60 -0
  58. package/src/client/main.tsx +6 -0
  59. package/src/server/lib/fs/git-actions-branch.ts +18 -1
  60. package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
  61. package/src/server/lib/fs/git-publish.test.ts +96 -0
  62. package/src/server/lib/fs/git-publish.ts +35 -3
  63. package/src/server/lib/fs/listing.test.ts +86 -0
  64. package/src/server/lib/fs/listing.ts +20 -6
  65. package/src/server/lib/updates/android-release.test.ts +92 -0
  66. package/src/server/lib/updates/android-release.ts +148 -0
  67. package/src/server/plugins/ssr.ts +9 -2
  68. package/src/server/routes/fs/read.ts +24 -9
  69. package/src/server/routes/sessions/batch-delete.test.ts +14 -11
  70. package/src/server/routes/updates/android.ts +22 -0
  71. package/src/server/routes/updates/index.ts +2 -0
  72. package/src/shared/lib/updates/shell-ua.test.ts +54 -0
  73. package/src/shared/lib/updates/shell-ua.ts +36 -0
  74. package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
  75. package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
  76. package/src/shared/lib/workspace/session-state/store.ts +13 -2
  77. package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
  78. package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
  79. package/dist/client/static/js/index-yn17t04b.css +0 -1
@@ -38,7 +38,10 @@ export function AttachmentToolbar({ attachments, onFilesSelected, onRemove, mode
38
38
 
39
39
  return (
40
40
  <>
41
- <div className="flex items-center flex-wrap gap-1.5 px-3 py-2 border-b border-ink/5 text-ink/60">
41
+ {/* No separator and no second surface: the card is ONE colour, so the
42
+ header, the draft and the toolbar read as one panel. A hairline over
43
+ a tinted strip made the composer look like three stacked bars. */}
44
+ <div className="flex items-center flex-wrap gap-1.5 px-3 py-2 text-ink/60">
42
45
  <button
43
46
  onClick={() => fileInputRef.current?.click()}
44
47
  className="flex items-center justify-center hover:bg-ink/5 p-1 rounded transition-colors text-ink/60 hover:text-ink shrink-0"
@@ -8,7 +8,7 @@ import { UploadCloud } from 'lucide-preact';
8
8
  export function DropOverlay({ visible }: { visible: boolean }) {
9
9
  if (!visible) return null;
10
10
  return (
11
- <div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-md border-2 border-dashed border-ink/40 bg-canvas/90 text-ink">
11
+ <div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-[inherit] border-2 border-dashed border-ink/40 bg-canvas/90 text-ink">
12
12
  <UploadCloud size={18} className="text-ink/60" />
13
13
  <span className="text-[11px] font-semibold">Drop files to attach</span>
14
14
  <span className="text-[10px] text-ink/50">Folders are expanded into their files</span>
@@ -138,7 +138,12 @@ export function GoalBanner({ record, running, continuation, evaluating, pending,
138
138
 
139
139
  return (
140
140
  <div
141
- className="flex items-center gap-2 border-b border-ink/8 bg-canvas/40 px-3 py-1.5 text-[11px]"
141
+ // The strip sits on the card's own surface — no hairline and no tinted
142
+ // band, so the goal row, the draft and the toolbar read as ONE panel
143
+ // rather than three stacked bars. Its state still shows: the icon and
144
+ // the badge carry the tone (`badge`/`tone`), which is the semantic half;
145
+ // the background was only ever separation.
146
+ className="flex items-center gap-2 px-3 py-1.5 text-[11px]"
142
147
  data-goal-banner={record.status}
143
148
  data-goal-stop={stop ?? undefined}
144
149
  >
@@ -99,7 +99,10 @@ export function ComposerToolbar({
99
99
 
100
100
  return (
101
101
  <div
102
- className={`@container flex items-center justify-between border-t border-ink/5 bg-canvas/50 rounded-b-md ${isMobile ? 'px-2 py-2' : 'px-3 py-2'}`}
102
+ // No separator and no tinted strip: the composer is one card, so its
103
+ // toolbar shares the card's surface. `rounded-b-[inherit]` still follows
104
+ // the frame (the phone's stretched composer is `rounded-none`).
105
+ className={`@container flex items-center justify-between rounded-b-[inherit] ${isMobile ? 'px-2 py-2' : 'px-3 py-2'}`}
103
106
  >
104
107
  <div className={`flex items-center min-w-0 ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
105
108
  {showModel && (
@@ -19,7 +19,7 @@ export interface VoiceNoticeProps {
19
19
  export function VoiceNotice({ status, error }: VoiceNoticeProps) {
20
20
  if (status) {
21
21
  return (
22
- <div className="flex items-center gap-1.5 border-t border-ink/10 bg-canvas/40 px-3 py-1.5 text-[11px] text-ink/60">
22
+ <div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-ink/60">
23
23
  <span className="h-1.5 w-1.5 shrink-0 rounded-full bg-ink/50 animate-pulse" />
24
24
  <span className="min-w-0 flex-1 break-words">{status}</span>
25
25
  </div>
@@ -28,7 +28,7 @@ export function VoiceNotice({ status, error }: VoiceNoticeProps) {
28
28
 
29
29
  if (error) {
30
30
  return (
31
- <div className="flex items-start gap-1.5 border-t border-error/20 bg-error/5 px-3 py-1.5 text-[11px] text-error">
31
+ <div className="flex items-start gap-1.5 bg-error/5 px-3 py-1.5 text-[11px] text-error">
32
32
  <AlertTriangle size={11} className="mt-0.5 shrink-0" />
33
33
  <span className="min-w-0 flex-1 break-words">{error}</span>
34
34
  </div>
@@ -23,7 +23,7 @@ import { describe, expect, test } from 'bun:test';
23
23
 
24
24
  import { composerExpansion } from '@/client/components/workspace/chat-timeline/chat-input/expansion';
25
25
 
26
- const base = { expanded: false, isDragging: false, keyboardInset: 0 } as const;
26
+ const base = { expanded: false, isDragging: false, keyboardInset: 0, compact: false } as const;
27
27
 
28
28
  describe('composerExpansion docked', () => {
29
29
  test('a phone keeps its touch-sized box and opens the popup upward', () => {
@@ -48,6 +48,77 @@ describe('composerExpansion docked', () => {
48
48
  });
49
49
  });
50
50
 
51
+ /**
52
+ * The composer is a card in the same transcript as the tool calls, so its frame
53
+ * is the tool card's own (`ToolCardShell`): `rounded-xl`, `border-ink/10` on
54
+ * `bg-paper`, darkening to `ink/20` — plus a shadow, which the tool card only
55
+ * grows when expanded but the composer always has, because it is the input the
56
+ * user is looking at. A `rounded-md` here made it read as a smaller, different
57
+ * card sitting under the conversation.
58
+ */
59
+ describe('composerExpansion frame matches a tool card', () => {
60
+ test('docked: rounded-xl, ink/10 hairline, a resting shadow', () => {
61
+ for (const variant of ['desktop', 'mobile'] as const) {
62
+ const { cardClass } = composerExpansion({ ...base, variant });
63
+ expect(cardClass).toContain('rounded-xl');
64
+ expect(cardClass).not.toContain('rounded-md');
65
+ expect(cardClass).toContain('border-ink/10');
66
+ expect(cardClass).toContain('bg-paper');
67
+ expect(cardClass).toContain('shadow-sm');
68
+ }
69
+ });
70
+
71
+ test('the border darkens on hover and on focus, never at rest', () => {
72
+ const { cardClass } = composerExpansion({ ...base, variant: 'desktop' });
73
+ expect(cardClass).toContain('hover:border-ink/20');
74
+ expect(cardClass).toContain('focus-within:border-ink/30');
75
+ });
76
+
77
+ test('a stretched desktop keeps the radius and lifts the shadow one step', () => {
78
+ const { cardClass } = composerExpansion({ ...base, expanded: true, variant: 'desktop' });
79
+ expect(cardClass).toContain('rounded-xl');
80
+ expect(cardClass).toContain('shadow-md');
81
+ });
82
+
83
+ test('a stretched phone drops the radius so the bars follow it', () => {
84
+ const { cardClass } = composerExpansion({ ...base, expanded: true, variant: 'mobile' });
85
+ expect(cardClass).toContain('rounded-none');
86
+ expect(cardClass).toContain('shadow-none');
87
+ });
88
+ });
89
+
90
+ /**
91
+ * The phone's compact composer: a reader scrolled back through the transcript
92
+ * gets the height back. The rules that matter are which layout may shrink (only
93
+ * a docked phone — a desktop sits beside the chat, a stretched one IS the
94
+ * screen) and that it stays a card and a touch target rather than becoming a
95
+ * second, differently shaped control.
96
+ */
97
+ describe('composerExpansion compact', () => {
98
+ test('a docked phone shrinks to a 44px box, still framed as a card', () => {
99
+ const layout = composerExpansion({ ...base, variant: 'mobile', compact: true });
100
+ // A fixed height, not a min-height: a textarea's own `rows` sets its
101
+ // height, so a minimum alone never shrinks it.
102
+ expect(layout.textareaClass).toContain('h-11');
103
+ expect(layout.textareaClass).not.toContain('min-h-[68px]');
104
+ expect(layout.cardClass).toContain('rounded-xl');
105
+ expect(layout.cardClass).toContain('shadow-sm');
106
+ // The frame's own state does not change: compact is a SIZE, not a style.
107
+ expect(layout.cardClass).toContain('border-ink/10');
108
+ });
109
+
110
+ test('a desktop ignores it: its composer sits beside the chat', () => {
111
+ const layout = composerExpansion({ ...base, variant: 'desktop', compact: true });
112
+ expect(layout.textareaClass).toContain('min-h-[80px]');
113
+ });
114
+
115
+ test('a stretched phone is the screen, so there is nothing to give back', () => {
116
+ const layout = composerExpansion({ ...base, expanded: true, variant: 'mobile', compact: true });
117
+ expect(layout.cardClass).toContain('rounded-none');
118
+ expect(layout.textareaClass).toContain('h-full');
119
+ });
120
+ });
121
+
51
122
  describe('composerExpansion stretched', () => {
52
123
  test('a phone takes the viewport, and its popup opens over the draft', () => {
53
124
  const layout = composerExpansion({ ...base, expanded: true, variant: 'mobile' });
@@ -59,6 +59,13 @@ export interface ComposerExpansionInput {
59
59
  * `useKeyboardInset`. 0 when there is no keyboard (or no `visualViewport`).
60
60
  */
61
61
  keyboardInset: number;
62
+ /**
63
+ * A phone reader is scrolled back through the transcript and the draft is
64
+ * empty, so the composer gives the conversation its height back. Only the
65
+ * docked phone layout has anything to give: a desktop composer sits beside
66
+ * the chat rather than over it, and a stretched one is the screen.
67
+ */
68
+ compact: boolean;
62
69
  }
63
70
 
64
71
  export interface ComposerExpansion {
@@ -88,17 +95,34 @@ export function composerExpansion({
88
95
  expanded,
89
96
  isDragging,
90
97
  keyboardInset,
98
+ compact,
91
99
  }: ComposerExpansionInput): ComposerExpansion {
92
100
  const mobile = variant === 'mobile';
93
- const border = isDragging ? 'border-ink/40' : 'border-ink/20 focus-within:border-ink';
101
+ // Same frame as a tool card (`ToolCardShell`): `rounded-xl`, `border-ink/10`
102
+ // on `bg-paper`, darkening to `ink/20` under the pointer — the composer is a
103
+ // card in the same transcript, so it must not be a smaller, differently
104
+ // coloured one. `focus-within` is the composer's own analogue of the card's
105
+ // hover, since the interactive surface is the textarea inside it.
106
+ const border = isDragging
107
+ ? 'border-ink/40'
108
+ : 'border-ink/10 hover:border-ink/20 focus-within:border-ink/30';
94
109
 
95
110
  if (!expanded) {
96
111
  return {
97
- cardClass: `border rounded-md bg-paper shadow-sm ${border}`,
112
+ cardClass: `border rounded-xl bg-paper shadow-sm ${border}`,
98
113
  cardPositionClass: 'relative',
99
114
  cardLayoutClass: '',
115
+ // A compact phone composer gives the transcript its height back. `h-11`
116
+ // rather than a `min-h`: a textarea's height comes from its `rows`
117
+ // (two lines by default), so a min-height alone never shrinks it —
118
+ // measured, `min-h-[44px]` still laid out at 64px. The box is fixed at
119
+ // 44px, the smallest reliable touch target, and it never needs to grow:
120
+ // a draft restores the full composer on the same commit the first
121
+ // character lands in it.
100
122
  textareaClass: mobile
101
- ? 'px-3 py-3 text-base min-h-[68px] max-h-40'
123
+ ? compact
124
+ ? 'px-3 py-2 text-base h-11 max-h-40'
125
+ : 'px-3 py-3 text-base min-h-[68px] max-h-40'
102
126
  : 'px-3 py-3 text-sm min-h-[80px]',
103
127
  wrapperClass: '',
104
128
  pickerPlacement: 'up',
@@ -128,7 +152,7 @@ export function composerExpansion({
128
152
  }
129
153
 
130
154
  return {
131
- cardClass: `border rounded-md bg-paper shadow-lg ${border}`,
155
+ cardClass: `border rounded-xl bg-paper shadow-md ${border}`,
132
156
  cardPositionClass: 'relative',
133
157
  cardLayoutClass: '',
134
158
  // A share of the viewport, not of the panel: the dock sits at the bottom of
@@ -54,6 +54,7 @@ export function ChatInput({
54
54
  deferredComposerPickRef = NO_PENDING_PICK,
55
55
  chatRunning,
56
56
  expandable = false,
57
+ shrinkWhileReading = false,
57
58
  }: ChatInputProps) {
58
59
  const [internalAttachments, setInternalAttachments] = useState<Attachment[]>([]);
59
60
  // The Goal modal is owned here, not by the toolbar: the button is a toggle,
@@ -64,6 +65,10 @@ export function ChatInput({
64
65
  // layout preference for one draft, not a setting, and the URL is what carries
65
66
  // the session it belongs to.
66
67
  const [expanded, setExpanded] = useState(false);
68
+ // Whether anything inside the card has focus. A compacted composer restores
69
+ // itself the moment the user reaches for it: shrinking the box under a finger
70
+ // that is already typing is the one way this can be wrong.
71
+ const [composerFocused, setComposerFocused] = useState(false);
67
72
  const cardRef = useRef<HTMLDivElement>(null);
68
73
 
69
74
  const { selectedModel, setSelectedModel, thinkingLevels, currentThinking, selectThinking } = useModelSelection({
@@ -137,7 +142,13 @@ export function ChatInput({
137
142
  // Enter, so the covered toolbar would be the only way to send). Docked, the
138
143
  // card is inside the chat's own column and the measurement is unused.
139
144
  const keyboardInset = useKeyboardInset(cardRef, expandable && expanded);
140
- const layout = composerExpansion({ variant, expanded, isDragging, keyboardInset });
145
+ // A phone gives its height back to the transcript while the reader is
146
+ // scrolling back — but only when there is nothing to lose: a draft means the
147
+ // user is writing, and the box must stay the size it was. A focused composer
148
+ // is the same rule from the other side (the tap that re-opens it must not be
149
+ // undone by the scroll position it was tapped from).
150
+ const compact = shrinkWhileReading && !composerFocused && !value.trim() && attachments.length === 0;
151
+ const layout = composerExpansion({ variant, expanded, isDragging, keyboardInset, compact });
141
152
 
142
153
  // Escape returns to the docked composer. Bound to the document rather than to
143
154
  // the textarea so it works while the picker has focus — the picker consumes
@@ -182,6 +193,13 @@ export function ChatInput({
182
193
  ref={cardRef}
183
194
  className={`@container transition-colors flex flex-col ${layout.cardPositionClass} ${layout.cardClass} ${layout.cardLayoutClass} ${className}`}
184
195
  style={layout.style}
196
+ // focusin/focusout BUBBLE, unlike focus/blur: the card has to learn that
197
+ // the textarea inside it was focused, and a capture-phase `onFocus` on
198
+ // this element is not the same question (it fires for the card's own
199
+ // focus, which never happens). Preact maps `onFocusIn`/`onFocusOut` to
200
+ // the native bubble-phase events.
201
+ onFocusIn={() => setComposerFocused(true)}
202
+ onFocusOut={() => setComposerFocused(false)}
185
203
  {...dropProps}
186
204
  >
187
205
  <DropOverlay visible={isDragging} />
@@ -212,7 +230,7 @@ export function ChatInput({
212
230
 
213
231
  {/* Refused plan/goal command notice. */}
214
232
  {(notice || modes?.error) && (
215
- <div className="flex items-start gap-1.5 border-b border-error/20 bg-error/5 px-3 py-1.5 text-[11px] text-error">
233
+ <div className="flex items-start gap-1.5 bg-error/5 px-3 py-1.5 text-[11px] text-error">
216
234
  <AlertTriangle size={11} className="mt-0.5 shrink-0" />
217
235
  <span className="min-w-0 flex-1 break-words">{notice || modes?.error}</span>
218
236
  <button
@@ -97,4 +97,11 @@ export interface ChatInputProps {
97
97
  * form lives inside a panel that is already sized to the answers beside it.
98
98
  */
99
99
  expandable?: boolean;
100
+ /**
101
+ * The reader has scrolled back through the transcript. On a phone with an
102
+ * empty draft the composer then gives its height back to the conversation
103
+ * (`composerExpansion`'s `compact`); it returns to full size as soon as the
104
+ * reader reaches the tail, types, or focuses the box.
105
+ */
106
+ shrinkWhileReading?: boolean;
100
107
  }
@@ -64,6 +64,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
64
64
  scrollRef,
65
65
  contentRef,
66
66
  showScrollBottom,
67
+ readingBack,
67
68
  handleScroll,
68
69
  jumpToBottom,
69
70
  handleSend,
@@ -93,7 +94,7 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
93
94
  // fetch lands) has no card to be matched against yet, so deciding "no card
94
95
  // owns this" during that window painted the ask modal and then took it back —
95
96
  // the flash on switching to a session parked on a question.
96
- const { framesByTool, modalRequest } = useMemo(
97
+ const { framesByTool, orphanAsks, modalRequest } = useMemo(
97
98
  () => splitAskFrames(localMessages, extensionDialogs, !sessionLoading),
98
99
  [localMessages, extensionDialogs, sessionLoading],
99
100
  );
@@ -102,8 +103,8 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
102
103
  resolveExtensionDialog(request.id);
103
104
  }, [respondToExtensionUi, resolveExtensionDialog]);
104
105
  const askFrames = useMemo<AskFramesHandle>(
105
- () => ({ framesByTool, respond: respondToFrame }),
106
- [framesByTool, respondToFrame],
106
+ () => ({ framesByTool, orphanAsks, respond: respondToFrame }),
107
+ [framesByTool, orphanAsks, respondToFrame],
107
108
  );
108
109
 
109
110
  const [newChatInitialContent, setNewChatInitialContent] = useState<string | null>(null);
@@ -300,6 +301,11 @@ export function ChatTimeline({ className = '', appSettings = {}, onSessionTitle,
300
301
  sessionModel={typeof sessionData?.model === 'object' ? sessionData.model : null}
301
302
  sessionThinkingLevel={sessionData?.thinkingLevel}
302
303
  variant={variant}
304
+ // The same scroll geometry that draws the scroll-to-bottom
305
+ // button, with hysteresis: the reader is back through the
306
+ // transcript, so a phone composer may give its height back.
307
+ // `composerExpansion` ignores it on a desktop.
308
+ shrinkWhileReading={readingBack}
303
309
  />
304
310
  )}
305
311
  </>
@@ -0,0 +1,127 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The reported defect: an `ask` dialog that no tool card owns was handed to the
8
+ * MODAL, which has no `ask` body — the panel came up empty with a single
9
+ * "Submit Response" button, and that button sent a bare `{ value }` omp rejects
10
+ * ("Ask dialog response must carry N answers"), so the agent stayed parked on a
11
+ * question the user could not even read.
12
+ *
13
+ * These pin the routing end to end: an ask frame is never `modalRequest`, and
14
+ * with no card to claim it the timeline draws it inline instead.
15
+ */
16
+
17
+ import { afterAll, afterEach, beforeEach, describe, expect, test } from 'bun:test';
18
+ import { Window } from 'happy-dom';
19
+ import { h, render } from 'preact';
20
+ import { act } from 'preact/test-utils';
21
+ import { AskFramesContext, splitAskFrames, type AskFramesHandle } from '@/client/hooks/chat/timeline/ask-frames';
22
+ import { OrphanAsks } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks';
23
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
24
+ import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
25
+
26
+ const QUESTIONS = [
27
+ { id: 'q1', question: 'Pick colors', options: [{ label: 'red' }, { label: 'green' }], multi: true },
28
+ { id: 'q2', question: 'Pick a size', options: [{ label: 's' }, { label: 'm' }] },
29
+ ];
30
+
31
+ const ORPHAN: ExtensionUiDialogRequest = {
32
+ type: 'extension_ui_request',
33
+ id: 'ui-orphan',
34
+ method: 'ask',
35
+ title: '',
36
+ questions: QUESTIONS,
37
+ };
38
+
39
+ let container: HTMLElement;
40
+
41
+ beforeEach(() => {
42
+ installDomGlobals(new Window());
43
+ container = document.createElement('div');
44
+ document.body.appendChild(container);
45
+ });
46
+
47
+ afterEach(() => {
48
+ render(null, container);
49
+ container.remove();
50
+ restoreDomGlobals();
51
+ });
52
+
53
+ afterAll(() => restoreDomGlobals());
54
+
55
+ /** The split the timeline computes, with nothing on the timeline to claim it. */
56
+ function splitWithoutCards(frames: ExtensionUiDialogRequest[]) {
57
+ return splitAskFrames([], frames, true);
58
+ }
59
+
60
+ describe('an ask dialog with no tool card', () => {
61
+ test('is never handed to the modal', () => {
62
+ const split = splitWithoutCards([ORPHAN]);
63
+ expect(split.modalRequest).toBeNull();
64
+ expect(split.orphanAsks).toEqual([ORPHAN]);
65
+ });
66
+
67
+ test('renders inline with every question and its own submit', () => {
68
+ const split = splitWithoutCards([ORPHAN]);
69
+ const handle: AskFramesHandle = { framesByTool: split.framesByTool, orphanAsks: split.orphanAsks, respond: () => {} };
70
+
71
+ act(() => {
72
+ render(h(AskFramesContext.Provider, { value: handle }, h(OrphanAsks, {})), container);
73
+ });
74
+
75
+ // The question text — the thing the empty modal did not have at all.
76
+ expect(container.textContent).toContain('Pick colors');
77
+ expect(container.textContent).toContain('Pick a size');
78
+ // Its answers are chosen here, and submitted by the card itself.
79
+ expect(container.textContent).toContain('Submit answers');
80
+ // No dialog role anywhere: this is a timeline row, not an overlay.
81
+ expect(container.querySelector('[role="dialog"]')).toBeNull();
82
+ });
83
+
84
+ test('answering sends one answers entry per question', () => {
85
+ const sent: unknown[] = [];
86
+ const split = splitWithoutCards([ORPHAN]);
87
+ const handle: AskFramesHandle = {
88
+ framesByTool: split.framesByTool,
89
+ orphanAsks: split.orphanAsks,
90
+ respond: (_request, response) => sent.push(response),
91
+ };
92
+
93
+ act(() => {
94
+ render(h(AskFramesContext.Provider, { value: handle }, h(OrphanAsks, {})), container);
95
+ });
96
+
97
+ const button = (text: string) => [...container.querySelectorAll('button')]
98
+ .find((element) => element.textContent?.includes(text)) as HTMLElement | undefined;
99
+
100
+ act(() => { button('red')?.click(); });
101
+ act(() => { button('s')?.click(); });
102
+ act(() => { button('Submit answers')?.click(); });
103
+
104
+ expect(sent).toHaveLength(1);
105
+ expect(sent[0]).toEqual({
106
+ answers: [
107
+ { id: 'q1', selectedOptions: ['red'] },
108
+ { id: 'q2', selectedOptions: ['s'] },
109
+ ],
110
+ });
111
+ });
112
+
113
+ test('the orphan row is held until the session history has loaded', () => {
114
+ // Same window as the modal hold: on session open a replayed frame looks
115
+ // cardless until the JSONL lands, so drawing it now would paint the
116
+ // question at the tail and then move it onto its card.
117
+ expect(splitAskFrames([], [ORPHAN], false).orphanAsks).toEqual([]);
118
+ expect(splitAskFrames([], [ORPHAN], true).orphanAsks).toEqual([ORPHAN]);
119
+ });
120
+
121
+ test('an approval gate still keeps its modal', () => {
122
+ const gate: ExtensionUiDialogRequest = { type: 'extension_ui_request', id: 'g1', method: 'confirm', title: 'Run bash?' };
123
+ const split = splitWithoutCards([gate]);
124
+ expect(split.modalRequest).toBe(gate);
125
+ expect(split.orphanAsks).toEqual([]);
126
+ });
127
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * Ask dialogs with no tool card behind them, drawn IN the timeline.
8
+ *
9
+ * An `ask` frame is normally claimed by the tool call that raised it (see
10
+ * `ask-frames.ts`), but three real shapes have no card to claim:
11
+ *
12
+ * - a subagent's own question — the subagent runs in its own transcript and
13
+ * its tool call is not in this session's row list;
14
+ * - an extension's `askDialog` (the ratchet approval is one), which is not an
15
+ * `ask` tool call at all;
16
+ * - a tool call outside the loaded history window, or one whose arguments
17
+ * never arrived.
18
+ *
19
+ * All of them still BLOCK the agent until answered, so they must stay
20
+ * answerable — and the one place they must not be is a modal over the
21
+ * conversation: `ExtensionDialog` has no `ask` body, so an orphaned frame there
22
+ * rendered an empty panel with a single button, and that button sent a bare
23
+ * `{ value }` that omp rejects ("Ask dialog response must carry N answers").
24
+ * The reported symptom was exactly that empty modal.
25
+ *
26
+ * The card is the same `GroupedAsk` the inline tool card uses, so a question
27
+ * reads identically wherever it came from.
28
+ */
29
+
30
+ import { HelpCircle } from 'lucide-preact';
31
+ import type { ExtensionUiDialogRequest } from '@/shared/types/omp/agent';
32
+ import { useAskFrames } from '@/client/hooks/chat/timeline/ask-frames';
33
+ import { GroupedAsk } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-panel/GroupedAsk';
34
+ import type { ExtensionDialogResponse } from '@/client/components/workspace/chat-timeline/tool-renderers/extension-dialog/Lazy';
35
+
36
+ /** One orphan ask, rendered as a self-contained card at the end of the timeline. */
37
+ function OrphanAskCard({ frame, onRespond }: {
38
+ frame: ExtensionUiDialogRequest;
39
+ onRespond: (request: ExtensionUiDialogRequest, response: ExtensionDialogResponse) => void;
40
+ }) {
41
+ return (
42
+ <div className="rounded-lg border border-ink/12 bg-paper p-3 shadow-xs">
43
+ <div className="mb-2 flex items-center gap-1.5 text-[10.5px] text-ink/50">
44
+ <HelpCircle size={11} className="shrink-0" />
45
+ <span>Input needed — the agent is blocked until you answer.</span>
46
+ </div>
47
+ <GroupedAsk
48
+ frame={frame}
49
+ recorded={(frame.questions ?? []).map(() => null)}
50
+ onRespond={(response) => onRespond(frame, response)}
51
+ />
52
+ </div>
53
+ );
54
+ }
55
+
56
+ /**
57
+ * Every orphan ask, oldest first. Rendered inside the timeline's scroll body so
58
+ * it behaves like any other row — a question is part of the conversation, and a
59
+ * surface floating above the transcript hid the turn it belongs to.
60
+ */
61
+ export function OrphanAsks() {
62
+ const { orphanAsks, respond } = useAskFrames();
63
+ if (orphanAsks.length === 0) return null;
64
+ return (
65
+ <div className="mt-2 space-y-2">
66
+ {orphanAsks.map((frame) => (
67
+ <OrphanAskCard key={frame.id} frame={frame} onRespond={respond} />
68
+ ))}
69
+ </div>
70
+ );
71
+ }
@@ -111,6 +111,7 @@ afterAll(() => restoreDomGlobals());
111
111
  function mount(tool: ToolCallData, groups: ExtensionUiDialogRequest[][]): void {
112
112
  const handle: AskFramesHandle = {
113
113
  framesByTool: new Map([[tool.id, groups]]),
114
+ orphanAsks: [],
114
115
  respond: (request, response) => calls.push({ request, response }),
115
116
  };
116
117
  act(() => {
@@ -44,7 +44,7 @@ function mountFromFrame(frame: IncomingExtensionUiRequest): ExtensionUiDialogReq
44
44
  createOmpAgentCallbacks(deps).onExtensionUiRequest?.(frame);
45
45
 
46
46
  const split = splitAskFrames([MESSAGE], queue, true);
47
- const handle: AskFramesHandle = { framesByTool: split.framesByTool, respond: () => {} };
47
+ const handle: AskFramesHandle = { framesByTool: split.framesByTool, orphanAsks: [], respond: () => {} };
48
48
  act(() => { render(h(AskFramesContext.Provider, { value: handle }, h(AskPanel, { tool: ASK_TOOL })), container); });
49
49
  return queue;
50
50
  }
@@ -52,7 +52,7 @@ function mountFromFrame(frame: IncomingExtensionUiRequest): ExtensionUiDialogReq
52
52
  /** Mount the card with an explicit frame queue and tool, keeping it mounted. */
53
53
  function paint(queue: ExtensionUiDialogRequest[], tool: ToolCallData = ASK_TOOL): void {
54
54
  const split = splitAskFrames([MESSAGE], queue, true);
55
- const handle: AskFramesHandle = { framesByTool: split.framesByTool, respond: () => {} };
55
+ const handle: AskFramesHandle = { framesByTool: split.framesByTool, orphanAsks: [], respond: () => {} };
56
56
  act(() => { render(h(AskFramesContext.Provider, { value: handle }, h(AskPanel, { tool })), container); });
57
57
  }
58
58
 
@@ -170,6 +170,9 @@ export function GitBranchToolbar({
170
170
  <CloudDownload size={13} />
171
171
  </button>
172
172
 
173
+ {/* The row's only text is the count, so an accessible name built from
174
+ it would read "↑1 ↓1" — it names the ACTION, the same way Fetch
175
+ and Commit History do. */}
173
176
  <button
174
177
  type="button"
175
178
  onClick={onSync}
@@ -183,6 +186,7 @@ export function GitBranchToolbar({
183
186
  ? `Pull ${syncCount.behind} commit(s)`
184
187
  : 'Up to date — sync anyway'
185
188
  }
189
+ aria-label="Sync with upstream"
186
190
  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"
187
191
  >
188
192
  <RefreshCw size={13} />
@@ -201,6 +201,21 @@ describe('GitPanel sync badge', () => {
201
201
  for (let i = 0; i < 20; i += 1) await act(async () => {});
202
202
  expect(container.textContent).toContain('↑0 ↓3');
203
203
  });
204
+
205
+ /**
206
+ * The Sync button is enabled whenever no action is running — including the
207
+ * DIVERGED case that used to be refused by git itself. Its accessible name
208
+ * is the action, not the count: a name built from the row's own text reads
209
+ * "↑1 ↓1", which names no control.
210
+ */
211
+ test('a diverged branch leaves Sync enabled, and the control is named', async () => {
212
+ await mount({ topicSync: { ahead: 1, behind: 1 } });
213
+
214
+ const sync = container.querySelector('button[aria-label="Sync with upstream"]');
215
+ if (!sync) throw new Error('no Sync control rendered');
216
+ expect((sync as HTMLButtonElement).disabled).toBe(false);
217
+ expect(container.textContent).toContain('↑1 ↓1');
218
+ });
204
219
  });
205
220
 
206
221
  /**