@kabmo-labs/kabmo 0.3.1 → 0.4.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 (135) 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-twsk4wfa.js} +791 -791
  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/WorkspacePanels.tsx +22 -5
  14. package/src/client/components/layout/desktop-layout/index.tsx +88 -61
  15. package/src/client/components/layout/desktop-layout/left-column.test.tsx +192 -0
  16. package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +6 -0
  17. package/src/client/components/layout/session-sidebar/CategoryItem.tsx +9 -0
  18. package/src/client/components/layout/session-sidebar/SessionItem.tsx +30 -5
  19. package/src/client/components/layout/session-sidebar/SessionList.tsx +4 -0
  20. package/src/client/components/layout/session-sidebar/{index.tsx → SessionsView.tsx} +48 -72
  21. package/src/client/components/layout/session-sidebar/new-session-context.test.ts +55 -0
  22. package/src/client/components/mobile/RightSidebar.tsx +8 -5
  23. package/src/client/components/mobile/SessionSidebar.tsx +6 -0
  24. package/src/client/components/mobile/mobile-session-sidebar/Header.tsx +6 -3
  25. package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +34 -44
  26. package/src/client/components/mobile/mobile-session-sidebar/List.tsx +9 -1
  27. package/src/client/components/mobile/mobile-session-sidebar/RevealRow.tsx +60 -0
  28. package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +41 -13
  29. package/src/client/components/mobile/mobile-session-sidebar/Skeleton.tsx +2 -2
  30. package/src/client/components/settings/categories/panel-plugins/InstalledList.tsx +2 -2
  31. package/src/client/components/settings/categories/panel-plugins/LeftDockSettings.tsx +268 -0
  32. package/src/client/components/settings/categories/panel-plugins/Section.tsx +8 -0
  33. package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +69 -1
  34. package/src/client/components/workspace/chat-timeline/MessageItem.gutter.test.ts +118 -0
  35. package/src/client/components/workspace/chat-timeline/MessageItem.tsx +13 -3
  36. package/src/client/components/workspace/chat-timeline/MessageList.tsx +1 -0
  37. package/src/client/components/workspace/chat-timeline/PendingChatView.tsx +106 -0
  38. package/src/client/components/workspace/chat-timeline/SessionSkeleton.tsx +1 -1
  39. package/src/client/components/workspace/chat-timeline/SubagentView.tsx +4 -1
  40. package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +5 -2
  41. package/src/client/components/workspace/chat-timeline/ThinkingSection.tsx +6 -2
  42. package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +1 -1
  43. package/src/client/components/workspace/chat-timeline/ToolCallingSection.test.ts +5 -5
  44. package/src/client/components/workspace/chat-timeline/ToolCallingSection.tsx +5 -2
  45. package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +25 -1
  46. package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +14 -9
  47. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.test.ts +84 -0
  48. package/src/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker.tsx +185 -0
  49. package/src/client/components/workspace/chat-timeline/chat-input/compact.test.tsx +154 -0
  50. package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +22 -21
  51. package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +12 -20
  52. package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +54 -27
  53. package/src/client/components/workspace/chat-timeline/chat-input/props.ts +16 -0
  54. package/src/client/components/workspace/chat-timeline/index.tsx +15 -16
  55. package/src/client/components/workspace/editor/PluginRail.tsx +79 -0
  56. package/src/client/components/workspace/editor/index.tsx +1 -0
  57. package/src/client/components/workspace/editor/plugin-rail.test.tsx +126 -0
  58. package/src/client/components/workspace/model-dropdown/groups.test.ts +60 -0
  59. package/src/client/components/workspace/model-dropdown/groups.ts +7 -1
  60. package/src/client/components/workspace/model-dropdown/use-catalog.ts +8 -2
  61. package/src/client/components/workspace/panels/builtin.tsx +8 -0
  62. package/src/client/components/workspace/plugin-panel/catalog.tsx +237 -0
  63. package/src/client/components/workspace/plugin-panel/resolve.tsx +19 -122
  64. package/src/client/hooks/chat/composer/compact.ts +119 -0
  65. package/src/client/hooks/chat/omp/pending-rows.ts +4 -0
  66. package/src/client/hooks/chat/omp/pending-sessions.ts +47 -0
  67. package/src/client/hooks/chat/omp/pending-sessions.waiting.test.ts +102 -0
  68. package/src/client/hooks/chat/omp/pending-waiting.ts +48 -0
  69. package/src/client/hooks/chat/omp/session-sidebar-controller.ts +29 -0
  70. package/src/client/hooks/chat/timeline/actions.ts +34 -35
  71. package/src/client/hooks/chat/timeline/composer-refs.ts +47 -0
  72. package/src/client/hooks/chat/timeline/enqueue-follow-up.ts +37 -0
  73. package/src/client/hooks/chat/timeline/index.ts +15 -19
  74. package/src/client/hooks/chat/timeline/local-commands.ts +29 -0
  75. package/src/client/hooks/chat/timeline/run-identity.ts +45 -0
  76. package/src/client/hooks/chat/waiting/cancel.ts +34 -0
  77. package/src/client/hooks/chat/waiting/choice.ts +53 -0
  78. package/src/client/hooks/chat/waiting/dispatch.ts +91 -0
  79. package/src/client/hooks/chat/waiting/held.ts +42 -0
  80. package/src/client/hooks/chat/waiting/hold.test.ts +139 -0
  81. package/src/client/hooks/chat/waiting/hold.ts +156 -0
  82. package/src/client/hooks/chat/waiting/store.ts +100 -0
  83. package/src/client/hooks/workspace/left-dock.ts +132 -0
  84. package/src/client/hooks/workspace/panel-registry.ts +13 -0
  85. package/src/client/tailwind.css +22 -0
  86. package/src/server/db.server.test.ts +52 -1
  87. package/src/server/db.server.ts +49 -1
  88. package/src/server/index.ts +9 -0
  89. package/src/server/lib/assets/compressed-cache.server.test.ts +78 -0
  90. package/src/server/lib/assets/compressed-cache.server.ts +88 -0
  91. package/src/server/lib/db/schema.test.ts +1 -0
  92. package/src/server/lib/db/schema.ts +3 -0
  93. package/src/server/lib/fs/fs-path.test.ts +1 -1
  94. package/src/server/lib/omp/rpc/terminal-settle.ts +7 -0
  95. package/src/server/lib/omp/session/delete.server.ts +8 -0
  96. package/src/server/lib/omp/session/ownership.server.test.ts +45 -4
  97. package/src/server/lib/panels/registry.server.ts +2 -2
  98. package/src/server/lib/queue/delivery.server.ts +31 -39
  99. package/src/server/lib/queue/payload.ts +73 -0
  100. package/src/server/lib/realtime/signals.server.ts +10 -1
  101. package/src/server/lib/realtime/topics/catalog.server.ts +2 -0
  102. package/src/server/lib/realtime/topics/global.server.ts +5 -0
  103. package/src/server/lib/realtime/topics.server.ts +2 -0
  104. package/src/server/lib/util/with-timeout.ts +29 -0
  105. package/src/server/lib/waiting/row-clock.server.ts +37 -0
  106. package/src/server/lib/waiting/runtime.server.test.ts +278 -0
  107. package/src/server/lib/waiting/runtime.server.ts +313 -0
  108. package/src/server/lib/waiting/schema.server.ts +46 -0
  109. package/src/server/lib/waiting/settle.test.ts +84 -0
  110. package/src/server/lib/waiting/settle.ts +77 -0
  111. package/src/server/lib/waiting/store.server.test.ts +167 -0
  112. package/src/server/lib/waiting/store.server.ts +268 -0
  113. package/src/server/lib/waiting/sweep-trigger.ts +35 -0
  114. package/src/server/plugins/compress.ts +18 -6
  115. package/src/server/plugins/ssr.ts +16 -8
  116. package/src/server/plugins/static.test.ts +59 -0
  117. package/src/server/plugins/static.ts +56 -14
  118. package/src/server/routes/index.ts +2 -0
  119. package/src/server/routes/waiting/bindings.ts +14 -0
  120. package/src/server/routes/waiting/index.ts +123 -0
  121. package/src/server/routes/waiting/routes.test.ts +121 -0
  122. package/src/shared/lib/chat/timeline/gutter.test.ts +37 -0
  123. package/src/shared/lib/chat/timeline/gutter.ts +37 -0
  124. package/src/shared/lib/realtime/protocol.ts +10 -0
  125. package/src/shared/lib/workspace/left-dock-width.ts +78 -0
  126. package/src/shared/lib/workspace/left-dock.test.ts +203 -0
  127. package/src/shared/lib/workspace/left-dock.ts +197 -0
  128. package/src/shared/lib/workspace/panel-ids.test.ts +59 -10
  129. package/src/shared/lib/workspace/panel-ids.ts +114 -26
  130. package/src/shared/lib/workspace/sidebar-width.ts +4 -0
  131. package/src/shared/types/index.ts +1 -0
  132. package/src/shared/types/waiting.ts +54 -0
  133. package/src/shared/types/workspace.ts +8 -0
  134. package/src/test-support/isolated-db.ts +3 -4
  135. package/dist/client/static/js/index-pdp5dwc8.css +0 -1
