@kabmo-labs/kabmo 0.3.1 → 0.5.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 (155) hide show
  1. package/dist/client/index.html +1 -1
  2. package/dist/client/index.js +412 -383
  3. package/dist/client/static/js/index-3qamd33d.css +1 -0
  4. package/dist/client/static/js/{index-28z316qk.js → index-cs3b557t.js} +792 -792
  5. package/package.json +1 -1
  6. package/src/client/components/common/batch-selection/BatchActionBar.tsx +8 -1
  7. package/src/client/components/common/session-actions-menu/index.tsx +23 -2
  8. package/src/client/components/layout/{RightActivityBar.test.ts → ActivityBar.test.ts} +5 -5
  9. package/src/client/components/layout/{RightActivityBar.tsx → ActivityBar.tsx} +87 -52
  10. package/src/client/components/layout/LeftActivityBar.test.ts +201 -0
  11. package/src/client/components/layout/LeftDock.tsx +119 -0
  12. package/src/client/components/layout/desktop-layout/LeftColumn.tsx +172 -0
  13. package/src/client/components/layout/desktop-layout/TopNavbar.tsx +5 -3
  14. package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +22 -5
  15. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  16. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  17. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  18. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  19. package/src/client/components/layout/session-sidebar/Header.tsx +2 -1
  20. package/src/client/components/layout/session-sidebar/SessionItem.tsx +42 -6
  21. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  22. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  23. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  24. package/src/client/components/mobile/LayoutWrapper.tsx +4 -0
  25. package/src/client/components/mobile/RightSidebar.tsx +30 -36
  26. package/src/client/components/mobile/SessionSidebar.tsx +124 -101
  27. package/src/client/components/mobile/mobile-main-view/Header.tsx +3 -2
  28. package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -2
  29. package/src/client/components/mobile/mobile-right-sidebar/Header.tsx +3 -2
  30. package/src/client/components/mobile/mobile-session-sidebar/Dialogs.tsx +106 -0
  31. package/src/client/components/mobile/mobile-session-sidebar/Dock.tsx +80 -0
  32. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +11 -5
  33. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  34. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  35. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  36. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +47 -14
  37. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  38. package/src/client/components/mobile/mobile-session-sidebar/use-dock.ts +89 -0
  39. package/src/client/components/mobile/panel-chips.test.tsx +155 -0
  40. package/src/client/components/mobile/panel-chips.tsx +74 -0
  41. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  42. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  43. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  44. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  45. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  46. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  47. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  48. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  49. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  50. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  51. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  52. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  53. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  54. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  55. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  56. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  57. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +15 -10
  58. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  59. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  60. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +162 -0
  61. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  62. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  63. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  64. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  65. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  66. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  67. package/src/client/components/workspace/editor/index.tsx +1 -0
  68. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  69. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  70. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  71. package/src/client/components/workspace/model-dropdown/index.tsx +3 -3
  72. package/src/client/components/workspace/model-dropdown/trigger.test.ts +123 -0
  73. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  74. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  75. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  76. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  77. package/src/client/hooks/chat/composer/compact.ts +119 -0
  78. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  79. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  80. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  81. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  82. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  83. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  84. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  85. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  86. package/src/client/hooks/chat/timeline/index.ts +15 -19
  87. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  88. package/src/client/hooks/chat/timeline/queue-rewind.test.ts +6 -0
  89. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  90. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  91. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  92. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  93. package/src/client/hooks/chat/waiting/held.ts +42 -0
  94. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  95. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  96. package/src/client/hooks/chat/waiting/store.ts +100 -0
  97. package/src/client/hooks/ui/toasts.ts +12 -1
  98. package/src/client/hooks/workspace/left-dock.ts +132 -0
  99. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  100. package/src/client/tailwind.css +22 -0
  101. package/src/server/db.server.test.ts +52 -1
  102. package/src/server/db.server.ts +49 -1
  103. package/src/server/index.ts +9 -0
  104. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  105. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  106. package/src/server/lib/db/schema.test.ts +1 -0
  107. package/src/server/lib/db/schema.ts +3 -0
  108. package/src/server/lib/fs/fs-path.test.ts +1 -1
  109. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  110. package/src/server/lib/omp/session/delete.server.ts +8 -0
  111. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  112. package/src/server/lib/panels/registry.server.ts +2 -2
  113. package/src/server/lib/queue/delivery.server.ts +31 -39
  114. package/src/server/lib/queue/payload.ts +73 -0
  115. package/src/server/lib/realtime/signals.server.ts +10 -1
  116. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  117. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  118. package/src/server/lib/realtime/topics.server.ts +2 -0
  119. package/src/server/lib/util/with-timeout.ts +29 -0
  120. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  121. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  122. package/src/server/lib/waiting/runtime.server.ts +313 -0
  123. package/src/server/lib/waiting/schema.server.ts +46 -0
  124. package/src/server/lib/waiting/settle.test.ts +84 -0
  125. package/src/server/lib/waiting/settle.ts +77 -0
  126. package/src/server/lib/waiting/store.server.test.ts +167 -0
  127. package/src/server/lib/waiting/store.server.ts +268 -0
  128. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  129. package/src/server/plugins/compress.ts +18 -6
  130. package/src/server/plugins/ssr.ts +16 -8
  131. package/src/server/plugins/static.test.ts +59 -0
  132. package/src/server/plugins/static.ts +56 -14
  133. package/src/server/routes/index.ts +2 -0
  134. package/src/server/routes/waiting/bindings.ts +14 -0
  135. package/src/server/routes/waiting/index.ts +123 -0
  136. package/src/server/routes/waiting/routes.test.ts +121 -0
  137. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  138. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  139. package/src/shared/lib/realtime/protocol.ts +10 -0
  140. package/src/shared/lib/ui/titlebar.test.ts +124 -0
  141. package/src/shared/lib/ui/titlebar.ts +54 -0
  142. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  143. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  144. package/src/shared/lib/workspace/left-dock.ts +197 -0
  145. package/src/shared/lib/workspace/mobile-dock.test.ts +65 -0
  146. package/src/shared/lib/workspace/mobile-dock.ts +55 -0
  147. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  148. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  149. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  150. package/src/shared/types/index.ts +1 -0
  151. package/src/shared/types/waiting.ts +54 -0
  152. package/src/shared/types/workspace.ts +8 -0
  153. package/src/test-support/isolated-db.ts +3 -4
  154. package/src/test-support/silent-socket.ts +69 -0
  155. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -1,5 +1,5 @@
