@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.
- package/README.md +2 -1
- package/app/api/bus/read/route.ts +34 -0
- package/app/api/files/upload-temp/route.test.ts +144 -0
- package/app/api/files/upload-temp/route.ts +52 -21
- package/app/api/search/conversations/context/route.ts +26 -0
- package/app/api/search/conversations/route.test.ts +139 -0
- package/app/api/search/conversations/route.ts +25 -0
- package/app/api/sessions/[id]/route.ts +2 -0
- package/app/api/sessions/route.ts +6 -2
- package/app/globals.css +25 -0
- package/components/Bookmarks/BookmarksDialog.tsx +2 -8
- package/components/Chat/ChatPanel.tsx +9 -2
- package/components/Chat/FileChip.tsx +67 -0
- package/components/Chat/Messages.tsx +8 -0
- package/components/Chat/Queue.tsx +6 -0
- package/components/Chat/composer/Attachments.tsx +16 -2
- package/components/Chat/composer/ComposerBody.tsx +35 -14
- package/components/Chat/composer/Toolbar.tsx +11 -8
- package/components/Chat/composer/editor.test.ts +1 -1
- package/components/Chat/composer/paste.ts +5 -4
- package/components/Chat/composer/useAttachFiles.ts +142 -0
- package/components/Chat/composer/useComposerEditor.ts +3 -3
- package/components/Chat/useDraft.ts +9 -2
- package/components/Chat/useGoToItem.ts +11 -0
- package/components/CommandPalette/CommandPalette.tsx +23 -156
- package/components/CommandPalette/CommandsBody.tsx +216 -0
- package/components/CommandPalette/conversations/ConversationSearch.tsx +246 -0
- package/components/CommandPalette/conversations/EmptyState.tsx +95 -0
- package/components/CommandPalette/conversations/Preview.tsx +129 -0
- package/components/CommandPalette/conversations/ResultList.tsx +91 -0
- package/components/CommandPalette/conversations/Snippet.tsx +24 -0
- package/components/CommandPalette/conversations/format.ts +30 -0
- package/components/CommandPalette/conversations/recent.test.ts +20 -0
- package/components/CommandPalette/conversations/recent.ts +35 -0
- package/components/CommandPalette/conversations/useOpenHit.ts +30 -0
- package/components/Draft/DraftPanel.tsx +7 -4
- package/components/Terminal/index.tsx +6 -5
- package/data/chat/useChat.ts +10 -3
- package/data/client-config/queries.ts +18 -0
- package/data/search/index.ts +65 -0
- package/data/search/keys.ts +7 -0
- package/data/sessions/queries.ts +2 -1
- package/hooks/useDebouncedValue.ts +11 -0
- package/lib/agents/brief.ts +1 -0
- package/lib/attachments/check.ts +50 -0
- package/lib/attachments/message.ts +49 -0
- package/lib/attachments/policy.ts +116 -0
- package/lib/attachments/store.ts +92 -0
- package/lib/attachments/types.ts +8 -0
- package/lib/bus/condense.ts +242 -0
- package/lib/bus/read-scope.ts +109 -0
- package/lib/bus/read.ts +181 -0
- package/lib/chat/client-send.ts +6 -2
- package/lib/chat/driver.ts +4 -1
- package/lib/chat/drivers/claude.ts +2 -0
- package/lib/chat/events.ts +13 -1
- package/lib/chat/pending.ts +2 -1
- package/lib/chat/queued.ts +24 -7
- package/lib/chat/runner.ts +11 -2
- package/lib/chat/worker/host.ts +9 -3
- package/lib/chat/worker/protocol.ts +2 -0
- package/lib/config/client-config-types.ts +14 -3
- package/lib/config/client-config.ts +1 -1
- package/lib/db/migrations/20261019T1200_chat_queue_files.ts +14 -0
- package/lib/db/migrations/20261019T1500_conversation_search.ts +12 -0
- package/lib/file-upload.ts +61 -27
- package/lib/intake/intake.ts +2 -17
- package/lib/intake/sniff.ts +1 -1
- package/lib/orchestrator/read.ts +18 -4
- package/lib/palette/registry.ts +4 -0
- package/lib/resurrect/index.ts +2 -3
- package/lib/search/conversations/backfill.ts +117 -0
- package/lib/search/conversations/buildMatchQuery.ts +31 -0
- package/lib/search/conversations/index.ts +9 -0
- package/lib/search/conversations/messageContext.ts +45 -0
- package/lib/search/conversations/schema.ts +71 -0
- package/lib/search/conversations/searchConversations.ts +151 -0
- package/lib/search/conversations/types.ts +45 -0
- package/lib/sessions/launch.ts +12 -6
- package/lib/sidebar/row-actions.ts +19 -8
- package/lib/sidebar/row-menu.ts +1 -1
- package/lib/tasks/restorable.ts +16 -0
- package/package.json +1 -1
- package/scripts/bench-conversation-search.ts +73 -0
- package/server.ts +3 -0
- package/stores/chatJump.ts +10 -0
- package/stores/palette.ts +22 -4
- 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
|
|
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 {
|
|
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
|
|
125
|
-
if (
|
|
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 {
|
package/data/chat/useChat.ts
CHANGED
|
@@ -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
|
-
(
|
|
404
|
-
|
|
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
|
+
};
|