@saadnvd1/agent-os 0.3.768 → 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.
Files changed (77) hide show
  1. package/app/api/sessions/[id]/artifacts/[artifactId]/compare/route.ts +41 -0
  2. package/app/api/sessions/resolve/route.test.ts +81 -0
  3. package/app/api/sessions/resolve/route.ts +14 -0
  4. package/app/api/shares/preview/route.ts +6 -1
  5. package/app/api/shares/route.test.ts +51 -0
  6. package/app/page.tsx +1 -0
  7. package/components/Artifacts/ArtifactActions.tsx +27 -3
  8. package/components/Artifacts/ArtifactPreview.tsx +27 -12
  9. package/components/Artifacts/Compare/DiffView.tsx +116 -0
  10. package/components/Artifacts/Compare/SideBySide.tsx +60 -0
  11. package/components/Artifacts/Compare/VersionPicker.tsx +47 -0
  12. package/components/Artifacts/Compare/index.tsx +191 -0
  13. package/components/Chat/ChatPanel.tsx +3 -9
  14. package/components/Chat/EventRow.tsx +4 -4
  15. package/components/Chat/Messages.tsx +12 -6
  16. package/components/Chat/SessionAnchor.tsx +22 -0
  17. package/components/Chat/ShareFromHere.tsx +39 -0
  18. package/components/Chat/session-link.ts +21 -4
  19. package/components/CommandPalette/CommandPalette.tsx +5 -4
  20. package/components/CommandPalette/CommandsBody.tsx +97 -131
  21. package/components/CommandPalette/PaletteRows.tsx +79 -0
  22. package/components/CommandPalette/PaletteTabs.tsx +51 -0
  23. package/components/CommandPalette/conversations/ConversationSearch.tsx +13 -11
  24. package/components/CommandPalette/useAppCommands.ts +14 -0
  25. package/components/CommandPalette/useConversationRows.ts +55 -0
  26. package/components/Health/EnvFailures/SessionList.tsx +2 -2
  27. package/components/Pane/PaneMenu.tsx +1 -1
  28. package/components/SessionList/QueuedRow.tsx +4 -4
  29. package/components/SessionList/SessionRowMenu.tsx +1 -1
  30. package/components/SessionList/useSessionSwitching.ts +2 -2
  31. package/components/Share/MessageList.tsx +87 -0
  32. package/components/Share/RangePicker.tsx +59 -0
  33. package/components/Share/ShareDialog.tsx +113 -52
  34. package/components/Shares/SharedLinksPanel.tsx +3 -5
  35. package/data/session-artifacts/keys.ts +2 -0
  36. package/data/session-artifacts/queries.ts +25 -0
  37. package/data/shares/queries.ts +11 -0
  38. package/hooks/useCopyToClipboard.ts +5 -2
  39. package/hooks/usePaletteCommands.ts +4 -1
  40. package/hooks/useSessionHref.ts +20 -0
  41. package/hooks/useSessionUrl.ts +40 -11
  42. package/lib/artifacts/actions.ts +11 -1
  43. package/lib/artifacts/compare/compareVersions.ts +66 -0
  44. package/lib/artifacts/compare/diff.ts +178 -0
  45. package/lib/artifacts/compare/extractText.ts +64 -0
  46. package/lib/artifacts/compare/index.ts +11 -0
  47. package/lib/artifacts/compare/types.ts +44 -0
  48. package/lib/artifacts/match.ts +64 -0
  49. package/lib/artifacts/store.ts +39 -10
  50. package/lib/artifacts/tools.ts +9 -4
  51. package/lib/artifacts/versions.ts +2 -1
  52. package/lib/away/deploy.ts +39 -2
  53. package/lib/palette/registry.ts +71 -17
  54. package/lib/palette/sections.ts +143 -0
  55. package/lib/palette/tabs.ts +21 -0
  56. package/lib/palette/usage.ts +81 -0
  57. package/lib/reports/evidence/build.ts +4 -3
  58. package/lib/security/demo.ts +3 -0
  59. package/lib/session-audit/render.ts +2 -1
  60. package/lib/session-logs/gather.ts +2 -2
  61. package/lib/session-slug.ts +29 -0
  62. package/lib/session-url.ts +162 -40
  63. package/lib/sessions/resolve.ts +55 -0
  64. package/lib/settings-url.ts +1 -1
  65. package/lib/share/markdown.ts +3 -0
  66. package/lib/share/range.ts +189 -0
  67. package/lib/share/render.tsx +3 -0
  68. package/lib/share/service.ts +64 -19
  69. package/lib/share/snapshot.ts +39 -2
  70. package/lib/share/styles.ts +2 -0
  71. package/lib/share/view.ts +11 -0
  72. package/lib/welcome-url.ts +1 -1
  73. package/next.config.ts +4 -0
  74. package/package.json +1 -1
  75. package/stores/chatJump.ts +5 -1
  76. package/stores/palette.ts +13 -11
  77. package/stores/shareUi.ts +6 -1
