| ... | @@ -9,6 +9,7 @@ import { | ... | @@ -9,6 +9,7 @@ import { |
| 9 | useState, | 9 | useState, |
| 10 | } from "react"; | 10 | } from "react"; |
| 11 | | 11 | |
| | 12 | import { EDITORS, type EditorId } from "@acme/contracts"; |
| 12 | import { | 13 | import { |
| 13 | DEFAULT_MODEL, | 14 | DEFAULT_MODEL, |
| 14 | DEFAULT_REASONING, | 15 | DEFAULT_REASONING, |
| ... | @@ -33,6 +34,18 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { | ... | @@ -33,6 +34,18 @@ function formatMessageMeta(createdAt: string, duration: string | null): string { |
| 33 | return `${formatTimestamp(createdAt)} • ${duration}`; | 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 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { | 49 | function workToneClass(tone: "thinking" | "tool" | "info" | "error"): string { |
| 37 | if (tone === "error") return "text-rose-300/50"; | 50 | if (tone === "error") return "text-rose-300/50"; |
| 38 | if (tone === "tool") return "text-[#8a8a8a]"; | 51 | if (tone === "tool") return "text-[#8a8a8a]"; |
| ... | @@ -111,6 +124,13 @@ export default function ChatView() { | ... | @@ -111,6 +124,13 @@ export default function ChatView() { |
| 111 | const [isSending, setIsSending] = useState(false); | 124 | const [isSending, setIsSending] = useState(false); |
| 112 | const [isConnecting, setIsConnecting] = useState(false); | 125 | const [isConnecting, setIsConnecting] = useState(false); |
| 113 | const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); | 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 | const [selectedEffort, setSelectedEffort] = | 134 | const [selectedEffort, setSelectedEffort] = |
| 115 | useState<string>(DEFAULT_REASONING); | 135 | useState<string>(DEFAULT_REASONING); |
| 116 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); | 136 | const [isSwitchingRuntimeMode, setIsSwitchingRuntimeMode] = useState(false); |
| ... | @@ -121,6 +141,7 @@ export default function ChatView() { | ... | @@ -121,6 +141,7 @@ export default function ChatView() { |
| 121 | const messagesEndRef = useRef<HTMLDivElement>(null); | 141 | const messagesEndRef = useRef<HTMLDivElement>(null); |
| 122 | const textareaRef = useRef<HTMLTextAreaElement>(null); | 142 | const textareaRef = useRef<HTMLTextAreaElement>(null); |
| 123 | const modelMenuRef = useRef<HTMLDivElement>(null); | 143 | const modelMenuRef = useRef<HTMLDivElement>(null); |
| | 144 | const editorMenuRef = useRef<HTMLDivElement>(null); |
| 124 | | 145 | |
| 125 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); | 146 | const activeThread = state.threads.find((t) => t.id === state.activeThreadId); |
| 126 | const activeProject = state.projects.find( | 147 | const activeProject = state.projects.find( |
| ... | @@ -291,6 +312,47 @@ export default function ChatView() { | ... | @@ -291,6 +312,47 @@ export default function ChatView() { |
| 291 | }; | 312 | }; |
| 292 | }, [isModelMenuOpen]); | 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 | const ensureSession = async (): Promise<string | null> => { | 356 | const ensureSession = async (): Promise<string | null> => { |
| 295 | if (!api || !activeThread || !activeProject) return null; | 357 | if (!api || !activeThread || !activeProject) return null; |
| 296 | if (activeThread.session && activeThread.session.status !== "closed") { | 358 | if (activeThread.session && activeThread.session.status !== "closed") { |
| ... | @@ -458,6 +520,39 @@ export default function ChatView() { | ... | @@ -458,6 +520,39 @@ export default function ChatView() { |
| 458 | </h2> | 520 | </h2> |
| 459 | </div> | 521 | </div> |
| 460 | <div className="flex items-center gap-3"> | 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 | {/* Diff toggle */} | 556 | {/* Diff toggle */} |
| 462 | <button | 557 | <button |
| 463 | type="button" | 558 | type="button" |