| ... | @@ -33,8 +33,6 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; | ... | @@ -33,8 +33,6 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; |
| 33 | import { isElectron } from "../env"; | 33 | import { isElectron } from "../env"; |
| 34 | import { buildBootstrapInput } from "../historyBootstrap"; | 34 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 35 | import { | 35 | import { |
| 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" |
| 92 | import { CursorIcon, Icon } from "./Icons"; | 90 | import { CursorIcon, Icon } from "./Icons"; |
| 93 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; | 91 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 94 | import { Badge } from "./ui/badge"; | 92 | import { Badge } from "./ui/badge"; |
| 95 | import { Command, CommandItem, CommandList, CommandPanel } from "./ui/command"; | 93 | import { Command, CommandItem, CommandList } from "./ui/command"; |
| 96 | | 94 | |
| 97 | function formatMessageMeta(createdAt: string, duration: string | null): string { | 95 | function 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]); |
| 554 | | 551 | |
| | 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]; |
| 899 | | 914 | |
| ... | @@ -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 > 0 | 951 | (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); |
| 970 | | 983 | |
| ... | @@ -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 = shouldBootstrap | 1004 | 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 | ).text | 1009 | ).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 | ); |
| 1112 | | 1126 | |
| 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 | <CommandItem | 1259 | <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 | )} |
| 1294 | | 1308 | |
| 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" |