diff --git a/apps/desktop/package.json b/apps/desktop/package.json index f33592b..66b45a5 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -18,7 +18,7 @@ "main": "dist-electron/main/index.mjs", "scripts": { "build:core": "pnpm --dir ../../packages/core build", - "predev": "pnpm build:core", + "predev": "pnpm build:core && pnpm build:electron", "dev": "concurrently -k \"vite --host 127.0.0.1\" \"wait-on tcp:5173 && cross-env VITE_DEV_SERVER_URL=http://127.0.0.1:5173 electron .\"", "prebuild": "pnpm build:core", "build:electron": "tsup", diff --git a/apps/desktop/src/main/index.ts b/apps/desktop/src/main/index.ts index 4cfc172..becc3fd 100644 --- a/apps/desktop/src/main/index.ts +++ b/apps/desktop/src/main/index.ts @@ -35,6 +35,7 @@ import { AppSettings } from "./app-settings"; import { listCodexThemes } from "./themes"; import { archiveSession, + deleteSession, listSessions, setSessionPinned, unarchiveSession, @@ -254,6 +255,7 @@ function registerIpc(): void { ipcMain.handle("sessions:pin", (_event, id: string, pinned: boolean) => setSessionPinned(id, pinned)); ipcMain.handle("sessions:archive", (_event, id: string) => archiveSession(id)); ipcMain.handle("sessions:unarchive", (_event, id: string) => unarchiveSession(id)); + ipcMain.handle("sessions:delete", (_event, id: string) => deleteSession(id)); ipcMain.handle("auth:status", async (): Promise => { const credentialStore = await createDSCodeCredentialStore(); diff --git a/apps/desktop/src/main/session-index.ts b/apps/desktop/src/main/session-index.ts index 44af2b0..d9e9e1a 100644 --- a/apps/desktop/src/main/session-index.ts +++ b/apps/desktop/src/main/session-index.ts @@ -33,6 +33,10 @@ export async function unarchiveSession(id: string): Promise { + return withStore((store) => store.delete(id)); +} + function toSessionSummary(thread: Awaited>[number]): SessionSummary { return { path: thread.sessionPath, diff --git a/apps/desktop/src/preload/index.ts b/apps/desktop/src/preload/index.ts index d010fa4..22e53d4 100644 --- a/apps/desktop/src/preload/index.ts +++ b/apps/desktop/src/preload/index.ts @@ -44,6 +44,7 @@ const api: DesktopApi & { onAppCommand(listener: (command: string) => void): () pin: (id, pinned) => ipcRenderer.invoke("sessions:pin", id, pinned), archive: (id) => ipcRenderer.invoke("sessions:archive", id), unarchive: (id) => ipcRenderer.invoke("sessions:unarchive", id), + delete: (id) => ipcRenderer.invoke("sessions:delete", id), }, auth: { status: () => ipcRenderer.invoke("auth:status"), diff --git a/apps/desktop/src/renderer/App.tsx b/apps/desktop/src/renderer/App.tsx index 7f6eeb6..ef3262a 100644 --- a/apps/desktop/src/renderer/App.tsx +++ b/apps/desktop/src/renderer/App.tsx @@ -1,4 +1,5 @@ import { + memo, useCallback, useEffect, useMemo, @@ -22,6 +23,7 @@ import { CircleAlert, CircleStop, Code2, + Copy, ExternalLink, Eye, File as FileIcon, @@ -45,6 +47,7 @@ import { Paperclip, PanelLeft, PanelRight, + Pencil, Plus, Search, Settings, @@ -56,6 +59,7 @@ import { X, } from "lucide-react"; import type { + AgentEvent, AgentSnapshot, AgentSessionStats, AuthUiEvent, @@ -76,8 +80,10 @@ import type { import { AUTH_PROMPT_CANCEL_VALUE } from "../shared/types"; import { applyAgentEvent, + coalesceStreamingAgentEvents, getAssistantActivity, groupConversation, + isStreamingAgentEvent, normalizeMessages, optimisticUserMessage, splitAssistantTurn, @@ -159,6 +165,9 @@ export default function App() { const [sessionStats, setSessionStats] = useState(); const [draft, setDraft] = useState(""); const [attachments, setAttachments] = useState([]); + const [editingMessageId, setEditingMessageId] = useState(); + const [editingText, setEditingText] = useState(""); + const [editingSubmitting, setEditingSubmitting] = useState(false); const [previewImage, setPreviewImage] = useState(); const [running, setRunning] = useState(false); const [loading, setLoading] = useState(true); @@ -180,6 +189,7 @@ export default function App() { const [sessionQuery, setSessionQuery] = useState(""); const [toast, setToast] = useState<{ message: string; type: "info" | "error" }>(); const [uiRequest, setUiRequest] = useState(); + const [resumingUiRequest, setResumingUiRequest] = useState(false); const [authEvent, setAuthEvent] = useState(); const authCancellationRef = useRef(false); const textareaRef = useRef(null); @@ -191,6 +201,32 @@ export default function App() { const sidebarFooterRef = useRef(null); const previewRequestRef = useRef(0); const inspectorResizeCleanupRef = useRef<() => void>(() => undefined); + const pendingAgentEventsRef = useRef([]); + const pendingAgentFrameRef = useRef(undefined); + const previewFileCandidatesRef = useRef([]); + + const flushPendingAgentEvents = useCallback(() => { + const frame = pendingAgentFrameRef.current; + if (frame !== undefined) window.cancelAnimationFrame(frame); + pendingAgentFrameRef.current = undefined; + const pending = pendingAgentEventsRef.current; + if (pending.length === 0) return; + pendingAgentEventsRef.current = []; + const events = coalesceStreamingAgentEvents(pending); + setMessages((current) => events.reduce((next, event) => applyAgentEvent(next, event), current)); + }, []); + + const enqueueAgentEvent = useCallback((event: AgentEvent) => { + if (!isStreamingAgentEvent(event)) { + flushPendingAgentEvents(); + setMessages((current) => applyAgentEvent(current, event)); + return; + } + pendingAgentEventsRef.current.push(event); + if (pendingAgentFrameRef.current === undefined) { + pendingAgentFrameRef.current = window.requestAnimationFrame(flushPendingAgentEvents); + } + }, [flushPendingAgentEvents]); const hydrateSnapshot = useCallback((snapshot: AgentSnapshot) => { setMessages(normalizeMessages(snapshot.messages)); @@ -220,6 +256,23 @@ export default function App() { } }, []); + const readAgentSnapshot = useCallback(async (): Promise => { + const [state, messageData, models, thinkingLevels, stats] = await Promise.all([ + window.dscode.agent.command>("get_state"), + window.dscode.agent.command<{ messages: unknown[] }>("get_messages"), + window.dscode.agent.command<{ models: AgentSnapshot["models"] }>("get_available_models"), + window.dscode.agent.command<{ levels: string[] }>("get_available_thinking_levels"), + window.dscode.agent.command("get_session_stats").catch(() => undefined), + ]); + return { + state, + messages: messageData.messages, + models: models.models, + thinkingLevels: thinkingLevels.levels, + ...(stats ? { stats } : {}), + }; + }, []); + const openFilePreview = useCallback(async (filePath: string) => { const requestId = ++previewRequestRef.current; setContextCardOpen(false); @@ -275,6 +328,9 @@ export default function App() { const providerStatuses = behavior?.providerStatuses ?? providers; const status = providerStatuses.find((candidate) => candidate.id === nextProvider); setUiRequest(undefined); + setEditingMessageId(undefined); + setEditingText(""); + setEditingSubmitting(false); setActiveSession(sessionPath); activeCwdRef.current = cwd; setWorkspace(projectPath); @@ -368,7 +424,9 @@ export default function App() { permission: "auto", sandbox: "workspace-write", }); - if (!cancelled) hydrateSnapshot(snapshot); + if (!cancelled) { + hydrateSnapshot(snapshot); + } } catch (error) { if (!cancelled && !isAgentSessionClosedError(error)) { setToast({ message: cleanError(error instanceof Error ? error.message : String(error)), type: "error" }); @@ -384,7 +442,10 @@ export default function App() { useEffect(() => { const offEvent = window.dscode.agent.onEvent((event) => { - if (event.type === "agent_start") setRunning(true); + if (event.type === "agent_start") { + setResumingUiRequest(false); + setRunning(true); + } if (event.type === "agent_settled") { setRunning(false); setUiRequest(undefined); @@ -396,14 +457,16 @@ export default function App() { if (request.method === "notify") { setToast({ message: request.message ?? t("app.notification"), type: request.notifyType === "error" ? "error" : "info" }); } else if (["select", "confirm", "input", "editor"].includes(request.method)) { + setResumingUiRequest(false); setUiRequest(request); } } - setMessages((current) => applyAgentEvent(current, event)); + enqueueAgentEvent(event); }); const offError = window.dscode.agent.onError((message) => { if (isAgentSessionClosedError(message)) return; setRunning(false); + setResumingUiRequest(false); setUiRequest(undefined); setToast({ message: cleanError(message), type: "error" }); }); @@ -425,8 +488,12 @@ export default function App() { offError(); offAuth(); offCommand?.(); + const frame = pendingAgentFrameRef.current; + if (frame !== undefined) window.cancelAnimationFrame(frame); + pendingAgentFrameRef.current = undefined; + pendingAgentEventsRef.current = []; }; - }, [refreshSessionStats, refreshSessions, t]); + }, [enqueueAgentEvent, refreshSessionStats, refreshSessions, t]); useEffect(() => { if (!accountMenuOpen) return; @@ -524,10 +591,13 @@ export default function App() { return selected; }; - const createNewThread = async () => { + const createNewThread = async (projectPath?: string) => { setMessages([]); setActiveSession(undefined); - await startAgent(); + if (projectPath) { + setExpandedProjects((current) => new Set(current).add(projectPath)); + } + await startAgent(projectPath, undefined, undefined, projectPath); textareaRef.current?.focus(); }; @@ -546,18 +616,97 @@ export default function App() { await startAgent(session.cwd, session.path, undefined, projectPath); }; + const deleteSession = useCallback(async (session: SessionSummary) => { + if (running) { + setToast({ message: t("status.cannotDeleteWhileRunning"), type: "error" }); + return; + } + if (!window.confirm(t("sidebar.confirmDeleteSession", { title: session.title }))) return; + const wasActive = session.path === activeSession; + try { + if (wasActive) { + await window.dscode.agent.stop(); + setRunning(false); + setMessages([]); + setActiveSession(undefined); + } + const deleted = await window.dscode.sessions.delete(session.id); + if (!deleted) throw new Error("The conversation could not be deleted"); + setSessions((current) => current.filter((item) => item.id !== session.id)); + if (wasActive) { + const projectPath = workspaces.some((item) => item.path === session.cwd) ? session.cwd : undefined; + await startAgent(session.cwd, undefined, undefined, projectPath); + } else { + await refreshSessions(); + } + } catch (error) { + setToast({ message: cleanError(error instanceof Error ? error.message : String(error)), type: "error" }); + } + }, [activeSession, refreshSessions, running, startAgent, t, workspaces]); + + const editMessage = useCallback((message: ChatMessage) => { + if (running || editingMessageId) return; + setEditingMessageId(message.id); + setEditingText(message.text); + }, [editingMessageId, running]); + + const cancelEditingMessage = useCallback(() => { + if (editingSubmitting) return; + setEditingMessageId(undefined); + setEditingText(""); + }, [editingSubmitting]); + + const submitEditingMessage = useCallback(async () => { + if (!editingMessageId || editingSubmitting || running) return; + const original = messages.find((message) => message.id === editingMessageId); + if (!original) return; + const text = editingText.trim(); + const messageImages = original.images.map(({ data, mimeType }) => ({ data, mimeType })); + if (!text && messageImages.length === 0) return; + const images = messageImages.map((image) => ({ type: "image", ...image })); + // Replacing the session shortens the scrollable content. Treat that + // programmatic jump as a new tail-following sequence for the resend. + followingConversationTailRef.current = true; + previousConversationScrollTopRef.current = 0; + // Leave the inline editor immediately. The session rewrite and snapshot + // reload happen asynchronously, so keeping this state would briefly + // render a large editing box above the replacement turn. + setEditingMessageId(undefined); + setEditingText(""); + setEditingSubmitting(true); + setRunning(true); + try { + await window.dscode.agent.command("prompt", { message: "/edit-last" }); + const snapshot = await readAgentSnapshot(); + hydrateSnapshot(snapshot); + setMessages((current) => [...current, optimisticUserMessage(text || t("composer.attachedImage"), false, messageImages)]); + setEditingSubmitting(false); + await window.dscode.agent.command("prompt", { + message: text || t("composer.describeImage"), + ...(images.length ? { images } : {}), + }); + } catch (error) { + setRunning(false); + if (!isAgentSessionClosedError(error)) { + setToast({ message: cleanError(error instanceof Error ? error.message : String(error)), type: "error" }); + } + } finally { + setEditingSubmitting(false); + } + }, [editingMessageId, editingSubmitting, editingText, hydrateSnapshot, messages, readAgentSnapshot, running, t]); + const sendMessage = async () => { const text = draft.trim(); if (!text && attachments.length === 0) return; const alreadyRunning = running; - setDraft(""); const queued = alreadyRunning; const messageImages = attachments.map(({ data, mimeType }) => ({ data, mimeType })); - setMessages((current) => [...current, optimisticUserMessage(text || t("composer.attachedImage"), queued, messageImages)]); const images = messageImages.map((image) => ({ type: "image", ...image })); - setAttachments([]); setRunning(true); try { + setDraft(""); + setMessages((current) => [...current, optimisticUserMessage(text || t("composer.attachedImage"), queued, messageImages)]); + setAttachments([]); await window.dscode.agent.command(alreadyRunning ? "steer" : "prompt", { message: text || t("composer.describeImage"), ...(images.length ? { images } : {}), @@ -654,7 +803,6 @@ export default function App() { setThemes(await window.dscode.themes.list()); }; - const allTools = useMemo(() => messages.flatMap((message) => message.tools), [messages]); const previewFileCandidates = useMemo(() => { const seen = new Set(); const files: string[] = []; @@ -676,12 +824,11 @@ export default function App() { } if (files.length === 6) break; } - for (const tool of [...allTools].reverse()) { - addFile(toolFilePath(tool)); - if (files.length === 6) break; - } + const previous = previewFileCandidatesRef.current; + if (sameStringArray(previous, files)) return previous; + previewFileCandidatesRef.current = files; return files; - }, [activeSession, allTools, messages, workspace]); + }, [activeSession, messages, workspace]); useEffect(() => { let cancelled = false; @@ -698,12 +845,32 @@ export default function App() { if (!cancelled) setRecentPreviewFiles([]); }); return () => { cancelled = true; }; - }, [previewFileCandidates]); + }, [activeSession, previewFileCandidates, workspace]); + const handlePreviewFile = useCallback((filePath: string) => { + void openFilePreview(filePath); + }, [openFilePreview]); const conversationGroups = useMemo(() => groupConversation(messages), [messages]); + const editableUserMessageId = [...conversationGroups].reverse().find((group) => group.type === "user")?.id; const latestAssistantGroup = [...conversationGroups].reverse().find((group) => group.type === "assistant"); - const activeAssistantGroupId = running ? latestAssistantGroup?.id : undefined; - const activeAssistantHasWork = latestAssistantGroup?.type === "assistant" - && splitAssistantTurn(latestAssistantGroup.messages, running).work.length > 0; + const latestConversationGroup = conversationGroups.at(-1); + const latestAssistantPreview = latestAssistantGroup ? splitAssistantTurn(latestAssistantGroup.messages, false) : undefined; + const latestAssistantHasVisibleResponse = (latestAssistantPreview?.responses.length ?? 0) > 0; + const activityRunning = running && !editingSubmitting; + const latestAssistantIsActive = ( + activityRunning + && latestConversationGroup?.type === "assistant" + && latestAssistantGroup?.type === "assistant" + && latestAssistantGroup.id === latestConversationGroup.id + ) || ( + resumingUiRequest + && !latestAssistantHasVisibleResponse + && latestConversationGroup?.type === "assistant" + && latestAssistantGroup?.type === "assistant" + && latestAssistantGroup.id === latestConversationGroup.id + ); + const activeAssistantGroupId = latestAssistantIsActive ? latestAssistantGroup?.id : undefined; + const activeAssistantHasWork = latestAssistantIsActive && latestAssistantGroup?.type === "assistant" + && splitAssistantTurn(latestAssistantGroup.messages, true).work.length > 0; const projectPaths = useMemo(() => new Set(workspaces.map((item) => item.path)), [workspaces]); const showPreviewPanel = () => { @@ -828,28 +995,49 @@ export default function App() { const visibleTasks = showAll ? taskSource : taskSource.slice(0, PROJECT_TASK_PREVIEW_COUNT); return (
- +
+ + +
{isExpanded && (
{visibleTasks.length === 0 &&
{t("sidebar.noProjectTasks")}
} {visibleTasks.map((session) => ( - +
+ + +
))} {!query && taskSource.length > PROJECT_TASK_PREVIEW_COUNT && ( +
+ + +
))} }
@@ -965,16 +1163,29 @@ export default function App() {
{conversationGroups.map((group) => ( group.type === "user" - ? + ? void submitEditingMessage()} + onEditCancel={cancelEditingMessage} + /> : void openFilePreview(filePath)} + onPreviewFile={handlePreviewFile} /> ))} - {running && !uiRequest && !activeAssistantHasWork && ( + {latestAssistantIsActive && !uiRequest && !activeAssistantHasWork && (
@@ -1324,30 +1541,154 @@ function EmptyState({ workspace, onSuggest }: { workspace?: string; onSuggest(va ); } -function UserMessage({ message, onPreview }: { message: ChatMessage; onPreview(image: PreviewImage): void }) { +const UserMessage = memo(function UserMessage({ + message, + onPreview, + onEdit, + editable, + editing, + disabled, + editingText, + editingSubmitting, + onEditChange, + onEditSubmit, + onEditCancel, +}: { + message: ChatMessage; + onPreview(image: PreviewImage): void; + onEdit(message: ChatMessage): void; + editable: boolean; + editing: boolean; + disabled: boolean; + editingText: string; + editingSubmitting: boolean; + onEditChange(value: string): void; + onEditSubmit(): void; + onEditCancel(): void; +}) { const { t } = useI18n(); + const [copied, setCopied] = useState(false); + + const copyMessage = async () => { + if (!message.text) return; + try { + if (navigator.clipboard?.writeText) { + await navigator.clipboard.writeText(message.text); + } else { + const fallback = document.createElement("textarea"); + fallback.value = message.text; + fallback.setAttribute("readonly", "true"); + fallback.style.position = "fixed"; + fallback.style.opacity = "0"; + document.body.appendChild(fallback); + fallback.select(); + document.execCommand("copy"); + fallback.remove(); + } + setCopied(true); + window.setTimeout(() => setCopied(false), 1_200); + } catch { + setCopied(false); + } + }; + return ( -
0 ? " has-images" : ""}`}> - {message.images.length > 0 && ( -
1 ? " multiple" : ""}`}> - {message.images.map((image, index) => ( - - ))} +
+
0 ? " has-images" : ""}${editing ? " is-editing" : ""}`}> + {message.images.length > 0 && ( +
1 ? " multiple" : ""}`}> + {message.images.map((image, index) => ( + + ))} +
+ )} + {editing ? ( +