@cueloop/client 0.1.0-alpha.78 → 0.1.0-alpha.79
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/package.json +14 -13
- package/src/App.diff.test.tsx +9 -4
- package/src/App.focus.test.ts +1 -59
- package/src/App.identity.test.tsx +3 -3
- package/src/App.plan-render.test.tsx +209 -0
- package/src/App.plan-review.test.tsx +75 -26
- package/src/App.readonly.test.tsx +6 -5
- package/src/App.share-dialog.test.tsx +172 -0
- package/src/App.submit-confirm.test.tsx +6 -4
- package/src/App.test.tsx +31 -28
- package/src/App.toast.test.tsx +11 -4
- package/src/App.tsx +259 -274
- package/src/App.walk.test.tsx +9 -8
- package/src/App.workbench.test.tsx +86 -0
- package/src/annotation-palette.ts +1 -0
- package/src/app-screens.tsx +12 -6
- package/src/app-view-model.ts +0 -1
- package/src/components/ConfirmDialog.stories.tsx +3 -3
- package/src/components/DiffContentView.tsx +14 -7
- package/src/components/DiscussionMarkerRail.tsx +9 -4
- package/src/components/GridTabContent.tsx +8 -2
- package/src/components/HandleChipsInput.stories.tsx +24 -0
- package/src/components/HandleChipsInput.test.tsx +35 -0
- package/src/components/HandleChipsInput.tsx +145 -0
- package/src/components/MarkdownGridBlock.stories.tsx +33 -0
- package/src/components/MarkdownGridBlock.tsx +72 -0
- package/src/components/MarkdownThreadEditor.stories.tsx +15 -0
- package/src/components/MarkdownThreadEditor.test.tsx +98 -0
- package/src/components/MarkdownThreadEditor.tsx +162 -0
- package/src/components/NavModeHint.stories.tsx +40 -0
- package/src/components/NavModeHint.tsx +31 -0
- package/src/components/SettingsDialog.tsx +1 -0
- package/src/components/ShareDialog.stories.tsx +121 -0
- package/src/components/ShareDialog.tsx +552 -0
- package/src/components/ThreadFooter.tsx +4 -1
- package/src/components/ThreadTree.tsx +1 -1
- package/src/components/ThreadView.slash.test.tsx +0 -35
- package/src/components/ThreadView.tsx +142 -68
- package/src/components/Toast.stories.tsx +7 -0
- package/src/components/Toast.tsx +15 -3
- package/src/components/__snapshots__/stories.test.tsx.snap +258 -134
- package/src/components/primitives/Button.tsx +6 -2
- package/src/components/primitives/Card.tsx +2 -2
- package/src/components/primitives/Dialog.tsx +2 -2
- package/src/components/primitives/Switch.stories.tsx +17 -0
- package/src/components/primitives/Switch.tsx +32 -0
- package/src/components/primitives/Tree.tsx +5 -1
- package/src/components/primitives/frame.ts +5 -0
- package/src/components/share-dialog-store.ts +58 -0
- package/src/components/stories.test.tsx +4 -3
- package/src/config.test.ts +18 -0
- package/src/config.ts +8 -1
- package/src/diff-fold.ts +1 -1
- package/src/intent-dispatch.test.ts +12 -3
- package/src/intent-dispatch.ts +10 -25
- package/src/key-bindings.ts +0 -6
- package/src/keymap.ts +0 -1
- package/src/markdown-editor-syntax-style.ts +50 -0
- package/src/markdown-grid.test.ts +101 -0
- package/src/markdown-grid.ts +203 -0
- package/src/markdown-highlight.test.ts +61 -0
- package/src/markdown-highlight.ts +122 -0
- package/src/prototype-browser.test.ts +9 -1
- package/src/prototype-browser.ts +22 -0
- package/src/run.ts +8 -6
- package/src/share.ts +15 -1
- package/src/test-support.ts +14 -0
- package/src/thread-chords.test.ts +55 -178
- package/src/thread-chords.ts +78 -169
- package/src/thread-controller.curate.test.ts +1 -0
- package/src/thread-controller.live-diff.test.ts +3 -0
- package/src/thread-controller.share.test.ts +60 -5
- package/src/thread-controller.switch-cache.test.ts +30 -0
- package/src/thread-controller.tree.test.ts +6 -1
- package/src/thread-controller.ts +229 -54
- package/src/use-annotation-surface.tsx +45 -36
- package/src/use-settings-dialog.tsx +14 -3
- package/src/use-thread-body-editing.tsx +58 -0
- package/src/view-plan.test.ts +56 -6
- package/src/view-plan.ts +154 -5
- package/src/App.share-choice.test.tsx +0 -105
- package/src/components/ManageAccessDialog.stories.tsx +0 -36
- package/src/components/ManageAccessDialog.test.tsx +0 -94
- package/src/components/ManageAccessDialog.tsx +0 -122
- package/src/components/ShareChoiceDialog.stories.tsx +0 -34
- package/src/components/ShareChoiceDialog.test.tsx +0 -103
- package/src/components/ShareChoiceDialog.tsx +0 -80
- package/src/components/ThreadsSidebar.stories.tsx +0 -36
- package/src/components/ThreadsSidebar.tsx +0 -71
- package/src/components/agent-launcher.stories.tsx +0 -26
- package/src/components/agent-launcher.test.tsx +0 -50
- package/src/components/agent-launcher.tsx +0 -149
|
@@ -0,0 +1,552 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The share overlay: a settings-style two-column modal. The left nav lists
|
|
3
|
+
* "share externally" and "export"; the right pane shows either the thread's
|
|
4
|
+
* share links - each an address with copy and a ⋮ menu to revoke or open its
|
|
5
|
+
* settings - or, reached from "+ new link" / "open link settings", a full-body
|
|
6
|
+
* wizard that names a link and toggles a GitHub-login allowlist. Export is a
|
|
7
|
+
* coming-soon placeholder. The dialog's UI state (which category, whether the
|
|
8
|
+
* wizard is open, its draft) lives in the shared zustand store; the link data
|
|
9
|
+
* arrives from the controller through props and the actions call back into it.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import React, { useEffect, useRef, useState } from "react";
|
|
13
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react";
|
|
14
|
+
import type { KeyBinding, MouseEvent, TextareaRenderable } from "@opentui/core";
|
|
15
|
+
import { DEFAULT_SHARE_HOST } from "@cueloop/daemon/share-blob";
|
|
16
|
+
import type { ShareLink } from "@cueloop/schema";
|
|
17
|
+
import type { Theme } from "../theme";
|
|
18
|
+
import type { NewShareLink } from "../thread-controller";
|
|
19
|
+
import { useComponentTheme } from "./theme-context";
|
|
20
|
+
import { Dialog } from "./primitives/Dialog";
|
|
21
|
+
import { DialogActions } from "./primitives/DialogActions";
|
|
22
|
+
import { Button } from "./primitives/Button";
|
|
23
|
+
import { Switch } from "./primitives/Switch";
|
|
24
|
+
import { Tree } from "./primitives/Tree";
|
|
25
|
+
import type { TreeNode } from "./primitives/tree-model";
|
|
26
|
+
import { NERD } from "./primitives/icons";
|
|
27
|
+
import { HandleChipsInput } from "./HandleChipsInput";
|
|
28
|
+
import {
|
|
29
|
+
newWizardDraft,
|
|
30
|
+
useShareDialog,
|
|
31
|
+
shareDialogStore,
|
|
32
|
+
type ShareCategory,
|
|
33
|
+
type ShareWizardDraft,
|
|
34
|
+
} from "./share-dialog-store";
|
|
35
|
+
|
|
36
|
+
// ⏎ saves the link rather than adding a newline to the one-line name field.
|
|
37
|
+
const NAME_KEY_BINDINGS: KeyBinding[] = [{ name: "return", action: "submit" }];
|
|
38
|
+
|
|
39
|
+
/** The two left-nav categories, in order. */
|
|
40
|
+
const CATEGORIES: { id: ShareCategory; label: string }[] = [
|
|
41
|
+
{ id: "external", label: "share externally" },
|
|
42
|
+
{ id: "export", label: "export" },
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
const NAV_NODES: TreeNode[] = CATEGORIES.map((category) => ({
|
|
46
|
+
id: category.id,
|
|
47
|
+
label: category.label,
|
|
48
|
+
}));
|
|
49
|
+
|
|
50
|
+
/** Narrow a nav row id back to its category (the nav only holds the two known ids). */
|
|
51
|
+
function categoryFromId(id: string): ShareCategory {
|
|
52
|
+
return id === "export" ? "export" : "external";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** The address a viewer connects to, e.g. `p_ab12@cueloop.dev` (no `ssh ` prefix; the row is a label). */
|
|
56
|
+
function shareAddress(id: string): string {
|
|
57
|
+
return `${id}@${DEFAULT_SHARE_HOST}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Leave the wizard back to the links list. */
|
|
61
|
+
function backToList(): void {
|
|
62
|
+
shareDialogStore.getState().closeWizard();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** The wizard draft that edits an existing link in place. */
|
|
66
|
+
function draftForLink(link: ShareLink, threadName: string): ShareWizardDraft {
|
|
67
|
+
return {
|
|
68
|
+
editingId: link.id,
|
|
69
|
+
name: link.name ?? threadName,
|
|
70
|
+
requireAuth: link.requireAuth,
|
|
71
|
+
allowlist: link.allowlist,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function NavColumn({
|
|
76
|
+
activeCategory,
|
|
77
|
+
onSelect,
|
|
78
|
+
theme,
|
|
79
|
+
}: {
|
|
80
|
+
activeCategory: ShareCategory;
|
|
81
|
+
onSelect: (category: ShareCategory) => void;
|
|
82
|
+
theme?: Theme;
|
|
83
|
+
}): React.ReactNode {
|
|
84
|
+
const tokens = useComponentTheme(theme);
|
|
85
|
+
|
|
86
|
+
return (
|
|
87
|
+
<box style={{ flexDirection: "column", width: 22, paddingLeft: 1, paddingRight: 1 }}>
|
|
88
|
+
<Tree
|
|
89
|
+
nodes={NAV_NODES}
|
|
90
|
+
expandedIds={new Set<string>()}
|
|
91
|
+
selectedId={activeCategory}
|
|
92
|
+
hideIcons
|
|
93
|
+
selectedBackground={tokens.border}
|
|
94
|
+
onSelect={(id) => onSelect(categoryFromId(id))}
|
|
95
|
+
onToggle={(id) => onSelect(categoryFromId(id))}
|
|
96
|
+
theme={theme}
|
|
97
|
+
/>
|
|
98
|
+
</box>
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function KebabMenu({
|
|
103
|
+
onDelete,
|
|
104
|
+
onSettings,
|
|
105
|
+
tokens,
|
|
106
|
+
}: {
|
|
107
|
+
onDelete: () => void;
|
|
108
|
+
onSettings: () => void;
|
|
109
|
+
tokens: Theme;
|
|
110
|
+
}): React.ReactNode {
|
|
111
|
+
const item = (label: string, onPick: () => void): React.ReactNode => (
|
|
112
|
+
<box
|
|
113
|
+
onMouseUp={(event: MouseEvent) => {
|
|
114
|
+
event.stopPropagation();
|
|
115
|
+
onPick();
|
|
116
|
+
}}
|
|
117
|
+
style={{ paddingLeft: 1, paddingRight: 1 }}
|
|
118
|
+
>
|
|
119
|
+
<text fg={tokens.text}>{label}</text>
|
|
120
|
+
</box>
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
return (
|
|
124
|
+
<box
|
|
125
|
+
onMouseUp={(event: MouseEvent) => event.stopPropagation()}
|
|
126
|
+
style={{
|
|
127
|
+
position: "absolute",
|
|
128
|
+
top: 1,
|
|
129
|
+
right: 0,
|
|
130
|
+
flexDirection: "column",
|
|
131
|
+
border: true,
|
|
132
|
+
borderStyle: "single",
|
|
133
|
+
borderColor: tokens.border,
|
|
134
|
+
backgroundColor: tokens.elevated,
|
|
135
|
+
}}
|
|
136
|
+
>
|
|
137
|
+
{item("delete link", onDelete)}
|
|
138
|
+
{item("open link settings", onSettings)}
|
|
139
|
+
</box>
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function LinkRow({
|
|
144
|
+
link,
|
|
145
|
+
selected,
|
|
146
|
+
menuOpen,
|
|
147
|
+
onCopy,
|
|
148
|
+
onToggleMenu,
|
|
149
|
+
onDelete,
|
|
150
|
+
onSettings,
|
|
151
|
+
tokens,
|
|
152
|
+
}: {
|
|
153
|
+
link: ShareLink;
|
|
154
|
+
selected: boolean;
|
|
155
|
+
menuOpen: boolean;
|
|
156
|
+
onCopy: () => void;
|
|
157
|
+
onToggleMenu: () => void;
|
|
158
|
+
onDelete: () => void;
|
|
159
|
+
onSettings: () => void;
|
|
160
|
+
tokens: Theme;
|
|
161
|
+
}): React.ReactNode {
|
|
162
|
+
// the name is a short local label; unnamed links show their address instead
|
|
163
|
+
const label = link.name ?? shareAddress(link.id);
|
|
164
|
+
|
|
165
|
+
return (
|
|
166
|
+
<box
|
|
167
|
+
style={{
|
|
168
|
+
flexDirection: "row",
|
|
169
|
+
position: "relative",
|
|
170
|
+
alignItems: "center",
|
|
171
|
+
paddingLeft: 1,
|
|
172
|
+
backgroundColor: selected ? tokens.border : undefined,
|
|
173
|
+
}}
|
|
174
|
+
>
|
|
175
|
+
<text
|
|
176
|
+
fg={selected ? tokens.text : tokens.textMuted}
|
|
177
|
+
style={{ wrapMode: "none", flexShrink: 1 }}
|
|
178
|
+
>
|
|
179
|
+
{label}
|
|
180
|
+
</text>
|
|
181
|
+
<box style={{ flexGrow: 1 }} />
|
|
182
|
+
<box onMouseUp={onCopy} style={{ paddingLeft: 2 }}>
|
|
183
|
+
<text fg={tokens.textDim}>copy link</text>
|
|
184
|
+
</box>
|
|
185
|
+
<box
|
|
186
|
+
onMouseUp={(event: MouseEvent) => {
|
|
187
|
+
event.stopPropagation();
|
|
188
|
+
onToggleMenu();
|
|
189
|
+
}}
|
|
190
|
+
style={{ paddingLeft: 2 }}
|
|
191
|
+
>
|
|
192
|
+
<text fg={tokens.textDim}>{NERD.kebab}</text>
|
|
193
|
+
</box>
|
|
194
|
+
{menuOpen ? <KebabMenu onDelete={onDelete} onSettings={onSettings} tokens={tokens} /> : null}
|
|
195
|
+
</box>
|
|
196
|
+
);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function LinksList({
|
|
200
|
+
threadName,
|
|
201
|
+
links,
|
|
202
|
+
selectedIndex,
|
|
203
|
+
menuOpenId,
|
|
204
|
+
onCopy,
|
|
205
|
+
onToggleMenu,
|
|
206
|
+
onDelete,
|
|
207
|
+
onSettings,
|
|
208
|
+
onNewLink,
|
|
209
|
+
tokens,
|
|
210
|
+
theme,
|
|
211
|
+
}: {
|
|
212
|
+
threadName: string;
|
|
213
|
+
links: ShareLink[];
|
|
214
|
+
selectedIndex: number;
|
|
215
|
+
menuOpenId: string | null;
|
|
216
|
+
onCopy: (id: string) => void;
|
|
217
|
+
onToggleMenu: (id: string) => void;
|
|
218
|
+
onDelete: (id: string) => void;
|
|
219
|
+
onSettings: (link: ShareLink) => void;
|
|
220
|
+
onNewLink: () => void;
|
|
221
|
+
tokens: Theme;
|
|
222
|
+
theme?: Theme;
|
|
223
|
+
}): React.ReactNode {
|
|
224
|
+
if (links.length === 0) {
|
|
225
|
+
return (
|
|
226
|
+
<box style={{ flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
|
|
227
|
+
<Button variant="solid" onPress={onNewLink} theme={theme}>
|
|
228
|
+
{" + new link "}
|
|
229
|
+
</Button>
|
|
230
|
+
</box>
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return (
|
|
235
|
+
<box style={{ flexDirection: "column", flexGrow: 1 }}>
|
|
236
|
+
<text fg={tokens.textDim}>{`links created for ${threadName}`}</text>
|
|
237
|
+
<text> </text>
|
|
238
|
+
{links.map((link, index) => (
|
|
239
|
+
<LinkRow
|
|
240
|
+
key={link.id}
|
|
241
|
+
link={link}
|
|
242
|
+
selected={index === selectedIndex}
|
|
243
|
+
menuOpen={menuOpenId === link.id}
|
|
244
|
+
onCopy={() => onCopy(link.id)}
|
|
245
|
+
onToggleMenu={() => onToggleMenu(link.id)}
|
|
246
|
+
onDelete={() => onDelete(link.id)}
|
|
247
|
+
onSettings={() => onSettings(link)}
|
|
248
|
+
tokens={tokens}
|
|
249
|
+
/>
|
|
250
|
+
))}
|
|
251
|
+
<text> </text>
|
|
252
|
+
<box style={{ flexDirection: "row" }}>
|
|
253
|
+
<Button variant="solid" onPress={onNewLink} theme={theme}>
|
|
254
|
+
{" + new link "}
|
|
255
|
+
</Button>
|
|
256
|
+
</box>
|
|
257
|
+
</box>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** The focus ring positions inside the wizard, in vertical order. */
|
|
262
|
+
type WizardFocus = "name" | "auth" | "allowlist" | "actions";
|
|
263
|
+
|
|
264
|
+
function Wizard({
|
|
265
|
+
draft,
|
|
266
|
+
focus,
|
|
267
|
+
saveDisabled,
|
|
268
|
+
onName,
|
|
269
|
+
onToggleAuth,
|
|
270
|
+
onAllowlist,
|
|
271
|
+
onSave,
|
|
272
|
+
onCancel,
|
|
273
|
+
tokens,
|
|
274
|
+
theme,
|
|
275
|
+
}: {
|
|
276
|
+
draft: ShareWizardDraft;
|
|
277
|
+
focus: WizardFocus;
|
|
278
|
+
saveDisabled: boolean;
|
|
279
|
+
onName: (name: string) => void;
|
|
280
|
+
onToggleAuth: () => void;
|
|
281
|
+
onAllowlist: (logins: string[]) => void;
|
|
282
|
+
onSave: () => void;
|
|
283
|
+
onCancel: () => void;
|
|
284
|
+
tokens: Theme;
|
|
285
|
+
theme?: Theme;
|
|
286
|
+
}): React.ReactNode {
|
|
287
|
+
const nameRef = useRef<TextareaRenderable | null>(null);
|
|
288
|
+
|
|
289
|
+
useEffect(() => {
|
|
290
|
+
if (focus !== "name") return;
|
|
291
|
+
nameRef.current?.focus();
|
|
292
|
+
if (nameRef.current) nameRef.current.cursorOffset = draft.name.length;
|
|
293
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
294
|
+
}, [focus]);
|
|
295
|
+
|
|
296
|
+
return (
|
|
297
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 1 }}>
|
|
298
|
+
<text fg={focus === "name" ? tokens.accent : tokens.textDim}>link name</text>
|
|
299
|
+
<textarea
|
|
300
|
+
ref={nameRef}
|
|
301
|
+
focused={focus === "name"}
|
|
302
|
+
initialValue={draft.name}
|
|
303
|
+
placeholder="link name"
|
|
304
|
+
keyBindings={NAME_KEY_BINDINGS}
|
|
305
|
+
onSubmit={onSave}
|
|
306
|
+
onContentChange={() => onName(nameRef.current?.plainText ?? "")}
|
|
307
|
+
style={{
|
|
308
|
+
height: 1,
|
|
309
|
+
backgroundColor: tokens.elevated,
|
|
310
|
+
focusedBackgroundColor: tokens.elevated,
|
|
311
|
+
textColor: tokens.text,
|
|
312
|
+
focusedTextColor: tokens.text,
|
|
313
|
+
}}
|
|
314
|
+
/>
|
|
315
|
+
<text> </text>
|
|
316
|
+
<box style={{ flexDirection: "row", alignItems: "center" }}>
|
|
317
|
+
<text fg={focus === "auth" ? tokens.accent : tokens.textDim}>require auth</text>
|
|
318
|
+
<box style={{ flexGrow: 1 }} />
|
|
319
|
+
<Switch on={draft.requireAuth} onToggle={onToggleAuth} theme={theme} />
|
|
320
|
+
</box>
|
|
321
|
+
{draft.requireAuth ? (
|
|
322
|
+
<box style={{ marginTop: 1 }}>
|
|
323
|
+
<HandleChipsInput
|
|
324
|
+
logins={draft.allowlist}
|
|
325
|
+
onChange={onAllowlist}
|
|
326
|
+
focused={focus === "allowlist"}
|
|
327
|
+
onEmptyBackspace={onToggleAuth}
|
|
328
|
+
theme={theme}
|
|
329
|
+
/>
|
|
330
|
+
</box>
|
|
331
|
+
) : null}
|
|
332
|
+
<box style={{ flexGrow: 1 }} />
|
|
333
|
+
<DialogActions
|
|
334
|
+
confirmLabel={draft.editingId ? "save" : "create"}
|
|
335
|
+
onConfirm={onSave}
|
|
336
|
+
onCancel={onCancel}
|
|
337
|
+
confirmDisabled={saveDisabled}
|
|
338
|
+
theme={theme}
|
|
339
|
+
/>
|
|
340
|
+
</box>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export interface ShareDialogProps {
|
|
345
|
+
isOpen: boolean;
|
|
346
|
+
/** The thread's title, shown in the list header and prefilled as a new link's name. */
|
|
347
|
+
threadName: string;
|
|
348
|
+
/** The thread's live share links, newest last. */
|
|
349
|
+
links: ShareLink[];
|
|
350
|
+
/** Owners publish and edit; an observer sees the list read-only (no create/edit/delete). */
|
|
351
|
+
isOwner: boolean;
|
|
352
|
+
onCreateLink: (input: NewShareLink) => void;
|
|
353
|
+
onUpdateLink: (id: string, input: NewShareLink) => void;
|
|
354
|
+
onDeleteLink: (id: string) => void;
|
|
355
|
+
onCopyLink: (id: string) => void;
|
|
356
|
+
onClose: () => void;
|
|
357
|
+
theme?: Theme;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
export function ShareDialog({
|
|
361
|
+
isOpen,
|
|
362
|
+
threadName,
|
|
363
|
+
links,
|
|
364
|
+
isOwner,
|
|
365
|
+
onCreateLink,
|
|
366
|
+
onUpdateLink,
|
|
367
|
+
onDeleteLink,
|
|
368
|
+
onCopyLink,
|
|
369
|
+
onClose,
|
|
370
|
+
theme,
|
|
371
|
+
}: ShareDialogProps): React.ReactNode {
|
|
372
|
+
const tokens = useComponentTheme(theme);
|
|
373
|
+
const { width: terminalWidth, height: terminalHeight } = useTerminalDimensions();
|
|
374
|
+
const category = useShareDialog((state) => state.category);
|
|
375
|
+
const wizard = useShareDialog((state) => state.wizard);
|
|
376
|
+
|
|
377
|
+
const [activeZone, setActiveZone] = useState<"nav" | "body">("nav");
|
|
378
|
+
const [listCursor, setListCursor] = useState(0);
|
|
379
|
+
const [menuOpenId, setMenuOpenId] = useState<string | null>(null);
|
|
380
|
+
const [wizardFocus, setWizardFocus] = useState<WizardFocus>("name");
|
|
381
|
+
|
|
382
|
+
const rowCount = links.length + 1; // links plus the "+ new link" row
|
|
383
|
+
|
|
384
|
+
const openWizardWith = (draft: ShareWizardDraft): void => {
|
|
385
|
+
setMenuOpenId(null);
|
|
386
|
+
setWizardFocus("name");
|
|
387
|
+
shareDialogStore.getState().openWizard(draft);
|
|
388
|
+
};
|
|
389
|
+
|
|
390
|
+
const wizardRing = (): WizardFocus[] =>
|
|
391
|
+
wizard?.requireAuth ? ["name", "auth", "allowlist", "actions"] : ["name", "auth", "actions"];
|
|
392
|
+
|
|
393
|
+
const moveWizardFocus = (delta: number): void => {
|
|
394
|
+
const ring = wizardRing();
|
|
395
|
+
const at = Math.max(0, ring.indexOf(wizardFocus));
|
|
396
|
+
setWizardFocus(ring[Math.min(ring.length - 1, Math.max(0, at + delta))] ?? "name");
|
|
397
|
+
};
|
|
398
|
+
|
|
399
|
+
// a private link needs at least one handle, else the gateway locks everyone out
|
|
400
|
+
const saveDisabled = wizard !== null && wizard.requireAuth && wizard.allowlist.length === 0;
|
|
401
|
+
|
|
402
|
+
const saveWizard = (): void => {
|
|
403
|
+
if (!wizard || saveDisabled) return;
|
|
404
|
+
const input: NewShareLink = {
|
|
405
|
+
name: wizard.name.trim() || undefined,
|
|
406
|
+
requireAuth: wizard.requireAuth,
|
|
407
|
+
allowlist: wizard.allowlist,
|
|
408
|
+
};
|
|
409
|
+
|
|
410
|
+
if (wizard.editingId) onUpdateLink(wizard.editingId, input);
|
|
411
|
+
else onCreateLink(input);
|
|
412
|
+
backToList();
|
|
413
|
+
};
|
|
414
|
+
|
|
415
|
+
const activateListRow = (): void => {
|
|
416
|
+
if (listCursor >= links.length) return openWizardWith(newWizardDraft(threadName));
|
|
417
|
+
const link = links[listCursor];
|
|
418
|
+
|
|
419
|
+
if (link) onCopyLink(link.id);
|
|
420
|
+
};
|
|
421
|
+
|
|
422
|
+
// turning auth on drops the cursor straight into the handles field; turning it off resets them
|
|
423
|
+
const toggleAuth = (): void => {
|
|
424
|
+
if (!wizard) return;
|
|
425
|
+
const next = !wizard.requireAuth;
|
|
426
|
+
|
|
427
|
+
shareDialogStore.getState().setRequireAuth(next);
|
|
428
|
+
if (!next) shareDialogStore.getState().setAllowlist([]);
|
|
429
|
+
setWizardFocus(next ? "allowlist" : "auth");
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
// grammar keys only bite outside a focused text field, where they cannot be typed
|
|
433
|
+
const handleWizardKey = (name: string): void => {
|
|
434
|
+
if (!wizard) return;
|
|
435
|
+
if (name === "escape") return backToList();
|
|
436
|
+
if (name === "down") return moveWizardFocus(1);
|
|
437
|
+
if (name === "up") return moveWizardFocus(-1);
|
|
438
|
+
const activated = name === "return" || name === "enter";
|
|
439
|
+
|
|
440
|
+
if (wizardFocus === "auth" && (activated || name === "space")) return toggleAuth();
|
|
441
|
+
if (wizardFocus === "actions" && activated) saveWizard();
|
|
442
|
+
};
|
|
443
|
+
|
|
444
|
+
const handleNavKey = (name: string): void => {
|
|
445
|
+
if (name === "j" || name === "down") return shareDialogStore.getState().setCategory("export");
|
|
446
|
+
if (name === "k" || name === "up") return shareDialogStore.getState().setCategory("external");
|
|
447
|
+
const enter =
|
|
448
|
+
name === "l" || name === "right" || name === "return" || name === "enter" || name === "tab";
|
|
449
|
+
|
|
450
|
+
if (enter && category === "external" && isOwner) setActiveZone("body");
|
|
451
|
+
};
|
|
452
|
+
|
|
453
|
+
const editLinkAtCursor = (): void => {
|
|
454
|
+
const link = links[listCursor];
|
|
455
|
+
|
|
456
|
+
if (link) openWizardWith(draftForLink(link, threadName));
|
|
457
|
+
};
|
|
458
|
+
|
|
459
|
+
const handleBodyKey = (name: string): void => {
|
|
460
|
+
if (name === "h" || name === "left") return setActiveZone("nav");
|
|
461
|
+
if (name === "j" || name === "down")
|
|
462
|
+
return setListCursor((cursor) => Math.min(cursor + 1, rowCount - 1));
|
|
463
|
+
if (name === "k" || name === "up") return setListCursor((cursor) => Math.max(cursor - 1, 0));
|
|
464
|
+
if (name === "return" || name === "enter") return activateListRow();
|
|
465
|
+
if (listCursor >= links.length) return;
|
|
466
|
+
if (name === "e") return editLinkAtCursor();
|
|
467
|
+
if (name === "d") onDeleteLink(links[listCursor]!.id);
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
useKeyboard((key) => {
|
|
471
|
+
if (!isOpen) return;
|
|
472
|
+
if (wizard) return handleWizardKey(key.name);
|
|
473
|
+
if (menuOpenId !== null) return void (key.name === "escape" && setMenuOpenId(null));
|
|
474
|
+
if (key.name === "escape") return onClose();
|
|
475
|
+
if (activeZone === "nav") return handleNavKey(key.name);
|
|
476
|
+
handleBodyKey(key.name);
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
if (!isOpen) return null;
|
|
480
|
+
|
|
481
|
+
const footerHint = wizard
|
|
482
|
+
? "↑/↓ move · space toggle · enter save · esc back"
|
|
483
|
+
: activeZone === "nav"
|
|
484
|
+
? "j/k nav · l/enter open · esc close"
|
|
485
|
+
: "j/k row · enter copy · e edit · d delete · h back";
|
|
486
|
+
|
|
487
|
+
return (
|
|
488
|
+
<Dialog
|
|
489
|
+
isOpen
|
|
490
|
+
title=" Share "
|
|
491
|
+
width={Math.min(76, terminalWidth - 6)}
|
|
492
|
+
height={Math.min(22, terminalHeight - 4)}
|
|
493
|
+
background={tokens.elevated}
|
|
494
|
+
onDismiss={onClose}
|
|
495
|
+
theme={theme}
|
|
496
|
+
>
|
|
497
|
+
{wizard ? (
|
|
498
|
+
<Wizard
|
|
499
|
+
draft={wizard}
|
|
500
|
+
focus={wizardFocus}
|
|
501
|
+
saveDisabled={saveDisabled}
|
|
502
|
+
onName={(name) => shareDialogStore.getState().setName(name)}
|
|
503
|
+
onToggleAuth={toggleAuth}
|
|
504
|
+
onAllowlist={(logins) => shareDialogStore.getState().setAllowlist(logins)}
|
|
505
|
+
onSave={saveWizard}
|
|
506
|
+
onCancel={backToList}
|
|
507
|
+
tokens={tokens}
|
|
508
|
+
theme={theme}
|
|
509
|
+
/>
|
|
510
|
+
) : (
|
|
511
|
+
<box style={{ flexDirection: "row", flexGrow: 1 }}>
|
|
512
|
+
<NavColumn
|
|
513
|
+
activeCategory={category}
|
|
514
|
+
onSelect={(next) => {
|
|
515
|
+
shareDialogStore.getState().setCategory(next);
|
|
516
|
+
setActiveZone("nav");
|
|
517
|
+
}}
|
|
518
|
+
theme={theme}
|
|
519
|
+
/>
|
|
520
|
+
<box style={{ borderStyle: "single", border: ["left"], borderColor: tokens.border }} />
|
|
521
|
+
<box style={{ flexDirection: "column", flexGrow: 1, paddingLeft: 2, paddingRight: 1 }}>
|
|
522
|
+
{category === "export" ? (
|
|
523
|
+
<box style={{ flexGrow: 1, alignItems: "center", justifyContent: "center" }}>
|
|
524
|
+
<text fg={tokens.textDim}>coming soon</text>
|
|
525
|
+
</box>
|
|
526
|
+
) : (
|
|
527
|
+
<LinksList
|
|
528
|
+
threadName={threadName}
|
|
529
|
+
links={links}
|
|
530
|
+
selectedIndex={activeZone === "body" ? listCursor : -1}
|
|
531
|
+
menuOpenId={menuOpenId}
|
|
532
|
+
onCopy={onCopyLink}
|
|
533
|
+
onToggleMenu={(id) => setMenuOpenId((open) => (open === id ? null : id))}
|
|
534
|
+
onDelete={(id) => {
|
|
535
|
+
setMenuOpenId(null);
|
|
536
|
+
onDeleteLink(id);
|
|
537
|
+
}}
|
|
538
|
+
onSettings={(link) => openWizardWith(draftForLink(link, threadName))}
|
|
539
|
+
onNewLink={() => openWizardWith(newWizardDraft(threadName))}
|
|
540
|
+
tokens={tokens}
|
|
541
|
+
theme={theme}
|
|
542
|
+
/>
|
|
543
|
+
)}
|
|
544
|
+
</box>
|
|
545
|
+
</box>
|
|
546
|
+
)}
|
|
547
|
+
<box style={{ flexDirection: "row", height: 1, paddingLeft: 1, paddingRight: 1 }}>
|
|
548
|
+
<text fg={tokens.textDim}>{footerHint}</text>
|
|
549
|
+
</box>
|
|
550
|
+
</Dialog>
|
|
551
|
+
);
|
|
552
|
+
}
|
|
@@ -21,6 +21,9 @@ export interface ThreadFooterProps {
|
|
|
21
21
|
const ICON_COLUMNS = 2; // folder glyph + its trailing space
|
|
22
22
|
const SEPARATOR = " / ";
|
|
23
23
|
|
|
24
|
+
/** Rows the footer band occupies, so a footer-less sibling pane can offset its centered content to match. */
|
|
25
|
+
export const THREAD_FOOTER_HEIGHT = 2;
|
|
26
|
+
|
|
24
27
|
export function ThreadFooter({
|
|
25
28
|
repo,
|
|
26
29
|
branch,
|
|
@@ -42,7 +45,7 @@ export function ThreadFooter({
|
|
|
42
45
|
<box
|
|
43
46
|
style={{
|
|
44
47
|
flexDirection: "row",
|
|
45
|
-
height:
|
|
48
|
+
height: THREAD_FOOTER_HEIGHT,
|
|
46
49
|
alignItems: "center",
|
|
47
50
|
paddingLeft: 1,
|
|
48
51
|
paddingRight: 1,
|
|
@@ -60,41 +60,6 @@ async function mountComposerWithSkills(skills: SlashItem[] = [], width = 72) {
|
|
|
60
60
|
|
|
61
61
|
const mountComposer = () => mountComposerWithSkills();
|
|
62
62
|
|
|
63
|
-
describe("the command leader", () => {
|
|
64
|
-
test("the leader chord then a letter runs a command instead of composing a comment", async () => {
|
|
65
|
-
const commands: string[] = [];
|
|
66
|
-
const display = buildDisplay(PLAN, undefined);
|
|
67
|
-
const setup = await testRender(
|
|
68
|
-
<ThreadView
|
|
69
|
-
session={fixturePlanSession({
|
|
70
|
-
artifact: { type: "plan", content: PLAN, meta: { title: "Plan" } },
|
|
71
|
-
})}
|
|
72
|
-
display={display}
|
|
73
|
-
marks={marksByDisplay([], display)}
|
|
74
|
-
quickActions={[]}
|
|
75
|
-
observer={false}
|
|
76
|
-
leaderCombos={["ctrl+g"]}
|
|
77
|
-
onLeaderCommand={(key) => commands.push(key.name)}
|
|
78
|
-
onAnnotate={() => {}}
|
|
79
|
-
onReply={() => {}}
|
|
80
|
-
onUpdateAnnotation={() => {}}
|
|
81
|
-
onExit={() => {}}
|
|
82
|
-
/>,
|
|
83
|
-
{ width: 72, height: 20 },
|
|
84
|
-
);
|
|
85
|
-
await settle(setup);
|
|
86
|
-
|
|
87
|
-
setup.mockInput.pressKey("g", { ctrl: true });
|
|
88
|
-
setup.mockInput.pressKey("x");
|
|
89
|
-
await settle(setup);
|
|
90
|
-
|
|
91
|
-
// the command fired and no composer opened (a bare "x" would have started a draft)
|
|
92
|
-
expect(commands).toEqual(["x"]);
|
|
93
|
-
|
|
94
|
-
setup.renderer.destroy();
|
|
95
|
-
});
|
|
96
|
-
});
|
|
97
|
-
|
|
98
63
|
describe("skills in the palette", () => {
|
|
99
64
|
test("a skill from context lists in the / palette and a pick inserts its /name", async () => {
|
|
100
65
|
const setup = await mountComposerWithSkills([
|