1
1
  import { useRef, useState } from 'preact/hooks';
2
- import { ChevronDown, ChevronRight, ChevronUp, GitBranch, MessageSquare, MoreHorizontal, Plus } from 'lucide-preact';
2
+ import { ChevronDown, ChevronRight, GitBranch, MessageSquare, MoreHorizontal, Plus } from 'lucide-preact';
3
3
  import { useSearchParams } from '@/client/lib/router/search-params';
4
4
  import { MobileSessionRow } from '@/client/components/mobile/mobile-session-sidebar/SessionRow';
5
5
  import { SubagentList } from '@/client/components/common/subagent-list';
@@ -17,6 +17,7 @@ import type { WorkspaceFolderData } from '@/shared/types';
17
17
  import type { BatchBinding } from '@/client/components/common/batch-selection/types';
18
18
  import { folderBatchState } from '@/client/components/common/batch-selection/folder-state';
19
19
  import { SelectionCheckbox } from '@/client/components/common/batch-selection/SelectionCheckbox';
20
+ import { SessionRevealRow } from '@/client/components/mobile/mobile-session-sidebar/RevealRow';
20
21
 
21
22
  interface MobileSessionCategoryProps {
22
23
  folder: WorkspaceFolderData;
@@ -32,6 +33,8 @@ interface MobileSessionCategoryProps {
32
33
  batch?: BatchBinding;
33
34
  /** Start a session selection scoped to this workspace, from its `⋯` menu. */
34
35
  onStartSessionSelection?: (folderId: number) => void;
36
+ /** Drop a held prompt from its row's menu. */
37
+ onCancelWait?: (waitId: string) => void;
35
38
  }
36
39
 
37
40
  export function MobileSessionCategory({
@@ -46,6 +49,7 @@ export function MobileSessionCategory({
46
49
  onToast,
47
50
  batch,
48
51
  onStartSessionSelection,
52
+ onCancelWait,
49
53
  }: MobileSessionCategoryProps) {
50
54
  const { visibleCount, showMore, showLess, canShowLess } = useShowMore();
51
55
  const { refresh } = useSidebarData();
@@ -125,7 +129,7 @@ export function MobileSessionCategory({
125
129
  <div className="mb-4">
126
130
  <div
127
131
  onClick={batch ? undefined : onToggleExpand}
128
- className="flex items-center justify-between pl-4 pr-3 py-2 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
132
+ className="flex items-center justify-between gap-2 py-2 cursor-pointer hover:bg-ink/5 rounded-lg select-none transition-colors"
129
133
  >
130
134
  <div className="flex items-center space-x-2 min-w-0">
131
135
  {folderCheckbox && folderSummary ? (
@@ -138,11 +142,11 @@ export function MobileSessionCategory({
138
142
  />
139
143
  ) : (
140
144
  <>
141
- {/* The folder glyph occupies the same 16px left slot the session rows
142
- give their roster toggle, so the two columns line up down the
143
- drawer — the icon, the chevron and every row's status glyph share
144
- one x. Desktop gets this from one shared slot; the drawer's
145
- padding is what differs, hence the matching `pl-4`. */}
145
+ {/* The folder glyph occupies the same 16px slot the session rows give
146
+ their status/roster glyph, and the row carries NO leading inset of
147
+ its own: the drawer list's `p-3` is the gutter, so this glyph starts
148
+ at the same x as the New Session button above it (12px) and as the
149
+ search field's frame. One left edge down the whole drawer. */}
146
150
  <span className="w-4 h-4 flex items-center justify-center flex-shrink-0">
147
151
  {folder.customIconUrl ? (
148
152
  <img
@@ -227,14 +231,23 @@ export function MobileSessionCategory({
227
231
  )}
228
232
  </div>
229
233
 
230
- {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
234
+ {/* The disclosure glyph's BOX ends on the list's content edge — the
235
+ same x the toolbar row's last control ends on above it — and its
236
+ ink sits 4px inside it (`pr-1`), which is the inset a session
237
+ row's `⋯` / roster chevron uses. So the drawer's right column is
238
+ one line of INK, not just one line of boxes: measured before, the
239
+ folder chevron's ink ran 6px further right than every row glyph
240
+ beneath it. */}
241
+ <span className="flex items-center justify-center pr-1">
242
+ {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
243
+ </span>
231
244
  </>
232
245
  )}
233
246
  </div>
234
247
  </div>
235
248
 
236
249
  {listOpen && (
237
- <div className="mt-0.5 space-y-0.5 pl-2">
250
+ <div className="mt-0.5 space-y-0.5">
238
251
  {visibleSessions.map((session) => {
239
252
  const sessionKey = String(session.id);
240
253
  const isSessionActive = String(activeSessionId) === sessionKey;
@@ -272,6 +285,12 @@ export function MobileSessionCategory({
272
285
  onRenameWithAi={
273
286
  sessionKey.startsWith('new-') ? undefined : () => void handleRenameWithAi(session)
274
287
  }
288
+ waitingFor={session.waitingFor ? { title: session.waitingFor.title } : undefined}
289
+ onCancelWait={
290
+ session.waitingFor
291
+ ? () => onCancelWait?.(session.waitingFor!.waitId)
292
+ : undefined
293
+ }
275
294
  selectable={sessionCheckboxes}
276
295
  selected={Boolean(batch?.selected.includes(sessionKey))}
277
296
  onToggleSelect={batch ? () => batch.toggle(sessionKey) : undefined}
@@ -283,42 +302,13 @@ export function MobileSessionCategory({
283
302
  );
284
303
  })}
285
304
 
286
- {/* Reveal / collapse controls, one row — parity with the desktop
287
- category. Same window, so the phone cannot offer a different
288
- boundary than the sidebar. */}
289
- {(hiddenCount > 0 || canShowLess) && !sessionCheckboxes && (
290
- <div className="flex items-center gap-1">
291
- {hiddenCount > 0 && (
292
- <button
293
- type="button"
294
- onClick={showMore}
295
- className="flex items-center min-w-0 text-left pl-2 pr-3 py-2 text-xs text-ink/60 hover:text-ink"
296
- >
297
- {/* `pl-2` inside the `pl-2` list is what lands this chevron's
298
- centre on the folder header's icon column (16px + 8px), and
299
- the label then starts at 40px — the x a row's title occupies
300
- (16px slot + 8px gap). The `space-x-1.5` this replaced put
301
- both 4px left of the list they belong to. */}
302
- <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">
303
- <ChevronDown size={12} />
304
- </span>
305
- <span className="w-2 shrink-0" />
306
- <span className="truncate">Show more sessions ({hiddenCount})</span>
307
- </button>
308
- )}
305
+ <SessionRevealRow
306
+ hiddenCount={sessionCheckboxes ? 0 : hiddenCount}
307
+ canShowLess={!sessionCheckboxes && canShowLess}
308
+ onShowMore={showMore}
309
+ onShowLess={showLess}
310
+ />
309
311
 
310
- {canShowLess && (
311
- <button
312
- type="button"
313
- onClick={showLess}
314
- className="ml-auto shrink-0 flex items-center gap-1 text-xs text-ink/60 hover:text-ink px-3 py-2"
315
- >
316
- <ChevronUp size={12} />
317
- <span className="truncate">Show less</span>
318
- </button>
319
- )}
320
- </div>
321
- )}
322
312
  </div>
323
313
  )}
324
314
 
@@ -16,6 +16,8 @@ interface MobileSessionListProps {
16
16
  /** Present only while a batch selection is active. */
17
17
  batch?: BatchBinding;
18
18
  onStartSessionSelection?: (folderId: number) => void;
19
+ /** Drop a held prompt from its row's menu. */
20
+ onCancelWait?: (waitId: string) => void;
19
21
  }
20
22
 
21
23
  export function MobileSessionList({
@@ -30,12 +32,17 @@ export function MobileSessionList({
30
32
  onToast,
31
33
  batch,
32
34
  onStartSessionSelection,
35
+ onCancelWait,
33
36
  }: MobileSessionListProps) {
34
37
  // No fade wiring here: the app-wide scrollbar rule reveals this list's bar
35
38
  // from the document's own capture-phase listener (`lib/scrollbar-activity`),
36
39
  // so the list does not need a ref, a handler, or a re-render per scroll.
40
+ // `pl-3 pr-2`, not an even `p-3`: this container scrolls, so its scrollbar is
41
+ // taken out of the box and the same 12px on both sides LOOKS wider on the
42
+ // right. One step less on the right evens the optical margin back up, and it
43
+ // is why the rows below are aligned to 12 and not to 12 + the bar.
37
44
  return (
38
- <div className="flex-1 scrollbar-overlay-container p-3">
45
+ <div className="flex-1 scrollbar-overlay-container pl-3 pr-2 py-3">
39
46
  {folders.length === 0 ? (
40
47
  <div className="text-center py-12 text-xs text-ink/50 italic">
41
48
  No matching sessions found
@@ -55,6 +62,7 @@ export function MobileSessionList({
55
62
  onToast={onToast}
56
63
  batch={batch}
57
64
  onStartSessionSelection={onStartSessionSelection}
65
+ onCancelWait={onCancelWait}
58
66
  />
59
67
  ))
60
68
  )}
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The drawer's reveal / collapse row, below a workspace's sessions.
8
+ *
9
+ * Split out of `Item.tsx` (which is the folder row AND its list) so that file
10
+ * stays under the repo's per-file ceiling. Parity with the desktop category is
11
+ * the whole point: the same window, so the phone cannot offer a different
12
+ * boundary than the sidebar.
13
+ *
14
+ * The chevron starts at the list's own gutter — the same x as a session row's
15
+ * status slot and the folder header's icon — because the list carries no
16
+ * `pl-2` of its own any more. The label then lands on the title column
17
+ * (16px slot + 8px gap).
18
+ */
19
+
20
+ import { ChevronDown, ChevronUp } from 'lucide-preact';
21
+
22
+ interface Props {
23
+ hiddenCount: number;
24
+ canShowLess: boolean;
25
+ onShowMore: () => void;
26
+ onShowLess: () => void;
27
+ }
28
+
29
+ export function SessionRevealRow({ hiddenCount, canShowLess, onShowMore, onShowLess }: Props) {
30
+ if (hiddenCount <= 0 && !canShowLess) return null;
31
+
32
+ return (
33
+ <div className="flex items-center gap-1">
34
+ {hiddenCount > 0 && (
35
+ <button
36
+ type="button"
37
+ onClick={onShowMore}
38
+ className="flex items-center min-w-0 text-left py-2 pr-3 text-xs text-ink/60 hover:text-ink"
39
+ >
40
+ <span className="w-4 h-4 flex-shrink-0 flex items-center justify-center">
41
+ <ChevronDown size={12} />
42
+ </span>
43
+ <span className="w-2 shrink-0" />
44
+ <span className="truncate">Show more sessions ({hiddenCount})</span>
45
+ </button>
46
+ )}
47
+
48
+ {canShowLess && (
49
+ <button
50
+ type="button"
51
+ onClick={onShowLess}
52
+ className="ml-auto shrink-0 flex items-center gap-1 text-xs text-ink/60 hover:text-ink pl-3 py-2"
53
+ >
54
+ <ChevronUp size={12} />
55
+ <span className="truncate">Show less</span>
56
+ </button>
57
+ )}
58
+ </div>
59
+ );
60
+ }
@@ -1,4 +1,4 @@
1
- import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Loader2, MoreHorizontal } from 'lucide-preact';
1
+ import { Check, ChevronDown, ChevronRight, CircleQuestionMark, Hourglass, Loader2, MoreHorizontal } from 'lucide-preact';
2
2
  import type { SessionItemData } from '@/shared/types';
3
3
  import { useInlineRename } from '@/client/hooks/ui/inline-rename';
4
4
  import { useSessionRenaming } from '@/client/hooks/workspace/renaming-sessions';
@@ -28,6 +28,11 @@ export interface MobileSessionRowProps {
28
28
  onRenameWithAi?: () => void;
29
29
  /** Draw a checkbox and let the row's tap toggle the selection instead of
30
30
  * opening the session (batch mode). */
31
+ /** The row's prompt is held until another session finishes (see the desktop
32
+ * row's `waitingFor`: a pending chat has nothing else to show). */
33
+ waitingFor?: { title: string | null };
34
+ /** Drop this row's held prompt, from the row's own menu. */
35
+ onCancelWait?: () => void;
31
36
  selectable?: boolean;
32
37
  selected?: boolean;
33
38
  onToggleSelect?: () => void;
@@ -47,6 +52,8 @@ export function MobileSessionRow({
47
52
  onDelete,
48
53
  onRename,
49
54
  onRenameWithAi,
55
+ waitingFor,
56
+ onCancelWait,
50
57
  selectable = false,
51
58
  selected = false,
52
59
  onToggleSelect,
@@ -65,7 +72,8 @@ export function MobileSessionRow({
65
72
  const renaming = useSessionRenaming(session.id);
66
73
  // One trigger for every row action; also reachable by long-press contextmenu.
67
74
  const menu = useRowMenu();
68
- const hasActions = Boolean(onRename || onArchive || onDelete);
75
+ const hasActions = Boolean(onRename || onArchive || onDelete || onCancelWait);
76
+ const waiting = Boolean(waitingFor);
69
77
 
70
78
  // The roster toggle is the row's LAST element, so its glyph lands in the same
71
79
  // column as the folder header's expand chevron — one vertical line of
@@ -101,6 +109,15 @@ export function MobileSessionRow({
101
109
  data-testid="session-item"
102
110
  data-session-id={String(session.id)}
103
111
  onContextMenu={hasActions && !selectable ? menu.openAtCursor : undefined}
112
+ // The tint covers the WHOLE row — the `⋯` included. It is the row that
113
+ // is active, not the part of it left over after the triggers, and a
114
+ // block that stopped short of the trigger read as a half-painted
115
+ // selection.
116
+ //
117
+ // The gap between the trigger's ink and the block's rounded corner is
118
+ // `pr-1.5` INSIDE the trigger (see below), never padding on this row:
119
+ // a row inset would shrink the tint and pull every trailing glyph 6px
120
+ // left of the folder header's chevron.
104
121
  className={`w-full rounded-lg flex items-center transition-colors ${
105
122
  selectable && selected
106
123
  ? 'bg-ink/10 ring-1 ring-inset ring-ink/20 text-ink'
@@ -110,7 +127,7 @@ export function MobileSessionRow({
110
127
  <button
111
128
  type="button"
112
129
  onClick={selectable ? onToggleSelect : onSelect}
113
- className="flex-1 text-left pl-2 pr-3 py-2 flex items-center justify-between min-w-0"
130
+ className="flex-1 text-left py-2 flex items-center justify-between min-w-0"
114
131
  >
115
132
  <div className="flex items-center min-w-0 pr-2">
116
133
  {/* Status slot, 16px at the same x as the folder header's icon slot,
@@ -135,14 +152,30 @@ export function MobileSessionRow({
135
152
  </span>
136
153
  ) : awaitingInput ? (
137
154
  <CircleQuestionMark size={13} className="text-ink/70 animate-pulse" />
155
+ ) : waiting ? (
156
+ <span role="status" aria-label={`Waiting for ${waitingFor?.title ?? 'another session'}`}>
157
+ <Hourglass size={13} className="text-ink/60 animate-pulse" />
158
+ </span>
138
159
  ) : status === 'stream' ? (
139
- <Loader2 size={13} className="text-ink/50 animate-spin" />
160
+ // Same label the desktop row carries, for the same reason: the
161
+ // phone draws the identical cue, and an unlabelled spinner is
162
+ // both unreachable to a screen reader and unassertable.
163
+ <span role="status" aria-label="Running">
164
+ <Loader2 size={13} className="text-ink/50 animate-spin" />
165
+ </span>
140
166
  ) : status ? (
141
167
  <Check size={13} className="text-ink/50" />
142
168
  ) : null}
143
169
  </span>
144
170
  )}
145
- <span className="text-xs truncate leading-snug ml-2">{title}</span>
171
+ <span className="flex flex-col min-w-0 ml-2 leading-snug">
172
+ <span className="text-xs truncate">{title}</span>
173
+ {waiting && (
174
+ <span className="text-[10px] text-ink/40 truncate">
175
+ waiting for {waitingFor?.title ?? 'another session'}
176
+ </span>
177
+ )}
178
+ </span>
146
179
  </div>
147
180
 
148
181
  <span className="text-[11px] text-ink/45 font-mono flex-shrink-0 ml-2">{timeAgo ?? ''}</span>
@@ -151,10 +184,10 @@ export function MobileSessionRow({
151
184
  {/* One trigger for rename / archive / delete, always visible: a touch
152
185
  surface has no hover, and the always-on action buttons this replaced
153
186
  consumed a third of a phone's row width.
154
- `mr-1` only when the row has NO roster toggle — it reserves that
155
- column so the trailing glyph's centre stays 19px from the row's right
156
- edge whether the last element is this trigger or the chevron. Without
157
- it the glyphs step in and out from row to row. */}
187
+ `pl-1.5 pr-1`: the BOX still ends on the row's trailing edge (so its
188
+ right edge matches the folder header's chevron and the toolbar's
189
+ last control), while the glyph's ink sits 4px inside it — clear of
190
+ the tinted block's rounded corner without shrinking that block. */}
158
191
  {hasActions && !selectable && (
159
192
  <button
160
193
  type="button"
@@ -163,15 +196,14 @@ export function MobileSessionRow({
163
196
  aria-label="Session actions"
164
197
  aria-haspopup="menu"
165
198
  aria-expanded={menu.anchor !== null}
166
- className={`flex-shrink-0 p-2 text-ink/35 hover:text-ink rounded-lg cursor-pointer ${showChevron ? '' : 'mr-1'}`}
199
+ className="flex-shrink-0 py-1.5 pl-1.5 pr-1 text-ink/35 hover:text-ink rounded-lg cursor-pointer"
167
200
  >
168
201
  <MoreHorizontal size={16} />
169
202
  </button>
170
203
  )}
171
204
 
172
- {/* Last element, so its glyph sits in the folder header's chevron column.
173
- `p-1.5 mr-1.5` centres a 14px glyph on that column while keeping a
174
- touch-sized tap target. */}
205
+ {/* Last element, so its glyph shares the trigger's column — the same
206
+ inset pair and the same 4px ink margin. */}
175
207
  {showChevron && !selectable && (
176
208
  <button
177
209
  type="button"
@@ -179,7 +211,7 @@ export function MobileSessionRow({
179
211
  title={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
180
212
  aria-expanded={isExpanded}
181
213
  aria-label={isExpanded ? 'Collapse subagents' : 'Expand subagents'}
182
- className="flex-shrink-0 p-1.5 mr-1.5 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
214
+ className="flex-shrink-0 py-1.5 pl-1.5 pr-1 flex items-center justify-center text-ink/50 hover:text-ink rounded-lg cursor-pointer"
183
215
  >
184
216
  {isExpanded ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
185
217
  </button>
@@ -194,6 +226,7 @@ export function MobileSessionRow({
194
226
  onRenameWithAi={onRenameWithAi}
195
227
  onArchive={onArchive}
196
228
  onDelete={onDelete}
229
+ onCancelWait={onCancelWait}
197
230
  onClose={menu.close}
198
231
  />
199
232
  )}
@@ -16,12 +16,12 @@ export function MobileSessionListSkeleton() {
16
16
  <div className="flex-1 p-3 space-y-4" aria-hidden="true">
17
17
  {PLACEHOLDER_FOLDERS.map((rows, folderIndex) => (
18
18
  <div key={folderIndex} className="space-y-2">
19
- <div className="flex items-center gap-2 px-1 py-2">
19
+ <div className="flex items-center gap-2 py-2">
20
20
  <span className="w-4 h-4 rounded-sm animate-pulse bg-ink/10" />
21
21
  <span className="h-3 w-24 rounded animate-pulse bg-ink/10" />
22
22
  </div>
23
23
  {Array.from({ length: rows }, (_, rowIndex) => (
24
- <div key={rowIndex} className="flex items-center gap-2 rounded px-2 py-2">
24
+ <div key={rowIndex} className="flex items-center gap-2 rounded py-2">
25
25
  <span className="w-4 h-4 rounded-full animate-pulse bg-ink/10" />
26
26
  <span
27
27
  className="h-3 rounded animate-pulse bg-ink/10"
@@ -0,0 +1,89 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone drawer's LEFT dock state.
8
+ *
9
+ * The desktop's left column is a dock — the session list plus whatever plugin
10
+ * panel the user placed beside it, switched by a vertical rail. A phone has no
11
+ * room for a rail, so the same catalog is switched by the chip strip the right
12
+ * drawer uses, and everything that decides WHICH view is on screen lives here:
13
+ *
14
+ * - the catalog is `usePanelCatalog('left')`, the dock's own list (`sessions`
15
+ * first, then the placed plugins in placement order), so this drawer and the
16
+ * desktop column cannot disagree about what lives here;
17
+ * - the selection is PERSISTED per session, like the right drawer's tab, because
18
+ * the drawer unmounts when it closes;
19
+ * - a stored id naming a panel that has moved back to the right resolves to the
20
+ * session list, while one naming nothing at all is returned as-is so the body
21
+ * can say WHY rather than showing a blank column.
22
+ */
23
+
24
+ import type { PanelBodyProps } from '@/client/components/workspace/panels/builtin';
25
+ import { usePanelCatalog, useLeftDockView } from '@/client/components/workspace/plugin-panel/catalog';
26
+ import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
27
+ import { useLeftDock } from '@/client/hooks/workspace/left-dock';
28
+ import { useSessionState } from '@/client/hooks/workspace/session-state';
29
+ import { SESSIONS_VIEW_ID } from '@/shared/lib/workspace/left-dock';
30
+
31
+ export interface MobileDockState {
32
+ /** The strip's chips: `sessions` and every placed panel. */
33
+ catalog: ResolvedPanel[];
34
+ /** The placed panels only — what makes the strip worth drawing. */
35
+ placed: ResolvedPanel[];
36
+ activeView: string;
37
+ setActiveView: (id: string) => void;
38
+ /** True while the session list is the view on screen. */
39
+ showingSessions: boolean;
40
+ activePanel: ResolvedPanel | undefined;
41
+ /** Whether the rail is on at all. */
42
+ enabled: boolean;
43
+ }
44
+
45
+ export function useMobileDock(): MobileDockState {
46
+ const dock = useLeftDock();
47
+ const catalog = usePanelCatalog('left');
48
+ const [stored, setStored] = useSessionState<string>('layout.mobileLeftPanel', SESSIONS_VIEW_ID);
49
+ const activeView = useLeftDockView(stored, dock.enabled);
50
+ const placed = catalog.filter((panel) => panel.id !== SESSIONS_VIEW_ID);
51
+
52
+ return {
53
+ catalog,
54
+ placed,
55
+ activeView,
56
+ setActiveView: setStored,
57
+ showingSessions: activeView === SESSIONS_VIEW_ID,
58
+ activePanel: catalog.find((panel) => panel.id === activeView),
59
+ enabled: dock.enabled,
60
+ };
61
+ }
62
+
63
+ /**
64
+ * The props a placed panel's body receives.
65
+ *
66
+ * The drawer draws its own title row, so `showHeader` is false — a view's own
67
+ * header would be a second one, the same rule the right drawer follows.
68
+ */
69
+ export function dockBodyProps(input: {
70
+ sessionId: number | string | null;
71
+ refreshKey: number;
72
+ rootPath?: string;
73
+ onRefresh?: () => void;
74
+ onOpenFile?: (file: unknown) => void;
75
+ onClose: () => void;
76
+ }): PanelBodyProps {
77
+ return {
78
+ className: 'w-full h-full',
79
+ sessionId: input.sessionId === null ? null : String(input.sessionId),
80
+ enabled: true,
81
+ active: true,
82
+ refreshKey: input.refreshKey,
83
+ ...(input.rootPath ? { rootPath: input.rootPath } : {}),
84
+ ...(input.onRefresh ? { onRefresh: input.onRefresh } : {}),
85
+ ...(input.onOpenFile ? { onOpenFile: input.onOpenFile } : {}),
86
+ onClose: input.onClose,
87
+ showHeader: false,
88
+ };
89
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The phone's panel strip and dock body — the LEFT drawer's half of the panel
8
+ * system, which did not exist before.
9
+ *
10
+ * Three properties, and each was the reported symptom:
11
+ *
12
+ * - A placed plugin panel gets a CHIP in the left drawer. Before this the
13
+ * placement was stored, the desktop drew a button for it, and a phone had no
14
+ * surface at all — the panel was reachable only through the right drawer.
15
+ * - The chip that is ON is the one marked pressed, so the drawer says which
16
+ * panel it is showing.
17
+ * - A stored view naming a panel that no longer resolves draws the NOTICE, not
18
+ * a blank column, and keeps the strip so the session list is reachable.
19
+ *
20
+ * Rendered with `h()` against happy-dom, with the kit's services installed so a
21
+ * plugin row resolves its identity the way the real host resolves it.
22
+ */
23
+
24
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from 'bun:test';
25
+ import { Window } from 'happy-dom';
26
+ import { h, render } from 'preact';
27
+ import { act } from 'preact/test-utils';
28
+ import { configureUiKit } from '@kabmo-labs/kabmo-ui';
29
+ import { makeUiKitServices } from '@kabmo-labs/kabmo-sdk/testing';
30
+ import { PanelChips } from '@/client/components/mobile/panel-chips';
31
+ import { MobileDockBody } from '@/client/components/mobile/mobile-session-sidebar/Dock';
32
+ import type { ResolvedPanel } from '@/client/components/workspace/plugin-panel/catalog';
33
+ import type { PanelBodyProps } from '@/client/components/workspace/panels/builtin';
34
+ import { restoreRealtime, silenceRealtime } from '@/test-support/silent-socket';
35
+
36
+ const DOM_GLOBALS = ['window', 'document', 'navigator', 'Node', 'Element', 'HTMLElement', 'Event', 'MouseEvent'] as const;
37
+ const nativeGlobals: Partial<Record<(typeof DOM_GLOBALS)[number], unknown>> = {};
38
+
39
+ let container: HTMLElement;
40
+
41
+ beforeAll(() => {
42
+ configureUiKit(makeUiKitServices());
43
+ });
44
+
45
+ beforeEach(() => {
46
+ const win = new Window({ url: 'http://localhost' });
47
+ const target = globalThis as unknown as Record<string, unknown>;
48
+ for (const key of DOM_GLOBALS) {
49
+ if (!(key in nativeGlobals)) nativeGlobals[key] = target[key];
50
+ target[key] = (win as unknown as Record<string, unknown>)[key];
51
+ }
52
+ // The dock's body renders panels, and a real panel reads a realtime topic on
53
+ // mount. This window's origin is `localhost`, where nothing listens, so a real
54
+ // dial fails asynchronously — after this suite has restored the globals, which
55
+ // reports as an unhandled error in the NEXT file.
56
+ silenceRealtime();
57
+ container = document.createElement('div');
58
+ document.body.appendChild(container);
59
+ });
60
+
61
+ afterEach(() => {
62
+ render(null, container);
63
+ container.remove();
64
+ restoreRealtime();
65
+ });
66
+
67
+ afterAll(() => {
68
+ const target = globalThis as unknown as Record<string, unknown>;
69
+ for (const key of DOM_GLOBALS) {
70
+ if (key in nativeGlobals) target[key] = nativeGlobals[key];
71
+ else delete target[key];
72
+ }
73
+ });
74
+
75
+ /** A catalog entry with only what the strip and the body read. */
76
+ function panel(id: string, overrides: Partial<ResolvedPanel> = {}): ResolvedPanel {
77
+ return {
78
+ id,
79
+ side: id === 'sessions' ? 'left' : 'left',
80
+ title: `${id} title`,
81
+ label: id,
82
+ name: '',
83
+ builtin: true,
84
+ requiresWorkspace: false,
85
+ liveWhileHidden: false,
86
+ minWidth: 200,
87
+ defaultWidth: 280,
88
+ defaultFraction: 0.25,
89
+ render: () => h('div', { 'data-rendered': id }, `body of ${id}`),
90
+ ...overrides,
91
+ };
92
+ }
93
+
94
+ const body: PanelBodyProps = { enabled: true, active: true, refreshKey: 0 };
95
+
96
+ async function mountStrip(panels: ResolvedPanel[], activeId: string, onSelect = (_id: string) => {}) {
97
+ await act(async () => {
98
+ render(h(PanelChips, { panels, activeId, onSelect }), container);
99
+ });
100
+ }
101
+
102
+ describe('PanelChips', () => {
103
+ test('draws one chip per panel, in the order it is given', async () => {
104
+ await mountStrip([panel('sessions'), panel('plugin:demo:rightPanel', { builtin: false, name: 'demo', title: 'Demo — demo' })], 'sessions');
105
+ const labels = [...container.querySelectorAll('button')].map((el) => el.getAttribute('title'));
106
+ expect(labels).toEqual(['sessions title', 'Demo — demo']);
107
+ });
108
+
109
+ test('the active chip is the pressed one, and only it', async () => {
110
+ await mountStrip([panel('sessions'), panel('plugin:demo:rightPanel', { builtin: false, name: 'demo' })], 'plugin:demo:rightPanel');
111
+ const pressed = [...container.querySelectorAll('button')].map((el) => el.getAttribute('aria-pressed'));
112
+ expect(pressed).toEqual(['false', 'true']);
113
+ });
114
+
115
+ test('a click hands back the panel id, which is what the drawer stores', async () => {
116
+ const picked: string[] = [];
117
+ await mountStrip([panel('sessions'), panel('plugin:demo:rightPanel', { builtin: false, name: 'demo' })], 'sessions', (id) => picked.push(id));
118
+ const chip = [...container.querySelectorAll('button')].at(-1) as HTMLButtonElement;
119
+ await act(async () => chip.click());
120
+ expect(picked).toEqual(['plugin:demo:rightPanel']);
121
+ });
122
+
123
+ test('the Source Control dot is drawn only where it is asked for', async () => {
124
+ // The left catalog never holds a built-in view, so the left drawer passes
125
+ // nothing and must not grow a dot it can never mean.
126
+ await mountStrip([panel('git', { title: 'Source Control', label: 'GIT' })], 'git');
127
+ expect(container.querySelector('[title="Uncommitted changes"]')).toBeNull();
128
+ render(null, container);
129
+ await act(async () => {
130
+ render(h(PanelChips, { panels: [panel('git', { title: 'Source Control', label: 'GIT' })], activeId: 'git', onSelect: () => {}, hasGitChanges: true }), container);
131
+ });
132
+ expect(container.querySelector('[title="Uncommitted changes"]')).not.toBeNull();
133
+ });
134
+ });
135
+
136
+ describe('MobileDockBody', () => {
137
+ test('renders the panel it is handed', async () => {
138
+ await act(async () => {
139
+ render(h(MobileDockBody, { panelKey: 'plugin:demo:rightPanel', panel: panel('plugin:demo:rightPanel', { builtin: false, name: 'demo' }), body }), container);
140
+ });
141
+ expect(container.querySelector('[data-rendered="plugin:demo:rightPanel"]')).not.toBeNull();
142
+ });
143
+
144
+ test('a panel that does not resolve says WHY instead of drawing a blank column', async () => {
145
+ // No provider is installed for this key, so the notice reports "no plugin
146
+ // with this id is installed" — the same component the desktop dock uses.
147
+ await act(async () => {
148
+ render(h(MobileDockBody, { panelKey: 'plugin:gone:rightPanel', panel: undefined, body }), container);
149
+ });
150
+ expect(container.textContent).toContain('Plugin unavailable');
151
+ // The notice names the PLUGIN, not the instance key, and gives the reason.
152
+ expect(container.textContent).toContain('gone');
153
+ expect(container.textContent).toContain('no plugin with this id is installed');
154
+ });
155
+ });