diff --git a/TODO.md b/TODO.md index e8e87a638ab630dcfd54c0743c6c7b0d0f3c6af9..ee0f104d682f4ab5e21578ed963f217969cd3c14 100644 --- a/TODO.md +++ b/TODO.md @@ -1,11 +1,11 @@ -- [ ] Folder picker for new projects -- [ ] Projects should be persisted so they're still visible after closing -- [ ] Remove model selection from project creation -- [ ] First message vanishes when thread is created?? Awful ux -- [ ] Show more clearly when thinking is happening -- [ ] Enable react compiler +- [x] Folder picker for new projects +- [x] Projects should be persisted so they're still visible after closing +- [x] Remove model selection from project creation +- [x] First message vanishes when thread is created?? Awful ux +- [x] Show more clearly when thinking is happening +- [x] Enable react compiler ## Bigger overhauls -- [ ] Markdown rendering -- [ ] Code block rendering +- [x] Markdown rendering +- [x] Code block rendering diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index b86e04cd89ad9069c532b9fa12c784507c46bae5..03fc88d050b51b3723ea1a73df37f10f213a78e9 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -3,7 +3,7 @@ fixPath(); import { spawn } from "node:child_process"; import path from "node:path"; -import { BrowserWindow, app, ipcMain, session } from "electron"; +import { BrowserWindow, app, dialog, ipcMain, session } from "electron"; import { IPC_CHANNELS, @@ -78,6 +78,21 @@ function registerIpcHandlers(): void { return todoStore.remove(todoIdSchema.parse(id)); }); + ipcMain.handle(IPC_CHANNELS.dialogPickFolder, async () => { + const owner = + BrowserWindow.getFocusedWindow() ?? BrowserWindow.getAllWindows()[0]; + const result = owner + ? await dialog.showOpenDialog(owner, { + properties: ["openDirectory", "createDirectory"], + }) + : await dialog.showOpenDialog({ + properties: ["openDirectory", "createDirectory"], + }); + + if (result.canceled) return null; + return result.filePaths[0] ?? null; + }); + // Terminal handlers ipcMain.handle(IPC_CHANNELS.terminalRun, async (_event, payload: unknown) => { return runTerminalCommand(terminalCommandInputSchema.parse(payload)); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 9c44b6ef95241ddcde451fc9694bd5aaf2de0572..b72be5e13dd0c16c89491d5c7053467876b799c9 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -9,6 +9,9 @@ const nativeApi: NativeApi = { toggle: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosToggle, id), remove: (id) => ipcRenderer.invoke(IPC_CHANNELS.todosRemove, id), }, + dialogs: { + pickFolder: () => ipcRenderer.invoke(IPC_CHANNELS.dialogPickFolder), + }, terminal: { run: (input) => ipcRenderer.invoke(IPC_CHANNELS.terminalRun, input), }, diff --git a/apps/renderer/package.json b/apps/renderer/package.json index 0306dc912edacdafb32875c6dddabe3c58c19e68..05b67881e5595994ca6c77ccf8508e144d63dfe4 100644 --- a/apps/renderer/package.json +++ b/apps/renderer/package.json @@ -13,8 +13,12 @@ }, "dependencies": { "@acme/contracts": "workspace:*", + "highlight.js": "^11.11.1", + "react-markdown": "^10.1.0", "react": "^19.0.0", - "react-dom": "^19.0.0" + "react-dom": "^19.0.0", + "rehype-highlight": "^7.0.2", + "remark-gfm": "^4.0.1" }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", diff --git a/apps/renderer/src/components/ChatMarkdown.tsx b/apps/renderer/src/components/ChatMarkdown.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d43ebef94a083ecc1cd4237c4873aae49997fe79 --- /dev/null +++ b/apps/renderer/src/components/ChatMarkdown.tsx @@ -0,0 +1,30 @@ +import type { Components } from "react-markdown"; +import ReactMarkdown from "react-markdown"; +import rehypeHighlight from "rehype-highlight"; +import remarkGfm from "remark-gfm"; + +interface ChatMarkdownProps { + text: string; +} + +const markdownComponents: Components = { + a({ node: _node, ...props }) { + return ; + }, +}; + +export default function ChatMarkdown({ text }: ChatMarkdownProps) { + return ( +
+ + {text} + +
+ ); +} diff --git a/apps/renderer/src/components/ChatView.tsx b/apps/renderer/src/components/ChatView.tsx index 444cf8c881a3a513b4e600741c606794025249d1..ec4c5d45d515a5a2cc2717077db1e4f4c1f423fe 100644 --- a/apps/renderer/src/components/ChatView.tsx +++ b/apps/renderer/src/components/ChatView.tsx @@ -7,13 +7,6 @@ import { useState, } from "react"; -import { - derivePhase, - deriveWorkLogEntries, - formatElapsed, - formatTimestamp, - readNativeApi, -} from "../session-logic"; import { DEFAULT_MODEL, DEFAULT_REASONING, @@ -21,7 +14,15 @@ import { REASONING_OPTIONS, resolveModelSlug, } from "../model-logic"; +import { + derivePhase, + deriveWorkLogEntries, + formatElapsed, + formatTimestamp, + readNativeApi, +} from "../session-logic"; import { useStore } from "../store"; +import ChatMarkdown from "./ChatMarkdown"; function formatMessageMeta(createdAt: string, duration: string | null): string { if (!duration) return formatTimestamp(createdAt); @@ -49,9 +50,8 @@ export default function ChatView() { const [isSending, setIsSending] = useState(false); const [isConnecting, setIsConnecting] = useState(false); const [isModelMenuOpen, setIsModelMenuOpen] = useState(false); - const [selectedEffort, setSelectedEffort] = useState( - DEFAULT_REASONING, - ); + const [selectedEffort, setSelectedEffort] = + useState(DEFAULT_REASONING); const [nowTick, setNowTick] = useState(() => Date.now()); const messagesEndRef = useRef(null); const textareaRef = useRef(null); @@ -356,9 +356,11 @@ export default function ChatView() { ) : (
-
-                      {msg.text || (msg.streaming ? "" : "(empty response)")}
-                    
+ {msg.streaming && (
@@ -422,10 +424,7 @@ export default function ChatView() { {/* Input bar */}
-
+
{/* Textarea area */}
@@ -458,8 +457,21 @@ export default function ChatView() { {selectedModel} - - + {isModelMenuOpen && ( @@ -505,7 +517,10 @@ export default function ChatView() { className="inline-flex cursor-pointer items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[13px] text-[#a0a0a0]/70 transition-colors duration-150 hover:bg-white/[0.06] hover:text-[#d0d0d0]" htmlFor="reasoning-effort" > - {selectedEffort.charAt(0).toUpperCase() + selectedEffort.slice(1)} + + {selectedEffort.charAt(0).toUpperCase() + + selectedEffort.slice(1)} + - - +
@@ -539,7 +571,13 @@ export default function ChatView() { onClick={() => void onInterrupt()} aria-label="Stop generation" > - + @@ -548,15 +586,48 @@ export default function ChatView() { type="submit" className="flex h-8 w-8 items-center justify-center rounded-full bg-white/90 text-[#0c0c0c] transition-all duration-150 hover:bg-white hover:scale-105 disabled:opacity-30 disabled:hover:scale-100" disabled={isSending || isConnecting || !prompt.trim()} - aria-label={isConnecting ? "Connecting" : isSending ? "Sending" : "Send message"} + aria-label={ + isConnecting + ? "Connecting" + : isSending + ? "Sending" + : "Send message" + } > {isConnecting || isSending ? ( - - + ) : ( - - + )} diff --git a/apps/renderer/src/components/Sidebar.tsx b/apps/renderer/src/components/Sidebar.tsx index 7e2c74d1fff9a8692d06fc389b8ecafda24d513e..f476e0fd9e0334912d79a7d9a77a0a601afd9612 100644 --- a/apps/renderer/src/components/Sidebar.tsx +++ b/apps/renderer/src/components/Sidebar.tsx @@ -1,5 +1,6 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { DEFAULT_MODEL, MODEL_OPTIONS, resolveModelSlug } from "../model-logic"; +import { readNativeApi } from "../session-logic"; import { useStore } from "../store"; import type { Project } from "../types"; @@ -15,9 +16,11 @@ function formatRelativeTime(iso: string): string { export default function Sidebar() { const { state, dispatch } = useStore(); + const api = useMemo(() => readNativeApi(), []); const [addingProject, setAddingProject] = useState(false); const [newCwd, setNewCwd] = useState(""); const [newModel, setNewModel] = useState(DEFAULT_MODEL); + const [isPickingFolder, setIsPickingFolder] = useState(false); const handleAddProject = () => { const cwd = newCwd.trim(); @@ -56,6 +59,18 @@ export default function Sidebar() { }); }; + const handlePickFolder = async () => { + if (!api || isPickingFolder) return; + setIsPickingFolder(true); + try { + const pickedPath = await api.dialogs.pickFolder(); + if (!pickedPath) return; + setNewCwd(pickedPath); + } finally { + setIsPickingFolder(false); + } + }; + return (