@@ -0,0 +1,106 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The PENDING chat's own view: the workspace/wait pickers, its optimistic
8
+ * rows, and the composer it hands them to.
9
+ *
10
+ * Split out of `chat-timeline/index.tsx` for the per-file ceiling, and it is
11
+ * the branch that file used to inline: a `new-…` chat has no omp session, so it
12
+ * renders from the client's own state and its composer is the one that can
13
+ * defer a prompt. Keeping the two branches in one component is what let the
14
+ * pending view drift behind the real one (same composer, different props).
15
+ */
16
+
17
+ import type { Attachment, ChatMessageData, WorkspaceFolderData } from '@/shared/types';
18
+ import type { ApprovalMode } from '@/shared/lib/omp/config/access-mode';
19
+ import type { ComposerModes } from '@/client/components/workspace/chat-timeline/chat-input/modes-props';
20
+ import type { WaitingCandidate } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
21
+ import type { DeferredModelStore } from '@/client/hooks/chat/timeline/deferred-model';
22
+ import { EmptyWorkspacePrompt } from '@/client/components/workspace/chat-timeline/EmptyWorkspacePrompt';
23
+
24
+ export interface PendingChatViewProps {
25
+ className: string;
26
+ folders: WorkspaceFolderData[];
27
+ selectedFolderId: number | null;
28
+ setSelectedFolderId: (id: number | null) => void;
29
+ rootPath: string | null;
30
+ inputValue: string;
31
+ setInputValue: (value: string) => void;
32
+ inputAttachments: Attachment[];
33
+ setInputAttachments: (attachments: Attachment[] | ((prev: Attachment[]) => Attachment[])) => void;
34
+ onSend: (attachments: Attachment[], options?: { steering?: boolean }) => void;
35
+ isGenerating: boolean;
36
+ appSettings: Record<string, unknown>;
37
+ messages: ChatMessageData[];
38
+ provider?: string;
39
+ providerNames?: Record<string, string>;
40
+ modelName?: string;
41
+ modelNames?: Record<string, string>;
42
+ onThinkingLevelChange: (level: string) => void;
43
+ onModelChange: (provider: string, modelId: string) => void;
44
+ sessionModel: { provider: string; modelId: string } | null;
45
+ sessionThinkingLevel?: string | null;
46
+ accessMode: ApprovalMode;
47
+ onAccessModeChange: (mode: ApprovalMode) => void;
48
+ modes?: ComposerModes;
49
+ composerModelRef: { current: { provider: string; modelId: string; thinkingLevel: string } | null };
50
+ deferredComposerPickRef: DeferredModelStore;
51
+ generatingVerb: string;
52
+ variant: 'desktop' | 'mobile';
53
+ onUndo?: (id: string, content?: string) => void;
54
+ onRetry?: (id: string) => void;
55
+ onNewChat?: (content: string, attachments?: Attachment[]) => void;
56
+ streamStatusMap: Record<string, string | undefined>;
57
+ titleForSession?: (id: string) => string | undefined;
58
+ waitingFor: WaitingCandidate | null;
59
+ onWaitingForChange: (candidate: WaitingCandidate | null) => void;
60
+ heldWait: { title: string | null } | null;
61
+ onCancelWait: () => void;
62
+ }
63
+
64
+ export function PendingChatView(props: PendingChatViewProps) {
65
+ return (
66
+ <EmptyWorkspacePrompt
67
+ className={props.className}
68
+ folders={props.folders}
69
+ selectedFolderId={props.selectedFolderId}
70
+ setSelectedFolderId={props.setSelectedFolderId}
71
+ rootPath={props.rootPath}
72
+ inputValue={props.inputValue}
73
+ setInputValue={props.setInputValue}
74
+ inputAttachments={props.inputAttachments}
75
+ setInputAttachments={props.setInputAttachments}
76
+ onSend={props.onSend}
77
+ isGenerating={props.isGenerating}
78
+ appSettings={props.appSettings}
79
+ messages={props.messages}
80
+ provider={props.provider}
81
+ providerNames={props.providerNames}
82
+ modelName={props.modelName}
83
+ modelNames={props.modelNames}
84
+ onThinkingLevelChange={props.onThinkingLevelChange}
85
+ onModelChange={props.onModelChange}
86
+ accessMode={props.accessMode}
87
+ onAccessModeChange={props.onAccessModeChange}
88
+ modes={props.modes}
89
+ composerModelRef={props.composerModelRef}
90
+ deferredComposerPickRef={props.deferredComposerPickRef}
91
+ sessionModel={props.sessionModel}
92
+ sessionThinkingLevel={props.sessionThinkingLevel}
93
+ generatingVerb={props.generatingVerb}
94
+ variant={props.variant}
95
+ onUndo={props.onUndo}
96
+ onRetry={props.onRetry}
97
+ onNewChat={props.onNewChat}
98
+ streamStatusMap={props.streamStatusMap}
99
+ titleForSession={props.titleForSession}
100
+ waitingFor={props.waitingFor}
101
+ onWaitingForChange={props.onWaitingForChange}
102
+ heldWait={props.heldWait}
103
+ onCancelWait={props.onCancelWait}
104
+ />
105
+ );
106
+ }
@@ -50,7 +50,7 @@ export function SessionSkeleton() {
50
50
  return (
51
51
  <div aria-hidden className="flex flex-col h-full min-h-0 overflow-hidden">
52
52
  {/* Timeline body — mirrors the real container's padding in both variants. */}
53
- <div className="flex-1 min-h-0 overflow-hidden px-3 py-3 pb-8 sm:p-4 sm:pb-10">
53
+ <div className="flex-1 min-h-0 overflow-hidden pl-4 pr-3 py-3 pb-8 sm:p-4 sm:pb-10">
54
54
  <div className="mx-auto w-full max-w-[970px]">
55
55
  {rows.map((row, i) => (
56
56
  <SkeletonBubble key={i} row={row} index={i} />
@@ -110,7 +110,9 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
110
110
  {/* Transcript */}
111
111
  <div
112
112
  ref={scrollRef}
113
- className="flex-1 min-h-0 overflow-y-auto overscroll-contain scrollbar-overlay-container p-4 pb-10"
113
+ className={`flex-1 min-h-0 overflow-y-auto overscroll-contain scrollbar-overlay-container ${
114
+ isMobile ? 'pl-4 pr-3 py-3 pb-8' : 'p-4 pb-10'
115
+ }`}
114
116
  >
115
117
  <div className="mx-auto w-full max-w-[970px]">
116
118
  {isLoading && messages.length === 0 ? (
@@ -137,6 +139,7 @@ export function SubagentView({ sessionId, subagent, onBack, provider, providerNa
137
139
  isStreaming={isLoading}
138
140
  isPrevAssistant={isPrevAssistant}
139
141
  userActions={false}
142
+ isMobile={isMobile}
140
143
  className={isNoticeRow(msg) ? 'mt-3 mb-1' : isPrevAssistant ? 'mt-1' : 'mt-3'}
141
144
  />
142
145
  {footer && (
@@ -10,6 +10,7 @@ import { parseGoalNotice, type GoalNoticeData } from '@/shared/lib/omp/mode/noti
10
10
  import { GoalNotice } from '@/client/components/workspace/chat-timeline/GoalNotice';
11
11
  import { TaskResultContent } from '@/client/components/workspace/chat-timeline/TaskResultContent';
12
12
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
13
+ import { transcriptGutter } from '@/shared/lib/chat/timeline/gutter';
13
14
 
14
15
  const KIND_LABEL: Record<GoalNoticeData['kind'], string> = {
15
16
  start: 'Goal started',
@@ -43,12 +44,14 @@ interface SystemNoticeProps {
43
44
  /** The `customType` this notice came from, when it has one — the only way to
44
45
  * recognize a goal's opening turn, whose text is the bare objective. */
45
46
  source?: string;
47
+ /** Phone layout: the card's frame is the timeline gutter itself. */
48
+ isMobile?: boolean;
46
49
  }
47
50
 
48
51
  /** System notice card — handles generic notices, structured <task-result> agent
49
52
  * jobs, and <system-reminder> blocks, all collapsed by default; a goal-mode
50
53
  * injection renders open and has no collapse control (see `forcedOpen`). */
51
- export function SystemNotice({ notice, source }: SystemNoticeProps) {
54
+ export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeProps) {
52
55
  const [userOpen, setUserOpen] = useState(false);
53
56
  useSyntaxReady();
54
57
 
@@ -195,7 +198,7 @@ export function SystemNotice({ notice, source }: SystemNoticeProps) {
195
198
 
196
199
  return (
197
200
  <div
198
- className={`mx-3 overflow-hidden rounded-xl border border-ink/10 bg-paper text-[12px] text-ink transition-colors ${
201
+ className={`${transcriptGutter(isMobile)} overflow-hidden rounded-xl border border-ink/10 bg-paper text-[12px] text-ink transition-colors ${
199
202
  canToggle ? 'hover:border-ink/20' : ''
200
203
  } select-text`}
201
204
  >
@@ -3,17 +3,21 @@ import { BrainCircuit, ChevronDown, Sparkles } from 'lucide-preact';
3
3
  import type { ThinkingData } from '@/shared/types';
4
4
  import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
5
5
  import { CopyButton } from '@/client/components/common/CopyButton';
6
+ import { transcriptGutter } from '@/shared/lib/chat/timeline/gutter';
6
7
 
7
8
  interface ThinkingSectionProps {
8
9
  thinking: ThinkingData | string;
9
10
  defaultExpanded?: boolean;
10
11
  showSeparator?: boolean;
12
+ /** Phone layout: the section's frame is the timeline gutter itself. */
13
+ isMobile?: boolean;
11
14
  }
12
15
 
13
16
  export function ThinkingSection({
14
17
  thinking,
15
18
  defaultExpanded = false,
16
- showSeparator = false
19
+ showSeparator = false,
20
+ isMobile = false,
17
21
  }: ThinkingSectionProps) {
18
22
  const isGenerating = typeof thinking === 'object' ? Boolean(thinking.isGenerating) : false;
19
23
  const [isOpen, setIsOpen] = useState(defaultExpanded || isGenerating);
@@ -43,7 +47,7 @@ export function ThinkingSection({
43
47
  if (!cleanThought) return null;
44
48
 
45
49
  return (
46
- <div className={`mx-3 ${showSeparator ? 'space-y-1.5' : ''}`}>
50
+ <div className={`${transcriptGutter(isMobile)} ${showSeparator ? 'space-y-1.5' : ''}`}>
47
51
  {showSeparator && (
48
52
  <div className="flex items-center gap-2 px-1 pt-0.5">
49
53
  <span className="text-[9.5px] font-semibold uppercase tracking-[0.14em] text-ink/35">
@@ -82,7 +82,7 @@ export function TimelineBody({
82
82
  ref={scrollRef}
83
83
  onScroll={handleScroll}
84
84
  className={`flex-1 scrollbar-overlay-container overscroll-contain scroll-smooth overflow-x-hidden ${
85
- isMobile ? 'px-3 py-3 pb-8' : 'p-4 pb-10'
85
+ isMobile ? 'pl-4 pr-3 py-3 pb-8' : 'p-4 pb-10'
86
86
  }`}
87
87
  >
88
88
  <div ref={contentRef} className="mx-auto w-full max-w-[970px]">
@@ -75,7 +75,7 @@ async function mount(tools: ToolCallData[]) {
75
75
  * cards. A section renders nothing else.
76
76
  */
77
77
  function sequence(el: HTMLElement): Array<{ kind: 'heading' | 'card'; text: string }> {
78
- const section = el.querySelector('div.mx-3')!;
78
+ const section = el.querySelector('[data-tool-section]')!;
79
79
  const items = Array.from(section.children).map((child) => {
80
80
  const cls = (child.className || '').toString();
81
81
  if (cls.includes('overflow-hidden rounded-xl')) {
@@ -109,7 +109,7 @@ describe('ToolCallingSection per-card intent heading', () => {
109
109
  { kind: 'card', text: 'Bash' },
110
110
  ]);
111
111
  // No header row: every direct child is a card.
112
- const children = Array.from(el.querySelector('div.mx-3')!.children) as HTMLElement[];
112
+ const children = Array.from(el.querySelector('[data-tool-section]')!.children) as HTMLElement[];
113
113
  expect(children).toHaveLength(2);
114
114
  expect(children.every((child) => child.className.includes('overflow-hidden rounded-xl'))).toBe(true);
115
115
  });
@@ -126,7 +126,7 @@ describe('ToolCallingSection per-card intent heading', () => {
126
126
  test('a long intent heading is clamped to one line with the full text in title', async () => {
127
127
  const long = 'x'.repeat(200);
128
128
  const el = await mount([tool('a', long)]);
129
- const heading = el.querySelector('div.mx-3 > div > span') as HTMLElement;
129
+ const heading = el.querySelector('[data-tool-section] > div > span') as HTMLElement;
130
130
  expect(heading.textContent).toBe(long);
131
131
  expect(heading.className).toContain('truncate');
132
132
  expect(heading.className).toContain('min-w-0');
@@ -137,7 +137,7 @@ describe('ToolCallingSection per-card intent heading', () => {
137
137
  describe('ToolCallingSection heading rule', () => {
138
138
  test('the intent heading rule fills the row, the way a card heading draws it', async () => {
139
139
  const el = await mount([tool('a', 'Finding PWA manifest')]);
140
- const row = el.querySelector('div.mx-3 > div') as HTMLElement;
140
+ const row = el.querySelector('[data-tool-section] > div') as HTMLElement;
141
141
  const [label, rule] = Array.from(row.children) as HTMLElement[];
142
142
  // The label sizes to its text and the rule takes the remainder, so the line
143
143
  // runs to the panel edge. Two `flex-1` items would split the row 50/50 and
@@ -154,7 +154,7 @@ describe('ToolCallingSection heading rule', () => {
154
154
 
155
155
  test('the rule keeps a minimum width so a long intent cannot collapse it', async () => {
156
156
  const el = await mount([tool('a', 'x'.repeat(400))]);
157
- const row = el.querySelector('div.mx-3 > div') as HTMLElement;
157
+ const row = el.querySelector('[data-tool-section] > div') as HTMLElement;
158
158
  const rule = row.children[1] as HTMLElement;
159
159
  expect(rule.className).toContain('min-w-6');
160
160
  });
@@ -8,10 +8,13 @@ import { useMemo } from 'preact/hooks';
8
8
  import type { AgentActionData, ToolCallData, ToolType } from '@/shared/types';
9
9
  import { ToolCallCard } from '@/client/components/workspace/chat-timeline/ToolCallCard';
10
10
  import { useToolOpenState } from '@/client/hooks/chat/timeline/tool-open';
11
+ import { transcriptGutter } from '@/shared/lib/chat/timeline/gutter';
11
12
 
12
13
  interface ToolCallingSectionProps {
13
14
  tools: (ToolCallData | AgentActionData)[];
14
15
  defaultExpanded?: boolean;
16
+ /** Phone layout: the section's frame is the timeline gutter itself. */
17
+ isMobile?: boolean;
15
18
  }
16
19
 
17
20
  function normalizeToolData(action: ToolCallData | AgentActionData, index: number): ToolCallData {
@@ -53,7 +56,7 @@ function normalizeToolData(action: ToolCallData | AgentActionData, index: number
53
56
  };
54
57
  }
55
58
 
56
- export function ToolCallingSection({ tools, defaultExpanded = false }: ToolCallingSectionProps) {
59
+ export function ToolCallingSection({ tools, defaultExpanded = false, isMobile = false }: ToolCallingSectionProps) {
57
60
  const normalizedTools = useMemo(() => {
58
61
  return (tools || []).map((t, i) => normalizeToolData(t, i));
59
62
  }, [tools]);
@@ -65,7 +68,7 @@ export function ToolCallingSection({ tools, defaultExpanded = false }: ToolCalli
65
68
  if (!normalizedTools || normalizedTools.length === 0) return null;
66
69
 
67
70
  return (
68
- <div className="mx-3 space-y-1.5">
71
+ <div className={`${transcriptGutter(isMobile)} space-y-1.5`} data-tool-section>
69
72
  {normalizedTools.map((tool) => {
70
73
  const intent = tool.intent?.trim();
71
74
  return (
@@ -24,9 +24,17 @@ interface AttachmentToolbarProps {
24
24
  */
25
25
  expanded?: boolean;
26
26
  onToggleExpand?: () => void;
27
+ /**
28
+ * The phone's compact composer: the textarea is gone, so this row carries the
29
+ * affordance that brings it back — a "Type here" target beside the paperclip.
30
+ * Tapping it (or the card) is what expands the composer, which is why it is a
31
+ * button rather than a label.
32
+ */
33
+ compact?: boolean;
34
+ onExpandComposer?: () => void;
27
35
  }
28
36
 
29
- export function AttachmentToolbar({ attachments, onFilesSelected, onRemove, modes, onOpenGoal, expanded, onToggleExpand }: AttachmentToolbarProps) {
37
+ export function AttachmentToolbar({ attachments, onFilesSelected, onRemove, modes, onOpenGoal, expanded, onToggleExpand, compact = false, onExpandComposer }: AttachmentToolbarProps) {
30
38
  const fileInputRef = useRef<HTMLInputElement>(null);
31
39
 
32
40
  const handleFileSelect = (e: ChangeEvent<HTMLInputElement>) => {
@@ -50,6 +58,22 @@ export function AttachmentToolbar({ attachments, onFilesSelected, onRemove, mode
50
58
  <Paperclip size={14} />
51
59
  </button>
52
60
 
61
+ {/* The compact composer's own placeholder. The textarea is not rendered
62
+ at all in that state, so this is the only thing that says the card
63
+ is still a composer — and the only thing a tap can hit to bring the
64
+ box back. It sits right beside the paperclip, where the text used to
65
+ start, so the row keeps its reading order. */}
66
+ {compact && (
67
+ <button
68
+ type="button"
69
+ data-composer-reopen
70
+ onClick={onExpandComposer}
71
+ className="min-w-0 flex-1 text-left text-[13px] text-ink/40 truncate"
72
+ >
73
+ Type here
74
+ </button>
75
+ )}
76
+
53
77
  {attachments.map(att => (
54
78
  <div key={att.id} className="relative flex items-center bg-canvas border border-ink/10 rounded-md p-0.5 pr-6 text-xs shadow-sm group">
55
79
  {att.preview ? (
@@ -102,7 +102,12 @@ export function ComposerToolbar({
102
102
  // No separator and no tinted strip: the composer is one card, so its
103
103
  // toolbar shares the card's surface. `rounded-b-[inherit]` still follows
104
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'}`}
105
+ // `py-1.5`, and the phone's controls are `w-8 h-8`: measured on a Pixel 7,
106
+ // the row was 56px tall for a 40px button, which made the composer's
107
+ // footer read as a second, heavier bar under the draft. A 32px target
108
+ // still clears the 28px minimum for a touch control while bringing the
109
+ // row to 44px — the same height as the compact textarea above it.
110
+ className="@container flex items-center justify-between rounded-b-[inherit] px-3 py-1.5"
106
111
  >
107
112
  <div className={`flex items-center min-w-0 ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
108
113
  {showModel && (
@@ -166,7 +171,7 @@ export function ComposerToolbar({
166
171
  ? 'bg-error text-canvas animate-pulse shadow-xs cursor-pointer'
167
172
  : 'text-ink/50 hover:text-ink hover:bg-ink/10 cursor-pointer'
168
173
  } ${
169
- isMobile ? 'w-10 h-10 rounded-lg' : 'w-7 h-7'
174
+ isMobile ? 'w-8 h-8 rounded-md' : 'w-7 h-7'
170
175
  }`}
171
176
  title={
172
177
  voiceBusy
@@ -177,11 +182,11 @@ export function ComposerToolbar({
177
182
  }
178
183
  >
179
184
  {voiceBusy ? (
180
- <Loader2 size={isMobile ? 16 : 12} className="animate-spin" />
185
+ <Loader2 size={isMobile ? 14 : 12} className="animate-spin" />
181
186
  ) : voiceListening ? (
182
- <MicOff size={isMobile ? 16 : 12} />
187
+ <MicOff size={isMobile ? 14 : 12} />
183
188
  ) : (
184
- <Mic size={isMobile ? 16 : 12} />
189
+ <Mic size={isMobile ? 14 : 12} />
185
190
  )}
186
191
  </button>
187
192
  )}
@@ -192,12 +197,12 @@ export function ComposerToolbar({
192
197
  onClick={onStop}
193
198
  className={`flex items-center justify-center rounded transition-colors cursor-pointer shadow-xs animate-in zoom-in-90 duration-150 flex-shrink-0 ${
194
199
  isMobile
195
- ? 'w-10 h-10 rounded-lg border border-ink/25 text-ink hover:bg-error hover:text-canvas hover:border-error'
200
+ ? 'w-8 h-8 rounded-md border border-ink/25 text-ink hover:bg-error hover:text-canvas hover:border-error'
196
201
  : 'w-7 h-7 bg-ink text-canvas hover:bg-error hover:text-canvas'
197
202
  }`}
198
203
  title="Stop generation"
199
204
  >
200
- <Square size={isMobile ? 14 : 10} className="fill-current" />
205
+ <Square size={isMobile ? 12 : 10} className="fill-current" />
201
206
  </button>
202
207
  )}
203
208
 
@@ -206,12 +211,12 @@ export function ComposerToolbar({
206
211
  onClick={onSend}
207
212
  disabled={sendDisabled}
208
213
  className={`flex items-center justify-center rounded bg-ink text-canvas hover:bg-ink/80 transition-colors disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer flex-shrink-0 ${
209
- isMobile ? 'w-10 h-10 rounded-lg' : 'w-7 h-7'
214
+ isMobile ? 'w-8 h-8 rounded-md' : 'w-7 h-7'
210
215
  }`}
211
216
  title={chatInFlight ? 'Queue follow-up message' : 'Send message'}
212
217
  aria-label={chatInFlight ? 'Queue follow-up message' : 'Send message'}
213
218
  >
214
- <Send size={isMobile ? 16 : 12} className="ml-px" />
219
+ <Send size={isMobile ? 14 : 12} className="ml-px" />
215
220
  </button>
216
221
  </div>
217
222
  </div>
@@ -0,0 +1,84 @@
1
+ /**
2
+ * @license
3
+ * SPDX-License-Identifier: Apache-2.0
4
+ */
5
+
6
+ /**
7
+ * The two client rules that decide whether a prompt is HELD or SENT.
8
+ *
9
+ * Both are about a state the composer cannot see: a dropdown that lists only
10
+ * running sessions can still go stale while the draft is being written, and the
11
+ * pick itself has to survive a remount or the prompt the user deferred is sent
12
+ * immediately — the one outcome the feature exists to prevent.
13
+ */
14
+
15
+ import { describe, expect, test } from 'bun:test';
16
+ import { waitingCandidates } from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
17
+ import type { WorkspaceFolderData } from '@/shared/types/workspace';
18
+
19
+ function folder(id: number, name: string, sessions: { id: string; title: string }[]): WorkspaceFolderData {
20
+ return {
21
+ id,
22
+ name,
23
+ isExpanded: true,
24
+ hasMore: false,
25
+ totalSessions: sessions.length,
26
+ sessions: sessions.map((session) => ({ ...session, folder_id: id })),
27
+ };
28
+ }
29
+
30
+ const folders = [
31
+ folder(1, 'alpha', [{ id: 'sess-a', title: 'Refactor parser' }, { id: 'sess-idle', title: 'Idle chat' }]),
32
+ folder(2, 'beta', [{ id: 'sess-b', title: 'Build pipeline' }]),
33
+ ];
34
+
35
+ describe('waitingCandidates', () => {
36
+ test('offers only sessions with a run in flight', () => {
37
+ const candidates = waitingCandidates(folders, { 'sess-a': 'stream', 'sess-b': 'stream', 'sess-idle': 'finish' }, null);
38
+ expect(candidates.map((candidate) => candidate.id)).toEqual(['sess-a', 'sess-b']);
39
+ });
40
+
41
+ // A finished run's badge is a terminal one-shot; a session whose row says
42
+ // `finish` has nothing to wait for, and offering it would create a prompt the
43
+ // next sweep releases.
44
+ test('a finished or aborted session is not offered', () => {
45
+ expect(waitingCandidates(folders, { 'sess-a': 'finish', 'sess-b': 'abort' }, null)).toEqual([]);
46
+ });
47
+
48
+ test('never offers the session the composer writes to', () => {
49
+ const candidates = waitingCandidates(folders, { 'sess-a': 'stream', 'sess-b': 'stream' }, 'sess-a');
50
+ expect(candidates.map((candidate) => candidate.id)).toEqual(['sess-b']);
51
+ });
52
+
53
+ test('carries the folder name so two similarly titled sessions are told apart', () => {
54
+ const candidates = waitingCandidates(folders, { 'sess-b': 'stream' }, null);
55
+ expect(candidates[0]).toEqual({ id: 'sess-b', title: 'Build pipeline', folderName: 'beta' });
56
+ });
57
+
58
+ // The case the feature exists for, and the one the first version missed: a
59
+ // freshly spawned session has a `stream` row immediately, while omp's
60
+ // transcript — which the sidebar structure is scanned from — lands seconds
61
+ // later. Driving the list off the folders offered nothing at all.
62
+ test('offers a streaming id the structure has not scanned yet', () => {
63
+ const candidates = waitingCandidates(
64
+ [folder(1, 'alpha', [])],
65
+ { 'brand-new-id': 'stream' },
66
+ null,
67
+ (id) => (id === 'brand-new-id' ? 'Just started' : undefined),
68
+ );
69
+ expect(candidates).toEqual([{ id: 'brand-new-id', title: 'Just started', folderName: '' }]);
70
+ });
71
+
72
+ test('a streaming id with no hint is named by its id rather than dropped', () => {
73
+ expect(waitingCandidates([], { 'raw-id': 'stream' }, null)[0]).toEqual({
74
+ id: 'raw-id',
75
+ title: 'raw-id',
76
+ folderName: '',
77
+ });
78
+ });
79
+
80
+ test('a scanned streaming session is not listed twice', () => {
81
+ const candidates = waitingCandidates(folders, { 'sess-a': 'stream' }, null);
82
+ expect(candidates.map((candidate) => candidate.id)).toEqual(['sess-a']);
83
+ });
84
+ });