@saadnvd1/agent-os 0.3.770 → 0.3.777

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.
Files changed (89) hide show
  1. package/app/api/live-shares/[id]/route.ts +72 -0
  2. package/app/api/live-shares/route.test.ts +45 -0
  3. package/app/api/live-shares/route.ts +40 -0
  4. package/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
  5. package/app/api/shares/preview/route.ts +6 -1
  6. package/app/api/shares/route.test.ts +51 -0
  7. package/app/page.tsx +6 -0
  8. package/components/Artifacts/ArtifactActions.tsx +27 -3
  9. package/components/Artifacts/ArtifactPreview.tsx +27 -12
  10. package/components/Artifacts/Compare/DiffView.tsx +116 -0
  11. package/components/Artifacts/Compare/SideBySide.tsx +60 -0
  12. package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
  13. package/components/Artifacts/Compare/index.tsx +191 -0
  14. package/components/Chat/ChatPanel.tsx +6 -0
  15. package/components/Chat/EventRow.tsx +6 -0
  16. package/components/Chat/Messages.tsx +6 -0
  17. package/components/Chat/ShareFromHere.tsx +39 -0
  18. package/components/CommandPalette/CommandPalette.tsx +5 -4
  19. package/components/CommandPalette/CommandsBody.tsx +97 -131
  20. package/components/CommandPalette/PaletteRows.tsx +79 -0
  21. package/components/CommandPalette/PaletteTabs.tsx +51 -0
  22. package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
  23. package/components/CommandPalette/useAppCommands.ts +14 -0
  24. package/components/CommandPalette/useConversationRows.ts +55 -0
  25. package/components/Live/GuestPromptCard.tsx +64 -0
  26. package/components/Live/InviteDialog.tsx +225 -0
  27. package/components/Live/LiveChip.tsx +109 -0
  28. package/components/Live/LivePanel.tsx +81 -0
  29. package/components/Live/LobbyCard.tsx +64 -0
  30. package/components/Pane/MobileTabBar.tsx +8 -0
  31. package/components/Pane/PaneMenu.tsx +10 -0
  32. package/components/SessionList/SessionRowMenu.tsx +6 -0
  33. package/components/SessionList/useSessionSwitching.ts +2 -2
  34. package/components/Share/MessageList.tsx +87 -0
  35. package/components/Share/RangePicker.tsx +59 -0
  36. package/components/Share/ShareDialog.tsx +113 -52
  37. package/components/Shares/SharedLinksPanel.tsx +51 -0
  38. package/data/live/index.ts +2 -0
  39. package/data/live/keys.ts +4 -0
  40. package/data/live/queries.ts +81 -0
  41. package/data/session-artifacts/keys.ts +2 -0
  42. package/data/session-artifacts/queries.ts +25 -0
  43. package/data/shares/queries.ts +11 -0
  44. package/hooks/usePaletteCommands.ts +4 -1
  45. package/lib/artifacts/actions.ts +11 -1
  46. package/lib/artifacts/compare/compareVersions.ts +66 -0
  47. package/lib/artifacts/compare/diff.ts +178 -0
  48. package/lib/artifacts/compare/extractText.ts +64 -0
  49. package/lib/artifacts/compare/index.ts +11 -0
  50. package/lib/artifacts/compare/types.ts +44 -0
  51. package/lib/artifacts/match.ts +64 -0
  52. package/lib/artifacts/store.ts +39 -10
  53. package/lib/artifacts/tools.ts +9 -4
  54. package/lib/artifacts/versions.ts +2 -1
  55. package/lib/chat/events.ts +4 -1
  56. package/lib/chat/registry.ts +10 -0
  57. package/lib/connect/control-api.ts +44 -0
  58. package/lib/db/migrations/20261029T1400_live_shares.ts +42 -0
  59. package/lib/live/clean.ts +54 -0
  60. package/lib/live/frames.ts +132 -0
  61. package/lib/live/inbox.ts +225 -0
  62. package/lib/live/publisher.ts +84 -0
  63. package/lib/live/ring.ts +61 -0
  64. package/lib/live/runtime.ts +51 -0
  65. package/lib/live/service.ts +318 -0
  66. package/lib/live/socket.ts +293 -0
  67. package/lib/live/store.ts +149 -0
  68. package/lib/live/types.ts +39 -0
  69. package/lib/palette/registry.ts +71 -17
  70. package/lib/palette/sections.ts +143 -0
  71. package/lib/palette/tabs.ts +21 -0
  72. package/lib/palette/usage.ts +81 -0
  73. package/lib/security/demo.ts +3 -0
  74. package/lib/share/connect.ts +24 -0
  75. package/lib/share/markdown.ts +3 -0
  76. package/lib/share/range.ts +189 -0
  77. package/lib/share/redact.ts +23 -1
  78. package/lib/share/render.tsx +3 -0
  79. package/lib/share/secret-patterns.ts +77 -0
  80. package/lib/share/service.ts +64 -19
  81. package/lib/share/snapshot.ts +39 -2
  82. package/lib/share/styles.ts +2 -0
  83. package/lib/share/view.ts +11 -0
  84. package/lib/sidebar/row-menu.ts +2 -0
  85. package/package.json +1 -1
  86. package/server.ts +2 -0
  87. package/stores/liveUi.ts +17 -0
  88. package/stores/palette.ts +13 -11
  89. package/stores/shareUi.ts +6 -1
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import {
4
+ Select,
5
+ SelectContent,
6
+ SelectItem,
7
+ SelectTrigger,
8
+ SelectValue,
9
+ } from "@/components/ui/select";
10
+ import type { ArtifactVersion } from "@/lib/artifacts/actions";
11
+
12
+ const label = (v: ArtifactVersion, latest: boolean) =>
13
+ `v${v.version}${latest ? " · latest" : ""}`;
14
+
15
+ export function VersionPicker({
16
+ name,
17
+ value,
18
+ versions,
19
+ onChange,
20
+ }: {
21
+ name: string;
22
+ value: string;
23
+ versions: ArtifactVersion[];
24
+ onChange: (id: string) => void;
25
+ }) {
26
+ const last = versions[versions.length - 1];
27
+ return (
28
+ <div className="flex min-w-0 flex-1 flex-col gap-1">
29
+ <span className="text-muted-foreground text-[11px] font-medium tracking-wider uppercase">
30
+ {name}
31
+ </span>
32
+ <Select value={value} onValueChange={onChange}>
33
+ <SelectTrigger aria-label={`${name} version`} className="h-11 md:h-9">
34
+ <SelectValue />
35
+ </SelectTrigger>
36
+ <SelectContent>
37
+ {[...versions].reverse().map((v) => (
38
+ <SelectItem key={v.id} value={v.id} className="min-h-11 md:min-h-8">
39
+ {label(v, v.id === last?.id)}
40
+ {v.note ? ` · ${v.note}` : ""}
41
+ </SelectItem>
42
+ ))}
43
+ </SelectContent>
44
+ </Select>
45
+ </div>
46
+ );
47
+ }
@@ -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
+ }
@@ -40,6 +40,7 @@ import { SubagentCard } from "./Subagent";
40
40
  import { UndoDialog, UndoneBlock } from "./Undo";