@@ -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
  );
@@ -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
+ }
@@ -0,0 +1,51 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { PALETTE_TABS, type PaletteTab } from "@/lib/palette/registry";
5
+ import { cn } from "@/lib/utils";
6
+
7
+ // The palette's tabs. Tab and Shift-Tab in the input move between them, so
8
+ // these keep out of the focus order and never take focus from it.
9
+ export function PaletteTabs({
10
+ tab,
11
+ onSelect,
12
+ }: {
13
+ tab: PaletteTab;
14
+ onSelect: (tab: PaletteTab) => void;
15
+ }) {
16
+ const ref = useRef<HTMLDivElement>(null);
17
+ useEffect(() => {
18
+ ref.current
19
+ ?.querySelector('[aria-selected="true"]')
20
+ ?.scrollIntoView({ block: "nearest", inline: "nearest" });
21
+ }, [tab]);
22
+ return (
23
+ <div
24
+ ref={ref}
25
+ role="tablist"
26
+ aria-label="Palette sections"
27
+ className="border-foreground/[0.06] flex shrink-0 scrollbar-none gap-1 overflow-x-auto border-b px-2 py-1.5"
28
+ >
29
+ {PALETTE_TABS.map((t) => (
30
+ <button
31
+ key={t.id}
32
+ type="button"
33
+ role="tab"
34
+ id={`palette-tab-${t.id}`}
35
+ aria-selected={t.id === tab}
36
+ tabIndex={-1}
37
+ onMouseDown={(e) => e.preventDefault()}
38
+ onClick={() => onSelect(t.id)}
39
+ className={cn(
40
+ "flex min-h-11 shrink-0 items-center rounded-lg px-3 text-sm whitespace-nowrap md:min-h-8 md:px-2.5 md:text-xs",
41
+ t.id === tab
42
+ ? "bg-primary/10 text-foreground font-medium"
43
+ : "text-muted-foreground hover:text-foreground"
44
+ )}
45
+ >
46
+ {t.label}
47
+ </button>
48
+ ))}
49
+ </div>
50
+ );
51
+ }
@@ -8,14 +8,16 @@ import { MIN_QUERY, useConversationSearch } from "@/data/search";
8
8
  import { useDebouncedValue } from "@/hooks/useDebouncedValue";
9
9
  import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
10
10
  import { useViewport } from "@/hooks/useViewport";
11
+ import { cycleTab } from "@/lib/palette/tabs";
11
12
  import { paletteActions, paletteUi } from "@/stores/palette";
13
+ import { PaletteTabs } from "../PaletteTabs";
12
14
  import { NoResults, Recent, Skeleton } from "./EmptyState";
13
15
  import { Preview } from "./Preview";
14
16
  import { ResultList, flatten } from "./ResultList";
15
17
  import { loadRecent, saveRecent, withRecent } from "./recent";
16
18
  import { useOpenHit } from "./useOpenHit";
17
19
 
18
- // ⌘K's second mode: what was said in any chat, found as you type. Results on
20
+ // ⌘K's Conversations tab: what was said in any chat, found as you type. Results on
19
21
  // the left, the selected message in its conversation on the right; a phone
20
22
  // shows the list, and a tap shows the message.
21
23
  export function ConversationSearch() {
@@ -97,7 +99,10 @@ export function ConversationSearch() {
97
99
  openHit(highlighted);
98
100
  } else if (e.key === "Tab") {
99
101
  e.preventDefault();
100
- paletteActions.setMode("commands", text);
102
+ paletteActions.setTab(
103
+ cycleTab("conversations", e.shiftKey ? -1 : 1),
104
+ text
105
+ );
101
106
  }
102
107
  }}
103
108
  placeholder="Search every conversation…"
