| ... | ... | @@ -33,8 +33,6 @@ import { serverConfigQueryOptions } from "~/lib/serverReactQuery"; |
| 33 | 33 | import { isElectron } from "../env"; |
| 34 | 34 | import { buildBootstrapInput } from "../historyBootstrap"; |
| 35 | 35 | import { |
| 36 | | buildPromptInput, |
| 37 | | buildUserVisiblePrompt, |
| 38 | 36 | detectComposerTrigger, |
| 39 | 37 | replaceTextRange, |
| 40 | 38 | } from "../composer-logic"; |
| ... | ... | @@ -92,7 +90,7 @@ import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "./ui/menu" |
| 92 | 90 | import { CursorIcon, Icon } from "./Icons"; |
| 93 | 91 | import { cn, isMacPlatform, isWindowsPlatform } from "~/lib/utils"; |
| 94 | 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 | 95 | function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 98 | 96 | if (!duration) return formatTimestamp(createdAt); |
| ... | ... | @@ -185,7 +183,6 @@ export default function ChatView() { |
| 185 | 183 | gitCreateWorktreeMutationOptions({ api, queryClient }), |
| 186 | 184 | ); |
| 187 | 185 | const [prompt, setPrompt] = useState(""); |
| 188 | | const [taggedPaths, setTaggedPaths] = useState<string[]>([]); |
| 189 | 186 | const [composerImages, setComposerImages] = useState<ComposerImageAttachment[]>([]); |
| 190 | 187 | const [isDragOverComposer, setIsDragOverComposer] = useState(false); |
| 191 | 188 | const [expandedImage, setExpandedImage] = useState<ExpandedImagePreview | null>(null); |
| ... | ... | @@ -204,6 +201,8 @@ export default function ChatView() { |
| 204 | 201 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 205 | 202 | const shouldAutoScrollRef = useRef(true); |
| 206 | 203 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 204 | const composerMenuRef = useRef<HTMLDivElement>(null); |
| 205 | const previousComposerMenuIndexRef = useRef(0); |
| 207 | 206 | const composerImagesRef = useRef<ComposerImageAttachment[]>([]); |
| 208 | 207 | const dragDepthRef = useRef(0); |
| 209 | 208 | const terminalOpenByThreadRef = useRef<Record<string, boolean>>({}); |
| ... | ... | @@ -341,9 +340,7 @@ export default function ChatView() { |
| 341 | 340 | const composerMenuItems = useMemo<ComposerCommandItem[]>(() => { |
| 342 | 341 | if (!composerTrigger) return []; |
| 343 | 342 | if (composerTrigger.kind === "path") { |
| 344 | | return (workspaceEntriesQuery.data?.entries ?? []) |
| 345 | | .filter((entry) => !taggedPaths.includes(entry.path)) |
| 346 | | .map((entry) => ({ |
| 343 | return (workspaceEntriesQuery.data?.entries ?? []).map((entry) => ({ |
| 347 | 344 | id: `path:${entry.kind}:${entry.path}`, |
| 348 | 345 | type: "path", |
| 349 | 346 | path: entry.path, |
| ... | ... | @@ -378,7 +375,7 @@ export default function ChatView() { |
| 378 | 375 | label: name, |
| 379 | 376 | description: slug, |
| 380 | 377 | })); |
| 381 | | }, [composerTrigger, taggedPaths, workspaceEntriesQuery.data?.entries]); |
| 378 | }, [composerTrigger, workspaceEntriesQuery.data?.entries]); |
| 382 | 379 | const composerMenuOpen = Boolean(composerTrigger); |
| 383 | 380 | const activeComposerMenuItem = |
| 384 | 381 | composerMenuItems[Math.min(composerMenuIndex, Math.max(0, composerMenuItems.length - 1))] ?? |
| ... | ... | @@ -552,6 +549,28 @@ export default function ChatView() { |
| 552 | 549 | setComposerMenuIndex(0); |
| 553 | 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 | 574 | useEffect(() => { |
| 556 | 575 | if (!activeThread?.id || activeThread.terminalOpen) return; |
| 557 | 576 | const frame = window.requestAnimationFrame(() => { |
| ... | ... | @@ -571,7 +590,6 @@ export default function ChatView() { |
| 571 | 590 | revokePreviewUrls(existing); |
| 572 | 591 | return []; |
| 573 | 592 | }); |
| 574 | | setTaggedPaths([]); |
| 575 | 593 | setComposerCursor(0); |
| 576 | 594 | setComposerMenuIndex(0); |
| 577 | 595 | dragDepthRef.current = 0; |
| ... | ... | @@ -890,10 +908,7 @@ export default function ChatView() { |
| 890 | 908 | e.preventDefault(); |
| 891 | 909 | if (!api || !activeThread || isSending || isConnecting) return; |
| 892 | 910 | const trimmed = prompt.trim(); |
| 893 | | const taggedPathsSnapshot = [...taggedPaths]; |
| 894 | | const promptForModel = buildPromptInput(trimmed, taggedPathsSnapshot); |
| 895 | | const userVisiblePrompt = buildUserVisiblePrompt(trimmed, taggedPathsSnapshot); |
| 896 | | if (!promptForModel && composerImages.length === 0) return; |
| 911 | if (!trimmed && composerImages.length === 0) return; |
| 897 | 912 | if (!activeProject) return; |
| 898 | 913 | const composerImagesSnapshot = [...composerImages]; |
| 899 | 914 | |
| ... | ... | @@ -933,7 +948,6 @@ export default function ChatView() { |
| 933 | 948 | if (activeThread.messages.length === 0) { |
| 934 | 949 | const titleSeed = |
| 935 | 950 | trimmed || |
| 936 | | (taggedPathsSnapshot.length > 0 ? `Paths: ${taggedPathsSnapshot[0]}` : "") || |
| 937 | 951 | (composerImagesSnapshot.length > 0 |
| 938 | 952 | ? `Image: ${composerImagesSnapshot[0]?.name ?? "attachment"}` |
| 939 | 953 | : "New thread"); |
| ... | ... | @@ -958,13 +972,12 @@ export default function ChatView() { |
| 958 | 972 | type: "PUSH_USER_MESSAGE", |
| 959 | 973 | threadId: activeThread.id, |
| 960 | 974 | id: crypto.randomUUID(), |
| 961 | | text: userVisiblePrompt, |
| 975 | text: trimmed, |
| 962 | 976 | ...(messageAttachments.length > 0 ? { attachments: messageAttachments } : {}), |
| 963 | 977 | }); |
| 964 | 978 | const previousMessages = activeThread.messages; |
| 965 | 979 | setPrompt(""); |
| 966 | 980 | setComposerImages([]); |
| 967 | | setTaggedPaths([]); |
| 968 | 981 | setComposerCursor(0); |
| 969 | 982 | setComposerMenuIndex(0); |
| 970 | 983 | |
| ... | ... | @@ -987,14 +1000,14 @@ export default function ChatView() { |
| 987 | 1000 | const shouldBootstrap = |
| 988 | 1001 | previousMessages.length > 0 && |
| 989 | 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 | 1004 | const input = shouldBootstrap |
| 992 | 1005 | ? buildBootstrapInput( |
| 993 | 1006 | previousMessages, |
| 994 | 1007 | latestPromptForBootstrap, |
| 995 | 1008 | PROVIDER_SEND_TURN_MAX_INPUT_CHARS, |
| 996 | 1009 | ).text |
| 997 | | : promptForModel || undefined; |
| 1010 | : trimmed || undefined; |
| 998 | 1011 | await api.providers.sendTurn({ |
| 999 | 1012 | sessionId: sessionInfo.sessionId, |
| 1000 | 1013 | ...(input ? { input } : {}), |
| ... | ... | @@ -1094,10 +1107,11 @@ export default function ChatView() { |
| 1094 | 1107 | (item: ComposerCommandItem) => { |
| 1095 | 1108 | if (!composerTrigger) return; |
| 1096 | 1109 | if (item.type === "path") { |
| 1097 | | setTaggedPaths((existing) => |
| 1098 | | existing.includes(item.path) ? existing : [...existing, item.path], |
| 1110 | applyPromptReplacement( |
| 1111 | composerTrigger.rangeStart, |
| 1112 | composerTrigger.rangeEnd, |
| 1113 | `@${item.path} `, |
| 1099 | 1114 | ); |
| 1100 | | applyPromptReplacement(composerTrigger.rangeStart, composerTrigger.rangeEnd, ""); |
| 1101 | 1115 | return; |
| 1102 | 1116 | } |
| 1103 | 1117 | if (item.type === "slash-command") { |
| ... | ... | @@ -1110,10 +1124,6 @@ export default function ChatView() { |
| 1110 | 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 | 1127 | const onPromptChange = useCallback((nextPrompt: string, nextCursor: number) => { |
| 1118 | 1128 | setPrompt(nextPrompt); |
| 1119 | 1129 | setComposerCursor(nextCursor); |
| ... | ... | @@ -1240,12 +1250,16 @@ export default function ChatView() { |
| 1240 | 1250 | {composerMenuOpen && ( |
| 1241 | 1251 | <div className="absolute inset-x-0 bottom-full z-20 mb-2 px-1"> |
| 1242 | 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 | 1257 | <CommandList className="max-h-64"> |
| 1245 | 1258 | {composerMenuItems.map((item, index) => ( |
| 1246 | 1259 | <CommandItem |
| 1247 | 1260 | key={item.id} |
| 1248 | 1261 | value={item.id} |
| 1262 | data-composer-menu-index={index} |
| 1249 | 1263 | className={cn( |
| 1250 | 1264 | "gap-2", |
| 1251 | 1265 | index === composerMenuIndex && "bg-accent text-accent-foreground", |
| ... | ... | @@ -1287,30 +1301,11 @@ export default function ChatView() { |
| 1287 | 1301 | : "No matching command."} |
| 1288 | 1302 | </p> |
| 1289 | 1303 | )} |
| 1290 | | </CommandPanel> |
| 1304 | </div> |
| 1291 | 1305 | </Command> |
| 1292 | 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 | 1309 | {composerImages.length > 0 && ( |
| 1315 | 1310 | <div className="mb-3 flex flex-wrap gap-2"> |
| 1316 | 1311 | {composerImages.map((image) => ( |
| ... | ... | @@ -1446,7 +1441,7 @@ export default function ChatView() { |
| 1446 | 1441 | disabled={ |
| 1447 | 1442 | isSending || |
| 1448 | 1443 | isConnecting || |
| 1449 | | (!prompt.trim() && composerImages.length === 0 && taggedPaths.length === 0) |
| 1444 | (!prompt.trim() && composerImages.length === 0) |
| 1450 | 1445 | } |
| 1451 | 1446 | aria-label={ |
| 1452 | 1447 | isConnecting ? "Connecting" : isSending ? "Sending" : "Send message" |