@kabmo-labs/kabmo 0.1.0 → 0.3.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.
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +285 -285
- package/dist/client/static/js/{index-enkbbffs.js → index-8x5j8zrr.js} +671 -671
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/package.json +1 -1
- package/src/client/bootstrap.d.ts +17 -0
- package/src/client/components/common/header-controls/MoreMenu.tsx +136 -0
- package/src/client/components/common/header-controls/index.test.tsx +77 -11
- package/src/client/components/common/header-controls/index.tsx +53 -4
- package/src/client/components/common/session-state-provider/index.test.ts +22 -1
- package/src/client/components/layout/session-sidebar/Footer.tsx +24 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +2 -33
- package/src/client/components/layout/session-sidebar/index.tsx +8 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +8 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +29 -1
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +104 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +166 -106
- package/src/client/components/workspace/chat-timeline/ComposerDock.tsx +9 -0
- package/src/client/components/workspace/chat-timeline/TimelineBody.tsx +6 -0
- package/src/client/components/workspace/chat-timeline/chat-input/AttachmentToolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/DropOverlay.tsx +1 -1
- package/src/client/components/workspace/chat-timeline/chat-input/GoalBanner.tsx +6 -1
- package/src/client/components/workspace/chat-timeline/chat-input/Toolbar.tsx +4 -1
- package/src/client/components/workspace/chat-timeline/chat-input/VoiceNotice.tsx +2 -2
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.test.ts +72 -1
- package/src/client/components/workspace/chat-timeline/chat-input/expansion.ts +28 -4
- package/src/client/components/workspace/chat-timeline/chat-input/index.tsx +20 -2
- package/src/client/components/workspace/chat-timeline/chat-input/props.ts +7 -0
- package/src/client/components/workspace/chat-timeline/index.tsx +9 -3
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.test.tsx +127 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/OrphanAsks.tsx +71 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/PluginPendingInteractions.test.ts +1 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -2
- package/src/client/components/workspace/git-panel/BranchToolbar.tsx +4 -0
- package/src/client/components/workspace/git-panel/index.test.ts +15 -0
- package/src/client/components/workspace/search-panel/Body.test.ts +149 -0
- package/src/client/components/workspace/search-panel/Body.tsx +53 -9
- package/src/client/data/settings/omp-schema.json +89 -4
- package/src/client/hooks/chat/timeline/ask-frames-grouped.test.ts +46 -2
- package/src/client/hooks/chat/timeline/ask-frames.test.ts +1 -1
- package/src/client/hooks/chat/timeline/ask-frames.ts +79 -29
- package/src/client/hooks/chat/timeline/index.ts +2 -1
- package/src/client/hooks/chat/timeline/scroll-probe.test-util.ts +227 -0
- package/src/client/hooks/chat/timeline/scroll-reading-back.test.ts +67 -0
- package/src/client/hooks/chat/timeline/scroll.test.ts +84 -226
- package/src/client/hooks/chat/timeline/scroll.ts +34 -1
- package/src/client/hooks/ui/media-query.ts +41 -0
- package/src/client/hooks/ui/updates/android-download.test.ts +112 -0
- package/src/client/hooks/ui/updates/android-download.ts +143 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +165 -0
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/session-state/index.ts +24 -1
- package/src/client/hooks/workspace/toolbar-row-width.ts +43 -0
- package/src/client/lib/native/back.test.ts +108 -0
- package/src/client/lib/native/back.ts +60 -0
- package/src/client/main.tsx +6 -0
- package/src/server/lib/fs/git-actions-branch.ts +18 -1
- package/src/server/lib/fs/git-actions-sync.test.ts +144 -0
- package/src/server/lib/fs/git-publish.test.ts +96 -0
- package/src/server/lib/fs/git-publish.ts +35 -3
- package/src/server/lib/fs/listing.test.ts +86 -0
- package/src/server/lib/fs/listing.ts +20 -6
- package/src/server/lib/updates/android-release.test.ts +138 -0
- package/src/server/lib/updates/android-release.ts +183 -0
- package/src/server/plugins/ssr.ts +14 -2
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +14 -11
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +121 -0
- package/src/shared/lib/updates/shell-ua.ts +56 -0
- package/src/shared/lib/workspace/session-state/listeners.ts +38 -0
- package/src/shared/lib/workspace/session-state/replacement.test.ts +121 -0
- package/src/shared/lib/workspace/session-state/store.ts +13 -2
- package/src/shared/lib/workspace/sidebar-toolbar.test.ts +98 -0
- package/src/shared/lib/workspace/sidebar-toolbar.ts +78 -0
- package/dist/client/static/js/index-yn17t04b.css +0 -1
|
@@ -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="
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
screen reader
|
|
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="
|
|
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
|
-
{
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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.
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
selecting
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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={
|
|
173
|
-
className={
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
-
<
|
|
212
|
+
<RefreshCw size={15} strokeWidth={1.8} className={refreshing ? 'animate-spin' : undefined} />
|
|
182
213
|
</button>
|
|
214
|
+
)}
|
|
183
215
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
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
|
|
|
@@ -38,7 +38,10 @@ export function AttachmentToolbar({ attachments, onFilesSelected, onRemove, mode
|
|
|
38
38
|
|
|
39
39
|
return (
|
|
40
40
|
<>
|
|
41
|
-
|
|
41
|
+
{/* No separator and no second surface: the card is ONE colour, so the
|
|
42
|
+
header, the draft and the toolbar read as one panel. A hairline over
|
|
43
|
+
a tinted strip made the composer look like three stacked bars. */}
|
|
44
|
+
<div className="flex items-center flex-wrap gap-1.5 px-3 py-2 text-ink/60">
|
|
42
45
|
<button
|
|
43
46
|
onClick={() => fileInputRef.current?.click()}
|
|
44
47
|
className="flex items-center justify-center hover:bg-ink/5 p-1 rounded transition-colors text-ink/60 hover:text-ink shrink-0"
|
|
@@ -8,7 +8,7 @@ import { UploadCloud } from 'lucide-preact';
|
|
|
8
8
|
export function DropOverlay({ visible }: { visible: boolean }) {
|
|
9
9
|
if (!visible) return null;
|
|
10
10
|
return (
|
|
11
|
-
<div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-
|
|
11
|
+
<div className="pointer-events-none absolute inset-0 z-20 flex flex-col items-center justify-center gap-1 rounded-[inherit] border-2 border-dashed border-ink/40 bg-canvas/90 text-ink">
|
|
12
12
|
<UploadCloud size={18} className="text-ink/60" />
|
|
13
13
|
<span className="text-[11px] font-semibold">Drop files to attach</span>
|
|
14
14
|
<span className="text-[10px] text-ink/50">Folders are expanded into their files</span>
|
|
@@ -138,7 +138,12 @@ export function GoalBanner({ record, running, continuation, evaluating, pending,
|
|
|
138
138
|
|
|
139
139
|
return (
|
|
140
140
|
<div
|
|
141
|
-
|
|
141
|
+
// The strip sits on the card's own surface — no hairline and no tinted
|
|
142
|
+
// band, so the goal row, the draft and the toolbar read as ONE panel
|
|
143
|
+
// rather than three stacked bars. Its state still shows: the icon and
|
|
144
|
+
// the badge carry the tone (`badge`/`tone`), which is the semantic half;
|
|
145
|
+
// the background was only ever separation.
|
|
146
|
+
className="flex items-center gap-2 px-3 py-1.5 text-[11px]"
|
|
142
147
|
data-goal-banner={record.status}
|
|
143
148
|
data-goal-stop={stop ?? undefined}
|
|
144
149
|
>
|
|
@@ -99,7 +99,10 @@ export function ComposerToolbar({
|
|
|
99
99
|
|
|
100
100
|
return (
|
|
101
101
|
<div
|
|
102
|
-
|
|
102
|
+
// No separator and no tinted strip: the composer is one card, so its
|
|
103
|
+
// toolbar shares the card's surface. `rounded-b-[inherit]` still follows
|
|
104
|
+
// the frame (the phone's stretched composer is `rounded-none`).
|
|
105
|
+
className={`@container flex items-center justify-between rounded-b-[inherit] ${isMobile ? 'px-2 py-2' : 'px-3 py-2'}`}
|
|
103
106
|
>
|
|
104
107
|
<div className={`flex items-center min-w-0 ${isMobile ? 'space-x-1' : 'space-x-2'}`}>
|
|
105
108
|
{showModel && (
|
|
@@ -19,7 +19,7 @@ export interface VoiceNoticeProps {
|
|
|
19
19
|
export function VoiceNotice({ status, error }: VoiceNoticeProps) {
|
|
20
20
|
if (status) {
|
|
21
21
|
return (
|
|
22
|
-
<div className="flex items-center gap-1.5
|
|
22
|
+
<div className="flex items-center gap-1.5 px-3 py-1.5 text-[11px] text-ink/60">
|
|
23
23
|
<span className="h-1.5 w-1.5 shrink-0 rounded-full bg-ink/50 animate-pulse" />
|
|
24
24
|
<span className="min-w-0 flex-1 break-words">{status}</span>
|
|
25
25
|
</div>
|
|
@@ -28,7 +28,7 @@ export function VoiceNotice({ status, error }: VoiceNoticeProps) {
|
|
|
28
28
|
|
|
29
29
|
if (error) {
|
|
30
30
|
return (
|
|
31
|
-
<div className="flex items-start gap-1.5
|
|
31
|
+
<div className="flex items-start gap-1.5 bg-error/5 px-3 py-1.5 text-[11px] text-error">
|
|
32
32
|
<AlertTriangle size={11} className="mt-0.5 shrink-0" />
|
|
33
33
|
<span className="min-w-0 flex-1 break-words">{error}</span>
|
|
34
34
|
</div>
|