@@ -111,8 +116,12 @@ export function ConversationSearch() {
111
116
  esc
112
117
  </kbd>
113
118
  </div>
119
+ <PaletteTabs
120
+ tab="conversations"
121
+ onSelect={(tab) => paletteActions.setTab(tab, text)}
122
+ />
114
123
  {workspace && (
115
- <div className="flex items-center gap-2 px-3 pb-2">
124
+ <div className="flex items-center gap-2 px-3 py-2">
116
125
  <div className="flex overflow-hidden rounded-md border text-xs">
117
126
  {(
118
127
  [
@@ -222,19 +231,12 @@ export function ConversationSearch() {
222
231
  <kbd className="font-mono">↵</kbd> open
223
232
  </span>
224
233
  <span>
225
- <kbd className="font-mono">tab</kbd> commands
234
+ <kbd className="font-mono">tab</kbd> next tab
226
235
  </span>
227
236
  <span>
228
237
  <kbd className="font-mono">esc</kbd> close
229
238
  </span>
230
239
  </span>
231
- <button
232
- type="button"
233
- onClick={() => paletteActions.setMode("commands", text)}
234
- className="h-8 sm:hidden"
235
- >
236
- Commands
237
- </button>
238
240
  <span className="ml-auto font-mono">
239
241
  {searching && data
240
242
  ? `${groups.length} ${groups.length === 1 ? "chat" : "chats"} · ${data.tookMs} ms`
@@ -23,6 +23,7 @@ import {
23
23
  Scale,
24
24
  } from "lucide-react";
25
25
  import type { Session } from "@/lib/db";
26
+ import type { SessionStatus } from "@/components/views/types";
26
27
  import type { PaletteCommand } from "@/lib/palette/registry";
27
28
  import { useWorkspacesQuery } from "@/data/workspaces";
28
29
  import { useRecipesQuery } from "@/data/recipes";
@@ -57,13 +58,25 @@ function switchMode(current: string | undefined, to: "light" | "dark"): string {
57
58
  }
58
59
  }
59
60
 
61
+ const STATE: Record<string, { status: "running" | "waiting"; detail: string }> =
62
+ {
63
+ waiting: { status: "waiting", detail: "Waiting on you" },
64
+ running: { status: "running", detail: "Working" },
65
+ };
66
+
67
+ function sessionState(s: SessionStatus | undefined) {
68
+ return s?.need || (s?.asks ?? 0) > 0 ? STATE.waiting : STATE[s?.status ?? ""];
69
+ }
70
+
60
71
  // The palette's app-wide commands and its session search.
61
72
  export function useAppCommands({
62
73
  sessions,
74
+ statuses,
63
75
  onSelectSession,
64
76
  onSearchCode,
65
77
  }: {
66
78
  sessions: Session[];
79
+ statuses: Record<string, SessionStatus>;
67
80
  onSelectSession: (session: Session) => void;
68
81
  onSearchCode: () => void;
69
82
  }) {
@@ -260,6 +273,7 @@ export function useAppCommands({
260
273
  group: "Sessions",
261
274
  keywords: [s.working_directory, s.branch_name ?? ""].filter(Boolean),
262
275
  icon: MessageSquare,
276
+ ...sessionState(statuses[s.id]),
263
277
  run: () => onSelectSession(s),
264
278
  }))
265
279
  );
@@ -0,0 +1,55 @@
1
+ "use client";
2
+
3
+ import { useMemo } from "react";
4
+ import { MessagesSquare, Search } from "lucide-react";
5
+ import type { PaletteCommand } from "@/lib/palette/registry";
6
+ import { MIN_QUERY, useConversationSearch } from "@/data/search";
7
+ import { useDebouncedValue } from "@/hooks/useDebouncedValue";
8
+ import { useSelectedWorkspace } from "@/hooks/useSelectedWorkspace";
9
+ import { paletteActions } from "@/stores/palette";
10
+ import { plain } from "./conversations/format";
11
+ import { useOpenHit } from "./conversations/useOpenHit";
12
+
13
+ // Conversation matches offered last in All.
14
+ const CONVERSATIONS = 3;
15
+
16
+ // The few conversations a query finds, then a way into the full search.
17
+ export function useConversationRows(
18
+ query: string,
19
+ enabled: boolean
20
+ ): PaletteCommand[] {
21
+ const { workspace } = useSelectedWorkspace();
22
+ const debounced = useDebouncedValue(query.trim(), 150);
23
+ const openHit = useOpenHit();
24
+ const { data: found } = useConversationSearch(
25
+ debounced,
26
+ workspace?.id ?? "all",
27
+ { limit: CONVERSATIONS, enabled }
28
+ );
29
+ return useMemo(() => {
30
+ const q = query.trim();
31
+ if (!enabled || q.length < MIN_QUERY || q !== debounced) return [];
32
+ const hits = (found?.groups ?? []).flatMap((group) =>
33
+ group.hits.slice(0, 1).map((hit) => ({
34
+ id: `conversation:${group.sessionId}:${hit.itemId}`,
35
+ title: group.name,
36
+ detail: plain(hit.snippet),
37
+ group: "Conversations",
38
+ icon: MessagesSquare,
39
+ run: () => openHit(group, hit),
40
+ keepOpen: true,
41
+ }))
42
+ );
43
+ return [
44
+ ...hits,
45
+ {
46
+ id: "conversation:all",
47
+ title: `Search all conversations for “${q}”`,
48
+ group: "Conversations",
49
+ icon: Search,
50
+ keepOpen: true,
51
+ run: () => paletteActions.setTab("conversations", q),
52
+ },
53
+ ];
54
+ }, [found, query, debounced, enabled, openHit]);
55
+ }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { openSessionLink } from "@/components/Chat/session-link";
4
4
  import { compactTimeAgo } from "@/lib/session-meta";
5
- import { hrefWithSession } from "@/lib/session-url";
5
+ import { sessionPath } from "@/lib/session-url";
6
6
  import type { EnvSessionView } from "@/lib/health/env/types";
7
7
 
8
8
  export function SessionList({
@@ -27,7 +27,7 @@ export function SessionList({
27
27
  {sessions.map((s) => (
28
28
  <li key={s.sessionId} className="min-h-11 text-sm">
29
29
  <a
30
- href={hrefWithSession("/", s.sessionId)}
30
+ href={sessionPath({ id: s.sessionId, name: s.name })}
31
31
  onClick={(e) => openSessionLink(e, s.sessionId)}
32
32
  className="font-medium underline-offset-2 hover:underline"
33
33
  >
@@ -103,7 +103,7 @@ export function PaneMenu({
103
103
  </div>
104
104
  )}
105
105
  </DropdownMenuLabel>
106
- <DropdownMenuItem onSelect={() => copySessionLink(session.id)}>
106
+ <DropdownMenuItem onSelect={() => copySessionLink(session)}>
107
107
  <Link className="h-4 w-4" />
108
108
  Copy link
109
109
  </DropdownMenuItem>
@@ -12,7 +12,7 @@ import {
12
12
  queuedSubtitle,
13
13
  type QueuedRow as Row,
14
14
  } from "@/lib/sidebar/queued";
15
- import { sessionLink } from "@/lib/session-url";
15
+ import { SessionAnchor } from "@/components/Chat/SessionAnchor";
16
16
  import { cn } from "@/lib/utils";
17
17
  import { useRowContext } from "./RowContext";
18
18
  import { RowShell, type RowMenuKind } from "./RowShell";
@@ -184,8 +184,8 @@ function QueuedSubtitle({ row }: { row: Row }) {
184
184
  (target ? (
185
185
  <>
186
186
  {" "}
187
- <a
188
- href={sessionLink("", target)}
187
+ <SessionAnchor
188
+ sessionId={target}
189
189
  onClick={(e) => {
190
190
  // The row opens it too; the link alone handles its click.
191
191
  e.stopPropagation();
@@ -198,7 +198,7 @@ function QueuedSubtitle({ row }: { row: Row }) {
198
198
  className="hover:text-foreground underline decoration-dotted underline-offset-2"
199
199
  >
200
200
  {after}
201
- </a>
201
+ </SessionAnchor>
202
202
  </>
203
203
  ) : (
204
204
  ` ${after}`
@@ -191,7 +191,7 @@ export function SessionRowMenu({
191
191
  recipesUiActions.openRecipe(recipeRun!.id)
192
192
  );
193
193
  case "copy-link":
194
- return item("Copy link", Link, () => copySessionLink(session.id));
194
+ return item("Copy link", Link, () => copySessionLink(session));
195
195
  case "share":
196
196
  return item("Share…", Share2, () =>
197
197
  shareUiActions.open({