41
41
  import { Composer } from "./Composer";
42
42
  import { Queue } from "./Queue";
43
+ import { LivePanel } from "@/components/Live/LivePanel";
43
44
  import { StaleChip } from "./StaleChip";
44
45
  import { LearningCardsHost } from "@/components/LearningCards/LearningCard";
45
46
  import { composerCompacts } from "@/lib/chat/stale";
@@ -834,6 +835,11 @@ export function ChatPanel({
834
835
  onAsIs={setAsIs}
835
836
  />
836
837
  <LearningCardsHost sessionId={sessionId} />
838
+ <LivePanel
839
+ sessionId={sessionId}
840
+ title={sessionName}
841
+ onEdit={(text) => setPrefill({ text, at: Date.now() })}
842
+ />
837
843
  <Composer
838
844
  draftKey={sessionId}
839
845
  attachScope={sessionId}
@@ -7,6 +7,7 @@ import {
7
7
  Info,
8
8
  Smartphone,
9
9
  UserCheck,
10
+ Users,
10
11
  Zap,
11
12
  type LucideIcon,
12
13
  } from "lucide-react";
@@ -27,6 +28,7 @@ export const ICON: Record<ChatOriginKind, LucideIcon> = {
27
28
  system: Info,
28
29
  decision: UserCheck,
29
30
  device: Smartphone,
31
+ guest: Users,
30
32
  };
31
33
 
32
34
  // Longer than this (what fits beside a source on a phone), or more than one line,
@@ -177,6 +179,10 @@ export function EventRow({
177
179
  head={
178
180
  decision ? (
179
181
  <span className="truncate font-medium">Saad decided</span>
182
+ ) : origin.kind === "guest" ? (
183
+ <span className="truncate font-medium">
184
+ Suggested by {origin.label}
185
+ </span>
180
186
  ) : (
181
187
  <Source origin={origin} />
182
188
  )
@@ -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, and (Tab) what was said in any chat.
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, mode } = useSnapshot(paletteUi);
19
- const wide = mode === "conversations";
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 mode, so each starts from its own query. */}
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 { MessagesSquare, Search, type LucideIcon } from "lucide-react";
6
- import { matchCommands, type PaletteCommand } from "@/lib/palette/registry";
7
- import { cn } from "@/lib/utils";
8
- import { MIN_QUERY, useConversationSearch } from "@/data/search";
9
- import { useDebouncedValue } from "@/hooks/useDebouncedValue";
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 { plain } from "./conversations/format";
14
- import { useOpenHit } from "./conversations/useOpenHit";
18
+ import { PaletteRows } from "./PaletteRows";
19
+ import { PaletteTabs } from "./PaletteTabs";
20
+ import { useConversationRows } from "./useConversationRows";
15
21
 
16
- // How many of a long group (sessions) show before a query narrows it.
17
- const GROUP_PREVIEW = 8;
18
- // Conversation matches offered beside the commands.
19
- const CONVERSATIONS = 3;
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
- // Conversations that match, under the commands (never in a picker).
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 results = useMemo(() => {
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
- // With no query, long groups are cut short so every group shows.
76
- const seen = new Map<string, number>();
77
- return all.filter((c) => {
78
- const n = (seen.get(c.group) ?? 0) + 1;
79
- seen.set(c.group, n);
80
- return n <= GROUP_PREVIEW;
81
- });
82
- }, [all, query, picker, conversations]);
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
- // Under their headings with no query; one ranked list with one.
100
- const groups: { name: string; items: { c: PaletteCommand; i: number }[] }[] =
101
- [];
102
- results.forEach((c, i) => {
103
- const name = query.trim() && c.group !== "Conversations" ? "" : c.group;
104
- let g = groups.find((x) => x.name === name);
105
- if (!g) groups.push((g = { name, items: [] }));
106
- g.items.push({ c, i });
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
- paletteActions.setMode("conversations", query);
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
- picker?.placeholder ?? "Search actions, sessions and conversations…"
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
- {groups.map((g) => (
165
- <div key={g.name || "results"} className="mb-1">
166
- {g.name && (
167
- <p className="text-muted-foreground px-2 pt-2 pb-1 text-[11px] font-medium">
168
- {g.name}
169
- </p>
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
  );