@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
@@ -5,6 +5,7 @@ import { MobileSessionList } from '@/client/components/mobile/mobile-session-sid
5
5
  import { MobileSessionFooter } from '@/client/components/mobile/mobile-session-sidebar/Footer';
6
6
  import { ToastStack } from '@/client/components/common/ToastStack';
7
7
  import { AboutModal, NewWorkspaceModal } from '@/client/components/layout/session-sidebar/Modals';
8
+ import { useAndroidDownload } from '@/client/hooks/ui/updates/android-download';
8
9
  import { SchedulerModal } from '@/client/components/layout/session-sidebar/scheduler-modal';
9
10
  import { UpdatePopup } from '@/client/components/layout/update-popup';
10
11
  import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
@@ -72,6 +73,9 @@ export function MobileSessionSidebar({
72
73
  const scheduledCount = useScheduledTaskCount();
73
74
  const [aboutOpen, setAboutOpen] = useState(false);
74
75
  const updates = useUpdates();
76
+ // Null in a browser, and null when this APK is already current — see
77
+ // `useAndroidDownload`.
78
+ const androidDownload = useAndroidDownload(window.__OMP_BOOTSTRAP__?.shellVersion);
75
79
  const { toasts, pushToast, dismissToast } = useToasts();
76
80
 
77
81
  // The popup's "Update now" hands the run over rather than starting its own —
@@ -242,6 +246,7 @@ export function MobileSessionSidebar({
242
246
  <MobileSessionFooter
243
247
  onSettings={() => window.dispatchEvent(new CustomEvent('omp:open-settings', { detail: {} }))}
244
248
  onAbout={() => setAboutOpen(true)}
249
+ androidDownload={androidDownload}
245
250
  onDesktopToggle={onDesktopToggle}
246
251
  updateAvailable={updates.hasUpdate}
247
252
  onUpdateClick={() => {
@@ -88,7 +88,7 @@ export function MobileHeader({
88
88
 
89
89
  return (
90
90
  <header
91
- className="flex-shrink-0 bg-canvas border-b border-ink/10 flex items-center justify-between px-3 z-20 titlebar-drag-region select-none"
91
+ className="flex-shrink-0 bg-canvas border-b border-ink/10 flex items-center justify-between gap-2 px-3 z-20 titlebar-drag-region select-none"
92
92
  style={{
93
93
  height: 'calc(3.5rem + env(safe-area-inset-top, 0px))',
94
94
  paddingLeft: 'max(0.75rem, env(safe-area-inset-left, 0px), env(titlebar-area-x, 0px))',
@@ -96,8 +96,22 @@ export function MobileHeader({
96
96
  paddingTop: 'env(safe-area-inset-top, 0px)',
97
97
  }}
98
98
  >
99
- {/* Left: hamburger, app title & session dropdown */}
100
- <div className="flex items-center space-x-2 min-w-0 titlebar-no-drag">
99
+ {/* Left: hamburger, app title & session dropdown.
100
+ `flex-1 min-w-0` is what makes the title the ONLY part that gives up
101
+ width: the hamburger, wordmark and slash are `flex-shrink-0`, so the
102
+ row shrinks to the control cluster's width and the picker truncates
103
+ inside it. Without `flex-1` the row kept its max-content width and
104
+ the picker's 150px button spilled over the reload control.
105
+
106
+ The wordmark and its slash are DROPPED below 420px. They are fixed
107
+ chrome (53px + 4px + 16px of gaps = 73px, 19% of a 390px header) that
108
+ competes with the one functional thing in the row: the session title.
109
+ With a password set the cluster is 140px wide (reload, context, lock,
110
+ panel), which leaves the title `vw − 285px` — measured 89px at 390,
111
+ 59px at 360 and 19px at 320. The brand is not lost: the drawer header
112
+ draws it, as does the desktop navbar. Same decision, same reason, as
113
+ the desktop navbar's `hidden sm:flex` on its own wordmark. */}
114
+ <div className="flex items-center space-x-2 flex-1 min-w-0 titlebar-no-drag">
101
115
  <button
102
116
  type="button"
103
117
  onClick={onOpenSessionSidebar}
@@ -108,6 +122,10 @@ export function MobileHeader({
108
122
  <Menu size={20} strokeWidth={2} />
109
123
  </button>
110
124
 
125
+ {/* The wordmark is drawn at EVERY width. The width it needs was bought
126
+ back from the control cluster instead: reload and sign out moved
127
+ into the `⋯` menu (`HeaderControls`), which is what pays for the
128
+ wordmark plus the slash beside it. */}
111
129
  <span className="relative font-bold text-sm tracking-tight flex items-center flex-shrink-0">
112
130
  <BrandWordmark />
113
131
  <span className="absolute -top-0.5 -right-1.5 flex">
@@ -117,11 +135,15 @@ export function MobileHeader({
117
135
 
118
136
  <span className="text-ink/25 font-light text-xs flex-shrink-0 select-none">/</span>
119
137
 
120
- <div className="relative min-w-0" ref={sessionPickerRef}>
138
+ <div
139
+ className="relative min-w-0 flex-1 max-w-[150px]"
140
+ ref={sessionPickerRef}
141
+ data-testid="session-picker"
142
+ >
121
143
  <button
122
144
  type="button"
123
145
  onClick={() => setShowSessionPicker(!showSessionPicker)}
124
- className="flex items-center space-x-1 text-xs font-semibold text-ink hover:text-ink/80 transition-colors py-1 max-w-[150px] min-w-0"
146
+ className="flex w-full items-center space-x-1 text-xs font-semibold text-ink hover:text-ink/80 transition-colors py-1 min-w-0"
125
147
  title={displayTitle}
126
148
  >
127
149
  <span className="truncate">{displayTitle}</span>
@@ -1,4 +1,4 @@
1
- import { Info, Monitor, Settings } from 'lucide-preact';
1
+ import { Download, Info, Monitor, Settings } from 'lucide-preact';
2
2
  import packageJson from '@/../package.json';
3
3
 
4
4
  interface MobileSessionFooterProps {
@@ -6,6 +6,8 @@ interface MobileSessionFooterProps {
6
6
  onAbout: () => void;
7
7
  updateAvailable?: boolean;
8
8
  onUpdateClick?: () => void;
9
+ /** The newer APK to offer, or null. Only ever set inside the Android app. */
10
+ androidDownload?: { url: string; latest: string; releaseUrl: string | null } | null;
9
11
  /** Switch the app to the desktop layout for this page session only. The
10
12
  * choice is not persisted, so the next load re-detects the device. */
11
13
  onDesktopToggle?: () => void;
@@ -17,6 +19,7 @@ export function MobileSessionFooter({
17
19
  updateAvailable,
18
20
  onUpdateClick,
19
21
  onDesktopToggle,
22
+ androidDownload,
20
23
  }: MobileSessionFooterProps) {
21
24
  return (
22
25
  <div
@@ -40,6 +43,23 @@ export function MobileSessionFooter({
40
43
  <Info size={14} />
41
44
  <span>About</span>
42
45
  </button>
46
+ {androidDownload && (
47
+ // Directly after About, matching the desktop footer. An `<a>`, not a
48
+ // button: the download is the browser's (or the WebView's) job, and
49
+ // `target="_blank"` hands it to the system browser so the console is
50
+ // not replaced by a file download.
51
+ <a
52
+ href={androidDownload.url}
53
+ target="_blank"
54
+ rel="noopener noreferrer"
55
+ title={`Download Kabmo ${androidDownload.latest} for Android`}
56
+ aria-label={`Download Kabmo ${androidDownload.latest} for Android`}
57
+ className="flex items-center space-x-1 hover:text-ink transition-colors"
58
+ >
59
+ <Download size={14} />
60
+ <span>APK</span>
61
+ </a>
62
+ )}
43
63
  {onDesktopToggle && (
44
64
  <button
45
65
  type="button"
@@ -133,3 +133,107 @@ describe('mobile toolbar archive button', () => {
133
133
  expect(sortTrigger?.className).not.toContain('bg-ink/10');
134
134
  });
135
135
  });
136
+
137
+ /**
138
+ * The row's overflow rule, which is what the reported missing `+` came down to.
139
+ *
140
+ * happy-dom reports `clientWidth` as 0, so the toolbar renders the unmeasured
141
+ * state — every control on the row — and these cases pin the two halves that
142
+ * are decidable without layout: the primary pill's icon and label are BOTH in
143
+ * the markup at every width, and the `⋯` menu exists as the destination the
144
+ * measured rule moves a control into.
145
+ */
146
+ describe('mobile toolbar overflow', () => {
147
+ test('the primary pill always draws its icon and its label', async () => {
148
+ const { el } = await mount();
149
+ const pill = Array.from(el.querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === 'New Session');
150
+ expect(pill).not.toBeUndefined();
151
+ const icon = pill?.querySelector('svg');
152
+ expect(icon?.getAttribute('class')).toContain('lucide-plus');
153
+ // `shrink-0` is the fix: without it the icon is the flex item that gives up
154
+ // width, and the label's `whitespace-nowrap` min-content refuses to.
155
+ expect(icon?.getAttribute('class')).toContain('shrink-0');
156
+ const label = pill?.querySelector('span');
157
+ expect(label?.textContent).toBe('New Session');
158
+ // Not hidden at any width — the budget drops a CONTROL instead.
159
+ expect(label?.className).not.toContain('hidden');
160
+ });
161
+
162
+ test('every control is on the row until a width says otherwise', async () => {
163
+ // An unmeasured row reports 0, and `mobileToolbarCapacity(0)` keeps the
164
+ // full set: the menu must not appear before anything has been measured.
165
+ const { el } = await mount();
166
+ for (const name of [
167
+ 'New Workspace',
168
+ 'Scheduled tasks',
169
+ 'Show archived sessions',
170
+ 'Select workspaces',
171
+ 'Refresh sessions',
172
+ 'Sort workspaces',
173
+ ]) {
174
+ const button = Array.from(el.querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === name);
175
+ expect(button).not.toBeUndefined();
176
+ }
177
+ expect(el.querySelector('button[aria-label="More sidebar actions"]')).toBeNull();
178
+ });
179
+
180
+ test('the overflow menu opens and carries the controls it took', async () => {
181
+ // The row is stubbed to a phone's 366px (a 390px drawer minus its `p-3`),
182
+ // which is the width the reported bug was seen at. happy-dom computes no
183
+ // layout, so the measurement the hook reads is supplied here — the rule
184
+ // itself is pinned by `sidebar-toolbar.test.ts`.
185
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
186
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
187
+ configurable: true,
188
+ get() {
189
+ return (this as HTMLElement).className === 'flex items-center space-x-2' ? 366 : 0;
190
+ },
191
+ });
192
+ try {
193
+ const { el } = await mount();
194
+ const onRow = (name: string) =>
195
+ Array.from(el.querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === name);
196
+ // The tail moves out — scheduler and selection — while the head stays,
197
+ // including the two view controls a phone actually uses.
198
+ expect(onRow('Scheduled tasks')).toBeUndefined();
199
+ expect(onRow('Select workspaces')).toBeUndefined();
200
+ expect(onRow('New Workspace')).not.toBeUndefined();
201
+ expect(onRow('Sort workspaces')).not.toBeUndefined();
202
+
203
+ const trigger = el.querySelector('button[aria-label="More sidebar actions"]') as HTMLButtonElement;
204
+ expect(trigger).not.toBeNull();
205
+ await act(async () => { trigger.click(); });
206
+
207
+ // Named in words, not as glyphs — and each one still runs.
208
+ const menuItems = Array.from(el.querySelectorAll('[role="menuitem"]')).map((b) => b.textContent ?? '');
209
+ expect(menuItems).toContain('Scheduled tasks');
210
+ expect(menuItems).toContain('Select workspaces');
211
+ // The sort did NOT overflow here, so its options are not duplicated in
212
+ // the menu — the sort list arrives only when the sort control itself is
213
+ // one of the ones that left the row.
214
+ expect(el.textContent).not.toContain('Sort Workspaces');
215
+ } finally {
216
+ if (descriptor) Object.defineProperty(HTMLElement.prototype, 'clientWidth', descriptor);
217
+ else delete (HTMLElement.prototype as unknown as Record<string, unknown>).clientWidth;
218
+ }
219
+ });
220
+
221
+ test('a wide drawer keeps every control on the row', async () => {
222
+ const descriptor = Object.getOwnPropertyDescriptor(HTMLElement.prototype, 'clientWidth');
223
+ Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
224
+ configurable: true,
225
+ get() {
226
+ return (this as HTMLElement).className === 'flex items-center space-x-2' ? 600 : 0;
227
+ },
228
+ });
229
+ try {
230
+ const { el } = await mount();
231
+ expect(el.querySelector('button[aria-label="More sidebar actions"]')).toBeNull();
232
+ expect(Array.from(el.querySelectorAll('button')).find((b) => b.getAttribute('aria-label') === 'Sort workspaces'))
233
+ .not.toBeUndefined();
234
+ } finally {
235
+ if (descriptor) Object.defineProperty(HTMLElement.prototype, 'clientWidth', descriptor);
236
+ else delete (HTMLElement.prototype as unknown as Record<string, unknown>).clientWidth;
237
+ }
238
+ });
239
+ });
@@ -2,6 +2,13 @@ import type { RefObject } from 'preact/compat';
2
2
  import { Archive, ArrowUpDown, Calendar, FolderPlus, ListChecks, Plus, RefreshCw, Search, X } from 'lucide-preact';
3
3
  import type { SessionSortOption } from '@/shared/types';
4
4
  import { SortMenu } from '@/client/components/common/sort-menu';
5
+ import { ToolbarOverflowMenu } from '@/client/components/layout/session-sidebar/OverflowMenu';
6
+ import type { OverflowAction } from '@/client/components/layout/session-sidebar/OverflowMenu';
7
+ import { useToolbarRowWidth } from '@/client/hooks/workspace/toolbar-row-width';
8
+ import {
9
+ MOBILE_TOOLBAR_PRIORITY,
10
+ mobileToolbarCapacity,
11
+ } from '@/shared/lib/workspace/sidebar-toolbar';
5
12
 
6
13
  export interface MobileSessionToolbarProps {
7
14
  searchQuery: string;
@@ -30,6 +37,12 @@ export interface MobileSessionToolbarProps {
30
37
  selecting: boolean;
31
38
  }
32
39
 
40
+ /** One phone control's box — `w-9.5 h-9.5` with the toolbar's own border/shadow. */
41
+ const CONTROL_CLASS =
42
+ 'w-9.5 h-9.5 rounded-xl border active:scale-95 flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs';
43
+ const CONTROL_IDLE = 'border-ink/15 bg-paper hover:bg-ink/5 text-ink/80 hover:text-ink';
44
+ const CONTROL_ACTIVE = 'bg-ink/10 text-ink border-ink/30';
45
+
33
46
  export function MobileSessionToolbar({
34
47
  searchQuery,
35
48
  sortOption,
@@ -52,21 +65,59 @@ export function MobileSessionToolbar({
52
65
  onSelectWorkspaces,
53
66
  selecting,
54
67
  }: MobileSessionToolbarProps) {
68
+ // The row is measured, not breakpointed. The drawer is the device's width, and
69
+ // the primary pill plus six 38px controls need 316px of row — more than a
70
+ // 390px phone offers once the drawer's 24px of padding is gone. A
71
+ // `@container` query could not fix that: it would keep hiding controls after
72
+ // a tablet widened the drawer.
73
+ const { ref: rowRef, width } = useToolbarRowWidth(0);
74
+ const capacity = mobileToolbarCapacity(width);
75
+ const visible: Record<string, true> = {};
76
+ for (const control of MOBILE_TOOLBAR_PRIORITY.slice(0, capacity)) visible[control] = true;
77
+ const hidden = MOBILE_TOOLBAR_PRIORITY.slice(capacity);
78
+
79
+ const archiveLabel = showArchived ? 'Hide archived sessions' : 'Show archived sessions';
80
+ const schedulerLabel = scheduledCount > 0 ? `Scheduled tasks (${scheduledCount} armed)` : 'Scheduled tasks';
81
+
82
+ /** What the `⋯` menu offers, in priority order. */
83
+ const overflowActions: OverflowAction[] = [];
84
+ if (hidden.includes('new-workspace')) {
85
+ overflowActions.push({ id: 'new-workspace', label: 'New Workspace', icon: FolderPlus, onSelect: onNewWorkspace });
86
+ }
87
+ if (hidden.includes('refresh')) {
88
+ overflowActions.push({ id: 'refresh', label: 'Refresh sessions', icon: RefreshCw, onSelect: onRefresh });
89
+ }
90
+ if (hidden.includes('scheduler')) {
91
+ overflowActions.push({ id: 'scheduler', label: schedulerLabel, icon: Calendar, onSelect: onScheduler });
92
+ }
93
+ if (hidden.includes('archive')) {
94
+ overflowActions.push({ id: 'archive', label: archiveLabel, icon: Archive, onSelect: onToggleArchived, active: showArchived });
95
+ }
96
+ if (hidden.includes('select')) {
97
+ overflowActions.push({
98
+ id: 'select',
99
+ label: selecting ? 'Cancel selection' : 'Select workspaces',
100
+ icon: ListChecks,
101
+ onSelect: onSelectWorkspaces,
102
+ active: selecting,
103
+ });
104
+ }
105
+
55
106
  return (
56
- <div className="@container p-3 border-b border-ink/10 flex flex-col space-y-2.5 flex-shrink-0 bg-canvas">
107
+ <div className="p-3 border-b border-ink/10 flex flex-col space-y-2.5 flex-shrink-0 bg-canvas">
57
108
  {/* Row 1: Primary + New Session Button & Quick Utility Buttons */}
58
- <div className="flex items-center space-x-2">
59
- {/* Large Ergonomic New Session Button. Its label drops below a narrow
60
- drawer (@container, not the viewport) so the five fixed-size utility
61
- buttons beside it can never squeeze it past the row's own width.
62
- Measured: the row needs 356px of CONTAINER for the label to fit
63
- (190px of buttons + 40px of gaps + 51px of icon/padding + the 75px
64
- label), and the container is the viewport minus the toolbar's 12px
65
- of padding each side — so 360px of container (a 384px viewport)
66
- is the point the label stops fitting. The `aria-label` is not
67
- optional: hiding the visible text would otherwise leave the button
68
- with no accessible name at all, which is what a phone-width
69
- screen reader reads first. */}
109
+ <div ref={rowRef} className="flex items-center space-x-2">
110
+ {/* Large Ergonomic New Session Button. Its label is budgeted for in
111
+ FULL by `mobileToolbarCapacity` (126px, the desktop row's rule), so
112
+ it never ellipsises and never leaves the row: what gives way on a
113
+ narrow drawer is a utility control, which moves into the `⋯` menu.
114
+ `shrink-0` on the icon is the belt to that braces — measured with
115
+ both as ordinary flex items, the label's `whitespace-nowrap`
116
+ min-content refused to shrink and the `Plus` absorbed the whole
117
+ shortfall (15px at 430, 1px at 412, 0px at 390/375/360), so the
118
+ phone's primary action was a blank pill exactly where the drawer
119
+ first dropped the label. The `aria-label` still carries the name
120
+ for a screen reader. */}
70
121
  <button
71
122
  type="button"
72
123
  onClick={onNewSession}
@@ -74,119 +125,128 @@ export function MobileSessionToolbar({
74
125
  title="New Session"
75
126
  className="flex-1 min-w-0 h-9.5 flex items-center justify-center space-x-2 px-3.5 rounded-xl bg-ink text-canvas hover:bg-ink/90 active:scale-[0.98] text-xs font-semibold transition-all shadow-xs"
76
127
  >
77
- <Plus size={15} strokeWidth={2.4} />
78
- <span className="hidden @[360px]:inline whitespace-nowrap">New Session</span>
128
+ <Plus size={15} strokeWidth={2.4} className="shrink-0" />
129
+ <span className="min-w-0 truncate">New Session</span>
79
130
  </button>
80
131
 
81
- {/* New Workspace / Folder button */}
82
- <button
83
- type="button"
84
- onClick={onNewWorkspace}
85
- className="w-9.5 h-9.5 rounded-xl border border-ink/15 bg-paper hover:bg-ink/5 active:scale-95 text-ink/80 hover:text-ink flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs"
86
- title="New Workspace"
87
- aria-label="New Workspace"
88
- >
89
- <FolderPlus size={16} strokeWidth={1.8} />
90
- </button>
132
+ {visible['new-workspace'] && (
133
+ <button
134
+ type="button"
135
+ onClick={onNewWorkspace}
136
+ className={`${CONTROL_CLASS} ${CONTROL_IDLE}`}
137
+ title="New Workspace"
138
+ aria-label="New Workspace"
139
+ >
140
+ <FolderPlus size={16} strokeWidth={1.8} />
141
+ </button>
142
+ )}
91
143
 
92
144
  {/* Scheduler button. The badge is the whole point of the count: a
93
145
  button that opens a list you cannot see the state of reads as
94
146
  decorative, which is exactly what this control used to be. */}
95
- <button
96
- type="button"
97
- onClick={onScheduler}
98
- className="relative w-9.5 h-9.5 rounded-xl border border-ink/15 bg-paper hover:bg-ink/5 active:scale-95 text-ink/80 hover:text-ink flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs"
99
- title={scheduledCount > 0 ? `Scheduled tasks (${scheduledCount} armed)` : 'Scheduled tasks'}
100
- aria-label={scheduledCount > 0 ? `Scheduled tasks, ${scheduledCount} armed` : 'Scheduled tasks'}
101
- >
102
- <Calendar size={16} strokeWidth={1.8} />
103
- {scheduledCount > 0 && (
104
- <span className="absolute -top-0.5 -right-0.5 min-w-[13px] h-[13px] px-[3px] rounded-full bg-ink text-canvas text-[8px] leading-[13px] text-center font-semibold">
105
- {scheduledCount > 9 ? '9+' : scheduledCount}
106
- </span>
107
- )}
108
- </button>
147
+ {visible['scheduler'] && (
148
+ <button
149
+ type="button"
150
+ onClick={onScheduler}
151
+ className={`relative ${CONTROL_CLASS} ${CONTROL_IDLE}`}
152
+ title={schedulerLabel}
153
+ aria-label={scheduledCount > 0 ? `Scheduled tasks, ${scheduledCount} armed` : 'Scheduled tasks'}
154
+ >
155
+ <Calendar size={16} strokeWidth={1.8} />
156
+ {scheduledCount > 0 && (
157
+ <span className="absolute -top-0.5 -right-0.5 min-w-[13px] h-[13px] px-[3px] rounded-full bg-ink text-canvas text-[8px] leading-[13px] text-center font-semibold">
158
+ {scheduledCount > 9 ? '9+' : scheduledCount}
159
+ </span>
160
+ )}
161
+ </button>
162
+ )}
109
163
 
110
164
  {/* Archive toggle — its own button, the same shape as Scheduler's.
111
165
  It used to live inside the sort dropdown under a "Filter" heading,
112
166
  which made a view filter look like a sort mode: the control that
113
167
  decides whether archived rows are on screen was hidden one tap
114
- deeper than the control that reorders them. The pressed state is
115
- carried by the button itself (and by the pill in row 3), so the
116
- dropdown no longer needs to report it. */}
117
- <button
118
- type="button"
119
- onClick={onToggleArchived}
120
- aria-pressed={showArchived}
121
- className={`w-9.5 h-9.5 rounded-xl border active:scale-95 flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs ${
122
- showArchived
123
- ? 'bg-amber-500/15 border-amber-500/40 text-amber-900'
124
- : 'border-ink/15 bg-paper hover:bg-ink/5 text-ink/80 hover:text-ink'
125
- }`}
126
- title={showArchived ? 'Hide archived sessions' : 'Show archived sessions'}
127
- aria-label={showArchived ? 'Hide archived sessions' : 'Show archived sessions'}
128
- >
129
- <Archive size={16} strokeWidth={1.8} />
130
- </button>
168
+ deeper than the control that reorders them. */}
169
+ {visible['archive'] && (
170
+ <button
171
+ type="button"
172
+ onClick={onToggleArchived}
173
+ aria-pressed={showArchived}
174
+ className={`${CONTROL_CLASS} ${showArchived ? 'bg-amber-500/15 border-amber-500/40 text-amber-900' : CONTROL_IDLE}`}
175
+ title={archiveLabel}
176
+ aria-label={archiveLabel}
177
+ >
178
+ <Archive size={16} strokeWidth={1.8} />
179
+ </button>
180
+ )}
131
181
 
132
182
  {/* Batch selection over the WORKSPACES. A mode, like search, so it
133
183
  sits with the view controls and reports its pressed state — and it
134
184
  is the only way into the workspace batch, because a workspace has
135
185
  no row of its own to long-press. */}
136
- <button
137
- type="button"
138
- onClick={onSelectWorkspaces}
139
- aria-pressed={selecting}
140
- className={`w-9.5 h-9.5 rounded-xl border active:scale-95 flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs ${
141
- selecting
142
- ? 'bg-ink/10 text-ink border-ink/30'
143
- : 'border-ink/15 bg-paper hover:bg-ink/5 text-ink/80 hover:text-ink'
144
- }`}
145
- title={selecting ? 'Cancel selection' : 'Select workspaces'}
146
- aria-label={selecting ? 'Cancel selection' : 'Select workspaces'}
147
- >
148
- <ListChecks size={16} strokeWidth={1.8} />
149
- </button>
186
+ {visible['select'] && (
187
+ <button
188
+ type="button"
189
+ onClick={onSelectWorkspaces}
190
+ aria-pressed={selecting}
191
+ className={`${CONTROL_CLASS} ${selecting ? CONTROL_ACTIVE : CONTROL_IDLE}`}
192
+ title={selecting ? 'Cancel selection' : 'Select workspaces'}
193
+ aria-label={selecting ? 'Cancel selection' : 'Select workspaces'}
194
+ >
195
+ <ListChecks size={16} strokeWidth={1.8} />
196
+ </button>
197
+ )}
150
198
 
151
199
  {/* Refresh sessions button — same action the desktop toolbar's
152
200
  RefreshCw performs: refetch the session list from the server.
153
201
  The icon spins for the duration of that fetch, so the click has a
154
202
  visible end; `refreshing` is raised only by this button's own load. */}
155
- <button
156
- type="button"
157
- onClick={onRefresh}
158
- className="w-9.5 h-9.5 rounded-xl border border-ink/15 bg-paper hover:bg-ink/5 active:scale-95 text-ink/80 hover:text-ink flex items-center justify-center transition-all flex-shrink-0 cursor-pointer shadow-xs"
159
- title="Refresh sessions"
160
- aria-label="Refresh sessions"
161
- aria-busy={refreshing}
162
- >
163
- <RefreshCw size={15} strokeWidth={1.8} className={refreshing ? 'animate-spin' : undefined} />
164
- </button>
165
-
166
- {/* Sort Dropdown. Archive is NOT in here any more — it has its own
167
- button in this row, so this control means one thing (the order of
168
- the list) and its pressed state reads the sort alone. */}
169
- <div className="relative flex-shrink-0" ref={optionsRef}>
203
+ {visible['refresh'] && (
170
204
  <button
171
205
  type="button"
172
- onClick={onToggleOptions}
173
- className={`w-9.5 h-9.5 rounded-xl border active:scale-95 flex items-center justify-center transition-all cursor-pointer shadow-xs ${
174
- optionsOpen || sortOption !== 'A-Z'
175
- ? 'bg-ink/10 text-ink border-ink/30'
176
- : 'border-ink/15 bg-paper hover:bg-ink/5 text-ink/80 hover:text-ink'
177
- }`}
178
- title="Sort workspaces"
179
- aria-label="Sort workspaces"
206
+ onClick={onRefresh}
207
+ className={`${CONTROL_CLASS} ${CONTROL_IDLE}`}
208
+ title="Refresh sessions"
209
+ aria-label="Refresh sessions"
210
+ aria-busy={refreshing}
180
211
  >
181
- <ArrowUpDown size={15} strokeWidth={1.8} />
212
+ <RefreshCw size={15} strokeWidth={1.8} className={refreshing ? 'animate-spin' : undefined} />
182
213
  </button>
214
+ )}
183
215
 
184
- {optionsOpen && (
185
- <div className="absolute right-0 top-full mt-2 w-52 bg-paper border border-ink/15 rounded-xl shadow-xl z-50 p-2 text-xs">
186
- <SortMenu variant="mobile" sortOption={sortOption} onSortChange={onSortChange} />
187
- </div>
188
- )}
189
- </div>
216
+ {/* Sort Dropdown. Archive is NOT in here any more — it has its own
217
+ button in this row, so this control means one thing (the order of
218
+ the list) and its pressed state reads the sort alone. */}
219
+ {visible['sort'] && (
220
+ <div className="relative flex-shrink-0" ref={optionsRef}>
221
+ <button
222
+ type="button"
223
+ onClick={onToggleOptions}
224
+ className={`${CONTROL_CLASS} ${optionsOpen || sortOption !== 'A-Z' ? CONTROL_ACTIVE : CONTROL_IDLE}`}
225
+ title="Sort workspaces"
226
+ aria-label="Sort workspaces"
227
+ >
228
+ <ArrowUpDown size={15} strokeWidth={1.8} />
229
+ </button>
230
+
231
+ {optionsOpen && (
232
+ <div className="absolute right-0 top-full mt-2 w-52 bg-paper border border-ink/15 rounded-xl shadow-xl z-50 p-2 text-xs">
233
+ <SortMenu variant="mobile" sortOption={sortOption} onSortChange={onSortChange} />
234
+ </div>
235
+ )}
236
+ </div>
237
+ )}
238
+
239
+ {/* Whatever the row could not hold. The sort control's own options
240
+ travel with it, so a narrow drawer can still reorder the list. */}
241
+ {hidden.length > 0 && (
242
+ <ToolbarOverflowMenu
243
+ actions={overflowActions}
244
+ sortHidden={hidden.includes('sort')}
245
+ sortOption={sortOption}
246
+ onSortChange={onSortChange}
247
+ layout="mobile"
248
+ />
249
+ )}
190
250
  </div>
191
251
 
192
252
  {/* Row 2: Search Input Bar */}
@@ -217,9 +277,9 @@ export function MobileSessionToolbar({
217
277
  {sortOption !== 'A-Z' && (
218
278
  <span className="inline-flex items-center space-x-1 bg-ink/5 border border-ink/10 text-ink/80 rounded-lg px-2 py-0.5 text-[10px] font-mono">
219
279
  <span>Sort: {sortOption.replace('_', ' ')}</span>
220
- <button
221
- type="button"
222
- onClick={onResetSort}
280
+ <button
281
+ type="button"
282
+ onClick={onResetSort}
223
283
  className="hover:text-ink ml-0.5"
224
284
  title="Reset sort"
225
285
  >
@@ -231,9 +291,9 @@ export function MobileSessionToolbar({
231
291
  <span className="inline-flex items-center space-x-1 bg-amber-500/15 border border-amber-500/25 text-amber-900 rounded-lg px-2 py-0.5 text-[10px] font-mono">
232
292
  <Archive size={10} />
233
293
  <span>Archived</span>
234
- <button
235
- type="button"
236
- onClick={onHideArchived}
294
+ <button
295
+ type="button"
296
+ onClick={onHideArchived}
237
297
  className="hover:text-amber-950 ml-0.5"
238
298
  title="Hide archived"
239
299
  >
@@ -52,6 +52,13 @@ interface ComposerDockProps {
52
52
  sessionModel: { provider: string; modelId: string } | null;
53
53
  sessionThinkingLevel?: string | null;
54
54
  variant: 'desktop' | 'mobile';
55
+ /**
56
+ * The phone reader is scrolled back through the transcript, so the composer
57
+ * may give its height back (`ChatInput.shrinkWhileReading`). Derived from the
58
+ * timeline's own scroll geometry — the same signal that draws the
59
+ * scroll-to-bottom affordance.
60
+ */
61
+ shrinkWhileReading?: boolean;
55
62
  }
56
63
 
57
64
  /**
@@ -91,6 +98,7 @@ export function ComposerDock({
91
98
  sessionModel,
92
99
  sessionThinkingLevel,
93
100
  variant,
101
+ shrinkWhileReading = false,
94
102
  }: ComposerDockProps) {
95
103
  // Side questions take over the composer entirely: the chat's own input is
96
104
  // hidden while the form is open, so a stray Enter cannot send to the wrong
@@ -159,6 +167,7 @@ export function ComposerDock({
159
167
  sessionThinkingLevel={sessionThinkingLevel}
160
168
  variant={variant}
161
169
  expandable
170
+ shrinkWhileReading={shrinkWhileReading}
162
171
  />
163
172
  )}
164
173
  </div>
@@ -4,6 +4,7 @@ import { MessageList } from '@/client/components/workspace/chat-timeline/Message
4
4
  import { MinimapShortcuts } from '@/client/components/workspace/chat-timeline/MinimapShortcuts';
5
5
  import { MobileTurnJump } from '@/client/components/workspace/chat-timeline/MobileTurnJump';
6
6
  import { LoadingOlderIndicator } from '@/client/components/workspace/chat-timeline/SessionSkeleton';
7
+ import { OrphanAsks } from '@/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks';
7
8
  import type { Attachment, ChatMessageData, UserTurnRef } from '@/shared/types';
8
9
 
9
10
  interface TimelineBodyProps {
@@ -115,6 +116,11 @@ export function TimelineBody({
115
116
  onNewChat={onNewChat}
116
117
  isMobile={isMobile}
117
118
  />
119
+ {/* An ask whose tool card this client cannot see (a subagent's, an
120
+ extension's own dialog, a call outside the loaded window) still
121
+ blocks the agent. It renders HERE, as the last row of the
122
+ conversation, never as a modal over it. */}
123
+ <OrphanAsks />
118
124
  </div>
119
125
  </div>
120
126