@saadnvd1/agent-os 0.3.770 → 0.3.775
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/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
- package/app/api/shares/preview/route.ts +6 -1
- package/app/api/shares/route.test.ts +51 -0
- package/app/page.tsx +1 -0
- package/components/Artifacts/ArtifactActions.tsx +27 -3
- package/components/Artifacts/ArtifactPreview.tsx +27 -12
- package/components/Artifacts/Compare/DiffView.tsx +116 -0
- package/components/Artifacts/Compare/SideBySide.tsx +60 -0
- package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
- package/components/Artifacts/Compare/index.tsx +191 -0
- package/components/Chat/Messages.tsx +6 -0
- package/components/Chat/ShareFromHere.tsx +39 -0
- package/components/CommandPalette/CommandPalette.tsx +5 -4
- package/components/CommandPalette/CommandsBody.tsx +97 -131
- package/components/CommandPalette/PaletteRows.tsx +79 -0
- package/components/CommandPalette/PaletteTabs.tsx +51 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
- package/components/CommandPalette/useAppCommands.ts +14 -0
- package/components/CommandPalette/useConversationRows.ts +55 -0
- package/components/SessionList/useSessionSwitching.ts +2 -2
- package/components/Share/MessageList.tsx +87 -0
- package/components/Share/RangePicker.tsx +59 -0
- package/components/Share/ShareDialog.tsx +113 -52
- package/data/session-artifacts/keys.ts +2 -0
- package/data/session-artifacts/queries.ts +25 -0
- package/data/shares/queries.ts +11 -0
- package/hooks/usePaletteCommands.ts +4 -1
- package/lib/artifacts/actions.ts +11 -1
- package/lib/artifacts/compare/compareVersions.ts +66 -0
- package/lib/artifacts/compare/diff.ts +178 -0
- package/lib/artifacts/compare/extractText.ts +64 -0
- package/lib/artifacts/compare/index.ts +11 -0
- package/lib/artifacts/compare/types.ts +44 -0
- package/lib/artifacts/match.ts +64 -0
- package/lib/artifacts/store.ts +39 -10
- package/lib/artifacts/tools.ts +9 -4
- package/lib/artifacts/versions.ts +2 -1
- package/lib/palette/registry.ts +71 -17
- package/lib/palette/sections.ts +143 -0
- package/lib/palette/tabs.ts +21 -0
- package/lib/palette/usage.ts +81 -0
- package/lib/security/demo.ts +3 -0
- package/lib/share/markdown.ts +3 -0
- package/lib/share/range.ts +189 -0
- package/lib/share/render.tsx +3 -0
- package/lib/share/service.ts +64 -19
- package/lib/share/snapshot.ts +39 -2
- package/lib/share/styles.ts +2 -0
- package/lib/share/view.ts +11 -0
- package/package.json +1 -1
- package/stores/palette.ts +13 -11
- package/stores/shareUi.ts +6 -1
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import { ArrowRight, ChevronLeft } from "lucide-react";
|
|
5
|
+
import { Button } from "@/components/ui/button";
|
|
6
|
+
import { Notice } from "@/components/ui/notice";
|
|
7
|
+
import { Segmented } from "@/components/ui/segmented";
|
|
8
|
+
import { useArtifactCompare } from "@/data/session-artifacts";
|
|
9
|
+
import type { ArtifactVersion } from "@/lib/artifacts/actions";
|
|
10
|
+
import { summaryLabel } from "@/lib/artifacts/compare/diff";
|
|
11
|
+
import { relativeTime } from "@/lib/schedules/when";
|
|
12
|
+
import { DiffView } from "./DiffView";
|
|
13
|
+
import { SideBySide } from "./SideBySide";
|
|
14
|
+
import { VersionPicker } from "./VersionPicker";
|
|
15
|
+
|
|
16
|
+
type View = "changes" | "side" | "source";
|
|
17
|
+
|
|
18
|
+
// The shown version against the one before it; the first version against
|
|
19
|
+
// the one after.
|
|
20
|
+
export function defaultPair(
|
|
21
|
+
versions: ArtifactVersion[],
|
|
22
|
+
shownId: string
|
|
23
|
+
): { from: string; to: string } {
|
|
24
|
+
const i = Math.max(
|
|
25
|
+
0,
|
|
26
|
+
versions.findIndex((v) => v.id === shownId)
|
|
27
|
+
);
|
|
28
|
+
return i > 0
|
|
29
|
+
? { from: versions[i - 1].id, to: versions[i].id }
|
|
30
|
+
: {
|
|
31
|
+
from: versions[0].id,
|
|
32
|
+
to: versions[Math.min(1, versions.length - 1)].id,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function Note({ v, label }: { v: ArtifactVersion; label: string }) {
|
|
37
|
+
return (
|
|
38
|
+
<div className="bg-muted/50 min-w-0 rounded-lg px-3 py-2 text-[12.5px]">
|
|
39
|
+
<div className="text-muted-foreground text-[11px]">
|
|
40
|
+
{label} · v{v.version} · {relativeTime(v.createdAt)}
|
|
41
|
+
</div>
|
|
42
|
+
<div className="break-words">
|
|
43
|
+
{v.note || (
|
|
44
|
+
<span className="text-muted-foreground">No note on this version</span>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
</div>
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function Changes({
|
|
52
|
+
sessionId,
|
|
53
|
+
from,
|
|
54
|
+
to,
|
|
55
|
+
mode,
|
|
56
|
+
onSide,
|
|
57
|
+
}: {
|
|
58
|
+
sessionId: string;
|
|
59
|
+
from: ArtifactVersion;
|
|
60
|
+
to: ArtifactVersion;
|
|
61
|
+
mode: "changes" | "source";
|
|
62
|
+
onSide: () => void;
|
|
63
|
+
}) {
|
|
64
|
+
const q = useArtifactCompare(sessionId, from.id, to.id, mode);
|
|
65
|
+
if (q.isPending)
|
|
66
|
+
return <div className="bg-muted/40 m-3 h-40 animate-pulse rounded-lg" />;
|
|
67
|
+
if (q.isError)
|
|
68
|
+
return <p className="text-destructive p-4 text-sm">{q.error.message}</p>;
|
|
69
|
+
const d = q.data;
|
|
70
|
+
if ("tooLarge" in d)
|
|
71
|
+
return (
|
|
72
|
+
<div className="p-3">
|
|
73
|
+
<Notice
|
|
74
|
+
tone="warning"
|
|
75
|
+
action={
|
|
76
|
+
<Button variant="outline" className="h-11 md:h-8" onClick={onSide}>
|
|
77
|
+
Side by side
|
|
78
|
+
</Button>
|
|
79
|
+
}
|
|
80
|
+
>
|
|
81
|
+
These pages are {d.reason}. Compare them side by side instead.
|
|
82
|
+
</Notice>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
const same = d.summary.added + d.summary.removed === 0;
|
|
86
|
+
return (
|
|
87
|
+
<div>
|
|
88
|
+
<div className="px-4 pt-3 text-[13px] font-medium">
|
|
89
|
+
{summaryLabel(d.summary)}
|
|
90
|
+
<span className="text-muted-foreground font-normal">
|
|
91
|
+
{mode === "changes" ? " · visible text" : " · HTML source"}
|
|
92
|
+
</span>
|
|
93
|
+
</div>
|
|
94
|
+
{same ? (
|
|
95
|
+
<p className="text-muted-foreground p-4 text-sm">
|
|
96
|
+
{mode === "changes"
|
|
97
|
+
? "The visible text is the same. Look at Side by side or Source for styling and script changes."
|
|
98
|
+
: "The source is identical."}
|
|
99
|
+
</p>
|
|
100
|
+
) : (
|
|
101
|
+
<DiffView lines={d.lines} wrap={mode === "changes"} />
|
|
102
|
+
)}
|
|
103
|
+
</div>
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Two versions of a page: what changed in its text, side by side, or in its
|
|
108
|
+
// HTML. Owner's viewer only; never offered on a share or public link.
|
|
109
|
+
export function ArtifactCompare({
|
|
110
|
+
sessionId,
|
|
111
|
+
versions,
|
|
112
|
+
shownId,
|
|
113
|
+
onClose,
|
|
114
|
+
}: {
|
|
115
|
+
sessionId: string;
|
|
116
|
+
versions: ArtifactVersion[];
|
|
117
|
+
shownId: string;
|
|
118
|
+
onClose: () => void;
|
|
119
|
+
}) {
|
|
120
|
+
const [pair, setPair] = useState(() => defaultPair(versions, shownId));
|
|
121
|
+
const [view, setView] = useState<View>("changes");
|
|
122
|
+
const from = versions.find((v) => v.id === pair.from) ?? versions[0];
|
|
123
|
+
const to =
|
|
124
|
+
versions.find((v) => v.id === pair.to) ?? versions[versions.length - 1];
|
|
125
|
+
const [a, b] = from.version <= to.version ? [from, to] : [to, from];
|
|
126
|
+
|
|
127
|
+
return (
|
|
128
|
+
<div className="@container flex h-full min-w-0 flex-col">
|
|
129
|
+
<div className="flex flex-col gap-3 border-b px-3 py-3 sm:px-4">
|
|
130
|
+
<div className="flex items-center gap-2">
|
|
131
|
+
<Button
|
|
132
|
+
variant="ghost"
|
|
133
|
+
size="icon"
|
|
134
|
+
aria-label="Back to the page"
|
|
135
|
+
onClick={onClose}
|
|
136
|
+
className="-ml-1 h-11 w-11 shrink-0 md:h-8 md:w-8"
|
|
137
|
+
>
|
|
138
|
+
<ChevronLeft className="h-5 w-5" />
|
|
139
|
+
</Button>
|
|
140
|
+
<div className="text-[13px] font-medium">Compare versions</div>
|
|
141
|
+
</div>
|
|
142
|
+
<div className="flex items-end gap-2">
|
|
143
|
+
<VersionPicker
|
|
144
|
+
name="From"
|
|
145
|
+
value={from.id}
|
|
146
|
+
versions={versions}
|
|
147
|
+
onChange={(id) => setPair({ ...pair, from: id })}
|
|
148
|
+
/>
|
|
149
|
+
<ArrowRight className="text-muted-foreground mb-3.5 h-4 w-4 shrink-0 md:mb-2.5" />
|
|
150
|
+
<VersionPicker
|
|
151
|
+
name="To"
|
|
152
|
+
value={to.id}
|
|
153
|
+
versions={versions}
|
|
154
|
+
onChange={(id) => setPair({ ...pair, to: id })}
|
|
155
|
+
/>
|
|
156
|
+
</div>
|
|
157
|
+
<div className="grid gap-2 @xl:grid-cols-2">
|
|
158
|
+
<Note v={a} label="Before" />
|
|
159
|
+
<Note v={b} label="After" />
|
|
160
|
+
</div>
|
|
161
|
+
<Segmented
|
|
162
|
+
label="Comparison"
|
|
163
|
+
value={view}
|
|
164
|
+
onChange={setView}
|
|
165
|
+
options={[
|
|
166
|
+
{ value: "changes", label: "Changes" },
|
|
167
|
+
{ value: "side", label: "Side by side" },
|
|
168
|
+
{ value: "source", label: "Source" },
|
|
169
|
+
]}
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
<div className="min-h-0 flex-1 overflow-auto">
|
|
173
|
+
{from.id === to.id ? (
|
|
174
|
+
<p className="text-muted-foreground p-4 text-sm">
|
|
175
|
+
Pick two different versions.
|
|
176
|
+
</p>
|
|
177
|
+
) : view === "side" ? (
|
|
178
|
+
<SideBySide from={a} to={b} />
|
|
179
|
+
) : (
|
|
180
|
+
<Changes
|
|
181
|
+
sessionId={sessionId}
|
|
182
|
+
from={a}
|
|
183
|
+
to={b}
|
|
184
|
+
mode={view === "source" ? "source" : "changes"}
|
|
185
|
+
onSide={() => setView("side")}
|
|
186
|
+
/>
|
|
187
|
+
)}
|
|
188
|
+
</div>
|
|
189
|
+
</div>
|
|
190
|
+
);
|
|
191
|
+
}
|
|
@@ -29,6 +29,7 @@ import { Markdown } from "./Markdown";
|
|
|
29
29
|
import { openSessionLink } from "./session-link";
|
|
30
30
|
import { TurnCopy } from "./TurnCopy";
|
|
31
31
|
import { UndoButton } from "./Undo";
|
|
32
|
+
import { ShareFromHere } from "./ShareFromHere";
|
|
32
33
|
import { useRowState } from "./rowState";
|
|
33
34
|
import { useBookmarkChat } from "@/components/Bookmarks/context";
|
|
34
35
|
import { ChatItemContext } from "./itemContext";
|
|
@@ -280,6 +281,7 @@ export function UserMessage({
|
|
|
280
281
|
<div className="-mr-1.5 flex items-center">
|
|
281
282
|
{onUndo && <UndoButton onClick={onUndo} />}
|
|
282
283
|
<ExportFromHere itemId={item.id} />
|
|
284
|
+
<ShareFromHere itemId={item.id} />
|
|
283
285
|
<BookmarkButton itemId={item.id} />
|
|
284
286
|
</div>
|
|
285
287
|
)}
|
|
@@ -325,6 +327,10 @@ export function AssistantMessage({
|
|
|
325
327
|
<Quote className="h-3.5 w-3.5" />
|
|
326
328
|
</button>
|
|
327
329
|
)}
|
|
330
|
+
<ShareFromHere
|
|
331
|
+
itemId={item.id}
|
|
332
|
+
className="text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] flex h-9 w-9 items-center justify-center rounded-md md:h-7 md:w-7"
|
|
333
|
+
/>
|
|
328
334
|
</div>
|
|
329
335
|
<BookmarkButton itemId={item.id} />
|
|
330
336
|
</div>
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { Share2 } from "lucide-react";
|
|
4
|
+
import { useBookmarkChat } from "@/components/Bookmarks/context";
|
|
5
|
+
import { shareUiActions } from "@/stores/shareUi";
|
|
6
|
+
|
|
7
|
+
// Opens the share dialog on the part of the chat from this message to the latest.
|
|
8
|
+
export function ShareFromHere({
|
|
9
|
+
itemId,
|
|
10
|
+
className,
|
|
11
|
+
}: {
|
|
12
|
+
itemId: string;
|
|
13
|
+
className?: string;
|
|
14
|
+
}) {
|
|
15
|
+
const chat = useBookmarkChat();
|
|
16
|
+
if (!chat) return null;
|
|
17
|
+
return (
|
|
18
|
+
<button
|
|
19
|
+
type="button"
|
|
20
|
+
aria-label="Share from here"
|
|
21
|
+
title="Share from here"
|
|
22
|
+
onClick={(e) => {
|
|
23
|
+
e.stopPropagation();
|
|
24
|
+
shareUiActions.open({
|
|
25
|
+
kind: "chat",
|
|
26
|
+
sessionId: chat.sessionId,
|
|
27
|
+
title: "",
|
|
28
|
+
fromId: itemId,
|
|
29
|
+
});
|
|
30
|
+
}}
|
|
31
|
+
className={
|
|
32
|
+
className ??
|
|
33
|
+
"text-muted-foreground hover:text-foreground hover:bg-foreground/[0.06] flex h-11 w-11 shrink-0 items-center justify-center rounded-md opacity-40 group-hover:opacity-100 focus-visible:opacity-100 md:h-7 md:w-7 md:opacity-0"
|
|
34
|
+
}
|
|
35
|
+
>
|
|
36
|
+
<Share2 className="h-3.5 w-3.5" />
|
|
37
|
+
</button>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -13,10 +13,11 @@ import { CommandsBody } from "./CommandsBody";
|
|
|
13
13
|
import { ConversationSearch } from "./conversations/ConversationSearch";
|
|
14
14
|
|
|
15
15
|
// ⌘K anywhere, or the header button on a phone: every action the open
|
|
16
|
-
// features offer, found by typing,
|
|
16
|
+
// features offer, found by typing, in tabs (Tab cycles them) that include
|
|
17
|
+
// what was said in any chat.
|
|
17
18
|
export function CommandPalette() {
|
|
18
|
-
const { open,
|
|
19
|
-
const wide =
|
|
19
|
+
const { open, tab } = useSnapshot(paletteUi);
|
|
20
|
+
const wide = tab === "conversations";
|
|
20
21
|
return (
|
|
21
22
|
<Dialog open={open} onOpenChange={paletteActions.setOpen}>
|
|
22
23
|
<DialogContent
|
|
@@ -37,7 +38,7 @@ export function CommandPalette() {
|
|
|
37
38
|
? "Search what was said in every conversation"
|
|
38
39
|
: "Search for an action, a session or a conversation"}
|
|
39
40
|
</DialogDescription>
|
|
40
|
-
{/* Mounted per opening and per
|
|
41
|
+
{/* Mounted per opening and per side, so each starts from its own query. */}
|
|
41
42
|
{wide ? (
|
|
42
43
|
<ConversationSearch key="conversations" />
|
|
43
44
|
) : (
|
|
@@ -2,84 +2,84 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
4
4
|
import { useSnapshot } from "valtio";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
import { ArrowRight, Search } from "lucide-react";
|
|
6
|
+
import { type PaletteCommand, type PaletteTab } from "@/lib/palette/registry";
|
|
7
|
+
import {
|
|
8
|
+
allSections,
|
|
9
|
+
emptySections,
|
|
10
|
+
withSeeAll,
|
|
11
|
+
tabItems,
|
|
12
|
+
type Section,
|
|
13
|
+
} from "@/lib/palette/sections";
|
|
14
|
+
import { cycleTab, parsePrefix } from "@/lib/palette/tabs";
|
|
15
|
+
import { loadUsage, noteUse } from "@/lib/palette/usage";
|
|
10
16
|
import { usePaletteList } from "@/hooks/usePaletteCommands";
|
|
11
|
-
import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
|
|
12
17
|
import { paletteActions, paletteUi } from "@/stores/palette";
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
18
|
+
import { PaletteRows } from "./PaletteRows";
|
|
19
|
+
import { PaletteTabs } from "./PaletteTabs";
|
|
20
|
+
import { useConversationRows } from "./useConversationRows";
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
22
|
+
const PLACEHOLDER: Record<PaletteTab, string> = {
|
|
23
|
+
all: "Search everything…",
|
|
24
|
+
sessions: "Search sessions…",
|
|
25
|
+
commands: "Search commands…",
|
|
26
|
+
settings: "Search settings…",
|
|
27
|
+
conversations: "",
|
|
28
|
+
};
|
|
29
|
+
// A tab's rows are cut here, so a long list stays quick to draw.
|
|
30
|
+
const TAB_LIMIT = 100;
|
|
20
31
|
|
|
21
32
|
export function CommandsBody() {
|
|
22
33
|
const commands = usePaletteList();
|
|
23
34
|
// Subscribed for changes, read raw: its items are held by ref.
|
|
24
35
|
useSnapshot(paletteUi);
|
|
25
36
|
const picker = paletteUi.picker;
|
|
37
|
+
const tab = paletteUi.tab;
|
|
26
38
|
const all = picker?.items ?? commands;
|
|
27
39
|
const [query, setQuery] = useState(paletteUi.query);
|
|
28
40
|
const [active, setActive] = useState(0);
|
|
41
|
+
// The tab a `>` or `@` chose, which Backspace on an empty query undoes.
|
|
42
|
+
const [prefixTab, setPrefixTab] = useState<PaletteTab | null>(null);
|
|
43
|
+
const [usage, setUsage] = useState(loadUsage);
|
|
44
|
+
const [now] = useState(Date.now);
|
|
29
45
|
const listRef = useRef<HTMLDivElement>(null);
|
|
46
|
+
const q = query.trim();
|
|
30
47
|
|
|
31
|
-
|
|
32
|
-
const { workspace } = useSelectedWorkspace();
|
|
33
|
-
const debounced = useDebouncedValue(query.trim(), 150);
|
|
34
|
-
const openHit = useOpenHit();
|
|
35
|
-
const { data: found } = useConversationSearch(
|
|
36
|
-
debounced,
|
|
37
|
-
workspace?.id ?? "all",
|
|
38
|
-
{
|
|
39
|
-
limit: CONVERSATIONS,
|
|
40
|
-
enabled: !picker,
|
|
41
|
-
}
|
|
42
|
-
);
|
|
43
|
-
const conversations = useMemo<PaletteCommand[]>(() => {
|
|
44
|
-
const q = query.trim();
|
|
45
|
-
if (picker || q.length < MIN_QUERY || q !== debounced) return [];
|
|
46
|
-
const hits = (found?.groups ?? []).flatMap((group) =>
|
|
47
|
-
group.hits.slice(0, 1).map((hit) => ({
|
|
48
|
-
id: `conversation:${group.sessionId}:${hit.itemId}`,
|
|
49
|
-
title: group.name,
|
|
50
|
-
detail: plain(hit.snippet),
|
|
51
|
-
group: "Conversations",
|
|
52
|
-
icon: MessagesSquare,
|
|
53
|
-
run: () => openHit(group, hit),
|
|
54
|
-
keepOpen: true,
|
|
55
|
-
}))
|
|
56
|
-
);
|
|
57
|
-
return [
|
|
58
|
-
...hits,
|
|
59
|
-
{
|
|
60
|
-
id: "conversation:all",
|
|
61
|
-
title: `Search all conversations for “${q}”`,
|
|
62
|
-
group: "Conversations",
|
|
63
|
-
icon: Search,
|
|
64
|
-
hint: "tab",
|
|
65
|
-
keepOpen: true,
|
|
66
|
-
run: () => paletteActions.setMode("conversations", q),
|
|
67
|
-
},
|
|
68
|
-
];
|
|
69
|
-
}, [found, query, debounced, picker, openHit]);
|
|
48
|
+
const conversations = useConversationRows(query, !picker && tab === "all");
|
|
70
49
|
|
|
71
|
-
const
|
|
72
|
-
if (query.trim()) return [...matchCommands(all, query), ...conversations];
|
|
50
|
+
const sections = useMemo<Section[]>(() => {
|
|
73
51
|
// A picker shows every choice; it's the whole list.
|
|
74
|
-
if (picker) return all;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
52
|
+
if (picker) return [{ name: "", items: all }];
|
|
53
|
+
if (tab === "all") {
|
|
54
|
+
if (!q) return emptySections(all, usage);
|
|
55
|
+
const found = withSeeAll(
|
|
56
|
+
allSections(all, query, usage, now),
|
|
57
|
+
(to) => paletteActions.setTab(to, query),
|
|
58
|
+
ArrowRight
|
|
59
|
+
);
|
|
60
|
+
return conversations.length
|
|
61
|
+
? [...found, { name: "Conversations", items: conversations }]
|
|
62
|
+
: found;
|
|
63
|
+
}
|
|
64
|
+
const items = tabItems(all, tab, query, usage, now).slice(0, TAB_LIMIT);
|
|
65
|
+
if (q) return [{ name: "", items }];
|
|
66
|
+
// Without a query, a long tab reads better under its own headings.
|
|
67
|
+
const groups: Section[] = [];
|
|
68
|
+
for (const c of items) {
|
|
69
|
+
let g = groups.find((x) => x.name === c.group);
|
|
70
|
+
if (!g) groups.push((g = { name: c.group, items: [] }));
|
|
71
|
+
g.items.push(c);
|
|
72
|
+
}
|
|
73
|
+
return groups;
|
|
74
|
+
}, [all, tab, q, query, usage, now, picker, conversations]);
|
|
75
|
+
const results = useMemo(() => sections.flatMap((s) => s.items), [sections]);
|
|
76
|
+
|
|
77
|
+
// A new tab starts at its top.
|
|
78
|
+
const [shownTab, setShownTab] = useState(tab);
|
|
79
|
+
if (shownTab !== tab) {
|
|
80
|
+
setShownTab(tab);
|
|
81
|
+
setActive(0);
|
|
82
|
+
}
|
|
83
83
|
const highlighted = Math.min(active, Math.max(results.length - 1, 0));
|
|
84
84
|
|
|
85
85
|
useEffect(() => {
|
|
@@ -88,23 +88,29 @@ export function CommandsBody() {
|
|
|
88
88
|
?.scrollIntoView({ block: "nearest" });
|
|
89
89
|
}, [highlighted]);
|
|
90
90
|
|
|
91
|
+
const goTo = (next: PaletteTab) => {
|
|
92
|
+
setPrefixTab(null);
|
|
93
|
+
paletteActions.setTab(next, query);
|
|
94
|
+
};
|
|
95
|
+
|
|
91
96
|
const run = (c: PaletteCommand | undefined) => {
|
|
92
97
|
if (!c) return;
|
|
93
98
|
if (c.keepOpen) return c.run();
|
|
99
|
+
if (!picker && !c.id.startsWith("conversation:")) setUsage(noteUse(c.id));
|
|
94
100
|
paletteActions.setOpen(false);
|
|
95
101
|
// After the dialog lets go of focus, so what it opens can take it.
|
|
96
102
|
setTimeout(() => c.run(), 0);
|
|
97
103
|
};
|
|
98
104
|
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
}
|
|
105
|
+
const onChange = (value: string) => {
|
|
106
|
+
const prefix = !picker && !query ? parsePrefix(value) : null;
|
|
107
|
+
setActive(0);
|
|
108
|
+
if (prefix) {
|
|
109
|
+
setQuery(prefix.text);
|
|
110
|
+
setPrefixTab(prefix.tab);
|
|
111
|
+
paletteActions.setTab(prefix.tab, prefix.text);
|
|
112
|
+
} else setQuery(value);
|
|
113
|
+
};
|
|
108
114
|
|
|
109
115
|
return (
|
|
110
116
|
<>
|
|
@@ -113,10 +119,7 @@ export function CommandsBody() {
|
|
|
113
119
|
<input
|
|
114
120
|
autoFocus
|
|
115
121
|
value={query}
|
|
116
|
-
onChange={(e) =>
|
|
117
|
-
setQuery(e.target.value);
|
|
118
|
-
setActive(0);
|
|
119
|
-
}}
|
|
122
|
+
onChange={(e) => onChange(e.target.value)}
|
|
120
123
|
onKeyDown={(e) => {
|
|
121
124
|
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
|
122
125
|
e.preventDefault();
|
|
@@ -126,10 +129,18 @@ export function CommandsBody() {
|
|
|
126
129
|
);
|
|
127
130
|
} else if (e.key === "Tab" && !picker) {
|
|
128
131
|
e.preventDefault();
|
|
129
|
-
|
|
132
|
+
goTo(cycleTab(tab, e.shiftKey ? -1 : 1));
|
|
130
133
|
} else if (e.key === "Enter") {
|
|
131
134
|
e.preventDefault();
|
|
132
135
|
run(results[highlighted]);
|
|
136
|
+
} else if (
|
|
137
|
+
e.key === "Backspace" &&
|
|
138
|
+
!query &&
|
|
139
|
+
prefixTab &&
|
|
140
|
+
tab === prefixTab
|
|
141
|
+
) {
|
|
142
|
+
e.preventDefault();
|
|
143
|
+
goTo("all");
|
|
133
144
|
} else if (
|
|
134
145
|
picker &&
|
|
135
146
|
(e.metaKey || e.ctrlKey) &&
|
|
@@ -139,21 +150,19 @@ export function CommandsBody() {
|
|
|
139
150
|
run(picker.items[Number(e.key) - 1]);
|
|
140
151
|
}
|
|
141
152
|
}}
|
|
142
|
-
placeholder={
|
|
143
|
-
|
|
144
|
-
}
|
|
145
|
-
aria-label={
|
|
146
|
-
picker?.placeholder ?? "Search actions, sessions and conversations"
|
|
147
|
-
}
|
|
153
|
+
placeholder={picker?.placeholder ?? PLACEHOLDER[tab]}
|
|
154
|
+
aria-label={picker?.placeholder ?? PLACEHOLDER[tab].replace("…", "")}
|
|
148
155
|
className="placeholder:text-muted-foreground h-12 min-w-0 flex-1 bg-transparent text-base outline-none sm:text-sm"
|
|
149
156
|
/>
|
|
150
157
|
<kbd className="text-muted-foreground hidden font-mono text-[10px] sm:inline">
|
|
151
158
|
esc
|
|
152
159
|
</kbd>
|
|
153
160
|
</div>
|
|
161
|
+
{!picker && <PaletteTabs tab={tab} onSelect={goTo} />}
|
|
154
162
|
<div
|
|
155
163
|
ref={listRef}
|
|
156
164
|
role="listbox"
|
|
165
|
+
aria-labelledby={picker ? undefined : `palette-tab-${tab}`}
|
|
157
166
|
className="min-h-0 flex-1 overflow-y-auto overscroll-contain p-1.5"
|
|
158
167
|
>
|
|
159
168
|
{results.length === 0 && (
|
|
@@ -161,55 +170,12 @@ export function CommandsBody() {
|
|
|
161
170
|
Nothing matches.
|
|
162
171
|
</p>
|
|
163
172
|
)}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
)}
|
|
171
|
-
{g.items.map(({ c, i }) => {
|
|
172
|
-
const Icon = c.icon as LucideIcon | undefined;
|
|
173
|
-
return (
|
|
174
|
-
<button
|
|
175
|
-
key={c.id}
|
|
176
|
-
type="button"
|
|
177
|
-
role="option"
|
|
178
|
-
aria-selected={i === highlighted}
|
|
179
|
-
data-index={i}
|
|
180
|
-
onMouseMove={() => i !== highlighted && setActive(i)}
|
|
181
|
-
onClick={() => run(c)}
|
|
182
|
-
className={cn(
|
|
183
|
-
"flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2 text-left text-sm md:min-h-9",
|
|
184
|
-
i === highlighted && "bg-primary/10"
|
|
185
|
-
)}
|
|
186
|
-
>
|
|
187
|
-
{Icon && (
|
|
188
|
-
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
189
|
-
)}
|
|
190
|
-
<span className="min-w-0 flex-1">
|
|
191
|
-
<span className="block truncate">{c.title}</span>
|
|
192
|
-
{c.detail && (
|
|
193
|
-
<span className="text-muted-foreground block truncate text-xs">
|
|
194
|
-
{c.detail}
|
|
195
|
-
</span>
|
|
196
|
-
)}
|
|
197
|
-
</span>
|
|
198
|
-
{query.trim() && c.group !== "Conversations" && (
|
|
199
|
-
<span className="text-muted-foreground shrink-0 text-xs">
|
|
200
|
-
{c.group}
|
|
201
|
-
</span>
|
|
202
|
-
)}
|
|
203
|
-
{c.hint && (
|
|
204
|
-
<kbd className="text-muted-foreground hidden shrink-0 font-mono text-[11px] sm:inline">
|
|
205
|
-
{c.hint}
|
|
206
|
-
</kbd>
|
|
207
|
-
)}
|
|
208
|
-
</button>
|
|
209
|
-
);
|
|
210
|
-
})}
|
|
211
|
-
</div>
|
|
212
|
-
))}
|
|
173
|
+
<PaletteRows
|
|
174
|
+
sections={sections}
|
|
175
|
+
highlighted={highlighted}
|
|
176
|
+
onHover={setActive}
|
|
177
|
+
onRun={run}
|
|
178
|
+
/>
|
|
213
179
|
</div>
|
|
214
180
|
</>
|
|
215
181
|
);
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import type { LucideIcon } from "lucide-react";
|
|
4
|
+
import type { PaletteCommand } from "@/lib/palette/registry";
|
|
5
|
+
import type { Section } from "@/lib/palette/sections";
|
|
6
|
+
import { cn } from "@/lib/utils";
|
|
7
|
+
|
|
8
|
+
// The sections' rows under their headings, numbered in order so the caller's
|
|
9
|
+
// highlight and Enter agree with what's drawn.
|
|
10
|
+
export function PaletteRows({
|
|
11
|
+
sections,
|
|
12
|
+
highlighted,
|
|
13
|
+
onHover,
|
|
14
|
+
onRun,
|
|
15
|
+
}: {
|
|
16
|
+
sections: Section[];
|
|
17
|
+
highlighted: number;
|
|
18
|
+
onHover: (i: number) => void;
|
|
19
|
+
onRun: (c: PaletteCommand) => void;
|
|
20
|
+
}) {
|
|
21
|
+
let index = 0;
|
|
22
|
+
return (
|
|
23
|
+
<>
|
|
24
|
+
{sections.map((s) => (
|
|
25
|
+
<div key={s.name || "results"} className="mb-1">
|
|
26
|
+
{s.name && (
|
|
27
|
+
<p className="text-muted-foreground px-2 pt-2 pb-1 text-[11px] font-medium">
|
|
28
|
+
{s.name}
|
|
29
|
+
</p>
|
|
30
|
+
)}
|
|
31
|
+
{s.items.map((c) => {
|
|
32
|
+
const i = index++;
|
|
33
|
+
const Icon = c.icon as LucideIcon | undefined;
|
|
34
|
+
return (
|
|
35
|
+
<button
|
|
36
|
+
key={c.id}
|
|
37
|
+
type="button"
|
|
38
|
+
role="option"
|
|
39
|
+
aria-selected={i === highlighted}
|
|
40
|
+
data-index={i}
|
|
41
|
+
onMouseMove={() => i !== highlighted && onHover(i)}
|
|
42
|
+
onClick={() => onRun(c)}
|
|
43
|
+
className={cn(
|
|
44
|
+
"flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2 text-left text-sm md:min-h-9",
|
|
45
|
+
i === highlighted && "bg-primary/10",
|
|
46
|
+
c.id.startsWith("see-all:") && "text-muted-foreground"
|
|
47
|
+
)}
|
|
48
|
+
>
|
|
49
|
+
{Icon && (
|
|
50
|
+
<Icon className="text-muted-foreground h-4 w-4 shrink-0" />
|
|
51
|
+
)}
|
|
52
|
+
<span className="min-w-0 flex-1">
|
|
53
|
+
<span className="block truncate">{c.title}</span>
|
|
54
|
+
{c.detail && (
|
|
55
|
+
<span
|
|
56
|
+
className={cn(
|
|
57
|
+
"block truncate text-xs",
|
|
58
|
+
c.status === "waiting"
|
|
59
|
+
? "text-amber-700 dark:text-amber-400"
|
|
60
|
+
: "text-muted-foreground"
|
|
61
|
+
)}
|
|
62
|
+
>
|
|
63
|
+
{c.detail}
|
|
64
|
+
</span>
|
|
65
|
+
)}
|
|
66
|
+
</span>
|
|
67
|
+
{c.hint && (
|
|
68
|
+
<kbd className="text-muted-foreground hidden shrink-0 font-mono text-[11px] sm:inline">
|
|
69
|
+
{c.hint}
|
|
70
|
+
</kbd>
|
|
71
|
+
)}
|
|
72
|
+
</button>
|
|
73
|
+
);
|
|
74
|
+
})}
|
|
75
|
+
</div>
|
|
76
|
+
))}
|
|
77
|
+
</>
|
|
78
|
+
);
|
|
79
|
+
}
|