@saadnvd1/agent-os 0.3.672 → 0.3.678

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 (88) hide show
  1. package/README.md +2 -1
  2. package/app/api/bus/read/route.ts +34 -0
  3. package/app/api/files/upload-temp/route.test.ts +144 -0
  4. package/app/api/files/upload-temp/route.ts +52 -21
  5. package/app/api/search/conversations/context/route.ts +26 -0
  6. package/app/api/search/conversations/route.test.ts +139 -0
  7. package/app/api/search/conversations/route.ts +25 -0
  8. package/app/api/sessions/[id]/route.ts +2 -0
  9. package/app/api/sessions/route.ts +6 -2
  10. package/app/globals.css +25 -0
  11. package/components/Bookmarks/BookmarksDialog.tsx +2 -8
  12. package/components/Chat/ChatPanel.tsx +9 -2
  13. package/components/Chat/FileChip.tsx +67 -0
  14. package/components/Chat/Messages.tsx +8 -0
  15. package/components/Chat/Queue.tsx +6 -0
  16. package/components/Chat/composer/Attachments.tsx +16 -2
  17. package/components/Chat/composer/ComposerBody.tsx +35 -14
  18. package/components/Chat/composer/Toolbar.tsx +11 -8
  19. package/components/Chat/composer/editor.test.ts +1 -1
  20. package/components/Chat/composer/paste.ts +5 -4
  21. package/components/Chat/composer/useAttachFiles.ts +142 -0
  22. package/components/Chat/composer/useComposerEditor.ts +3 -3
  23. package/components/Chat/useDraft.ts +9 -2
  24. package/components/Chat/useGoToItem.ts +11 -0
  25. package/components/CommandPalette/CommandPalette.tsx +23 -156
  26. package/components/CommandPalette/CommandsBody.tsx +216 -0
  27. package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
  28. package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
  29. package/components/CommandPalette/conversations/Preview.tsx +129 -0
  30. package/components/CommandPalette/conversations/ResultList.tsx +91 -0
  31. package/components/CommandPalette/conversations/Snippet.tsx +24 -0
  32. package/components/CommandPalette/conversations/format.ts +30 -0
  33. package/components/CommandPalette/conversations/recent.test.ts +20 -0
  34. package/components/CommandPalette/conversations/recent.ts +35 -0
  35. package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
  36. package/components/Draft/DraftPanel.tsx +7 -4
  37. package/components/Terminal/index.tsx +6 -5
  38. package/data/chat/useChat.ts +10 -3
  39. package/data/client-config/queries.ts +18 -0
  40. package/data/search/index.ts +65 -0
  41. package/data/search/keys.ts +7 -0
  42. package/data/sessions/queries.ts +2 -1
  43. package/hooks/useDebouncedValue.ts +11 -0
  44. package/lib/agents/brief.ts +1 -0
  45. package/lib/attachments/check.ts +50 -0
  46. package/lib/attachments/message.ts +49 -0
  47. package/lib/attachments/policy.ts +116 -0
  48. package/lib/attachments/store.ts +92 -0
  49. package/lib/attachments/types.ts +8 -0
  50. package/lib/bus/condense.ts +242 -0
  51. package/lib/bus/read-scope.ts +109 -0
  52. package/lib/bus/read.ts +181 -0
  53. package/lib/chat/client-send.ts +6 -2
  54. package/lib/chat/driver.ts +4 -1
  55. package/lib/chat/drivers/claude.ts +2 -0
  56. package/lib/chat/events.ts +13 -1
  57. package/lib/chat/pending.ts +2 -1
  58. package/lib/chat/queued.ts +24 -7
  59. package/lib/chat/runner.ts +11 -2
  60. package/lib/chat/worker/host.ts +9 -3
  61. package/lib/chat/worker/protocol.ts +2 -0
  62. package/lib/config/client-config-types.ts +14 -3
  63. package/lib/config/client-config.ts +1 -1
  64. package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
  65. package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
  66. package/lib/file-upload.ts +61 -27
  67. package/lib/intake/intake.ts +2 -17
  68. package/lib/intake/sniff.ts +1 -1
  69. package/lib/orchestrator/read.ts +18 -4
  70. package/lib/palette/registry.ts +4 -0
  71. package/lib/resurrect/index.ts +2 -3
  72. package/lib/search/conversations/backfill.ts +117 -0
  73. package/lib/search/conversations/buildMatchQuery.ts +31 -0
  74. package/lib/search/conversations/index.ts +9 -0
  75. package/lib/search/conversations/messageContext.ts +45 -0
  76. package/lib/search/conversations/schema.ts +71 -0
  77. package/lib/search/conversations/searchConversations.ts +151 -0
  78. package/lib/search/conversations/types.ts +45 -0
  79. package/lib/sessions/launch.ts +12 -6
  80. package/lib/sidebar/row-actions.ts +19 -8
  81. package/lib/sidebar/row-menu.ts +1 -1
  82. package/lib/tasks/restorable.ts +16 -0
  83. package/package.json +1 -1
  84. package/scripts/bench-conversation-search.ts +73 -0
  85. package/server.ts +3 -0
  86. package/stores/chatJump.ts +10 -0
  87. package/stores/palette.ts +22 -4
  88. package/components/Chat/composer/useImageDrop.ts +0 -52
