@kabmo-labs/kabmo 0.8.0 → 0.9.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 +7 -0
- package/dist/client/index.html +1 -1
- package/dist/client/index.js +314 -314
- package/dist/client/static/js/{index-qwpvd9fv.js → index-bpptfkz2.js} +922 -892
- package/dist/client/static/js/index-k315p1e8.css +1 -0
- package/package.json +9 -1
- package/src/client/components/common/code-surface/documents.test.ts +120 -0
- package/src/client/components/common/code-surface/documents.ts +201 -0
- package/src/client/components/common/code-surface/extensions.test.ts +110 -0
- package/src/client/components/common/code-surface/extensions.ts +162 -0
- package/src/client/components/common/code-surface/handle.ts +102 -0
- package/src/client/components/common/code-surface/index.tsx +253 -314
- package/src/client/components/common/code-surface/keymap.test.ts +133 -0
- package/src/client/components/common/code-surface/keymap.ts +267 -0
- package/src/client/components/common/code-surface/marks.ts +74 -0
- package/src/client/components/common/code-surface/shiki.ts +234 -0
- package/src/client/components/common/code-surface/sync.test.ts +185 -0
- package/src/client/components/common/code-surface/sync.ts +58 -0
- package/src/client/components/common/code-surface/theme.test.ts +202 -0
- package/src/client/components/common/code-surface/theme.ts +111 -0
- package/src/client/components/common/editor-view-buttons/index.test.tsx +148 -0
- package/src/client/components/common/editor-view-buttons/index.tsx +109 -0
- package/src/client/components/common/shell-update-button/Modal.tsx +163 -0
- package/src/client/components/common/shell-update-button/index.test.tsx +251 -0
- package/src/client/components/common/shell-update-button/index.tsx +125 -0
- package/src/client/components/common/toolbar-row/index.test.tsx +166 -0
- package/src/client/components/common/toolbar-row/index.tsx +189 -0
- package/src/client/components/layout/desktop-layout/WorkspacePanels.tsx +41 -78
- package/src/client/components/layout/desktop-layout/index.tsx +47 -67
- package/src/client/components/layout/desktop-layout/resizer/index.tsx +6 -3
- package/src/client/components/layout/session-sidebar/Footer.tsx +8 -15
- package/src/client/components/mobile/LayoutWrapper.tsx +1 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullDiff.tsx +3 -0
- package/src/client/components/mobile/mobile-right-sidebar/FullEditor.tsx +63 -38
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.test.tsx +137 -0
- package/src/client/components/mobile/mobile-right-sidebar/OutlineButton.tsx +14 -3
- package/src/client/components/mobile/mobile-session-sidebar/Footer.tsx +9 -21
- package/src/client/components/settings/Modal.tsx +8 -0
- package/src/client/components/settings/categories/AppearanceSettings.tsx +3 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.test.tsx +167 -0
- package/src/client/components/settings/categories/appearance-settings/EditorThemeSection.tsx +184 -0
- package/src/client/components/settings/categories/project-settings/WorktreeSettings.tsx +8 -2
- package/src/client/components/workspace/chat-timeline/EmptyWorkspacePrompt.tsx +16 -8
- package/src/client/components/workspace/chat-timeline/SystemNotice.tsx +9 -4
- package/src/client/components/workspace/chat-timeline/TaskResultContent.tsx +9 -7
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.test.tsx +132 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorkspacePicker.tsx +120 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.test.tsx +319 -0
- package/src/client/components/workspace/chat-timeline/target-picker/WorktreePicker.tsx +285 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/ask-panel/index.test.ts +2 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/hashline-patch/index.tsx +34 -29
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/AstEdit.tsx +11 -9
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Bash.tsx +10 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Edit.tsx +25 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Eval.tsx +8 -11
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.test.ts +114 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/FileMatches.tsx +111 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/Read.tsx +7 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/panels/SearchTool.tsx +9 -59
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ArtifactBody.tsx +2 -7
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.test.ts +27 -28
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/DiffView.tsx +39 -26
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/ExcerptCode.tsx +8 -8
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/HighlightedCode.tsx +35 -0
- package/src/client/components/workspace/chat-timeline/tool-renderers/shared/JsonCodeBlock.tsx +4 -4
- package/src/client/components/workspace/diff-panel/MergeDiffView.tsx +179 -0
- package/src/client/components/workspace/diff-panel/ThemedMergeDiffView.tsx +24 -0
- package/src/client/components/workspace/diff-panel/Toolbar.tsx +224 -186
- package/src/client/components/workspace/diff-panel/ToolbarSegments.tsx +98 -0
- package/src/client/components/workspace/diff-panel/index.tsx +76 -145
- package/src/client/components/workspace/diff-panel/merge.ts +315 -0
- package/src/client/components/workspace/editor/Body.tsx +4 -7
- package/src/client/components/workspace/editor/CommandPalette.tsx +2 -2
- package/src/client/components/workspace/editor/FileDocument.tsx +58 -63
- package/src/client/components/workspace/editor/FilePane.tsx +4 -7
- package/src/client/components/workspace/editor/FindWidget.tsx +7 -2
- package/src/client/components/workspace/editor/Toolbar.tsx +220 -123
- package/src/client/components/workspace/editor/index.tsx +7 -9
- package/src/client/components/workspace/file-explorer/OutlineSection.test.tsx +18 -0
- package/src/client/components/workspace/file-explorer/OutlineSection.tsx +10 -3
- package/src/client/components/workspace/search-panel/Body.tsx +1 -1
- package/src/client/components/workspace/worktree-modal/index.test.tsx +118 -0
- package/src/client/hooks/chat/omp/reconnect.test.ts +338 -0
- package/src/client/hooks/chat/omp/reconnect.ts +187 -0
- package/src/client/hooks/chat/omp/run-liveness.test.ts +71 -0
- package/src/client/hooks/chat/omp/run-liveness.ts +65 -0
- package/src/client/hooks/chat/omp/session-list.activity.test.ts +114 -0
- package/src/client/hooks/chat/omp/session-list.test-util.ts +164 -0
- package/src/client/hooks/chat/omp/session-list.test.ts +30 -150
- package/src/client/hooks/chat/omp/session-list.tsx +69 -9
- package/src/client/hooks/chat/timeline/generating.ts +19 -2
- package/src/client/hooks/chat/timeline/index.ts +19 -24
- package/src/client/hooks/chat/timeline/lost-run.test.ts +329 -0
- package/src/client/hooks/chat/timeline/lost-run.ts +177 -0
- package/src/client/hooks/chat/timeline/session-load.test-util.ts +205 -0
- package/src/client/hooks/chat/timeline/session-load.test.ts +61 -162
- package/src/client/hooks/chat/timeline/session-load.ts +69 -37
- package/src/client/hooks/chat/timeline/session-switch.test.ts +65 -0
- package/src/client/hooks/chat/timeline/session-switch.ts +63 -0
- package/src/client/hooks/code/deferred-highlight.test.ts +182 -0
- package/src/client/hooks/code/deferred-highlight.ts +129 -0
- package/src/client/hooks/editor/find-state.ts +1 -1
- package/src/client/hooks/editor/theme.test.ts +206 -0
- package/src/client/hooks/editor/theme.ts +82 -0
- package/src/client/hooks/editor/use-editor-drop.test.ts +101 -0
- package/src/client/hooks/editor/use-editor-drop.ts +67 -14
- package/src/client/hooks/editor/use-editor-find.ts +7 -7
- package/src/client/hooks/editor/use-outline-view.test.ts +182 -0
- package/src/client/hooks/editor/use-outline-view.ts +94 -7
- package/src/client/hooks/editor/use-scroll-restore.test.ts +55 -2
- package/src/client/hooks/editor/use-scroll-restore.ts +124 -4
- package/src/client/hooks/editor/use-search-reveal.ts +1 -1
- package/src/client/hooks/ui/settings-modal.ts +56 -0
- package/src/client/hooks/ui/updates/shell-download.test.ts +2 -0
- package/src/client/hooks/ui/updates/shell-download.ts +55 -14
- package/src/client/hooks/ui/updates/shell-offer.test.ts +26 -0
- package/src/client/hooks/ui/updates/shell-update.test.ts +140 -0
- package/src/client/hooks/ui/updates/shell-update.ts +119 -0
- package/src/client/hooks/workspace/diff-file.ts +29 -10
- package/src/client/hooks/workspace/diff-git-actions.ts +147 -0
- package/src/client/hooks/workspace/editor-view.ts +61 -0
- package/src/client/hooks/workspace/file-tabs.test.ts +62 -5
- package/src/client/hooks/workspace/file-tabs.ts +24 -11
- package/src/client/hooks/workspace/navbar-session-actions.ts +72 -0
- package/src/client/hooks/workspace/worktree/index.ts +20 -5
- package/src/client/lib/desktop/bridge.ts +45 -0
- package/src/client/lib/search-reveal.ts +1 -1
- package/src/client/main.tsx +5 -1
- package/src/client/tailwind.css +37 -8
- package/src/server/lib/auth/guard.test.ts +28 -0
- package/src/server/lib/auth/guard.ts +10 -0
- package/src/server/lib/fs/worktree/create.ts +2 -3
- package/src/server/lib/fs/worktree/list.ts +13 -0
- package/src/server/lib/fs/worktree/remove.ts +3 -3
- package/src/server/lib/lifecycle/claim-port.test.ts +156 -0
- package/src/server/lib/lifecycle/port-guard.test.ts +20 -164
- package/src/server/lib/lifecycle/port-guard.ts +39 -7
- package/src/server/lib/lifecycle/probe-health.test.ts +166 -0
- package/src/server/lib/lifecycle/probe.test.ts +77 -164
- package/src/server/lib/lifecycle/probe.ts +121 -13
- package/src/server/lib/omp/rpc/frame-fold-signals.test.ts +112 -0
- package/src/server/lib/omp/rpc/frame-fold.ts +10 -5
- package/src/server/lib/omp/session/sidebar-activity.server.test.ts +162 -0
- package/src/server/lib/omp/session/sidebar-activity.server.ts +70 -0
- package/src/server/lib/omp/session/telemetry/compute.ts +13 -1
- package/src/server/lib/omp/session/telemetry.test.ts +33 -0
- package/src/server/lib/realtime/signals.server.ts +10 -0
- package/src/server/lib/realtime/topics/catalog.server.ts +4 -1
- package/src/server/lib/realtime/topics.server.ts +16 -0
- package/src/server/lib/updates/shell-assets.ts +94 -3
- package/src/server/lib/updates/shell-cache.ts +105 -0
- package/src/server/lib/updates/shell-pages.test.ts +33 -2
- package/src/server/lib/updates/shell-pages.ts +17 -5
- package/src/server/lib/updates/shell-release-desktop.test.ts +166 -0
- package/src/server/lib/updates/shell-release.test.ts +10 -27
- package/src/server/lib/updates/shell-release.ts +107 -111
- package/src/server/routes/fs/git-merge.test.ts +11 -1
- package/src/server/routes/fs/read.test.ts +24 -7
- package/src/server/routes/fs/scope.test.ts +15 -10
- package/src/server/routes/fs/scope.ts +3 -14
- package/src/server/routes/updates/android.ts +6 -1
- package/src/server/routes/updates/desktop.ts +29 -7
- package/src/server/routes/updates/ios.ts +5 -1
- package/src/server/routes/worktrees/list.ts +11 -1
- package/src/server/routes/worktrees/routes.test.ts +17 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.test.ts +36 -0
- package/src/shared/lib/chat/timeline/omp-callbacks.ts +18 -0
- package/src/shared/lib/chat/timeline/run-signals.test.ts +2 -0
- package/src/shared/lib/code/editor/comment-syntax.test.ts +37 -0
- package/src/shared/lib/code/editor/comment-syntax.ts +55 -0
- package/src/shared/lib/code/editor/find-replace.test.ts +134 -0
- package/src/shared/lib/code/editor/{find.ts → find-replace.ts} +23 -107
- package/src/shared/lib/code/editor/{find.test.ts → find-scan.test.ts} +19 -73
- package/src/shared/lib/code/editor/find-scan.ts +177 -0
- package/src/shared/lib/code/editor/keymap.ts +3 -3
- package/src/shared/lib/code/editor/lines.test.ts +7 -55
- package/src/shared/lib/code/editor/lines.ts +8 -48
- package/src/shared/lib/code/editor/marks.test.ts +4 -5
- package/src/shared/lib/code/editor/marks.ts +13 -25
- package/src/shared/lib/code/editor/theme-loader.ts +97 -0
- package/src/shared/lib/code/editor/themes.generated.ts +156 -0
- package/src/shared/lib/code/editor/themes.test.ts +165 -0
- package/src/shared/lib/code/editor/themes.ts +165 -0
- package/src/shared/lib/code/highlight-memo.test.ts +103 -0
- package/src/shared/lib/code/highlight-memo.ts +149 -0
- package/src/shared/lib/code/highlighter-warmup.ts +112 -0
- package/src/shared/lib/code/highlighter.ts +3 -1
- package/src/shared/lib/code/idle.ts +37 -0
- package/src/shared/lib/code/outline/rows.test.ts +96 -0
- package/src/shared/lib/code/outline/rows.ts +94 -0
- package/src/shared/lib/code/syntax-highlight.test.ts +1 -79
- package/src/shared/lib/code/syntax-highlight.ts +2 -3
- package/src/shared/lib/fs/file-kind-classify.test.ts +25 -11
- package/src/shared/lib/fs/file-kind.ts +47 -13
- package/src/shared/lib/fs/file-url.test.ts +52 -0
- package/src/shared/lib/fs/file-url.ts +37 -0
- package/src/shared/lib/fs/patch-sides.test.ts +114 -0
- package/src/shared/lib/fs/patch-sides.ts +139 -0
- package/src/shared/lib/realtime/protocol.ts +16 -0
- package/src/shared/lib/ui/toolbar-overflow.test.ts +83 -0
- package/src/shared/lib/ui/toolbar-overflow.ts +86 -0
- package/src/shared/lib/updates/shell-arch.ts +54 -0
- package/src/shared/lib/updates/shell-ua.ts +4 -4
- package/src/shared/lib/workspace/editor-view-mode.test.ts +70 -0
- package/src/shared/lib/workspace/editor-view-mode.ts +48 -0
- package/src/shared/lib/workspace/panel-widths.test.ts +30 -0
- package/src/shared/lib/workspace/panel-widths.ts +25 -5
- package/src/shared/lib/workspace/session-state/cache.ts +159 -0
- package/src/shared/lib/workspace/session-state/harness.test-util.ts +55 -0
- package/src/shared/lib/workspace/session-state/merge.ts +100 -0
- package/src/shared/lib/workspace/session-state/store-load.test.ts +206 -0
- package/src/shared/lib/workspace/session-state/store.test.ts +17 -139
- package/src/shared/lib/workspace/session-state/store.ts +92 -130
- package/src/shared/lib/workspace/workspace-panel-widths.ts +124 -0
- package/src/shared/types/realtime.ts +29 -0
- package/src/shared/types/settings/state.ts +10 -0
- package/src/test-support/loopback.ts +183 -0
- package/dist/client/static/js/index-gtbpewr7.css +0 -1
- package/src/client/components/common/code-editor/handler.test.ts +0 -274
- package/src/client/components/common/code-editor/handler.ts +0 -175
- package/src/client/components/common/code-editor/index.tsx +0 -314
- package/src/client/components/common/code-surface/measure.ts +0 -148
- package/src/client/components/workspace/chat-timeline/target-picker/index.test.tsx +0 -227
- package/src/client/components/workspace/chat-timeline/target-picker/index.tsx +0 -260
- package/src/client/components/workspace/diff-panel/SplitView.tsx +0 -209
- package/src/client/components/workspace/diff-panel/UnifiedView.tsx +0 -150
- package/src/client/hooks/editor/scroll-container.test.ts +0 -102
- package/src/client/hooks/editor/scroll-container.ts +0 -26
- package/src/client/hooks/editor/use-code-window.ts +0 -100
- package/src/client/hooks/editor/use-editor-history.test.ts +0 -216
- package/src/client/hooks/editor/use-editor-history.ts +0 -134
- package/src/client/hooks/editor/use-occurrences.test.ts +0 -236
- package/src/client/hooks/editor/use-occurrences.ts +0 -98
- package/src/shared/lib/code/editor/commands.test.ts +0 -155
- package/src/shared/lib/code/editor/commands.ts +0 -260
- package/src/shared/lib/code/editor/edits.test.ts +0 -100
- package/src/shared/lib/code/editor/edits.ts +0 -122
- package/src/shared/lib/code/editor/history.test.ts +0 -118
- package/src/shared/lib/code/editor/history.ts +0 -101
- package/src/shared/lib/code/editor/layers.test.ts +0 -77
- package/src/shared/lib/code/editor/layers.ts +0 -67
- package/src/shared/lib/code/editor/line-edits.test.ts +0 -131
- package/src/shared/lib/code/editor/line-edits.ts +0 -159
- package/src/shared/lib/code/editor/occurrences.test.ts +0 -117
- package/src/shared/lib/code/editor/occurrences.ts +0 -111
- package/src/shared/lib/code/editor/reset-css.test.ts +0 -35
- package/src/shared/lib/code/editor/reset-css.ts +0 -32
- package/src/shared/lib/code/editor/text-commands.test.ts +0 -178
- package/src/shared/lib/code/editor/text-commands.ts +0 -154
- package/src/shared/lib/code/lazy-window.test.ts +0 -161
- package/src/shared/lib/code/lazy-window.ts +0 -156
- package/src/shared/lib/code/windowed-highlight.ts +0 -114
- package/src/shared/lib/fs/diff-parser.test.ts +0 -116
- package/src/shared/lib/fs/diff-parser.ts +0 -213
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The code editor's colour theme, as a filterable grid of cards.
|
|
8
|
+
*
|
|
9
|
+
* A second axis beside the app palette (see `themes.ts`): the console keeps its
|
|
10
|
+
* own theme and the editor wears whichever TextMate theme the reader works in.
|
|
11
|
+
* `auto` is the first card and the default, so an install that never opens this
|
|
12
|
+
* picker behaves exactly as before.
|
|
13
|
+
*
|
|
14
|
+
* Three properties are load-bearing and each is a bug if dropped:
|
|
15
|
+
*
|
|
16
|
+
* - **The swatch is drawn from the generated colour rows, not from the theme
|
|
17
|
+
* module.** 65 TextMate definitions is 1.3 MB; a grid that fetched them to
|
|
18
|
+
* draw itself would download the whole catalogue to show a colour. The rows
|
|
19
|
+
* carry the surface, the ink and three syntax accents — the colours that
|
|
20
|
+
* actually tell two themes apart at card size.
|
|
21
|
+
* - **The preview is set in the theme's OWN colours**, so an unselected card
|
|
22
|
+
* still shows what selecting it does.
|
|
23
|
+
* - **Selecting is a write, and the write is announced** (`Modal` dispatches
|
|
24
|
+
* `omp:editor-theme-changed`), which is what reaches an editor that is
|
|
25
|
+
* already open — `appSettings` is a boot-time prop and would only apply on a
|
|
26
|
+
* reload.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { useMemo, useState } from 'preact/hooks';
|
|
30
|
+
import { Check, Moon, Palette, Sun } from 'lucide-preact';
|
|
31
|
+
import type { SettingsState } from '@/shared/types';
|
|
32
|
+
import {
|
|
33
|
+
EDITOR_AUTO_THEME_ID,
|
|
34
|
+
EDITOR_THEMES,
|
|
35
|
+
type EditorTheme,
|
|
36
|
+
} from '@/shared/lib/code/editor/themes';
|
|
37
|
+
import { EDITOR_FONT_CHOICES } from '@/shared/lib/code/editor/typography';
|
|
38
|
+
|
|
39
|
+
interface EditorThemeSectionProps {
|
|
40
|
+
settings: SettingsState;
|
|
41
|
+
onUpdate: (updater: Partial<SettingsState> | ((prev: SettingsState) => SettingsState)) => void;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
type VariantFilter = 'all' | 'light' | 'dark';
|
|
45
|
+
|
|
46
|
+
const FILTERS: ReadonlyArray<{ id: VariantFilter; label: string }> = [
|
|
47
|
+
{ id: 'all', label: 'All' },
|
|
48
|
+
{ id: 'light', label: 'Light' },
|
|
49
|
+
{ id: 'dark', label: 'Dark' },
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
/** The face the cards' sample line is set in: the editor's own choice. */
|
|
53
|
+
function sampleFace(font: string): string {
|
|
54
|
+
const choice = EDITOR_FONT_CHOICES.find((entry) => entry.id === font);
|
|
55
|
+
return `"${choice?.id ?? EDITOR_FONT_CHOICES[0].id}", monospace`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function ThemeSwatch({ theme, face }: { theme: EditorTheme; face: string }) {
|
|
59
|
+
return (
|
|
60
|
+
<span
|
|
61
|
+
className="block overflow-hidden rounded border border-ink/10"
|
|
62
|
+
style={{ backgroundColor: theme.bg, color: theme.fg }}
|
|
63
|
+
>
|
|
64
|
+
<span className="block px-1.5 py-1 font-mono text-[10px] leading-[1.4]" style={{ fontFamily: face }}>
|
|
65
|
+
<span style={{ color: theme.keyword }}>const</span> <span>answer</span>{' '}
|
|
66
|
+
<span style={{ color: theme.keyword }}>=</span> <span style={{ color: theme.string }}>42</span>
|
|
67
|
+
<span>{';'}</span>
|
|
68
|
+
<span className="block truncate" style={{ color: theme.comment }}>
|
|
69
|
+
{'// '}
|
|
70
|
+
{theme.label}
|
|
71
|
+
</span>
|
|
72
|
+
</span>
|
|
73
|
+
</span>
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function EditorThemeSection({ settings, onUpdate }: EditorThemeSectionProps) {
|
|
78
|
+
const active = settings.editorTheme || EDITOR_AUTO_THEME_ID;
|
|
79
|
+
const [filter, setFilter] = useState<VariantFilter>('all');
|
|
80
|
+
const face = sampleFace(settings.editorFont);
|
|
81
|
+
|
|
82
|
+
const themes = useMemo(
|
|
83
|
+
() => (filter === 'all' ? EDITOR_THEMES : EDITOR_THEMES.filter((theme) => theme.variant === filter)),
|
|
84
|
+
[filter],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<section className="space-y-3">
|
|
89
|
+
<div>
|
|
90
|
+
<h3 className="font-semibold text-sm uppercase tracking-wider text-ink/80 flex items-center gap-2">
|
|
91
|
+
<Palette size={16} className="text-ink/60" />
|
|
92
|
+
Editor Theme
|
|
93
|
+
</h3>
|
|
94
|
+
<p className="text-[11px] text-ink/60 mt-1">
|
|
95
|
+
Colours for the code editor only — the console around it keeps the theme above. <strong>Auto</strong>{' '}
|
|
96
|
+
follows the app palette; any other choice paints the editor's own surface, gutter, selection and
|
|
97
|
+
syntax, and applies to every open editor without a reload.
|
|
98
|
+
</p>
|
|
99
|
+
</div>
|
|
100
|
+
|
|
101
|
+
<div className="flex items-center gap-1.5">
|
|
102
|
+
{FILTERS.map((option) => (
|
|
103
|
+
<button
|
|
104
|
+
key={option.id}
|
|
105
|
+
type="button"
|
|
106
|
+
onClick={() => setFilter(option.id)}
|
|
107
|
+
className={`rounded-full border px-2.5 py-1 text-[11px] font-medium transition-colors cursor-pointer ${
|
|
108
|
+
filter === option.id
|
|
109
|
+
? 'border-ink bg-ink text-paper'
|
|
110
|
+
: 'border-ink/20 bg-paper text-ink/70 hover:border-ink/40 hover:text-ink'
|
|
111
|
+
}`}
|
|
112
|
+
>
|
|
113
|
+
{option.id === 'light' && <Sun size={11} className="mr-1 inline align-[-1px]" />}
|
|
114
|
+
{option.id === 'dark' && <Moon size={11} className="mr-1 inline align-[-1px]" />}
|
|
115
|
+
{option.label}
|
|
116
|
+
</button>
|
|
117
|
+
))}
|
|
118
|
+
<span className="ml-auto font-mono text-[11px] text-ink/50">
|
|
119
|
+
{filter === 'all' ? `${themes.length} themes` : `${themes.length} shown`}
|
|
120
|
+
</span>
|
|
121
|
+
</div>
|
|
122
|
+
|
|
123
|
+
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
|
124
|
+
{/* `Auto` is a card rather than a checkbox: it is one of the choices,
|
|
125
|
+
and a checkbox beside a grid makes the default look like an
|
|
126
|
+
override of the thing it is a peer of. It is drawn under EVERY
|
|
127
|
+
filter — it is neither light nor dark, and a user who filtered to
|
|
128
|
+
Dark must still be able to get back to the app palette without
|
|
129
|
+
first clearing the filter. */}
|
|
130
|
+
{(
|
|
131
|
+
<button
|
|
132
|
+
type="button"
|
|
133
|
+
onClick={() => onUpdate({ editorTheme: EDITOR_AUTO_THEME_ID })}
|
|
134
|
+
aria-pressed={active === EDITOR_AUTO_THEME_ID}
|
|
135
|
+
className={`flex w-full flex-col gap-2 rounded-lg border p-2 text-left transition-colors cursor-pointer ${
|
|
136
|
+
active === EDITOR_AUTO_THEME_ID
|
|
137
|
+
? 'border-ink bg-ink/5 ring-1 ring-ink/20'
|
|
138
|
+
: 'border-ink/20 bg-paper hover:border-ink/40 hover:bg-ink/5'
|
|
139
|
+
}`}
|
|
140
|
+
>
|
|
141
|
+
<span className="flex items-center gap-2">
|
|
142
|
+
<span className="truncate font-semibold text-ink">Auto</span>
|
|
143
|
+
<span className="shrink-0 rounded-full border border-ink/15 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-ink/50">
|
|
144
|
+
app palette
|
|
145
|
+
</span>
|
|
146
|
+
{active === EDITOR_AUTO_THEME_ID && <Check size={14} className="ml-auto shrink-0 text-ink" />}
|
|
147
|
+
</span>
|
|
148
|
+
<span className="flex h-8 items-center justify-center rounded border border-dashed border-ink/20 text-[10px] text-ink/50">
|
|
149
|
+
Follows the theme above
|
|
150
|
+
</span>
|
|
151
|
+
</button>
|
|
152
|
+
)}
|
|
153
|
+
|
|
154
|
+
{themes.map((theme) => {
|
|
155
|
+
const selected = active === theme.id;
|
|
156
|
+
return (
|
|
157
|
+
<button
|
|
158
|
+
key={theme.id}
|
|
159
|
+
type="button"
|
|
160
|
+
onClick={() => onUpdate({ editorTheme: theme.id })}
|
|
161
|
+
aria-pressed={selected}
|
|
162
|
+
className={`flex w-full flex-col gap-2 rounded-lg border p-2 text-left transition-colors cursor-pointer ${
|
|
163
|
+
selected
|
|
164
|
+
? 'border-ink bg-ink/5 ring-1 ring-ink/20'
|
|
165
|
+
: 'border-ink/20 bg-paper hover:border-ink/40 hover:bg-ink/5'
|
|
166
|
+
}`}
|
|
167
|
+
>
|
|
168
|
+
<span className="flex items-center gap-2">
|
|
169
|
+
<span className="truncate font-semibold text-ink" title={theme.label}>
|
|
170
|
+
{theme.label}
|
|
171
|
+
</span>
|
|
172
|
+
<span className="shrink-0 rounded-full border border-ink/15 px-1.5 py-px text-[9px] font-semibold uppercase tracking-wider text-ink/50">
|
|
173
|
+
{theme.variant}
|
|
174
|
+
</span>
|
|
175
|
+
{selected && <Check size={14} className="ml-auto shrink-0 text-ink" />}
|
|
176
|
+
</span>
|
|
177
|
+
<ThemeSwatch theme={theme} face={face} />
|
|
178
|
+
</button>
|
|
179
|
+
);
|
|
180
|
+
})}
|
|
181
|
+
</div>
|
|
182
|
+
</section>
|
|
183
|
+
);
|
|
184
|
+
}
|
|
@@ -34,7 +34,7 @@ export interface WorktreeSettingsProps {
|
|
|
34
34
|
}
|
|
35
35
|
|
|
36
36
|
export function WorktreeSettings({ root, folderId, onReport }: WorktreeSettingsProps) {
|
|
37
|
-
const { worktrees, base, loading, error, refresh } = useWorktrees({ root, folderId }, Boolean(root));
|
|
37
|
+
const { worktrees, base, loading, error, isRepo, refresh } = useWorktrees({ root, folderId }, Boolean(root));
|
|
38
38
|
const [directory, setDirectory] = useState(() => {
|
|
39
39
|
const stored = readKabmoSetting<string>(WORKTREE_DIRECTORY_SETTING);
|
|
40
40
|
return typeof stored === 'string' ? stored : '';
|
|
@@ -62,7 +62,13 @@ export function WorktreeSettings({ root, folderId, onReport }: WorktreeSettingsP
|
|
|
62
62
|
Worktrees could not be read: {error}
|
|
63
63
|
</p>
|
|
64
64
|
)}
|
|
65
|
-
{!loading && !error &&
|
|
65
|
+
{!loading && !error && isRepo === false && (
|
|
66
|
+
// The list is empty for a reason the row has to NAME: a folder that
|
|
67
|
+
// is not a repository can never have a worktree, and "No worktrees
|
|
68
|
+
// yet" would read as one that could be created.
|
|
69
|
+
<p className="text-[11px] text-ink/50">Worktrees need a Git repository.</p>
|
|
70
|
+
)}
|
|
71
|
+
{!loading && !error && isRepo !== false && removable.length === 0 && (
|
|
66
72
|
<p className="text-[11px] text-ink/50">No worktrees yet.</p>
|
|
67
73
|
)}
|
|
68
74
|
{removable.map((worktree) => (
|
|
@@ -14,7 +14,8 @@ import {
|
|
|
14
14
|
type WaitingCandidate,
|
|
15
15
|
} from '@/client/components/workspace/chat-timeline/chat-input/WaitingDependencyPicker';
|
|
16
16
|
import { Hourglass } from 'lucide-preact';
|
|
17
|
-
import {
|
|
17
|
+
import { WorkspacePicker } from '@/client/components/workspace/chat-timeline/target-picker/WorkspacePicker';
|
|
18
|
+
import { WorktreePicker } from '@/client/components/workspace/chat-timeline/target-picker/WorktreePicker';
|
|
18
19
|
import type { SpawnTarget } from '@/client/hooks/chat/timeline/spawn-target';
|
|
19
20
|
|
|
20
21
|
interface EmptyWorkspacePromptProps {
|
|
@@ -164,19 +165,26 @@ export function EmptyWorkspacePrompt({
|
|
|
164
165
|
a target workspace, so the composer is the only focus. */}
|
|
165
166
|
{messages.length === 0 && (
|
|
166
167
|
<div className="flex items-center gap-1 flex-wrap">
|
|
167
|
-
{/*
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
{/* Two controls, because they answer two questions: the workspace
|
|
169
|
+
(which project) and the worktree (which checkout of it). The
|
|
170
|
+
worktree control draws itself only for a git repository, and it
|
|
171
|
+
reads its list on mount because its own trigger names the branch
|
|
172
|
+
the project root holds. */}
|
|
173
|
+
<WorkspacePicker
|
|
174
|
+
folders={folders as WorkspaceFolderData[]}
|
|
175
|
+
selectedFolderId={selectedFolderId}
|
|
176
|
+
setSelectedFolderId={setSelectedFolderId}
|
|
177
|
+
onClearWorktree={() => onSpawnTargetChange?.(null)}
|
|
178
|
+
/>
|
|
179
|
+
{onSpawnTargetChange && (
|
|
180
|
+
<WorktreePicker
|
|
172
181
|
folders={folders as WorkspaceFolderData[]}
|
|
173
182
|
selectedFolderId={selectedFolderId}
|
|
174
|
-
setSelectedFolderId={setSelectedFolderId}
|
|
175
183
|
target={spawnTarget}
|
|
176
184
|
setTarget={onSpawnTargetChange}
|
|
177
185
|
onReport={onWorktreeReport}
|
|
178
186
|
/>
|
|
179
|
-
)
|
|
187
|
+
)}
|
|
180
188
|
{/* The wait picker sits BESIDE the workspace picker, because both
|
|
181
189
|
answer the same question about the chat that has not started:
|
|
182
190
|
where it runs, and what it waits for. It only appears while there
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { useMemo, useRef, useState } from 'preact/hooks';
|
|
2
2
|
import { AlertCircle, Bell, Bot, CheckCircle2, ChevronDown, Clock, Info, Layers, Target } from 'lucide-preact';
|
|
3
3
|
import { isCodeLike } from '@/shared/lib/code/language';
|
|
4
|
-
import {
|
|
5
|
-
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
4
|
+
import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
|
|
6
5
|
import { useIsTruncated } from '@/client/hooks/ui/text-overflow';
|
|
7
6
|
import { parseTaskNotice } from '@/shared/lib/chat/task-result-parser';
|
|
8
7
|
import { isReminderTag, stripNoticeTags, unwrapXmlEnvelope } from '@/shared/lib/chat/xml-envelope';
|
|
@@ -53,7 +52,6 @@ interface SystemNoticeProps {
|
|
|
53
52
|
* injection renders open and has no collapse control (see `forcedOpen`). */
|
|
54
53
|
export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeProps) {
|
|
55
54
|
const [userOpen, setUserOpen] = useState(false);
|
|
56
|
-
useSyntaxReady();
|
|
57
55
|
|
|
58
56
|
// 1. Check if notice contains a structured <task-result> block
|
|
59
57
|
const taskNotice = useMemo(() => parseTaskNotice(notice), [notice]);
|
|
@@ -115,6 +113,13 @@ export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeP
|
|
|
115
113
|
};
|
|
116
114
|
}, [notice]);
|
|
117
115
|
|
|
116
|
+
// Deferred and gated: the code branch only renders while the card is open, so
|
|
117
|
+
// a collapsed notice computes nothing and an opened one paints its text first.
|
|
118
|
+
const codeHtml = useDeferredHighlight(
|
|
119
|
+
genericInfo.isCode ? genericInfo.rest || genericInfo.clean : '',
|
|
120
|
+
'javascript',
|
|
121
|
+
);
|
|
122
|
+
|
|
118
123
|
const rawTitle = goalNotice
|
|
119
124
|
? goalNoticeTitle(goalNotice)
|
|
120
125
|
: taskNotice?.intro || reminderInfo?.title || genericInfo.firstLine || notice;
|
|
@@ -284,7 +289,7 @@ export function SystemNotice({ notice, source, isMobile = false }: SystemNoticeP
|
|
|
284
289
|
<pre
|
|
285
290
|
className="max-h-56 overflow-auto font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-ink/75 scrollbar-overlay-container"
|
|
286
291
|
dangerouslySetInnerHTML={{
|
|
287
|
-
__html:
|
|
292
|
+
__html: codeHtml,
|
|
288
293
|
}}
|
|
289
294
|
/>
|
|
290
295
|
) : (
|
|
@@ -4,15 +4,13 @@ import type { ParsedTaskNotice } from '@/shared/lib/chat/task-result-parser';
|
|
|
4
4
|
import { MarkdownRenderer } from '@/client/components/common/MarkdownRenderer';
|
|
5
5
|
import { CopyButton } from '@/client/components/common/CopyButton';
|
|
6
6
|
import { isCodeLike } from '@/shared/lib/code/language';
|
|
7
|
-
import {
|
|
8
|
-
import { useSyntaxReady } from '@/client/hooks/ui/syntax-ready';
|
|
7
|
+
import { useDeferredHighlight } from '@/client/hooks/code/deferred-highlight';
|
|
9
8
|
|
|
10
9
|
interface TaskResultContentProps {
|
|
11
10
|
task: ParsedTaskNotice;
|
|
12
11
|
}
|
|
13
12
|
|
|
14
13
|
export function TaskResultContent({ task }: TaskResultContentProps) {
|
|
15
|
-
useSyntaxReady();
|
|
16
14
|
const structured = task.structuredOutput;
|
|
17
15
|
const isRawCode = !task.formattedJson && isCodeLike(task.rawOutput);
|
|
18
16
|
|
|
@@ -20,6 +18,13 @@ export function TaskResultContent({ task }: TaskResultContentProps) {
|
|
|
20
18
|
structured || !isRawCode ? 'formatted' : 'raw'
|
|
21
19
|
);
|
|
22
20
|
|
|
21
|
+
// Gated on the tab that draws it: the raw view is one of two, and tokenizing
|
|
22
|
+
// it while the formatted view is on screen is work nobody sees.
|
|
23
|
+
const rawHtml = useDeferredHighlight(
|
|
24
|
+
activeTab === 'raw' ? task.formattedJson || task.rawOutput : '',
|
|
25
|
+
task.formattedJson ? 'json' : isRawCode ? 'javascript' : 'markdown',
|
|
26
|
+
);
|
|
27
|
+
|
|
23
28
|
return (
|
|
24
29
|
<div className="space-y-3">
|
|
25
30
|
{/* Top action toolbar / tab toggles */}
|
|
@@ -151,10 +156,7 @@ export function TaskResultContent({ task }: TaskResultContentProps) {
|
|
|
151
156
|
<pre
|
|
152
157
|
className="max-h-64 overflow-auto rounded-lg border border-ink/8 bg-paper p-3 font-mono text-[11px] leading-relaxed whitespace-pre-wrap break-words text-ink/80 scrollbar-overlay-container"
|
|
153
158
|
dangerouslySetInnerHTML={{
|
|
154
|
-
__html:
|
|
155
|
-
task.formattedJson || task.rawOutput,
|
|
156
|
-
task.formattedJson ? 'json' : isRawCode ? 'javascript' : 'markdown'
|
|
157
|
-
),
|
|
159
|
+
__html: rawHtml,
|
|
158
160
|
}}
|
|
159
161
|
/>
|
|
160
162
|
)}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The composer's WORKSPACE picker, rendered.
|
|
8
|
+
*
|
|
9
|
+
* The cases a reader would otherwise find only in a browser:
|
|
10
|
+
*
|
|
11
|
+
* - the trigger names the selected folder, and says what it is waiting for
|
|
12
|
+
* when nothing is selected;
|
|
13
|
+
* - the menu lists folders and NOTHING about worktrees — the split is the
|
|
14
|
+
* point of this control, and a worktree row here is the regression;
|
|
15
|
+
* - choosing another folder clears the worktree pick, because a checkout of
|
|
16
|
+
* the previous project names a path the new one cannot resolve.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import { afterAll, afterEach, beforeAll, describe, expect, test } from 'bun:test';
|
|
20
|
+
import { Window } from 'happy-dom';
|
|
21
|
+
import { h, render } from 'preact';
|
|
22
|
+
import { act } from 'preact/test-utils';
|
|
23
|
+
import { installDomGlobals, restoreDomGlobals } from '@/test-support/pristine-globals';
|
|
24
|
+
import { WorkspacePicker, workspaceLabel } from '@/client/components/workspace/chat-timeline/target-picker/WorkspacePicker';
|
|
25
|
+
import type { WorkspaceFolderData } from '@/shared/types';
|
|
26
|
+
|
|
27
|
+
const folder = (id: number, name: string): WorkspaceFolderData => ({
|
|
28
|
+
id,
|
|
29
|
+
name,
|
|
30
|
+
project_path: `/repo/${name}`,
|
|
31
|
+
isExpanded: true,
|
|
32
|
+
hasMore: false,
|
|
33
|
+
totalSessions: 0,
|
|
34
|
+
sessions: [],
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const mounted: HTMLElement[] = [];
|
|
38
|
+
|
|
39
|
+
beforeAll(() => {
|
|
40
|
+
installDomGlobals(new Window({ url: 'http://localhost' }));
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
for (const host of mounted.splice(0)) {
|
|
45
|
+
render(null, host);
|
|
46
|
+
host.remove();
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterAll(() => {
|
|
51
|
+
restoreDomGlobals();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
async function mount(node: h.JSX.Element): Promise<HTMLElement> {
|
|
55
|
+
const host = document.createElement('div');
|
|
56
|
+
document.body.appendChild(host);
|
|
57
|
+
await act(async () => {
|
|
58
|
+
render(node, host);
|
|
59
|
+
});
|
|
60
|
+
mounted.push(host);
|
|
61
|
+
return host;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function textOf(host: HTMLElement): string {
|
|
65
|
+
return (host.textContent ?? '').replace(/\s+/g, ' ').trim();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
describe('workspaceLabel', () => {
|
|
69
|
+
test('the selected folder', () => {
|
|
70
|
+
expect(workspaceLabel([folder(1, 'kabmo')], 1)).toBe('kabmo');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('what it is waiting for, with nothing selected', () => {
|
|
74
|
+
expect(workspaceLabel([], null)).toBe('Select Workspace Context');
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe('WorkspacePicker', () => {
|
|
79
|
+
test('the menu lists the workspaces and nothing about worktrees', async () => {
|
|
80
|
+
const host = await mount(
|
|
81
|
+
h(WorkspacePicker, {
|
|
82
|
+
folders: [folder(1, 'kabmo'), folder(2, 'notes')],
|
|
83
|
+
selectedFolderId: 1,
|
|
84
|
+
setSelectedFolderId: () => {},
|
|
85
|
+
}),
|
|
86
|
+
);
|
|
87
|
+
|
|
88
|
+
await act(async () => {
|
|
89
|
+
(host.querySelector('button') as HTMLButtonElement).click();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const text = textOf(host);
|
|
93
|
+
expect(text).toContain('Target Workspace');
|
|
94
|
+
expect(text).toContain('kabmo');
|
|
95
|
+
expect(text).toContain('notes');
|
|
96
|
+
// The split: a worktree row here would be the merged menu coming back.
|
|
97
|
+
expect(text).not.toContain('Quick worktree');
|
|
98
|
+
expect(text).not.toContain('New worktree');
|
|
99
|
+
expect(text).not.toContain('Worktrees');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('choosing a folder clears the worktree pick', async () => {
|
|
103
|
+
// A record rather than two locals: a value assigned inside a callback is
|
|
104
|
+
// still narrowed to its initializer by control-flow analysis, so `picked`
|
|
105
|
+
// would be `null` at the assertion.
|
|
106
|
+
const seen: { picked: number | null; cleared: number } = { picked: null, cleared: 0 };
|
|
107
|
+
const host = await mount(
|
|
108
|
+
h(WorkspacePicker, {
|
|
109
|
+
folders: [folder(1, 'kabmo'), folder(2, 'notes')],
|
|
110
|
+
selectedFolderId: 1,
|
|
111
|
+
setSelectedFolderId: (id) => {
|
|
112
|
+
seen.picked = id;
|
|
113
|
+
},
|
|
114
|
+
onClearWorktree: () => {
|
|
115
|
+
seen.cleared += 1;
|
|
116
|
+
},
|
|
117
|
+
}),
|
|
118
|
+
);
|
|
119
|
+
|
|
120
|
+
await act(async () => {
|
|
121
|
+
(host.querySelector('button') as HTMLButtonElement).click();
|
|
122
|
+
});
|
|
123
|
+
const rows = [...host.querySelectorAll('[role="menuitemradio"]')] as HTMLButtonElement[];
|
|
124
|
+
expect(rows.length).toBe(2);
|
|
125
|
+
await act(async () => {
|
|
126
|
+
rows[1]?.click();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
expect(seen.picked).toBe(2);
|
|
130
|
+
expect(seen.cleared).toBe(1);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @license
|
|
3
|
+
* SPDX-License-Identifier: Apache-2.0
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* WHERE the chat runs: the workspace folder.
|
|
8
|
+
*
|
|
9
|
+
* One control of the composer's draft-target pair, and the pair exists because
|
|
10
|
+
* the two questions are different: the folder is the project, and the worktree
|
|
11
|
+
* is the checkout inside it (`WorktreePicker`). They were merged into one menu
|
|
12
|
+
* once, and the result read as a workspace picker with worktree rows buried in
|
|
13
|
+
* it — a user looking for another project scrolled past branch names, and a
|
|
14
|
+
* user looking for a worktree could not tell the control was the place to look.
|
|
15
|
+
* OpenChamber draws them as two controls for the same reason.
|
|
16
|
+
*
|
|
17
|
+
* Picking a folder CLEARS the worktree pick: a checkout of the previous project
|
|
18
|
+
* names a path that does not exist under the new one.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { useRef, useState } from 'preact/hooks';
|
|
22
|
+
import { Check, ChevronDown, Folder } from 'lucide-preact';
|
|
23
|
+
import { useOnClickOutside } from '@/client/hooks/ui/on-click-outside';
|
|
24
|
+
import type { WorkspaceFolderData } from '@/shared/types';
|
|
25
|
+
|
|
26
|
+
export interface WorkspacePickerProps {
|
|
27
|
+
folders: WorkspaceFolderData[];
|
|
28
|
+
selectedFolderId: number | null;
|
|
29
|
+
setSelectedFolderId: (id: number | null) => void;
|
|
30
|
+
/** Called with `null` when the user moves to another project. */
|
|
31
|
+
onClearWorktree?: () => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The trigger's words, exported so a test asserts them without the markup. */
|
|
35
|
+
export function workspaceLabel(folders: WorkspaceFolderData[], selectedFolderId: number | null): string {
|
|
36
|
+
const folder = folders.find((candidate) => String(candidate.id) === String(selectedFolderId));
|
|
37
|
+
return folder?.name ?? 'Select Workspace Context';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function WorkspacePicker({
|
|
41
|
+
folders,
|
|
42
|
+
selectedFolderId,
|
|
43
|
+
setSelectedFolderId,
|
|
44
|
+
onClearWorktree,
|
|
45
|
+
}: WorkspacePickerProps) {
|
|
46
|
+
const [open, setOpen] = useState(false);
|
|
47
|
+
const containerRef = useRef<HTMLDivElement>(null);
|
|
48
|
+
useOnClickOutside(containerRef, () => setOpen(false));
|
|
49
|
+
|
|
50
|
+
const pick = (folder: WorkspaceFolderData) => {
|
|
51
|
+
setSelectedFolderId(folder.id);
|
|
52
|
+
// A worktree belongs to the project it was made from; keeping the pick
|
|
53
|
+
// across a project change would spawn the chat in a path this project
|
|
54
|
+
// cannot resolve.
|
|
55
|
+
onClearWorktree?.();
|
|
56
|
+
setOpen(false);
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
const label = workspaceLabel(folders, selectedFolderId);
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<div className="relative shrink-0" ref={containerRef}>
|
|
63
|
+
<button
|
|
64
|
+
type="button"
|
|
65
|
+
// Two triggers sit side by side, so each needs a name that says which
|
|
66
|
+
// question it answers: "kabmo" and "main" alone are both ambiguous.
|
|
67
|
+
aria-label={`Workspace: ${label}`}
|
|
68
|
+
aria-haspopup="menu"
|
|
69
|
+
aria-expanded={open}
|
|
70
|
+
onClick={() => setOpen((value) => !value)}
|
|
71
|
+
className="group flex items-center space-x-1.5 px-1.5 py-1 rounded-md text-xs font-medium text-ink/75 hover:text-ink hover:bg-ink/5 focus-visible:bg-ink/5 transition-all outline-none cursor-pointer select-none"
|
|
72
|
+
>
|
|
73
|
+
<Folder size={13} className="text-ink/50 group-hover:text-ink/80 transition-colors flex-shrink-0" />
|
|
74
|
+
<span className="max-w-[260px] truncate tracking-tight">{label}</span>
|
|
75
|
+
<ChevronDown
|
|
76
|
+
size={13}
|
|
77
|
+
className={`text-ink/40 group-hover:text-ink/70 flex-shrink-0 transition-transform duration-150 ${open ? 'rotate-180 text-ink/70' : ''}`}
|
|
78
|
+
/>
|
|
79
|
+
</button>
|
|
80
|
+
|
|
81
|
+
{open && (
|
|
82
|
+
<div
|
|
83
|
+
role="menu"
|
|
84
|
+
className="absolute bottom-full left-0 mb-1.5 w-64 bg-paper border border-ink/10 rounded-lg shadow-lg z-50 flex flex-col overflow-hidden text-xs py-1"
|
|
85
|
+
>
|
|
86
|
+
<div className="px-3 py-1.5 text-[10px] uppercase tracking-wider text-ink/40 font-semibold border-b border-ink/5">
|
|
87
|
+
Target Workspace
|
|
88
|
+
</div>
|
|
89
|
+
<div className="max-h-56 scrollbar-overlay-container py-1">
|
|
90
|
+
{folders.length === 0 ? (
|
|
91
|
+
<div className="px-3 py-2 text-ink/40 italic">No workspaces available</div>
|
|
92
|
+
) : (
|
|
93
|
+
folders.map((folder) => {
|
|
94
|
+
const active = String(selectedFolderId) === String(folder.id);
|
|
95
|
+
return (
|
|
96
|
+
<button
|
|
97
|
+
key={folder.id}
|
|
98
|
+
type="button"
|
|
99
|
+
role="menuitemradio"
|
|
100
|
+
aria-checked={active}
|
|
101
|
+
onClick={() => pick(folder)}
|
|
102
|
+
className={`w-full text-left px-3 py-1.5 flex items-center justify-between transition-colors cursor-pointer ${
|
|
103
|
+
active ? 'bg-ink/10 text-ink font-semibold' : 'text-ink/80 hover:bg-ink/5 hover:text-ink'
|
|
104
|
+
}`}
|
|
105
|
+
>
|
|
106
|
+
<span className="flex items-center space-x-2 truncate pr-2">
|
|
107
|
+
<Folder size={12} className={active ? 'text-ink flex-shrink-0' : 'text-ink/50 flex-shrink-0'} />
|
|
108
|
+
<span className="truncate font-medium">{folder.name}</span>
|
|
109
|
+
</span>
|
|
110
|
+
{active && <Check size={12} className="text-ink flex-shrink-0" />}
|
|
111
|
+
</button>
|
|
112
|
+
);
|
|
113
|
+
})
|
|
114
|
+
)}
|
|
115
|
+
</div>
|
|
116
|
+
</div>
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
);
|
|
120
|
+
}
|