@kabmo-labs/kabmo 0.0.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +16 -11
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +333 -333
- package/dist/client/static/js/index-pdp5dwc8.css +1 -0
- package/dist/client/static/js/{index-4vyzz0z4.js → index-zsskckw7.js} +681 -681
- package/package.json +2 -1
- package/public/kabmo-wordmark.svg +31 -0
- package/src/cli/lib/commands/update.js +17 -2
- package/src/cli/lib/commands/update.test.js +36 -0
- package/src/client/bootstrap.d.ts +8 -0
- package/src/client/components/auth/login-screen/index.test.tsx +93 -0
- package/src/client/components/auth/login-screen/index.tsx +43 -22
- package/src/client/components/auth/login-screen/password-toggle.test.tsx +121 -0
- package/src/client/components/common/batch-selection/BatchActionBar.test.ts +132 -0
- package/src/client/components/common/batch-selection/BatchActionBar.tsx +118 -0
- package/src/client/components/common/batch-selection/BatchDeleteModal.tsx +177 -0
- package/src/client/components/common/batch-selection/SelectionCheckbox.tsx +58 -0
- package/src/client/components/common/batch-selection/folder-state.test.ts +130 -0
- package/src/client/components/common/batch-selection/folder-state.ts +101 -0
- package/src/client/components/common/batch-selection/types.ts +42 -0
- package/src/client/components/common/brand-wordmark/Mark.tsx +62 -0
- package/src/client/components/common/brand-wordmark/index.test.tsx +29 -7
- package/src/client/components/common/brand-wordmark/index.tsx +33 -11
- package/src/client/components/common/brand-wordmark/wordmark-svg.test.ts +88 -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/common/workspace-options-menu/index.tsx +25 -11
- package/src/client/components/layout/session-sidebar/CategoryHeader.tsx +169 -0
- package/src/client/components/layout/session-sidebar/CategoryItem.tsx +72 -91
- package/src/client/components/layout/session-sidebar/Footer.tsx +20 -2
- package/src/client/components/layout/session-sidebar/OverflowMenu.tsx +23 -5
- package/src/client/components/layout/session-sidebar/SessionItem.batch.test.ts +162 -0
- package/src/client/components/layout/session-sidebar/SessionItem.tsx +35 -6
- package/src/client/components/layout/session-sidebar/SessionList.tsx +10 -1
- package/src/client/components/layout/session-sidebar/Toolbar.test.ts +2 -0
- package/src/client/components/layout/session-sidebar/Toolbar.tsx +30 -34
- package/src/client/components/layout/session-sidebar/index.tsx +92 -0
- package/src/client/components/mobile/LayoutWrapper.tsx +32 -1
- package/src/client/components/mobile/SessionSidebar.tsx +83 -0
- package/src/client/components/mobile/mobile-main-view/Header.tsx +27 -5
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +21 -1
- package/src/client/components/mobile/mobile-session-sidebar/Item.tsx +56 -12
- package/src/client/components/mobile/mobile-session-sidebar/List.tsx +8 -0
- package/src/client/components/mobile/mobile-session-sidebar/SessionRow.tsx +27 -6
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.test.ts +106 -0
- package/src/client/components/mobile/mobile-session-sidebar/Toolbar.tsx +178 -93
- 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/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 +73 -0
- package/src/client/hooks/ui/updates/android-download.ts +106 -0
- package/src/client/hooks/workspace/batch-delete-run.ts +95 -0
- package/src/client/hooks/workspace/batch-delete.ts +63 -0
- package/src/client/hooks/workspace/batch-selection.ts +98 -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/client/tailwind.css +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/lifecycle/restart.ts +52 -14
- package/src/server/lib/omp/session/delete-flow.server.ts +101 -0
- package/src/server/lib/updates/android-release.test.ts +92 -0
- package/src/server/lib/updates/android-release.ts +148 -0
- package/src/server/lib/updates/apply.ts +8 -0
- package/src/server/lib/updates/install-steps.ts +81 -0
- package/src/server/lib/updates/install.ts +19 -67
- package/src/server/lib/updates/installed-cli.test.ts +222 -0
- package/src/server/lib/updates/installed-cli.ts +139 -0
- package/src/server/lib/updates/omp.ts +1 -1
- package/src/server/plugins/ssr.ts +9 -2
- package/src/server/routes/folders/batch-delete.ts +68 -0
- package/src/server/routes/folders/index.ts +4 -0
- package/src/server/routes/fs/read.ts +24 -9
- package/src/server/routes/sessions/batch-delete.test.ts +142 -0
- package/src/server/routes/sessions/batch-delete.ts +60 -0
- package/src/server/routes/sessions/delete.ts +13 -51
- package/src/server/routes/sessions/index.ts +5 -0
- package/src/server/routes/updates/android.ts +22 -0
- package/src/server/routes/updates/apply.ts +1 -1
- package/src/server/routes/updates/index.ts +2 -0
- package/src/shared/lib/updates/shell-ua.test.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +36 -0
- package/src/shared/lib/workspace/batch-select.test.ts +92 -0
- package/src/shared/lib/workspace/batch-select.ts +86 -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 +84 -0
- package/dist/client/static/js/index-x9cehh1a.css +0 -1
|
@@ -1,7 +1,14 @@
|
|
|
1
1
|
import type { RefObject } from 'preact/compat';
|
|
2
|
-
import { Archive, ArrowUpDown, Calendar, FolderPlus, Plus, RefreshCw, Search, X } from 'lucide-preact';
|
|
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;
|
|
@@ -24,8 +31,18 @@ export interface MobileSessionToolbarProps {
|
|
|
24
31
|
onHideArchived: () => void;
|
|
25
32
|
onSearchChange: (value: string) => void;
|
|
26
33
|
onClearSearch: () => void;
|
|
34
|
+
/** Enter the workspace batch-selection mode (checkbox on each workspace). */
|
|
35
|
+
onSelectWorkspaces: () => void;
|
|
36
|
+
/** True while a batch selection is active — the control reads pressed. */
|
|
37
|
+
selecting: boolean;
|
|
27
38
|
}
|
|
28
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
|
+
|
|
29
46
|
export function MobileSessionToolbar({
|
|
30
47
|
searchQuery,
|
|
31
48
|
sortOption,
|
|
@@ -45,22 +62,62 @@ export function MobileSessionToolbar({
|
|
|
45
62
|
onHideArchived,
|
|
46
63
|
onSearchChange,
|
|
47
64
|
onClearSearch,
|
|
65
|
+
onSelectWorkspaces,
|
|
66
|
+
selecting,
|
|
48
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
|
+
|
|
49
106
|
return (
|
|
50
|
-
<div className="
|
|
107
|
+
<div className="p-3 border-b border-ink/10 flex flex-col space-y-2.5 flex-shrink-0 bg-canvas">
|
|
51
108
|
{/* Row 1: Primary + New Session Button & Quick Utility Buttons */}
|
|
52
|
-
<div className="flex items-center space-x-2">
|
|
53
|
-
{/* Large Ergonomic New Session Button. Its label
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
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. */}
|
|
64
121
|
<button
|
|
65
122
|
type="button"
|
|
66
123
|
onClick={onNewSession}
|
|
@@ -68,100 +125,128 @@ export function MobileSessionToolbar({
|
|
|
68
125
|
title="New Session"
|
|
69
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"
|
|
70
127
|
>
|
|
71
|
-
<Plus size={15} strokeWidth={2.4} />
|
|
72
|
-
<span className="
|
|
128
|
+
<Plus size={15} strokeWidth={2.4} className="shrink-0" />
|
|
129
|
+
<span className="min-w-0 truncate">New Session</span>
|
|
73
130
|
</button>
|
|
74
131
|
|
|
75
|
-
{
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
+
)}
|
|
85
143
|
|
|
86
144
|
{/* Scheduler button. The badge is the whole point of the count: a
|
|
87
145
|
button that opens a list you cannot see the state of reads as
|
|
88
146
|
decorative, which is exactly what this control used to be. */}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
+
)}
|
|
103
163
|
|
|
104
164
|
{/* Archive toggle — its own button, the same shape as Scheduler's.
|
|
105
165
|
It used to live inside the sort dropdown under a "Filter" heading,
|
|
106
166
|
which made a view filter look like a sort mode: the control that
|
|
107
167
|
decides whether archived rows are on screen was hidden one tap
|
|
108
|
-
deeper than the control that reorders them.
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
+
)}
|
|
181
|
+
|
|
182
|
+
{/* Batch selection over the WORKSPACES. A mode, like search, so it
|
|
183
|
+
sits with the view controls and reports its pressed state — and it
|
|
184
|
+
is the only way into the workspace batch, because a workspace has
|
|
185
|
+
no row of its own to long-press. */}
|
|
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
|
+
)}
|
|
125
198
|
|
|
126
199
|
{/* Refresh sessions button — same action the desktop toolbar's
|
|
127
200
|
RefreshCw performs: refetch the session list from the server.
|
|
128
201
|
The icon spins for the duration of that fetch, so the click has a
|
|
129
202
|
visible end; `refreshing` is raised only by this button's own load. */}
|
|
130
|
-
|
|
131
|
-
type="button"
|
|
132
|
-
onClick={onRefresh}
|
|
133
|
-
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"
|
|
134
|
-
title="Refresh sessions"
|
|
135
|
-
aria-label="Refresh sessions"
|
|
136
|
-
aria-busy={refreshing}
|
|
137
|
-
>
|
|
138
|
-
<RefreshCw size={15} strokeWidth={1.8} className={refreshing ? 'animate-spin' : undefined} />
|
|
139
|
-
</button>
|
|
140
|
-
|
|
141
|
-
{/* Sort Dropdown. Archive is NOT in here any more — it has its own
|
|
142
|
-
button in this row, so this control means one thing (the order of
|
|
143
|
-
the list) and its pressed state reads the sort alone. */}
|
|
144
|
-
<div className="relative flex-shrink-0" ref={optionsRef}>
|
|
203
|
+
{visible['refresh'] && (
|
|
145
204
|
<button
|
|
146
205
|
type="button"
|
|
147
|
-
onClick={
|
|
148
|
-
className={
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
}`}
|
|
153
|
-
title="Sort workspaces"
|
|
154
|
-
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}
|
|
155
211
|
>
|
|
156
|
-
<
|
|
212
|
+
<RefreshCw size={15} strokeWidth={1.8} className={refreshing ? 'animate-spin' : undefined} />
|
|
157
213
|
</button>
|
|
214
|
+
)}
|
|
158
215
|
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
+
)}
|
|
165
250
|
</div>
|
|
166
251
|
|
|
167
252
|
{/* Row 2: Search Input Bar */}
|
|
@@ -192,9 +277,9 @@ export function MobileSessionToolbar({
|
|
|
192
277
|
{sortOption !== 'A-Z' && (
|
|
193
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">
|
|
194
279
|
<span>Sort: {sortOption.replace('_', ' ')}</span>
|
|
195
|
-
<button
|
|
196
|
-
type="button"
|
|
197
|
-
onClick={onResetSort}
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={onResetSort}
|
|
198
283
|
className="hover:text-ink ml-0.5"
|
|
199
284
|
title="Reset sort"
|
|
200
285
|
>
|
|
@@ -206,9 +291,9 @@ export function MobileSessionToolbar({
|
|
|
206
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">
|
|
207
292
|
<Archive size={10} />
|
|
208
293
|
<span>Archived</span>
|
|
209
|
-
<button
|
|
210
|
-
type="button"
|
|
211
|
-
onClick={onHideArchived}
|
|
294
|
+
<button
|
|
295
|
+
type="button"
|
|
296
|
+
onClick={onHideArchived}
|
|
212
297
|
className="hover:text-amber-950 ml-0.5"
|
|
213
298
|
title="Hide archived"
|
|
214
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>
|
|
@@ -23,7 +23,7 @@ import { describe, expect, test } from 'bun:test';
|
|
|
23
23
|
|
|
24
24
|
import { composerExpansion } from '@/client/components/workspace/chat-timeline/chat-input/expansion';
|
|
25
25
|
|
|
26
|
-
const base = { expanded: false, isDragging: false, keyboardInset: 0 } as const;
|
|
26
|
+
const base = { expanded: false, isDragging: false, keyboardInset: 0, compact: false } as const;
|
|
27
27
|
|
|
28
28
|
describe('composerExpansion docked', () => {
|
|
29
29
|
test('a phone keeps its touch-sized box and opens the popup upward', () => {
|
|
@@ -48,6 +48,77 @@ describe('composerExpansion docked', () => {
|
|
|
48
48
|
});
|
|
49
49
|
});
|
|
50
50
|
|
|
51
|
+
/**
|
|
52
|
+
* The composer is a card in the same transcript as the tool calls, so its frame
|
|
53
|
+
* is the tool card's own (`ToolCardShell`): `rounded-xl`, `border-ink/10` on
|
|
54
|
+
* `bg-paper`, darkening to `ink/20` — plus a shadow, which the tool card only
|
|
55
|
+
* grows when expanded but the composer always has, because it is the input the
|
|
56
|
+
* user is looking at. A `rounded-md` here made it read as a smaller, different
|
|
57
|
+
* card sitting under the conversation.
|
|
58
|
+
*/
|
|
59
|
+
describe('composerExpansion frame matches a tool card', () => {
|
|
60
|
+
test('docked: rounded-xl, ink/10 hairline, a resting shadow', () => {
|
|
61
|
+
for (const variant of ['desktop', 'mobile'] as const) {
|
|
62
|
+
const { cardClass } = composerExpansion({ ...base, variant });
|
|
63
|
+
expect(cardClass).toContain('rounded-xl');
|
|
64
|
+
expect(cardClass).not.toContain('rounded-md');
|
|
65
|
+
expect(cardClass).toContain('border-ink/10');
|
|
66
|
+
expect(cardClass).toContain('bg-paper');
|
|
67
|
+
expect(cardClass).toContain('shadow-sm');
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('the border darkens on hover and on focus, never at rest', () => {
|
|
72
|
+
const { cardClass } = composerExpansion({ ...base, variant: 'desktop' });
|
|
73
|
+
expect(cardClass).toContain('hover:border-ink/20');
|
|
74
|
+
expect(cardClass).toContain('focus-within:border-ink/30');
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('a stretched desktop keeps the radius and lifts the shadow one step', () => {
|
|
78
|
+
const { cardClass } = composerExpansion({ ...base, expanded: true, variant: 'desktop' });
|
|
79
|
+
expect(cardClass).toContain('rounded-xl');
|
|
80
|
+
expect(cardClass).toContain('shadow-md');
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
test('a stretched phone drops the radius so the bars follow it', () => {
|
|
84
|
+
const { cardClass } = composerExpansion({ ...base, expanded: true, variant: 'mobile' });
|
|
85
|
+
expect(cardClass).toContain('rounded-none');
|
|
86
|
+
expect(cardClass).toContain('shadow-none');
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The phone's compact composer: a reader scrolled back through the transcript
|
|
92
|
+
* gets the height back. The rules that matter are which layout may shrink (only
|
|
93
|
+
* a docked phone — a desktop sits beside the chat, a stretched one IS the
|
|
94
|
+
* screen) and that it stays a card and a touch target rather than becoming a
|
|
95
|
+
* second, differently shaped control.
|
|
96
|
+
*/
|
|
97
|
+
describe('composerExpansion compact', () => {
|
|
98
|
+
test('a docked phone shrinks to a 44px box, still framed as a card', () => {
|
|
99
|
+
const layout = composerExpansion({ ...base, variant: 'mobile', compact: true });
|
|
100
|
+
// A fixed height, not a min-height: a textarea's own `rows` sets its
|
|
101
|
+
// height, so a minimum alone never shrinks it.
|
|
102
|
+
expect(layout.textareaClass).toContain('h-11');
|
|
103
|
+
expect(layout.textareaClass).not.toContain('min-h-[68px]');
|
|
104
|
+
expect(layout.cardClass).toContain('rounded-xl');
|
|
105
|
+
expect(layout.cardClass).toContain('shadow-sm');
|
|
106
|
+
// The frame's own state does not change: compact is a SIZE, not a style.
|
|
107
|
+
expect(layout.cardClass).toContain('border-ink/10');
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
test('a desktop ignores it: its composer sits beside the chat', () => {
|
|
111
|
+
const layout = composerExpansion({ ...base, variant: 'desktop', compact: true });
|
|
112
|
+
expect(layout.textareaClass).toContain('min-h-[80px]');
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('a stretched phone is the screen, so there is nothing to give back', () => {
|
|
116
|
+
const layout = composerExpansion({ ...base, expanded: true, variant: 'mobile', compact: true });
|
|
117
|
+
expect(layout.cardClass).toContain('rounded-none');
|
|
118
|
+
expect(layout.textareaClass).toContain('h-full');
|
|
119
|
+
});
|
|
120
|
+
});
|
|
121
|
+
|
|
51
122
|
describe('composerExpansion stretched', () => {
|
|
52
123
|
test('a phone takes the viewport, and its popup opens over the draft', () => {
|
|
53
124
|
const layout = composerExpansion({ ...base, expanded: true, variant: 'mobile' });
|
|
@@ -59,6 +59,13 @@ export interface ComposerExpansionInput {
|
|
|
59
59
|
* `useKeyboardInset`. 0 when there is no keyboard (or no `visualViewport`).
|
|
60
60
|
*/
|
|
61
61
|
keyboardInset: number;
|
|
62
|
+
/**
|
|
63
|
+
* A phone reader is scrolled back through the transcript and the draft is
|
|
64
|
+
* empty, so the composer gives the conversation its height back. Only the
|
|
65
|
+
* docked phone layout has anything to give: a desktop composer sits beside
|
|
66
|
+
* the chat rather than over it, and a stretched one is the screen.
|
|
67
|
+
*/
|
|
68
|
+
compact: boolean;
|
|
62
69
|
}
|
|
63
70
|
|
|
64
71
|
export interface ComposerExpansion {
|
|
@@ -88,17 +95,34 @@ export function composerExpansion({
|
|
|
88
95
|
expanded,
|
|
89
96
|
isDragging,
|
|
90
97
|
keyboardInset,
|
|
98
|
+
compact,
|
|
91
99
|
}: ComposerExpansionInput): ComposerExpansion {
|
|
92
100
|
const mobile = variant === 'mobile';
|
|
93
|
-
|
|
101
|
+
// Same frame as a tool card (`ToolCardShell`): `rounded-xl`, `border-ink/10`
|
|
102
|
+
// on `bg-paper`, darkening to `ink/20` under the pointer — the composer is a
|
|
103
|
+
// card in the same transcript, so it must not be a smaller, differently
|
|
104
|
+
// coloured one. `focus-within` is the composer's own analogue of the card's
|
|
105
|
+
// hover, since the interactive surface is the textarea inside it.
|
|
106
|
+
const border = isDragging
|
|
107
|
+
? 'border-ink/40'
|
|
108
|
+
: 'border-ink/10 hover:border-ink/20 focus-within:border-ink/30';
|
|
94
109
|
|
|
95
110
|
if (!expanded) {
|
|
96
111
|
return {
|
|
97
|
-
cardClass: `border rounded-
|
|
112
|
+
cardClass: `border rounded-xl bg-paper shadow-sm ${border}`,
|
|
98
113
|
cardPositionClass: 'relative',
|
|
99
114
|
cardLayoutClass: '',
|
|
115
|
+
// A compact phone composer gives the transcript its height back. `h-11`
|
|
116
|
+
// rather than a `min-h`: a textarea's height comes from its `rows`
|
|
117
|
+
// (two lines by default), so a min-height alone never shrinks it —
|
|
118
|
+
// measured, `min-h-[44px]` still laid out at 64px. The box is fixed at
|
|
119
|
+
// 44px, the smallest reliable touch target, and it never needs to grow:
|
|
120
|
+
// a draft restores the full composer on the same commit the first
|
|
121
|
+
// character lands in it.
|
|
100
122
|
textareaClass: mobile
|
|
101
|
-
?
|
|
123
|
+
? compact
|
|
124
|
+
? 'px-3 py-2 text-base h-11 max-h-40'
|
|
125
|
+
: 'px-3 py-3 text-base min-h-[68px] max-h-40'
|
|
102
126
|
: 'px-3 py-3 text-sm min-h-[80px]',
|
|
103
127
|
wrapperClass: '',
|
|
104
128
|
pickerPlacement: 'up',
|
|
@@ -128,7 +152,7 @@ export function composerExpansion({
|
|
|
128
152
|
}
|
|
129
153
|
|
|
130
154
|
return {
|
|
131
|
-
cardClass: `border rounded-
|
|
155
|
+
cardClass: `border rounded-xl bg-paper shadow-md ${border}`,
|
|
132
156
|
cardPositionClass: 'relative',
|
|
133
157
|
cardLayoutClass: '',
|
|
134
158
|
// A share of the viewport, not of the panel: the dock sits at the bottom of
|