@@ -0,0 +1,95 @@
1
+ "use client";
2
+
3
+ import { Clock, SearchX, X } from "lucide-react";
4
+ import { Button } from "@/components/ui/button";
5
+
6
+ export function Hint() {
7
+ return (
8
+ <p className="text-muted-foreground px-4 py-10 text-center text-sm">
9
+ Search what you and your agents have said, in every chat.
10
+ <span className="mt-1 block text-xs">
11
+ Words match as you type. Quote a phrase to match it exactly.
12
+ </span>
13
+ </p>
14
+ );
15
+ }
16
+
17
+ export function Recent({
18
+ items,
19
+ onPick,
20
+ onClear,
21
+ }: {
22
+ items: string[];
23
+ onPick: (q: string) => void;
24
+ onClear: () => void;
25
+ }) {
26
+ if (!items.length) return <Hint />;
27
+ return (
28
+ <div className="p-1.5">
29
+ <div className="flex items-center justify-between px-2 pt-2 pb-1">
30
+ <p className="text-muted-foreground text-[11px] font-medium">Recent</p>
31
+ <button
32
+ type="button"
33
+ onClick={onClear}
34
+ className="text-muted-foreground hover:text-foreground flex h-6 items-center gap-1 text-[11px]"
35
+ >
36
+ <X className="h-3 w-3" /> Clear
37
+ </button>
38
+ </div>
39
+ {items.map((q) => (
40
+ <button
41
+ key={q}
42
+ type="button"
43
+ onClick={() => onPick(q)}
44
+ className="hover:bg-foreground/[0.05] flex min-h-11 w-full items-center gap-2.5 rounded-lg px-2 text-left text-sm md:min-h-9"
45
+ >
46
+ <Clock className="text-muted-foreground h-4 w-4 shrink-0" />
47
+ <span className="min-w-0 flex-1 truncate">{q}</span>
48
+ </button>
49
+ ))}
50
+ </div>
51
+ );
52
+ }
53
+
54
+ export function Skeleton() {
55
+ return (
56
+ <div className="space-y-4 p-3" role="status" aria-label="Searching">
57
+ {[0, 1, 2].map((g) => (
58
+ <div key={g} className="space-y-2">
59
+ <div className="bg-foreground/[0.08] h-3 w-1/3 animate-pulse rounded" />
60
+ <div className="bg-foreground/[0.06] h-9 animate-pulse rounded-lg" />
61
+ <div className="bg-muted/40 h-9 w-5/6 animate-pulse rounded-lg" />
62
+ </div>
63
+ ))}
64
+ </div>
65
+ );
66
+ }
67
+
68
+ export function NoResults({
69
+ query,
70
+ scoped,
71
+ onWiden,
72
+ }: {
73
+ query: string;
74
+ scoped: boolean;
75
+ onWiden: () => void;
76
+ }) {
77
+ return (
78
+ <div className="flex flex-col items-center gap-3 px-4 py-10 text-center">
79
+ <SearchX className="text-muted-foreground h-6 w-6" />
80
+ <p className="text-muted-foreground text-sm">
81
+ Nothing said matches “{query}”.
82
+ </p>
83
+ {scoped && (
84
+ <Button
85
+ variant="outline"
86
+ size="sm"
87
+ onClick={onWiden}
88
+ className="h-11 md:h-8"
89
+ >
90
+ Search all workspaces
91
+ </Button>
92
+ )}
93
+ </div>
94
+ );
95
+ }
@@ -0,0 +1,129 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef } from "react";
4
+ import { ArrowLeft, CornerDownLeft } from "lucide-react";
5
+ import { Button } from "@/components/ui/button";
6
+ import { Markdown } from "@/components/Chat/Markdown";
7
+ import { findRanges } from "@/components/Chat/find-highlight";
8
+ import { useMessageContext, type ConversationGroup } from "@/data/search";
9
+ import { cn } from "@/lib/utils";
10
+ import { whenLabel } from "./format";
11
+
12
+ // Marks `terms` in the preview with the browser's highlight registry, so the
13
+ // markdown and code blocks inside are never rewritten. Without the API the
14
+ // hit still scrolls into view and the result row still marks the words.
15
+ function markTerms(root: HTMLElement, terms: string[]) {
16
+ if (typeof CSS === "undefined" || !("highlights" in CSS)) return () => {};
17
+ const ranges = terms.flatMap((t) => findRanges(root, t));
18
+ CSS.highlights.set("conv-search", new Highlight(...ranges));
19
+ return () => {
20
+ CSS.highlights.delete("conv-search");
21
+ };
22
+ }
23
+
24
+ // The message a result found with the ones around it, drawn as the chat draws
25
+ // them. On a phone it has the whole dialog and a way back to the list.
26
+ export function Preview({
27
+ group,
28
+ itemId,
29
+ terms,
30
+ onOpen,
31
+ onBack,
32
+ }: {
33
+ group: ConversationGroup;
34
+ itemId: string;
35
+ terms: string[];
36
+ onOpen: () => void;
37
+ onBack?: () => void;
38
+ }) {
39
+ const {
40
+ data: items,
41
+ isPending,
42
+ isError,
43
+ } = useMessageContext(group.sessionId, itemId);
44
+ const root = useRef<HTMLDivElement>(null);
45
+ const termKey = terms.join(" ");
46
+ useEffect(() => {
47
+ const el = root.current;
48
+ if (!el || !items) return;
49
+ el.querySelector("[data-hit]")?.scrollIntoView({ block: "center" });
50
+ return markTerms(el, termKey.split(" ").filter(Boolean));
51
+ }, [items, termKey]);
52
+
53
+ return (
54
+ <div className="flex min-h-0 flex-1 flex-col">
55
+ <div className="border-foreground/[0.06] flex items-center gap-2 border-b px-3 py-2">
56
+ {onBack && (
57
+ <Button
58
+ variant="ghost"
59
+ size="icon"
60
+ onClick={onBack}
61
+ aria-label="Back to results"
62
+ className="h-11 w-11 shrink-0"
63
+ >
64
+ <ArrowLeft className="h-4 w-4" />
65
+ </Button>
66
+ )}
67
+ <div className="min-w-0 flex-1">
68
+ <p className="truncate text-sm font-medium">{group.name}</p>
69
+ <p className="text-muted-foreground truncate text-xs">
70
+ {[group.projectName, group.workspaceName]
71
+ .filter(Boolean)
72
+ .join(" · ")}
73
+ </p>
74
+ </div>
75
+ <Button
76
+ size="sm"
77
+ onClick={onOpen}
78
+ className="h-11 shrink-0 gap-1.5 md:h-8"
79
+ >
80
+ Open
81
+ <CornerDownLeft className="hidden h-3.5 w-3.5 md:inline" />
82
+ </Button>
83
+ </div>
84
+ <div
85
+ ref={root}
86
+ className="min-h-0 flex-1 space-y-6 overflow-y-auto overscroll-contain p-5"
87
+ >
88
+ {isPending && (
89
+ <div className="space-y-3" aria-label="Loading preview">
90
+ {[0, 1, 2].map((i) => (
91
+ <div
92
+ key={i}
93
+ className="bg-foreground/[0.06] h-12 animate-pulse rounded-xl"
94
+ />
95
+ ))}
96
+ </div>
97
+ )}
98
+ {isError && (
99
+ <p className="text-muted-foreground py-6 text-center text-sm">
100
+ That message is gone.
101
+ </p>
102
+ )}
103
+ {items?.map((m) => (
104
+ <div
105
+ key={m.id}
106
+ {...(m.hit ? { "data-hit": "" } : {})}
107
+ className={cn(
108
+ "rounded-xl",
109
+ m.hit && "ring-primary/30 -m-2 p-2 ring-1",
110
+ m.role === "user" && "flex flex-col items-end"
111
+ )}
112
+ >
113
+ <span className="label-mono text-muted-foreground mb-1 flex items-center gap-1.5">
114
+ {m.role === "user" ? "You" : "Agent"}
115
+ {m.createdAt ? <span>· {whenLabel(m.createdAt)}</span> : null}
116
+ </span>
117
+ {m.role === "user" ? (
118
+ <div className="bg-foreground/[0.07] max-w-[85%] rounded-2xl rounded-br-md px-3.5 py-2 text-sm whitespace-pre-wrap">
119
+ {m.text}
120
+ </div>
121
+ ) : (
122
+ <Markdown text={m.text} />
123
+ )}
124
+ </div>
125
+ ))}
126
+ </div>
127
+ </div>
128
+ );
129
+ }
@@ -0,0 +1,91 @@
1
+ "use client";
2
+
3
+ import { Fragment } from "react";
4
+ import { Bot, User } from "lucide-react";
5
+ import { Badge } from "@/components/ui/badge";
6
+ import type { ConversationGroup, ConversationHit } from "@/data/search";
7
+ import { cn } from "@/lib/utils";
8
+ import { Snippet } from "./Snippet";
9
+ import { statusChip, whenLabel } from "./format";
10
+
11
+ export interface FlatHit {
12
+ group: ConversationGroup;
13
+ hit: ConversationHit;
14
+ }
15
+
16
+ export const flatten = (groups: ConversationGroup[]): FlatHit[] =>
17
+ groups.flatMap((group) => group.hits.map((hit) => ({ group, hit })));
18
+
19
+ // Hits under their conversation: its name, where it lives, what became of it.
20
+ export function ResultList({
21
+ groups,
22
+ active,
23
+ onActive,
24
+ onPick,
25
+ }: {
26
+ groups: ConversationGroup[];
27
+ active: number;
28
+ onActive: (i: number) => void;
29
+ onPick: (i: number) => void;
30
+ }) {
31
+ let index = -1;
32
+ return (
33
+ <>
34
+ {groups.map((g) => {
35
+ const chip = statusChip(g.status);
36
+ const where = [g.projectName, g.workspaceName]
37
+ .filter(Boolean)
38
+ .join(" · ");
39
+ return (
40
+ <Fragment key={g.sessionId}>
41
+ <div className="flex items-center gap-2 px-2 pt-3 pb-1">
42
+ <p className="min-w-0 truncate text-xs font-medium">{g.name}</p>
43
+ {chip && (
44
+ <Badge
45
+ variant="outline"
46
+ className="h-4 shrink-0 px-1.5 text-[10px] font-normal"
47
+ >
48
+ {chip}
49
+ </Badge>
50
+ )}
51
+ {where && (
52
+ <p className="text-muted-foreground min-w-0 flex-1 truncate text-right text-[11px]">
53
+ {where}
54
+ </p>
55
+ )}
56
+ </div>
57
+ {g.hits.map((h) => {
58
+ const i = ++index;
59
+ const Icon = h.role === "user" ? User : Bot;
60
+ return (
61
+ <button
62
+ key={h.itemId}
63
+ type="button"
64
+ role="option"
65
+ aria-selected={i === active}
66
+ data-index={i}
67
+ onMouseMove={() => i !== active && onActive(i)}
68
+ onClick={() => onPick(i)}
69
+ className={cn(
70
+ "flex min-h-11 w-full items-start gap-2.5 rounded-lg px-2 py-2 text-left text-sm",
71
+ i === active && "bg-primary/10"
72
+ )}
73
+ >
74
+ <Icon className="text-muted-foreground mt-0.5 h-3.5 w-3.5 shrink-0" />
75
+ <span className="min-w-0 flex-1">
76
+ <span className="line-clamp-2 break-words">
77
+ <Snippet segments={h.snippet} />
78
+ </span>
79
+ </span>
80
+ <span className="text-muted-foreground mt-0.5 shrink-0 text-[11px]">
81
+ {whenLabel(h.createdAt)}
82
+ </span>
83
+ </button>
84
+ );
85
+ })}
86
+ </Fragment>
87
+ );
88
+ })}
89
+ </>
90
+ );
91
+ }
@@ -0,0 +1,24 @@
1
+ import type { SnippetSegment } from "@/data/search";
2
+
3
+ // A match with the words that hit marked. Segments, never HTML: what was said
4
+ // in a chat is only ever text here.
5
+ export function Snippet({ segments }: { segments: SnippetSegment[] }) {
6
+ return (
7
+ <>
8
+ {segments.map((s, i) =>
9
+ s.hit ? (
10
+ <mark
11
+ key={i}
12
+ className="bg-primary/20 text-foreground rounded-sm px-0.5"
13
+ >
14
+ {s.text}
15
+ </mark>
16
+ ) : (
17
+ <span key={i}>
18
+ {s.text.replace(/\*\*|__|`/g, "").replace(/\s+/g, " ")}
19
+ </span>
20
+ )
21
+ )}
22
+ </>
23
+ );
24
+ }
@@ -0,0 +1,30 @@
1
+ // When a message was said: the time today, else the date.
2
+ export function whenLabel(ms: number | null | undefined): string {
3
+ if (!ms) return "";
4
+ const d = new Date(ms);
5
+ const now = new Date();
6
+ if (d.toDateString() === now.toDateString())
7
+ return d.toLocaleTimeString(undefined, {
8
+ hour: "numeric",
9
+ minute: "2-digit",
10
+ });
11
+ return d.toLocaleDateString(undefined, {
12
+ month: "short",
13
+ day: "numeric",
14
+ year: d.getFullYear() === now.getFullYear() ? undefined : "numeric",
15
+ });
16
+ }
17
+
18
+ export const plain = (segments: { text: string }[]) =>
19
+ segments
20
+ .map((s) => s.text)
21
+ .join("")
22
+ .replace(/\*\*|__|`/g, "")
23
+ .replace(/\s+/g, " ")
24
+ .trim();
25
+
26
+ const LIVE = new Set(["running", "idle", "waiting"]);
27
+
28
+ // What became of a conversation, when it is not simply alive.
29
+ export const statusChip = (status: string): string | null =>
30
+ LIVE.has(status) ? null : status;
@@ -0,0 +1,20 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { MAX_RECENT, parseRecent, withRecent } from "./recent";
3
+
4
+ describe("recent searches", () => {
5
+ it("puts the newest first and keeps each search once", () => {
6
+ expect(withRecent(["b", "a"], " a ")).toEqual(["a", "b"]);
7
+ expect(withRecent(["b"], "c")).toEqual(["c", "b"]);
8
+ });
9
+ it("is capped and ignores a blank search", () => {
10
+ const many = Array.from({ length: 20 }, (_, i) => `q${i}`);
11
+ expect(withRecent(many, "new")).toHaveLength(MAX_RECENT);
12
+ expect(withRecent(["a"], " ")).toEqual(["a"]);
13
+ });
14
+ it("survives what storage may hold", () => {
15
+ expect(parseRecent(null)).toEqual([]);
16
+ expect(parseRecent("{")).toEqual([]);
17
+ expect(parseRecent('{"a":1}')).toEqual([]);
18
+ expect(parseRecent('["a",3,null,"b"]')).toEqual(["a", "b"]);
19
+ });
20
+ });
@@ -0,0 +1,35 @@
1
+ const KEY = "agentOS-recent-conversation-searches";
2
+ export const MAX_RECENT = 6;
3
+
4
+ export function parseRecent(raw: string | null): string[] {
5
+ try {
6
+ const v: unknown = JSON.parse(raw ?? "[]");
7
+ if (!Array.isArray(v)) return [];
8
+ return v
9
+ .filter((x): x is string => typeof x === "string")
10
+ .slice(0, MAX_RECENT);
11
+ } catch {
12
+ return [];
13
+ }
14
+ }
15
+
16
+ // Newest first, each search once.
17
+ export function withRecent(list: string[], q: string): string[] {
18
+ const text = q.trim();
19
+ if (!text) return list;
20
+ return [text, ...list.filter((x) => x !== text)].slice(0, MAX_RECENT);
21
+ }
22
+
23
+ export function loadRecent(): string[] {
24
+ try {
25
+ return parseRecent(localStorage.getItem(KEY));
26
+ } catch {
27
+ return [];
28
+ }
29
+ }
30
+
31
+ export function saveRecent(list: string[]): void {
32
+ try {
33
+ localStorage.setItem(KEY, JSON.stringify(list));
34
+ } catch {}
35
+ }
@@ -0,0 +1,30 @@
1
+ "use client";
2
+
3
+ import { useCallback } from "react";
4
+ import { toast } from "sonner";
5
+ import { useUnarchiveSession } from "@/data/done";
6
+ import type { ConversationGroup, ConversationHit } from "@/data/search";
7
+ import { openChatAt } from "@/stores/chatJump";
8
+ import { paletteActions } from "@/stores/palette";
9
+
10
+ // Closes the palette and takes the reader to the message. An archived chat
11
+ // only opens from the sidebar, so it comes back first (as a bookmark's does).
12
+ export function useOpenHit() {
13
+ const { mutateAsync: unarchive } = useUnarchiveSession();
14
+ return useCallback(
15
+ (group: ConversationGroup, hit: ConversationHit) => {
16
+ paletteActions.setOpen(false);
17
+ // After the dialog lets go of focus, so the chat can take it.
18
+ const go = () =>
19
+ setTimeout(() => openChatAt(group.sessionId, hit.itemId), 0);
20
+ if (group.status !== "archived") return go();
21
+ unarchive(group.sessionId)
22
+ .then(() => {
23
+ toast.success(`${group.name} is back`);
24
+ go();
25
+ })
26
+ .catch((err: Error) => toast.error(err.message));
27
+ },
28
+ [unarchive]
29
+ );
30
+ }
@@ -5,7 +5,7 @@ import { useSnapshot } from "valtio";
5
5
  import { toast } from "sonner";
6
6
  import { Composer } from "@/components/Chat/Composer";
7
7
  import { Button } from "@/components/ui/button";
8
- import type { ChatImage } from "@/lib/chat/events";
8
+ import type { ChatFile, ChatImage } from "@/lib/chat/events";
9
9
  import type { Project } from "@/lib/db";
10
10
  import { draftComposerKey, type Draft } from "@/lib/drafts";
11
11
  import { getModelOptions } from "@/lib/model-catalog";
@@ -57,11 +57,12 @@ export function DraftPanel({
57
57
  </div>
58
58
  );
59
59
 
60
- const send = async (text: string, images: ChatImage[]) => {
60
+ const send = async (text: string, images: ChatImage[], files: ChatFile[]) => {
61
61
  try {
62
62
  if (draft.openPr) {
63
63
  if (!project) throw new Error("Pick a project for a task");
64
- if (images.length) throw new Error("A task takes text only");
64
+ if (images.length || files.length)
65
+ throw new Error("A task takes text only");
65
66
  const { session, queued } = await createTask.mutateAsync({
66
67
  projectId: project.id,
67
68
  prompt: text,
@@ -96,6 +97,7 @@ export function DraftPanel({
96
97
  baseBranch: draft.baseBranch,
97
98
  prompt: text,
98
99
  images: images.length ? images : undefined,
100
+ files: files.length ? files : undefined,
99
101
  });
100
102
  // A resend whose first answer was lost: the terminal still needs its
101
103
  // first prompt, and this is the text that was sent.
@@ -134,6 +136,7 @@ export function DraftPanel({
134
136
  />
135
137
  <Composer
136
138
  draftKey={draftComposerKey(draft.id)}
139
+ attachScope="draft"
137
140
  running={false}
138
141
  disabled={pending}
139
142
  placeholder={
@@ -143,7 +146,7 @@ export function DraftPanel({
143
146
  ? "Describe the task"
144
147
  : `Message ${project?.name ?? "a new chat"}`
145
148
  }
146
- onSend={(text, images) => void send(text, images)}
149
+ onSend={(text, images, files) => void send(text, images, files)}
147
150
  onStop={() => {}}
148
151
  models={getModelOptions(draft.agentType)}
149
152
  model={draft.model}
@@ -19,7 +19,8 @@ import { useTerminalConnection, useTerminalSearch } from "./hooks";
19
19
  import type { TerminalScrollState } from "./hooks";
20
20
  import { useViewport } from "@/hooks/useViewport";
21
21
  import { useFileDrop } from "@/hooks/useFileDrop";
22
- import { uploadFileToTemp } from "@/lib/file-upload";
22
+ import { toast } from "sonner";
23
+ import { uploadFile } from "@/lib/file-upload";
23
24
  import { FilePicker } from "@/components/FilePicker";
24
25
  import type { AttachSpec } from "@/lib/hosts/attach";
25
26
 
@@ -121,11 +122,11 @@ export const Terminal = forwardRef<TerminalHandle, TerminalProps>(
121
122
  async (file: File) => {
122
123
  setIsUploading(true);
123
124
  try {
124
- const path = await uploadFileToTemp(file);
125
- if (path) {
126
- sendInput(path);
125
+ const done = await uploadFile(file);
126
+ if (done.ok) {
127
+ sendInput(done.file.path);
127
128
  focus();
128
- }
129
+ } else toast.error(done.error);
129
130
  } catch (err) {
130
131
  console.error("Failed to upload file:", err);
131
132
  } finally {
@@ -9,6 +9,7 @@ import type {
9
9
  ChatAccess,
10
10
  ChatCommand,
11
11
  ChatContext,
12
+ ChatFile,
12
13
  ChatImage,
13
14
  ChatItem,
14
15
  ChatModel,
@@ -34,12 +35,13 @@ import { usePending } from "./usePending";
34
35
 
35
36
  // A pending send as the socket's `send`, under its own id.
36
37
  const sendFrame = (
37
- p: Pick<PendingSend, "id" | "text" | "images" | "compact">
38
+ p: Pick<PendingSend, "id" | "text" | "images" | "files" | "compact">
38
39
  ) =>
39
40
  JSON.stringify({
40
41
  type: "send",
41
42
  text: p.text,
42
43
  images: p.images,
44
+ files: p.files,
43
45
  clientId: p.id,
44
46
  ...(p.compact && { compact: true }),
45
47
  });
@@ -400,8 +402,13 @@ export function useChat(
400
402
  const send = useCallback(
401
403
  // `compact`: the reader let a stale chat compact first (lib/chat/stale).
402
404
  // It shows as a pending bubble at once, until it's in the chat.
403
- (text: string, images?: ChatImage[], compact?: boolean) => {
404
- const p = { id: clientId(), text, images, compact };
405
+ (
406
+ text: string,
407
+ images?: ChatImage[],
408
+ compact?: boolean,
409
+ files?: ChatFile[]
410
+ ) => {
411
+ const p = { id: clientId(), text, images, files, compact };
405
412
  pend({ type: "add", send: p, now: Date.now() });
406
413
  const ws = wsRef.current;
407
414
  if (ws?.readyState !== WebSocket.OPEN) {
@@ -0,0 +1,18 @@
1
+ import { queryOptions, useQuery } from "@tanstack/react-query";
2
+ import type { ClientConfig } from "@/lib/config/client-config-types";
3
+
4
+ export const clientConfigKeys = { all: ["client-config"] as const };
5
+
6
+ // What the server says clients render with and are limited by; it only
7
+ // changes with a server release, so one read per page load.
8
+ export const clientConfigQuery = queryOptions({
9
+ queryKey: clientConfigKeys.all,
10
+ queryFn: async (): Promise<ClientConfig> => {
11
+ const res = await fetch("/api/client-config");
12
+ if (!res.ok) throw new Error("Couldn't read the server's limits");
13
+ return res.json();
14
+ },
15
+ staleTime: Infinity,
16
+ });
17
+
18
+ export const useClientConfig = () => useQuery(clientConfigQuery);
@@ -0,0 +1,65 @@
1
+ import { useQuery } from "@tanstack/react-query";
2
+ import type {
3
+ ContextItem,
4
+ SearchResponse,
5
+ } from "@/lib/search/conversations/types";
6
+ import { searchKeys } from "./keys";
7
+
8
+ export type {
9
+ ContextItem,
10
+ ConversationGroup,
11
+ ConversationHit,
12
+ SearchResponse,
13
+ SnippetSegment,
14
+ } from "@/lib/search/conversations/types";
15
+
16
+ export const MIN_QUERY = 2;
17
+
18
+ async function json<T>(res: Response): Promise<T> {
19
+ const data = await res.json().catch(() => ({}));
20
+ if (!res.ok) throw new Error(data.error || "Request failed");
21
+ return data;
22
+ }
23
+
24
+ /**
25
+ * What was said in chats, grouped by conversation. A request for words the
26
+ * reader has since typed past is cancelled; the last results stay up until
27
+ * the new ones arrive.
28
+ */
29
+ export function useConversationSearch(
30
+ q: string,
31
+ workspace: string,
32
+ { limit = 30, enabled = true }: { limit?: number; enabled?: boolean } = {}
33
+ ) {
34
+ const text = q.trim();
35
+ return useQuery({
36
+ queryKey: searchKeys.conversations(text, workspace, limit),
37
+ queryFn: ({ signal }) =>
38
+ fetch(
39
+ `/api/search/conversations?${new URLSearchParams({ q: text, workspace, limit: String(limit) })}`,
40
+ { signal }
41
+ ).then((r) => json<SearchResponse>(r)),
42
+ enabled: enabled && text.length >= MIN_QUERY,
43
+ placeholderData: (previous) => previous,
44
+ staleTime: 5_000,
45
+ });
46
+ }
47
+
48
+ /** A hit and the messages around it, for the preview. */
49
+ export function useMessageContext(
50
+ sessionId: string | undefined,
51
+ itemId: string | undefined
52
+ ) {
53
+ return useQuery({
54
+ queryKey: searchKeys.context(sessionId ?? "", itemId ?? ""),
55
+ queryFn: ({ signal }) =>
56
+ fetch(
57
+ `/api/search/conversations/context?${new URLSearchParams({ session: sessionId!, item: itemId! })}`,
58
+ { signal }
59
+ )
60
+ .then((r) => json<{ items: ContextItem[] }>(r))
61
+ .then((d) => d.items),
62
+ enabled: !!sessionId && !!itemId,
63
+ staleTime: 60_000,
64
+ });
65
+ }
@@ -0,0 +1,7 @@
1
+ export const searchKeys = {
2
+ all: ["search"] as const,
3
+ conversations: (q: string, workspace: string, limit: number) =>
4
+ [...searchKeys.all, "conversations", q, workspace, limit] as const,
5
+ context: (sessionId: string, itemId: string) =>
6
+ [...searchKeys.all, "context", sessionId, itemId] as const,
7
+ };