| ... | ... | @@ -9,6 +9,7 @@ import { |
| 9 | 9 | useState, |
| 10 | 10 | } from "react"; |
| 11 | 11 | |
| 12 | import { EDITORS, type EditorId } from "@acme/contracts"; |
| 12 | 13 | import { |
| 13 | 14 | DEFAULT_MODEL, |
| 14 | 15 | DEFAULT_REASONING, |
| ... | ... | @@ -33,6 +34,18 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 33 | 34 | return `${formatTimestamp(createdAt)} • ${duration}`; |
| 34 | 35 | } |
| 35 | 36 | |
| 37 | const FILE_MANAGER_LABEL = navigator.platform.includes("Mac") |
| 38 | ? "Finder" |
| 39 | : navigator.platform.includes("Win") |
| 40 | ? "Explorer" |
| 41 | : "Files"; |
| 42 | |
| 43 | function editorLabel(editor: (typeof EDITORS)[number]): string { |
| 44 | return editor.command ? editor.label : FILE_MANAGER_LABEL; |
| 45 | } |
| 46 | |
| 47 | const LAST_EDITOR_KEY = "codething:last-editor"; |
| 48 | |
| 36 | 49 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 37 | 50 | if (tone === "error") return "text-rose-300/50"; |
| 38 | 51 | if (tone === "tool") return "text-[#8a8a8a]"; |
| ... | ... | @@ -111,6 +124,13 @@ export default function ChatView() { |
| 111 | 124 | const [isSending, setIsSending] = useState(false); |
| 112 | 125 | const [isConnecting, setIsConnecting] = useState(false); |
| 113 | 126 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); |
| 127 | const [isEditorMenuOpen, setIsEditorMenuOpen] = useState(false); |
| 128 | const [lastEditor, setLastEditor] = useState<EditorId>(() => { |
| 129 | const stored = localStorage.getItem(LAST_EDITOR_KEY); |
| 130 | return EDITORS.some((e) => e.id === stored) |
| 131 | ? (stored as EditorId) |
| 132 | : EDITORS[0].id; |
| 133 | }); |
| 114 | 134 | const [selectedEffort, setSelectedEffort] = |
| 115 | 135 | useState<string>(DEFAULT_REASONING); |
| 116 | 136 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| ... | ... | @@ -121,6 +141,7 @@ export default function ChatView() { |
| 121 | 141 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 122 | 142 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 123 | 143 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| 144 | const editorMenuRef = useRef<HTMLDivElement>(null); |
| 124 | 145 | |
| 125 | 146 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 126 | 147 | const activeProject = state.projects.find( |
| ... | ... | @@ -291,6 +312,47 @@ export default function ChatView() { |
| 291 | 312 | }; |
| 292 | 313 | }, [isModelMenuOpen]); |
| 293 | 314 | |
| 315 | useEffect(() => { |
| 316 | if (!isEditorMenuOpen) return; |
| 317 | |
| 318 | const handleClickOutside = (event: MouseEvent) => { |
| 319 | if (!editorMenuRef.current) return; |
| 320 | if ( |
| 321 | event.target instanceof Node && |
| 322 | !editorMenuRef.current.contains(event.target) |
| 323 | ) { |
| 324 | setIsEditorMenuOpen(false); |
| 325 | } |
| 326 | }; |
| 327 | |
| 328 | window.addEventListener("mousedown", handleClickOutside); |
| 329 | return () => { |
| 330 | window.removeEventListener("mousedown", handleClickOutside); |
| 331 | }; |
| 332 | }, [isEditorMenuOpen]); |
| 333 | |
| 334 | // Cmd+O / Ctrl+O to open in last-used editor |
| 335 | useEffect(() => { |
| 336 | const handler = (e: globalThis.KeyboardEvent) => { |
| 337 | if (e.key === "o" && (e.metaKey || e.ctrlKey) && !e.shiftKey) { |
| 338 | if (api && activeProject) { |
| 339 | e.preventDefault(); |
| 340 | void api.shell.openInEditor(activeProject.cwd, lastEditor); |
| 341 | } |
| 342 | } |
| 343 | }; |
| 344 | window.addEventListener("keydown", handler); |
| 345 | return () => window.removeEventListener("keydown", handler); |
| 346 | }, [api, activeProject, lastEditor]); |
| 347 | |
| 348 | const openInEditor = (editorId: EditorId) => { |
| 349 | if (!api || !activeProject) return; |
| 350 | void api.shell.openInEditor(activeProject.cwd, editorId); |
| 351 | setLastEditor(editorId); |
| 352 | localStorage.setItem(LAST_EDITOR_KEY, editorId); |
| 353 | setIsEditorMenuOpen(false); |
| 354 | }; |
| 355 | |
| 294 | 356 | const ensureSession = async (): Promise<string | null> => { |
| 295 | 357 | if (!api || !activeThread || !activeProject) return null; |
| 296 | 358 | if (activeThread.session && activeThread.session.status !== "closed") { |
| ... | ... | @@ -458,6 +520,39 @@ export default function ChatView() { |
| 458 | 520 | </h2> |
| 459 | 521 | </div> |
| 460 | 522 | <div className="flex items-center gap-3"> |
| 523 | {/* Open in editor */} |
| 524 | {activeProject && ( |
| 525 | <div className="relative" ref={editorMenuRef}> |
| 526 | <button |
| 527 | type="button" |
| 528 | className="rounded-md px-2 py-1 text-[10px] text-[#a0a0a0]/40 transition-colors duration-150 hover:text-[#a0a0a0]/60" |
| 529 | onClick={() => setIsEditorMenuOpen((v) => !v)} |
| 530 | > |
| 531 | Open in&hellip; |
| 532 | </button> |
| 533 | {isEditorMenuOpen && ( |
| 534 | <div className="absolute right-0 top-full z-50 mt-1 min-w-[120px] rounded-md border border-white/[0.08] bg-[#1b1b1d] py-1 shadow-xl"> |
| 535 | {EDITORS.map((editor) => ( |
| 536 | <button |
| 537 | key={editor.id} |
| 538 | type="button" |
| 539 | className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-[11px] text-[#e0e0e0] hover:bg-white/[0.06]" |
| 540 | onClick={() => openInEditor(editor.id)} |
| 541 | > |
| 542 | {editorLabel(editor)} |
| 543 | {editor.id === lastEditor && ( |
| 544 | <kbd className="ml-auto text-[9px] text-[#a0a0a0]/40"> |
| 545 | {navigator.platform.includes("Mac") |
| 546 | ? "\u2318O" |
| 547 | : "Ctrl+O"} |
| 548 | </kbd> |
| 549 | )} |
| 550 | </button> |
| 551 | ))} |
| 552 | </div> |
| 553 | )} |
| 554 | </div> |
| 555 | )} |
| 461 | 556 | {/* Diff toggle */} |
| 462 | 557 | <button |
| 463 | 558 | type="button" |