authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-17 11:00:06-08:00
committergravatar for jmarminge@gmail.comJulius Marminge <jmarminge@gmail.com> 2026-02-17 19:47:09-08:00
log9f48564d846e7dfd6df9741a50416af72d96b855
tree1b1180f342180e4bfc8b7715dd250d7197e9bb1e
parente8556bcff95f4bdd74d4a7334626056f1e26a856

Respect .gitignore in workspace search and inline @path mentions

- Build workspace index from `git ls-files` when in a Git repo, excluding ignored paths - Skip configured ignored top-level directories during filesystem fallback indexing - Update chat composer to insert `@path` directly in prompt and remove separate tagged-path chips - Keep command menu navigation visible by scrolling the active/next item into view

3 files changed, 167 insertions(+), 47 deletions(-)

apps/server/src/workspaceEntries.test.ts+25
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import fs from "node:fs";1import fs from "node:fs";
2import os from "node:os";2import os from "node:os";
3import path from "node:path";3import path from "node:path";
4import { spawnSync } from "node:child_process";
45
5import { afterEach, describe, expect, it } from "vitest";6import { afterEach, describe, expect, it } from "vitest";
67
...@@ -20,6 +21,13 @@ function writeFile(cwd: string, relativePath: string, contents = ""): void {...@@ -20,6 +21,13 @@ function writeFile(cwd: string, relativePath: string, contents = ""): void {
20 fs.writeFileSync(absolutePath, contents, "utf8");21 fs.writeFileSync(absolutePath, contents, "utf8");
21}22}
2223
24function runGit(cwd: string, args: string[]): void {
25 const result = spawnSync("git", args, { cwd, encoding: "utf8" });
26 if (result.status !== 0) {
27 throw new Error(result.stderr || `git ${args.join(" ")} failed`);
28 }
29}
30
23describe("searchWorkspaceEntries", () => {31describe("searchWorkspaceEntries", () => {
24 afterEach(() => {32 afterEach(() => {
25 for (const dir of tempDirs.splice(0, tempDirs.length)) {33 for (const dir of tempDirs.splice(0, tempDirs.length)) {
...@@ -59,4 +67,21 @@ describe("searchWorkspaceEntries", () => {...@@ -59,4 +67,21 @@ describe("searchWorkspaceEntries", () => {
59 expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true);67 expect(result.entries.some((entry) => entry.path === "src/components")).toBe(true);
60 expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(true);68 expect(result.entries.every((entry) => entry.path.toLowerCase().includes("compo"))).toBe(true);
61 });69 });
70
71 it("excludes gitignored paths for git repositories", async () => {
72 const cwd = makeTempDir("t3code-workspace-gitignore-");
73 runGit(cwd, ["init"]);
74 writeFile(cwd, ".gitignore", ".convex/\nignored.txt\n");
75 writeFile(cwd, "src/keep.ts", "export {};");
76 writeFile(cwd, "ignored.txt", "ignore me");
77 writeFile(cwd, ".convex/local-storage/data.json", "{}");
78
79 const result = await searchWorkspaceEntries({ cwd, query: "", limit: 100 });
80 const paths = result.entries.map((entry) => entry.path);
81
82 expect(paths).toContain("src");
83 expect(paths).toContain("src/keep.ts");
84 expect(paths).not.toContain("ignored.txt");
85 expect(paths.some((entryPath) => entryPath.startsWith(".convex/"))).toBe(false);
86 });
62});87});
apps/server/src/workspaceEntries.ts+100
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import fs from "node:fs/promises";1import fs from "node:fs/promises";
2import path from "node:path";2import path from "node:path";
3import { runProcess } from "./processRunner";
34
4import {5import {
5 projectSearchEntriesInputSchema,6 projectSearchEntriesInputSchema,
...@@ -70,7 +71,103 @@ function scoreEntry(entry: ProjectEntry, query: string): number {...@@ -70,7 +71,103 @@ function scoreEntry(entry: ProjectEntry, query: string): number {
70 return 5;71 return 5;
71}72}
7273
74function isPathInIgnoredDirectory(relativePath: string): boolean {
75 const firstSegment = relativePath.split("/")[0];
76 if (!firstSegment) return false;
77 return IGNORED_DIRECTORY_NAMES.has(firstSegment);
78}
79
80function splitNullSeparatedPaths(input: string, truncated: boolean): string[] {
81 const parts = input.split("\0");
82 if (parts.length === 0) return [];
83
84 // If output was truncated, the final token can be partial.
85 if (truncated && parts[parts.length - 1]?.length) {
86 parts.pop();
87 }
88
89 return parts.filter((value) => value.length > 0);
90}
91
92function directoryAncestorsOf(relativePath: string): string[] {
93 const segments = relativePath.split("/").filter((segment) => segment.length > 0);
94 if (segments.length <= 1) return [];
95 const directories: string[] = [];
96 for (let index = 1; index < segments.length; index += 1) {
97 directories.push(segments.slice(0, index).join("/"));
98 }
99 return directories;
100}
101
102async function buildWorkspaceIndexFromGit(cwd: string): Promise<WorkspaceIndex | null> {
103 const insideWorkTree = await runProcess("git", ["rev-parse", "--is-inside-work-tree"], {
104 cwd,
105 allowNonZeroExit: true,
106 timeoutMs: 5_000,
107 maxBufferBytes: 4_096,
108 }).catch(() => null);
109 if (!insideWorkTree || insideWorkTree.code !== 0 || insideWorkTree.stdout.trim() !== "true") {
110 return null;
111 }
112
113 const listedFiles = await runProcess(
114 "git",
115 ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
116 {
117 cwd,
118 allowNonZeroExit: true,
119 timeoutMs: 20_000,
120 maxBufferBytes: 16 * 1024 * 1024,
121 outputMode: "truncate",
122 },
123 ).catch(() => null);
124 if (!listedFiles || listedFiles.code !== 0) {
125 return null;
126 }
127
128 const filePaths = splitNullSeparatedPaths(listedFiles.stdout, Boolean(listedFiles.stdoutTruncated))
129 .map((entry) => toPosixPath(entry))
130 .filter((entry) => entry.length > 0 && !isPathInIgnoredDirectory(entry));
131
132 const directorySet = new Set<string>();
133 for (const filePath of filePaths) {
134 for (const directoryPath of directoryAncestorsOf(filePath)) {
135 if (!isPathInIgnoredDirectory(directoryPath)) {
136 directorySet.add(directoryPath);
137 }
138 }
139 }
140
141 const directoryEntries: ProjectEntry[] = [...directorySet]
142 .sort((left, right) => left.localeCompare(right))
143 .map((directoryPath) => ({
144 path: directoryPath,
145 kind: "directory",
146 parentPath: parentPathOf(directoryPath),
147 }));
148 const fileEntries: ProjectEntry[] = [...new Set(filePaths)]
149 .sort((left, right) => left.localeCompare(right))
150 .map((filePath) => ({
151 path: filePath,
152 kind: "file",
153 parentPath: parentPathOf(filePath),
154 }));
155
156 const entries = [...directoryEntries, ...fileEntries];
157 return {
158 scannedAt: Date.now(),
159 entries: entries.slice(0, WORKSPACE_INDEX_MAX_ENTRIES),
160 truncated:
161 Boolean(listedFiles.stdoutTruncated) || entries.length > WORKSPACE_INDEX_MAX_ENTRIES,
162 };
163}
164
73async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {165async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
166 const gitIndexed = await buildWorkspaceIndexFromGit(cwd);
167 if (gitIndexed) {
168 return gitIndexed;
169 }
170
74 let pendingDirectories: string[] = [""];171 let pendingDirectories: string[] = [""];
75 const entries: ProjectEntry[] = [];172 const entries: ProjectEntry[] = [];
76 let truncated = false;173 let truncated = false;
...@@ -117,6 +214,9 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {...@@ -117,6 +214,9 @@ async function buildWorkspaceIndex(cwd: string): Promise<WorkspaceIndex> {
117 const relativePath = toPosixPath(214 const relativePath = toPosixPath(
118 relativeDir ? path.join(relativeDir, dirent.name) : dirent.name,215 relativeDir ? path.join(relativeDir, dirent.name) : dirent.name,
119 );216 );
217 if (isPathInIgnoredDirectory(relativePath)) {
218 continue;
219 }
120 const entry: ProjectEntry = {220 const entry: ProjectEntry = {
121 path: relativePath,221 path: relativePath,
122 kind: dirent.isDirectory() ? "directory" : "file",222 kind: dirent.isDirectory() ? "directory" : "file",
apps/web/src/components/ChatView.tsx+42-47
...@@ -33,8 +33,6 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery";...@@ -33,8 +33,6 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery";
33import { isElectron } from "../env";33import { isElectron } from "../env";
34import { buildBootstrapInput } from "../historyBootstrap";34import { buildBootstrapInput } from "../historyBootstrap";
35import {35import {
36 buildPromptInput,
37 buildUserVisiblePrompt,
38 detectComposerTrigger,36 detectComposerTrigger,
39 replaceTextRange,37 replaceTextRange,
40} from "../composer-logic";38} from "../composer-logic";
...@@ -92,7 +90,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"...@@ -92,7 +90,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu"
92import { CursorIcon, Icon } from "./Icons";90import { CursorIcon, Icon } from "./Icons";
93import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";91import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils";
94import { Badge } from "./ui/badge";92import { Badge } from "./ui/badge";
95import { Command, CommandItem, CommandList, CommandPanel } from "./ui/command";93import { Command, CommandItem, CommandList } from "./ui/command";
9694
97function formatMessageMeta(createdAt: string, duration: string | null): string {95function formatMessageMeta(createdAt: string, duration: string | null): string {
98 if (!duration) return formatTimestamp(createdAt);96 if (!duration) return formatTimestamp(createdAt);
...@@ -185,7 +183,6 @@ export default function ChatView() {...@@ -185,7 +183,6 @@ export default function ChatView() {
185 gitCreateWorktreeMutationOptions({ api, queryClient }),183 gitCreateWorktreeMutationOptions({ api, queryClient }),
186 );184 );
187 const [prompt, setPrompt] = useState("");185 const [prompt, setPrompt] = useState("");
188 const [taggedPaths, setTaggedPaths] = useState<string[]>([]);
189 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);186 const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]);
190 const [isDragOverComposer, setIsDragOverComposer] = useState(false);187 const [isDragOverComposer, setIsDragOverComposer] = useState(false);
191 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);188 const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null);
...@@ -204,6 +201,8 @@ export default function ChatView() {...@@ -204,6 +201,8 @@ export default function ChatView() {
204 const messagesEndRef = useRef<HTMLDivElement>(null);201 const messagesEndRef = useRef<HTMLDivElement>(null);
205 const shouldAutoScrollRef = useRef(true);202 const shouldAutoScrollRef = useRef(true);
206 const textareaRef = useRef<HTMLTextAreaElement>(null);203 const textareaRef = useRef<HTMLTextAreaElement>(null);
204 const composerMenuRef = useRef<HTMLDivElement>(null);
205 const previousComposerMenuIndexRef = useRef(0);
207 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);206 const composerImagesRef = useRef<ComposerImageAttachment[]>([]);
208 const dragDepthRef = useRef(0);207 const dragDepthRef = useRef(0);
209 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});208 const terminalOpenByThreadRef = useRef<Record<string, boolean>>({});
...@@ -341,9 +340,7 @@ export default function ChatView() {...@@ -341,9 +340,7 @@ export default function ChatView() {
341 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {340 const composerMenuItems = useMemo<ComposerCommandItem[]>(() => {
342 if (!composerTrigger) return [];341 if (!composerTrigger) return [];
343 if (composerTrigger.kind === "path") {342 if (composerTrigger.kind === "path") {
344 return (workspaceEntriesQuery.data?.entries ?? [])343 return (workspaceEntriesQuery.data?.entries ?? []).map((entry) => ({
345 .filter((entry) => !taggedPaths.includes(entry.path))
346 .map((entry) => ({
347 id: `path:${entry.kind}:${entry.path}`,344 id: `path:${entry.kind}:${entry.path}`,
348 type: "path",345 type: "path",
349 path: entry.path,346 path: entry.path,
...@@ -378,7 +375,7 @@ export default function ChatView() {...@@ -378,7 +375,7 @@ export default function ChatView() {
378 label: name,375 label: name,
379 description: slug,376 description: slug,
380 }));377 }));
381 }, [composerTrigger, taggedPaths, workspaceEntriesQuery.data?.entries]);378 }, [composerTrigger, workspaceEntriesQuery.data?.entries]);
382 const composerMenuOpen = Boolean(composerTrigger);379 const composerMenuOpen = Boolean(composerTrigger);
383 const activeComposerMenuItem =380 const activeComposerMenuItem =
384 composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ??381 composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ??
...@@ -552,6 +549,28 @@ export default function ChatView() {...@@ -552,6 +549,28 @@ export default function ChatView() {
552 setComposerMenuIndex(0);549 setComposerMenuIndex(0);
553 }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]);550 }, [composerTrigger?.kind, composerTrigger?.query, composerMenuItems.length]);
554551
552 useEffect(() => {
553 if (!composerMenuOpen || composerMenuItems.length === 0) {
554 return;
555 }
556 const clampedIndex = Math.min(composerMenuIndex, composerMenuItems.length - 1);
557 const previousIndex = previousComposerMenuIndexRef.current;
558 const direction = clampedIndex - previousIndex;
559 previousComposerMenuIndexRef.current = clampedIndex;
560
561 const aheadIndex =
562 direction > 0
563 ? Math.min(clampedIndex + 1, composerMenuItems.length - 1)
564 : direction < 0
565 ? Math.max(clampedIndex - 1, 0)
566 : clampedIndex;
567
568 const node = composerMenuRef.current?.querySelector<HTMLElement>(
569 `[data-composer-menu-index="${aheadIndex}"]`,
570 );
571 node?.scrollIntoView({ block: "nearest" });
572 }, [composerMenuIndex, composerMenuItems, composerMenuOpen]);
573
555 useEffect(() => {574 useEffect(() => {
556 if (!activeThread?.id || activeThread.terminalOpen) return;575 if (!activeThread?.id || activeThread.terminalOpen) return;
557 const frame = window.requestAnimationFrame(() => {576 const frame = window.requestAnimationFrame(() => {
...@@ -571,7 +590,6 @@ export default function ChatView() {...@@ -571,7 +590,6 @@ export default function ChatView() {
571 revokePreviewUrls(existing);590 revokePreviewUrls(existing);
572 return [];591 return [];
573 });592 });
574 setTaggedPaths([]);
575 setComposerCursor(0);593 setComposerCursor(0);
576 setComposerMenuIndex(0);594 setComposerMenuIndex(0);
577 dragDepthRef.current = 0;595 dragDepthRef.current = 0;
...@@ -890,10 +908,7 @@ export default function ChatView() {...@@ -890,10 +908,7 @@ export default function ChatView() {
890 e.preventDefault();908 e.preventDefault();
891 if (!api || !activeThread || isSending || isConnecting) return;909 if (!api || !activeThread || isSending || isConnecting) return;
892 const trimmed = prompt.trim();910 const trimmed = prompt.trim();
893 const taggedPathsSnapshot = [...taggedPaths];911 if (!trimmed && composerImages.length === 0) return;
894 const promptForModel = buildPromptInput(trimmed, taggedPathsSnapshot);
895 const userVisiblePrompt = buildUserVisiblePrompt(trimmed, taggedPathsSnapshot);
896 if (!promptForModel && composerImages.length === 0) return;
897 if (!activeProject) return;912 if (!activeProject) return;
898 const composerImagesSnapshot = [...composerImages];913 const composerImagesSnapshot = [...composerImages];
899914
...@@ -933,7 +948,6 @@ export default function ChatView() {...@@ -933,7 +948,6 @@ export default function ChatView() {
933 if (activeThread.messages.length === 0) {948 if (activeThread.messages.length === 0) {
934 const titleSeed =949 const titleSeed =
935 trimmed ||950 trimmed ||
936 (taggedPathsSnapshot.length > 0 ? `Paths: ${taggedPathsSnapshot[0]}` : "") ||
937 (composerImagesSnapshot.length > 0951 (composerImagesSnapshot.length > 0
938 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`952 ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}`
939 : "New thread");953 : "New thread");
...@@ -958,13 +972,12 @@ export default function ChatView() {...@@ -958,13 +972,12 @@ export default function ChatView() {
958 type: "PUSH_USER_MESSAGE",972 type: "PUSH_USER_MESSAGE",
959 threadId: activeThread.id,973 threadId: activeThread.id,
960 id: crypto.randomUUID(),974 id: crypto.randomUUID(),
961 text: userVisiblePrompt,975 text: trimmed,
962 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),976 ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}),
963 });977 });
964 const previousMessages = activeThread.messages;978 const previousMessages = activeThread.messages;
965 setPrompt("");979 setPrompt("");
966 setComposerImages([]);980 setComposerImages([]);
967 setTaggedPaths([]);
968 setComposerCursor(0);981 setComposerCursor(0);
969 setComposerMenuIndex(0);982 setComposerMenuIndex(0);
970983
...@@ -987,14 +1000,14 @@ export default function ChatView() {...@@ -987,14 +1000,14 @@ export default function ChatView() {
987 const shouldBootstrap =1000 const shouldBootstrap =
988 previousMessages.length > 0 &&1001 previousMessages.length > 0 &&
989 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");1002 (sessionInfo.continuityState === "new" || sessionInfo.continuityState === "fallback_new");
990 const latestPromptForBootstrap = promptForModel || IMAGE_ONLY_BOOTSTRAP_PROMPT;1003 const latestPromptForBootstrap = trimmed || IMAGE_ONLY_BOOTSTRAP_PROMPT;
991 const input = shouldBootstrap1004 const input = shouldBootstrap
992 ? buildBootstrapInput(1005 ? buildBootstrapInput(
993 previousMessages,1006 previousMessages,
994 latestPromptForBootstrap,1007 latestPromptForBootstrap,
995 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,1008 PROVIDER_SEND_TURN_MAX_INPUT_CHARS,
996 ).text1009 ).text
997 : promptForModel || undefined;1010 : trimmed || undefined;
998 await api.providers.sendTurn({1011 await api.providers.sendTurn({
999 sessionId: sessionInfo.sessionId,1012 sessionId: sessionInfo.sessionId,
1000 ...(input ? { input } : {}),1013 ...(input ? { input } : {}),
...@@ -1094,10 +1107,11 @@ export default function ChatView() {...@@ -1094,10 +1107,11 @@ export default function ChatView() {
1094 (item: ComposerCommandItem) => {1107 (item: ComposerCommandItem) => {
1095 if (!composerTrigger) return;1108 if (!composerTrigger) return;
1096 if (item.type === "path") {1109 if (item.type === "path") {
1097 setTaggedPaths((existing) =>1110 applyPromptReplacement(
1098 existing.includes(item.path) ? existing : [...existing, item.path],1111 composerTrigger.rangeStart,
1112 composerTrigger.rangeEnd,
1113 `@${item.path} `,
1099 );1114 );
1100 applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, "");
1101 return;1115 return;
1102 }1116 }
1103 if (item.type === "slash-command") {1117 if (item.type === "slash-command") {
...@@ -1110,10 +1124,6 @@ export default function ChatView() {...@@ -1110,10 +1124,6 @@ export default function ChatView() {
1110 [applyPromptReplacement, composerTrigger, onModelSelect],1124 [applyPromptReplacement, composerTrigger, onModelSelect],
1111 );1125 );
11121126
1113 const onRemoveTaggedPath = useCallback((pathToRemove: string) => {
1114 setTaggedPaths((existing) => existing.filter((entry) => entry !== pathToRemove));
1115 }, []);
1116
1117 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {1127 const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => {
1118 setPrompt(nextPrompt);1128 setPrompt(nextPrompt);
1119 setComposerCursor(nextCursor);1129 setComposerCursor(nextCursor);
...@@ -1240,12 +1250,16 @@ export default function ChatView() {...@@ -1240,12 +1250,16 @@ export default function ChatView() {
1240 {composerMenuOpen && (1250 {composerMenuOpen && (
1241 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">1251 <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1">
1242 <Command>1252 <Command>
1243 <CommandPanel className="rounded-xl border border-border/80 bg-popover/96 backdrop-blur-xs">1253 <div
1254 ref={composerMenuRef}
1255 className="overflow-hidden rounded-xl border border-border/80 bg-popover/96 shadow-lg/8 backdrop-blur-xs"
1256 >
1244 <CommandList className="max-h-64">1257 <CommandList className="max-h-64">
1245 {composerMenuItems.map((item, index) => (1258 {composerMenuItems.map((item, index) => (
1246 <CommandItem1259 <CommandItem
1247 key={item.id}1260 key={item.id}
1248 value={item.id}1261 value={item.id}
1262 data-composer-menu-index={index}
1249 className={cn(1263 className={cn(
1250 "gap-2",1264 "gap-2",
1251 index === composerMenuIndex && "bg-accent text-accent-foreground",1265 index === composerMenuIndex && "bg-accent text-accent-foreground",
...@@ -1287,30 +1301,11 @@ export default function ChatView() {...@@ -1287,30 +1301,11 @@ export default function ChatView() {
1287 : "No matching command."}1301 : "No matching command."}
1288 </p>1302 </p>
1289 )}1303 )}
1290 </CommandPanel>1304 </div>
1291 </Command>1305 </Command>
1292 </div>1306 </div>
1293 )}1307 )}
12941308
1295 {taggedPaths.length > 0 && (
1296 <div className="mb-3 flex flex-wrap gap-1.5">
1297 {taggedPaths.map((taggedPath) => (
1298 <button
1299 key={taggedPath}
1300 type="button"
1301 className="inline-flex items-center gap-1 rounded-md border border-border/80 bg-secondary/65 px-2 py-1 text-[11px] text-foreground/85 transition-colors hover:bg-secondary"
1302 onClick={() => onRemoveTaggedPath(taggedPath)}
1303 aria-label={`Remove ${taggedPath}`}
1304 title={taggedPath}
1305 >
1306 <span className="text-muted-foreground/70">@</span>
1307 <span className="max-w-64 truncate">{taggedPath}</span>
1308 <XIcon className="size-3 text-muted-foreground/70" />
1309 </button>
1310 ))}
1311 </div>
1312 )}
1313
1314 {composerImages.length > 0 && (1309 {composerImages.length > 0 && (
1315 <div className="mb-3 flex flex-wrap gap-2">1310 <div className="mb-3 flex flex-wrap gap-2">
1316 {composerImages.map((image) => (1311 {composerImages.map((image) => (
...@@ -1446,7 +1441,7 @@ export default function ChatView() {...@@ -1446,7 +1441,7 @@ export default function ChatView() {
1446 disabled={1441 disabled={
1447 isSending ||1442 isSending ||
1448 isConnecting ||1443 isConnecting ||
1449 (!prompt.trim() && composerImages.length === 0 && taggedPaths.length === 0)1444 (!prompt.trim() && composerImages.length === 0)
1450 }1445 }
1451 aria-label={1446 aria-label={
1452 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"1447 isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"