From 9240bbc710f98fa2b104ce979bf6c7ace05c084a Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Tue, 31 Mar 2026 21:56:04 -0500 Subject: [PATCH] Add inline thread renaming with draft title persistence - Reuse a shared title editor in the header and sidebar - Persist custom draft thread titles separately from branch context - Normalize empty titles to the default "New thread" --- apps/server/package.json | 2 +- apps/web/src/components/ChatView.browser.tsx | 3 + .../web/src/components/ChatView.logic.test.ts | 45 +++++- apps/web/src/components/ChatView.logic.ts | 3 +- apps/web/src/components/ChatView.tsx | 32 ++-- .../EditableThreadTitle.browser.tsx | 101 ++++++++++++ .../src/components/EditableThreadTitle.tsx | 147 ++++++++++++++++++ apps/web/src/components/Sidebar.tsx | 124 ++++----------- apps/web/src/components/chat/ChatHeader.tsx | 51 +++++- apps/web/src/composerDraftStore.test.ts | 24 +++ apps/web/src/composerDraftStore.ts | 61 ++++++-- apps/web/src/hooks/useThreadTitleEditor.ts | 130 ++++++++++++++++ apps/web/src/threadTitle.ts | 10 ++ bun.lock | 30 ++-- package.json | 14 +- scripts/dedupe-effect-install.mjs | 39 +++++ 16 files changed, 677 insertions(+), 139 deletions(-) create mode 100644 apps/web/src/components/EditableThreadTitle.browser.tsx create mode 100644 apps/web/src/components/EditableThreadTitle.tsx create mode 100644 apps/web/src/hooks/useThreadTitleEditor.ts create mode 100644 apps/web/src/threadTitle.ts create mode 100644 scripts/dedupe-effect-install.mjs diff --git a/apps/server/package.json b/apps/server/package.json index 9b5accbc8..26295f99c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -18,7 +18,7 @@ "dev": "bun run src/index.ts", "build": "node scripts/cli.ts build", "start": "node dist/index.mjs", - "prepare": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || node ../../scripts/patch-effect-language-service.ts", + "prepare": "node -e \"process.exit(process.env.CI ? 0 : 1)\" || (node ../../scripts/patch-effect-language-service.ts && node ../../scripts/dedupe-effect-install.mjs)", "typecheck": "tsc --noEmit", "test": "vitest run" }, diff --git a/apps/web/src/components/ChatView.browser.tsx b/apps/web/src/components/ChatView.browser.tsx index a25018cac..6098e66bd 100644 --- a/apps/web/src/components/ChatView.browser.tsx +++ b/apps/web/src/components/ChatView.browser.tsx @@ -994,6 +994,7 @@ describe("ChatView timeline estimator parity (full app)", () => { [THREAD_ID]: { projectId: PROJECT_ID, createdAt: NOW_ISO, + title: "New thread", runtimeMode: "full-access", interactionMode: "chat", branch: null, @@ -1051,6 +1052,7 @@ describe("ChatView timeline estimator parity (full app)", () => { [THREAD_ID]: { projectId: PROJECT_ID, createdAt: NOW_ISO, + title: "New thread", runtimeMode: "full-access", interactionMode: "chat", branch: null, @@ -1127,6 +1129,7 @@ describe("ChatView timeline estimator parity (full app)", () => { [THREAD_ID]: { projectId: PROJECT_ID, createdAt: NOW_ISO, + title: "New thread", runtimeMode: "full-access", interactionMode: "chat", branch: "feature/draft", diff --git a/apps/web/src/components/ChatView.logic.test.ts b/apps/web/src/components/ChatView.logic.test.ts index 7933bbf81..17516e5f6 100644 --- a/apps/web/src/components/ChatView.logic.test.ts +++ b/apps/web/src/components/ChatView.logic.test.ts @@ -1,8 +1,9 @@ -import { ThreadId } from "@okcode/contracts"; +import { ProjectId, ThreadId } from "@okcode/contracts"; import { describe, expect, it } from "vitest"; import { buildAutoSelectedWorktreeBaseBranchToastCopy, + buildLocalDraftThread, buildExpiredTerminalContextToastCopy, deriveComposerSendState, } from "./ChatView.logic"; @@ -86,3 +87,45 @@ describe("buildAutoSelectedWorktreeBaseBranchToastCopy", () => { }); }); }); + +describe("buildLocalDraftThread", () => { + it("uses a persisted draft title when present", () => { + const thread = buildLocalDraftThread( + ThreadId.makeUnsafe("thread-draft"), + { + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-17T12:52:29.000Z", + title: "Investigate flaky CI", + runtimeMode: "full-access", + interactionMode: "chat", + branch: null, + worktreePath: null, + envMode: "local", + }, + "gpt-5.4", + null, + ); + + expect(thread.title).toBe("Investigate flaky CI"); + }); + + it("falls back to the default title when the draft title is empty", () => { + const thread = buildLocalDraftThread( + ThreadId.makeUnsafe("thread-draft-empty"), + { + projectId: ProjectId.makeUnsafe("project-1"), + createdAt: "2026-03-17T12:52:29.000Z", + title: " ", + runtimeMode: "full-access", + interactionMode: "chat", + branch: null, + worktreePath: null, + envMode: "local", + }, + "gpt-5.4", + null, + ); + + expect(thread.title).toBe("New thread"); + }); +}); diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 17963561a..f502babaa 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -8,6 +8,7 @@ import { stripInlineTerminalContextPlaceholders, type TerminalContextDraft, } from "../lib/terminalContext"; +import { normalizeThreadTitle } from "../threadTitle"; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "okcode:last-invoked-script-by-project"; const WORKTREE_BRANCH_PREFIX = "okcode"; @@ -24,7 +25,7 @@ export function buildLocalDraftThread( id: threadId, codexThreadId: null, projectId: draftThread.projectId, - title: "New thread", + title: normalizeThreadTitle(draftThread.title), model: fallbackModel, runtimeMode: draftThread.runtimeMode, interactionMode: draftThread.interactionMode, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index f274ed113..332014616 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -212,6 +212,7 @@ import { readDesktopPreviewBridge } from "~/desktopPreview"; import { usePreviewStateStore } from "~/previewStateStore"; import { useClientMode } from "~/hooks/useClientMode"; import { useTransportState } from "~/hooks/useTransportState"; +import { hasCustomThreadTitle, normalizeThreadTitle } from "~/threadTitle"; const ATTACHMENT_PREVIEW_HANDOFF_TTL_MS = 5000; const IMAGE_SIZE_LIMIT_LABEL = `${Math.round(PROVIDER_SEND_TURN_MAX_IMAGE_BYTES / (1024 * 1024))}MB`; @@ -421,6 +422,7 @@ export default function ChatView({ threadId }: ChatViewProps) { ); const clearComposerDraftContent = useComposerDraftStore((store) => store.clearComposerContent); const setDraftThreadContext = useComposerDraftStore((store) => store.setDraftThreadContext); + const setDraftThreadTitle = useComposerDraftStore((store) => store.setDraftThreadTitle); const getDraftThreadByProjectId = useComposerDraftStore( (store) => store.getDraftThreadByProjectId, ); @@ -3116,17 +3118,23 @@ export default function ChatView({ threadId }: ChatViewProps) { firstComposerImageName = firstComposerImage.name; } } - let titleSeed = trimmed; - if (!titleSeed) { - if (firstComposerImageName) { - titleSeed = `Image: ${firstComposerImageName}`; - } else if (composerTerminalContextsSnapshot.length > 0) { - titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); - } else { - titleSeed = "New thread"; + const manualThreadTitle = hasCustomThreadTitle(activeThread.title) + ? normalizeThreadTitle(activeThread.title) + : null; + let title = manualThreadTitle; + if (!title) { + let titleSeed = trimmed; + if (!titleSeed) { + if (firstComposerImageName) { + titleSeed = `Image: ${firstComposerImageName}`; + } else if (composerTerminalContextsSnapshot.length > 0) { + titleSeed = formatTerminalContextLabel(composerTerminalContextsSnapshot[0]!); + } else { + titleSeed = normalizeThreadTitle(null); + } } + title = truncateTitle(titleSeed); } - const title = truncateTitle(titleSeed); let threadCreateModel: ModelSlug = selectedModel || (activeProject.model as ModelSlug) || DEFAULT_MODEL_BY_PROVIDER.codex; @@ -3173,7 +3181,7 @@ export default function ChatView({ threadId }: ChatViewProps) { } // Auto-title from first message - if (isFirstMessage && isServerThread) { + if (isFirstMessage && isServerThread && !hasCustomThreadTitle(activeThread.title)) { await api.orchestration.dispatchCommand({ type: "thread.meta.update", commandId: newCommandId(), @@ -4239,6 +4247,7 @@ export default function ChatView({ threadId }: ChatViewProps) { activeProjectName={activeProject?.name} activeProjectCwd={activeProject?.cwd} isGitRepo={isGitRepo} + isLocalDraftThread={isLocalDraftThread} openInCwd={gitCwd} activeProjectScripts={activeProject?.scripts} preferredScriptId={ @@ -4255,6 +4264,9 @@ export default function ChatView({ threadId }: ChatViewProps) { gitCwd={gitCwd} diffOpen={diffOpen} clientMode={clientMode} + onRenameDraftThreadTitle={(title) => { + setDraftThreadTitle(activeThread.id, title); + }} onRunProjectScript={(script) => { void runProjectScript(script); }} diff --git a/apps/web/src/components/EditableThreadTitle.browser.tsx b/apps/web/src/components/EditableThreadTitle.browser.tsx new file mode 100644 index 000000000..8c371a983 --- /dev/null +++ b/apps/web/src/components/EditableThreadTitle.browser.tsx @@ -0,0 +1,101 @@ +import "../index.css"; + +import { useState } from "react"; +import { page } from "vitest/browser"; +import { describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import { EditableThreadTitle } from "./EditableThreadTitle"; + +function EditableThreadTitleHarness(props: { + initialTitle?: string; + showEditButton?: boolean; + onCommit?: (title: string) => void; +}) { + const [isEditing, setIsEditing] = useState(false); + const [title, setTitle] = useState(props.initialTitle ?? "Thread title"); + const [draftTitle, setDraftTitle] = useState(title); + + return ( + { + setDraftTitle(title); + setIsEditing(true); + }} + onDraftTitleChange={setDraftTitle} + onCommit={() => { + const nextTitle = draftTitle.trim(); + setTitle(nextTitle); + setDraftTitle(nextTitle); + setIsEditing(false); + props.onCommit?.(nextTitle); + }} + onCancel={() => { + setDraftTitle(title); + setIsEditing(false); + }} + /> + ); +} + +describe("EditableThreadTitle", () => { + it("opens inline editing from the edit button and commits on Enter", async () => { + const onCommit = vi.fn(); + const screen = await render( + , + ); + + try { + await page.getByRole("button", { name: "Rename thread" }).click(); + const input = page.getByRole("textbox", { name: "Rename thread" }); + await input.fill("Renamed thread"); + if (!(document.activeElement instanceof HTMLElement)) { + throw new Error("Expected the inline title input to be focused."); + } + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + + await vi.waitFor(() => { + expect(onCommit).toHaveBeenCalledWith("Renamed thread"); + expect(document.body.textContent ?? "").toContain("Renamed thread"); + }); + } finally { + await screen.unmount(); + } + }); + + it("opens inline editing on double click even without the edit button", async () => { + const onCommit = vi.fn(); + const screen = await render( + , + ); + + try { + await page.getByText("Header thread").dblClick(); + const input = page.getByRole("textbox", { name: "Rename thread" }); + await input.fill("Header rename"); + if (!(document.activeElement instanceof HTMLElement)) { + throw new Error("Expected the inline title input to be focused."); + } + document.activeElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + + await vi.waitFor(() => { + expect(onCommit).toHaveBeenCalledWith("Header rename"); + expect(document.body.textContent ?? "").toContain("Header rename"); + }); + } finally { + await screen.unmount(); + } + }); +}); diff --git a/apps/web/src/components/EditableThreadTitle.tsx b/apps/web/src/components/EditableThreadTitle.tsx new file mode 100644 index 000000000..999c1b1c1 --- /dev/null +++ b/apps/web/src/components/EditableThreadTitle.tsx @@ -0,0 +1,147 @@ +import { PencilIcon } from "lucide-react"; +import { + useCallback, + useRef, + type MouseEvent as ReactMouseEvent, + type PointerEvent as ReactPointerEvent, +} from "react"; + +import { cn } from "~/lib/utils"; +import { Button } from "./ui/button"; + +interface EditableThreadTitleProps { + title: string; + isEditing: boolean; + draftTitle: string; + onStartEditing: () => void; + onDraftTitleChange: (title: string) => void; + onCommit: () => void | Promise; + onCancel: () => void; + inputRef?: (node: HTMLInputElement | null) => void; + containerClassName?: string; + titleClassName?: string; + inputClassName?: string; + showEditButton?: boolean; + editButtonClassName?: string; + editButtonLabel?: string; +} + +const DOUBLE_TAP_WINDOW_MS = 320; + +export function EditableThreadTitle({ + title, + isEditing, + draftTitle, + onStartEditing, + onDraftTitleChange, + onCommit, + onCancel, + inputRef, + containerClassName, + titleClassName, + inputClassName, + showEditButton = false, + editButtonClassName, + editButtonLabel = "Rename thread", +}: EditableThreadTitleProps) { + const ignoreNextBlurRef = useRef(false); + const lastTouchEndAtRef = useRef(null); + + const triggerEditing = useCallback(() => { + ignoreNextBlurRef.current = false; + onStartEditing(); + }, [onStartEditing]); + + const handleTitleDoubleClick = useCallback( + (event: ReactMouseEvent) => { + event.stopPropagation(); + triggerEditing(); + }, + [triggerEditing], + ); + + const handleTitlePointerUp = useCallback( + (event: ReactPointerEvent) => { + if (event.pointerType !== "touch") { + return; + } + const lastTouchEndAt = lastTouchEndAtRef.current; + lastTouchEndAtRef.current = event.timeStamp; + if (lastTouchEndAt !== null && event.timeStamp - lastTouchEndAt <= DOUBLE_TAP_WINDOW_MS) { + lastTouchEndAtRef.current = null; + triggerEditing(); + } + }, + [triggerEditing], + ); + + return ( +
+ {isEditing ? ( + onDraftTitleChange(event.target.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.key === "Enter") { + event.preventDefault(); + ignoreNextBlurRef.current = true; + void onCommit(); + return; + } + if (event.key === "Escape") { + event.preventDefault(); + ignoreNextBlurRef.current = true; + onCancel(); + } + }} + onBlur={() => { + if (ignoreNextBlurRef.current) { + ignoreNextBlurRef.current = false; + return; + } + void onCommit(); + }} + onPointerDown={(event) => event.stopPropagation()} + onClick={(event) => event.stopPropagation()} + /> + ) : ( + <> + + {title} + + {showEditButton ? ( + + ) : null} + + )} +
+ ); +} diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 86f33a0f8..6a11767ff 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -108,6 +108,8 @@ import { } from "./Sidebar.logic"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; import { WorkspaceFileTree } from "~/components/WorkspaceFileTree"; +import { EditableThreadTitle } from "~/components/EditableThreadTitle"; +import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor"; import { buildProjectScriptDraftsFromPackageScripts, materializeProjectScripts, @@ -396,14 +398,10 @@ export default function Sidebar() { const [addProjectError, setAddProjectError] = useState(null); const [manualProjectPathEntry, setManualProjectPathEntry] = useState(false); const addProjectInputRef = useRef(null); - const [renamingThreadId, setRenamingThreadId] = useState(null); - const [renamingTitle, setRenamingTitle] = useState(""); const [expandedThreadListsByProject, setExpandedThreadListsByProject] = useState< ReadonlySet >(() => new Set()); const [filesExpanded, setFilesExpanded] = useState(true); - const renamingCommittedRef = useRef(false); - const renamingInputRef = useRef(null); const dragInProgressRef = useRef(false); const suppressProjectClickAfterDragRef = useRef(false); const [desktopUpdateState, setDesktopUpdateState] = useState(null); @@ -416,6 +414,15 @@ export default function Sidebar() { const isLinuxDesktop = isElectron && isLinuxPlatform(navigator.platform); const shouldBrowseForProjectImmediately = isElectron && !isLinuxDesktop; const shouldShowProjectPathEntry = addingProject && !shouldBrowseForProjectImmediately; + const { + editingThreadId, + draftTitle: editingThreadTitle, + bindInputRef, + cancelEditing, + commitEditing, + setDraftTitle, + startEditing, + } = useThreadTitleEditor(); const projectCwdById = useMemo( () => new Map(projects.map((project) => [project.id, project.cwd] as const)), [projects], @@ -640,55 +647,6 @@ export default function Sidebar() { setAddingProject((prev) => !prev); }; - const cancelRename = useCallback(() => { - setRenamingThreadId(null); - renamingInputRef.current = null; - }, []); - - const commitRename = useCallback( - async (threadId: ThreadId, newTitle: string, originalTitle: string) => { - const finishRename = () => { - setRenamingThreadId((current) => { - if (current !== threadId) return current; - renamingInputRef.current = null; - return null; - }); - }; - - const trimmed = newTitle.trim(); - if (trimmed.length === 0) { - toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); - finishRename(); - return; - } - if (trimmed === originalTitle) { - finishRename(); - return; - } - const api = readNativeApi(); - if (!api) { - finishRename(); - return; - } - try { - await api.orchestration.dispatchCommand({ - type: "thread.meta.update", - commandId: newCommandId(), - threadId, - title: trimmed, - }); - } catch (error) { - toastManager.add({ - type: "error", - title: "Failed to rename thread", - description: error instanceof Error ? error.message : "An error occurred.", - }); - } - finishRename(); - }, - [], - ); - /** * Delete a single thread: stop session, close terminal, dispatch delete, * clean up drafts/state, and optionally remove orphaned worktree. @@ -858,9 +816,10 @@ export default function Sidebar() { ); if (clicked === "rename") { - setRenamingThreadId(threadId); - setRenamingTitle(thread.title); - renamingCommittedRef.current = false; + startEditing({ + threadId, + title: thread.title, + }); return; } @@ -905,6 +864,7 @@ export default function Sidebar() { deleteThread, markThreadUnread, projectCwdById, + startEditing, threads, ], ); @@ -1293,40 +1253,24 @@ export default function Sidebar() { {threadStatus.label} )} - {renamingThreadId === thread.id ? ( - { - if (el && renamingInputRef.current !== el) { - renamingInputRef.current = el; - el.focus(); - el.select(); - } - }} - className="min-w-0 flex-1 truncate text-xs bg-accent/30 outline-none rounded px-1 py-px ring-1 ring-ring/40 transition-[box-shadow] duration-150 focus:ring-ring/70" - value={renamingTitle} - onChange={(e) => setRenamingTitle(e.target.value)} - onKeyDown={(e) => { - e.stopPropagation(); - if (e.key === "Enter") { - e.preventDefault(); - renamingCommittedRef.current = true; - void commitRename(thread.id, renamingTitle, thread.title); - } else if (e.key === "Escape") { - e.preventDefault(); - renamingCommittedRef.current = true; - cancelRename(); - } - }} - onBlur={() => { - if (!renamingCommittedRef.current) { - void commitRename(thread.id, renamingTitle, thread.title); - } - }} - onClick={(e) => e.stopPropagation()} - /> - ) : ( - {thread.title} - )} + { + startEditing({ + threadId: thread.id, + title: thread.title, + }); + }} + onDraftTitleChange={setDraftTitle} + onCommit={() => void commitEditing()} + onCancel={cancelEditing} + />
{terminalStatus && ( diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index fa3ff5e1b..3f2a19a60 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -3,7 +3,7 @@ import { type ThreadId, type ResolvedKeybindingsConfig, } from "@okcode/contracts"; -import { memo } from "react"; +import { memo, useEffect } from "react"; import type { ProjectScriptDraft } from "~/projectScriptDefaults"; import GitActionsControl from "../GitActionsControl"; import { @@ -13,11 +13,13 @@ import { MonitorIcon, TerminalSquareIcon, } from "lucide-react"; +import { EditableThreadTitle } from "../EditableThreadTitle"; import { Badge } from "../ui/badge"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import ProjectScriptsControl, { type NewProjectScriptInput } from "../ProjectScriptsControl"; import { Toggle } from "../ui/toggle"; import { SidebarTrigger } from "../ui/sidebar"; +import { useThreadTitleEditor } from "~/hooks/useThreadTitleEditor"; import { OpenInPicker } from "./OpenInPicker"; import { useCodeViewerStore } from "~/codeViewerStore"; import type { ClientMode } from "~/lib/clientMode"; @@ -30,6 +32,7 @@ interface ChatHeaderProps { activeProjectName: string | undefined; activeProjectCwd: string | undefined; isGitRepo: boolean; + isLocalDraftThread: boolean; openInCwd: string | null; activeProjectScripts: ProjectScript[] | undefined; preferredScriptId: string | null; @@ -44,6 +47,7 @@ interface ChatHeaderProps { gitCwd: string | null; diffOpen: boolean; clientMode: ClientMode; + onRenameDraftThreadTitle: (title: string) => void; onRunProjectScript: (script: ProjectScript) => void; onAddProjectScript: (input: NewProjectScriptInput) => Promise; onUpdateProjectScript: (scriptId: string, input: NewProjectScriptInput) => Promise; @@ -62,6 +66,7 @@ export const ChatHeader = memo(function ChatHeader({ activeProjectName, activeProjectCwd, isGitRepo, + isLocalDraftThread, openInCwd: _openInCwd, activeProjectScripts, preferredScriptId, @@ -76,6 +81,7 @@ export const ChatHeader = memo(function ChatHeader({ gitCwd, diffOpen, clientMode, + onRenameDraftThreadTitle, onRunProjectScript, onAddProjectScript, onUpdateProjectScript, @@ -89,17 +95,50 @@ export const ChatHeader = memo(function ChatHeader({ }: ChatHeaderProps) { const isMobileCompanion = clientMode === "mobile"; const hasCodeViewerTabs = useCodeViewerStore((state) => state.tabs.length > 0); + const { + editingThreadId, + draftTitle, + bindInputRef, + cancelEditing, + commitEditing, + setDraftTitle, + startEditing, + } = useThreadTitleEditor({ + onRenameDraftThread: (_threadId, title) => { + onRenameDraftThreadTitle(title); + }, + }); + const isEditingTitle = editingThreadId === activeThreadId; + + useEffect(() => { + cancelEditing(); + }, [activeThreadId, cancelEditing]); return (
-

- {activeThreadTitle} -

+ isEditing={isEditingTitle} + draftTitle={draftTitle} + inputRef={bindInputRef} + containerClassName="min-w-0 shrink [-webkit-app-region:no-drag]" + titleClassName="min-w-0 truncate text-sm font-medium text-foreground" + inputClassName="h-7 text-sm" + showEditButton + editButtonClassName="size-6" + onStartEditing={() => { + startEditing({ + threadId: activeThreadId, + title: activeThreadTitle, + isDraft: isLocalDraftThread, + }); + }} + onDraftTitleChange={setDraftTitle} + onCommit={() => void commitEditing()} + onCancel={cancelEditing} + /> {activeProjectName && ( {activeProjectName} diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index c59bef9a0..b2dc49aea 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -441,6 +441,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(useComposerDraftStore.getState().getDraftThreadByProjectId(projectId)).toEqual({ threadId, projectId, + title: "New thread", branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", @@ -450,6 +451,7 @@ describe("composerDraftStore project draft thread mapping", () => { }); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toEqual({ projectId, + title: "New thread", branch: "feature/test", worktreePath: "/tmp/worktree-test", envMode: "worktree", @@ -539,6 +541,7 @@ describe("composerDraftStore project draft thread mapping", () => { ); expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, + title: "New thread", branch: "feature/next", worktreePath: "/tmp/feature-next", envMode: "worktree", @@ -562,6 +565,7 @@ describe("composerDraftStore project draft thread mapping", () => { expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, + title: "New thread", branch: "main", worktreePath: "/tmp/main-worktree", envMode: "worktree", @@ -588,11 +592,31 @@ describe("composerDraftStore project draft thread mapping", () => { expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ projectId, + title: "New thread", branch: "feature/base", worktreePath: null, envMode: "worktree", }); }); + + it("stores custom draft thread titles independently from branch context", () => { + const store = useComposerDraftStore.getState(); + store.setProjectDraftThreadId(projectId, threadId); + store.setDraftThreadTitle(threadId, "Investigate flaky CI"); + + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ + projectId, + title: "Investigate flaky CI", + }); + + store.setDraftThreadContext(threadId, { branch: "feature/next" }); + + expect(useComposerDraftStore.getState().getDraftThread(threadId)).toMatchObject({ + projectId, + title: "Investigate flaky CI", + branch: "feature/next", + }); + }); }); describe("composerDraftStore modelOptions", () => { diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index 72deb4a7b..5120288f1 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -24,6 +24,7 @@ import { import { create } from "zustand"; import { createJSONStorage, persist } from "zustand/middleware"; import { createDebouncedStorage, createMemoryStorage } from "./lib/storage"; +import { normalizeThreadTitle } from "./threadTitle"; function normalizePersistedInteractionModeValue(raw: unknown): ProviderInteractionMode | null { if (raw === "plan" || raw === "chat" || raw === "code") { @@ -103,6 +104,7 @@ type LegacyPersistedCodexThreadDraftState = PersistedComposerThreadDraftState & const PersistedDraftThreadState = Schema.Struct({ projectId: ProjectId, createdAt: Schema.String, + title: Schema.optionalKey(Schema.String), runtimeMode: RuntimeMode, interactionMode: ProviderInteractionMode, branch: Schema.NullOr(Schema.String), @@ -141,6 +143,7 @@ interface ComposerThreadDraftState { export interface DraftThreadState { projectId: ProjectId; createdAt: string; + title: string; runtimeMode: RuntimeMode; interactionMode: ProviderInteractionMode; branch: string | null; @@ -180,11 +183,13 @@ interface ComposerDraftStoreState { worktreePath?: string | null; projectId?: ProjectId; createdAt?: string; + title?: string; envMode?: DraftThreadEnvMode; runtimeMode?: RuntimeMode; interactionMode?: ProviderInteractionMode; }, ) => void; + setDraftThreadTitle: (threadId: ThreadId, title: string) => void; clearProjectDraftThreadId: (projectId: ProjectId) => void; clearProjectDraftThreadById: (projectId: ProjectId, threadId: ThreadId) => void; clearDraftThread: (threadId: ThreadId) => void; @@ -543,11 +548,8 @@ function normalizeDraftThreadEnvMode( function normalizePersistedDraftThreads( rawDraftThreadsByThreadId: unknown, rawProjectDraftThreadIdByProjectId: unknown, -): Pick< - PersistedComposerDraftStoreState, - "draftThreadsByThreadId" | "projectDraftThreadIdByProjectId" -> { - const draftThreadsByThreadId: Record = {}; +): Pick { + const draftThreadsByThreadId: Record = {}; if (rawDraftThreadsByThreadId && typeof rawDraftThreadsByThreadId === "object") { for (const [threadId, rawDraftThread] of Object.entries( rawDraftThreadsByThreadId as Record, @@ -561,6 +563,7 @@ function normalizePersistedDraftThreads( const candidateDraftThread = rawDraftThread as Record; const projectId = candidateDraftThread.projectId; const createdAt = candidateDraftThread.createdAt; + const title = candidateDraftThread.title; const branch = candidateDraftThread.branch; const worktreePath = candidateDraftThread.worktreePath; const normalizedWorktreePath = typeof worktreePath === "string" ? worktreePath : null; @@ -573,6 +576,7 @@ function normalizePersistedDraftThreads( typeof createdAt === "string" && createdAt.length > 0 ? createdAt : new Date().toISOString(), + title: normalizeThreadTitle(typeof title === "string" ? title : null), runtimeMode: candidateDraftThread.runtimeMode === "approval-required" || candidateDraftThread.runtimeMode === "full-access" @@ -607,6 +611,7 @@ function normalizePersistedDraftThreads( draftThreadsByThreadId[threadId as ThreadId] = { projectId: projectId as ProjectId, createdAt: new Date().toISOString(), + title: normalizeThreadTitle(null), runtimeMode: DEFAULT_RUNTIME_MODE, interactionMode: DEFAULT_INTERACTION_MODE, branch: null, @@ -799,11 +804,20 @@ function partializeComposerDraftStoreState( }; } -function normalizeCurrentPersistedComposerDraftStoreState( - persistedState: unknown, -): PersistedComposerDraftStoreState { +function normalizeCurrentPersistedComposerDraftStoreState(persistedState: unknown): Omit< + PersistedComposerDraftStoreState, + "draftThreadsByThreadId" +> & { + draftThreadsByThreadId: Record; +} { if (!persistedState || typeof persistedState !== "object") { - return EMPTY_PERSISTED_DRAFT_STORE_STATE; + return { + draftsByThreadId: {}, + draftThreadsByThreadId: {}, + projectDraftThreadIdByProjectId: {}, + stickyModel: null, + stickyModelOptions: EMPTY_PROVIDER_MODEL_OPTIONS, + }; } const normalizedPersistedState = persistedState as Record; const { draftThreadsByThreadId, projectDraftThreadIdByProjectId } = @@ -1017,6 +1031,7 @@ export const useComposerDraftStore = create()( const nextDraftThread: DraftThreadState = { projectId, createdAt: options?.createdAt ?? existingThread?.createdAt ?? new Date().toISOString(), + title: existingThread?.title ?? normalizeThreadTitle(null), runtimeMode: options?.runtimeMode ?? existingThread?.runtimeMode ?? DEFAULT_RUNTIME_MODE, interactionMode: @@ -1037,6 +1052,7 @@ export const useComposerDraftStore = create()( existingThread && existingThread.projectId === nextDraftThread.projectId && existingThread.createdAt === nextDraftThread.createdAt && + existingThread.title === nextDraftThread.title && existingThread.runtimeMode === nextDraftThread.runtimeMode && existingThread.interactionMode === nextDraftThread.interactionMode && existingThread.branch === nextDraftThread.branch && @@ -1095,6 +1111,8 @@ export const useComposerDraftStore = create()( options.createdAt === undefined ? existing.createdAt : options.createdAt || existing.createdAt, + title: + options.title === undefined ? existing.title : normalizeThreadTitle(options.title), runtimeMode: options.runtimeMode ?? existing.runtimeMode, interactionMode: options.interactionMode ?? existing.interactionMode, branch: options.branch === undefined ? existing.branch : (options.branch ?? null), @@ -1105,6 +1123,7 @@ export const useComposerDraftStore = create()( const isUnchanged = nextDraftThread.projectId === existing.projectId && nextDraftThread.createdAt === existing.createdAt && + nextDraftThread.title === existing.title && nextDraftThread.runtimeMode === existing.runtimeMode && nextDraftThread.interactionMode === existing.interactionMode && nextDraftThread.branch === existing.branch && @@ -1131,6 +1150,30 @@ export const useComposerDraftStore = create()( }; }); }, + setDraftThreadTitle: (threadId, title) => { + if (threadId.length === 0) { + return; + } + set((state) => { + const existing = state.draftThreadsByThreadId[threadId]; + if (!existing) { + return state; + } + const nextTitle = normalizeThreadTitle(title); + if (existing.title === nextTitle) { + return state; + } + return { + draftThreadsByThreadId: { + ...state.draftThreadsByThreadId, + [threadId]: { + ...existing, + title: nextTitle, + }, + }, + }; + }); + }, clearProjectDraftThreadId: (projectId) => { if (projectId.length === 0) { return; diff --git a/apps/web/src/hooks/useThreadTitleEditor.ts b/apps/web/src/hooks/useThreadTitleEditor.ts new file mode 100644 index 000000000..402409734 --- /dev/null +++ b/apps/web/src/hooks/useThreadTitleEditor.ts @@ -0,0 +1,130 @@ +import type { ThreadId } from "@okcode/contracts"; +import { useCallback, useRef, useState } from "react"; + +import { newCommandId } from "~/lib/utils"; +import { readNativeApi } from "~/nativeApi"; +import { toastManager } from "~/components/ui/toast"; + +interface ThreadTitleEditorSession { + threadId: ThreadId; + originalTitle: string; + draftTitle: string; + isDraft: boolean; +} + +export interface StartThreadTitleEditInput { + threadId: ThreadId; + title: string; + isDraft?: boolean; +} + +export function useThreadTitleEditor(options?: { + onRenameDraftThread?: (threadId: ThreadId, title: string) => void | Promise; +}) { + const [session, setSession] = useState(null); + const sessionRef = useRef(null); + const inputRef = useRef(null); + + const setEditingSession = useCallback((nextSession: ThreadTitleEditorSession | null) => { + sessionRef.current = nextSession; + setSession(nextSession); + }, []); + + const cancelEditing = useCallback(() => { + inputRef.current = null; + setEditingSession(null); + }, [setEditingSession]); + + const bindInputRef = useCallback((node: HTMLInputElement | null) => { + if (!node) { + inputRef.current = null; + return; + } + if (inputRef.current === node) { + return; + } + inputRef.current = node; + node.focus(); + node.select(); + }, []); + + const startEditing = useCallback( + (input: StartThreadTitleEditInput) => { + setEditingSession({ + threadId: input.threadId, + originalTitle: input.title, + draftTitle: input.title, + isDraft: input.isDraft === true, + }); + }, + [setEditingSession], + ); + + const setDraftTitle = useCallback( + (draftTitle: string) => { + setEditingSession( + sessionRef.current + ? { + ...sessionRef.current, + draftTitle, + } + : null, + ); + }, + [setEditingSession], + ); + + const commitEditing = useCallback(async () => { + const currentSession = sessionRef.current; + if (!currentSession) { + return; + } + + const trimmedTitle = currentSession.draftTitle.trim(); + if (trimmedTitle.length === 0) { + toastManager.add({ type: "warning", title: "Thread title cannot be empty" }); + cancelEditing(); + return; + } + if (trimmedTitle === currentSession.originalTitle) { + cancelEditing(); + return; + } + + try { + if (currentSession.isDraft) { + await options?.onRenameDraftThread?.(currentSession.threadId, trimmedTitle); + } else { + const api = readNativeApi(); + if (!api) { + cancelEditing(); + return; + } + await api.orchestration.dispatchCommand({ + type: "thread.meta.update", + commandId: newCommandId(), + threadId: currentSession.threadId, + title: trimmedTitle, + }); + } + } catch (error) { + toastManager.add({ + type: "error", + title: "Failed to rename thread", + description: error instanceof Error ? error.message : "An error occurred.", + }); + } + + cancelEditing(); + }, [cancelEditing, options]); + + return { + editingThreadId: session?.threadId ?? null, + draftTitle: session?.draftTitle ?? "", + bindInputRef, + cancelEditing, + commitEditing, + setDraftTitle, + startEditing, + }; +} diff --git a/apps/web/src/threadTitle.ts b/apps/web/src/threadTitle.ts new file mode 100644 index 000000000..f35b4ff08 --- /dev/null +++ b/apps/web/src/threadTitle.ts @@ -0,0 +1,10 @@ +export const DEFAULT_THREAD_TITLE = "New thread"; + +export function normalizeThreadTitle(title: string | null | undefined): string { + const trimmed = title?.trim() ?? ""; + return trimmed.length > 0 ? trimmed : DEFAULT_THREAD_TITLE; +} + +export function hasCustomThreadTitle(title: string | null | undefined): boolean { + return normalizeThreadTitle(title) !== DEFAULT_THREAD_TITLE; +} diff --git a/bun.lock b/bun.lock index ada0cc367..3bd49dc95 100644 --- a/bun.lock +++ b/bun.lock @@ -201,16 +201,20 @@ }, }, "overrides": { + "@effect/platform-node-shared/effect": "catalog:", + "@effect/platform-node/effect": "catalog:", + "@effect/sql-sqlite-bun/effect": "catalog:", + "@effect/vitest/effect": "catalog:", "vite": "^8.0.0", }, "catalog": { "@effect/language-service": "0.75.1", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", + "@effect/platform-node": "4.0.0-beta.25", + "@effect/sql-sqlite-bun": "4.0.0-beta.25", + "@effect/vitest": "4.0.0-beta.25", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", + "effect": "4.0.0-beta.25", "tsdown": "^0.20.3", "typescript": "^5.7.3", "vitest": "^4.0.0", @@ -372,13 +376,13 @@ "@effect/language-service": ["@effect/language-service@0.75.1", "", { "bin": { "effect-language-service": "cli.js" } }, "sha512-g9xD2tAQgRFpYC2YgpZq02VeSL5fBbFJ0B/g1o+14NuNmwtaYJc7SjiLWAA9eyhJHosNrn6h1Ye+Kx6j5mN0AA=="], - "@effect/platform-node": ["@effect/platform-node@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", { "dependencies": { "@effect/platform-node-shared": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25", "ioredis": "^5.7.0" } }], + "@effect/platform-node": ["@effect/platform-node@4.0.0-beta.25", "", { "dependencies": { "@effect/platform-node-shared": "^4.0.0-beta.25", "mime": "^4.1.0", "undici": "^7.20.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25", "ioredis": "^5.7.0" } }, "sha512-PIYF40MHPGQL/VKnUdVkUzBNfaLx7CjDHkHRCGIE4l/cdrRoSUNFQIHvDXKfyioftzuqO8HHAAdYIF2Zesc68g=="], - "@effect/platform-node-shared": ["@effect/platform-node-shared@https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node-shared@8881a9b606d84a6f5eb6615279138322984f5368", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.25" } }], + "@effect/platform-node-shared": ["@effect/platform-node-shared@4.0.0-beta.43", "", { "dependencies": { "@types/ws": "^8.18.1", "ws": "^8.19.0" }, "peerDependencies": { "effect": "^4.0.0-beta.43" } }, "sha512-A9q0GEb61pYcQ06Dr6gXj1nKlDI3KHsar1sk3qb1ZY+kVSR64tBAylI8zGon23KY+NPtTUj/sEIToB7jc3Qt5w=="], - "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25" } }], + "@effect/sql-sqlite-bun": ["@effect/sql-sqlite-bun@4.0.0-beta.25", "", { "peerDependencies": { "effect": "^4.0.0-beta.25" } }, "sha512-DFCMBXmcT2olRAB0L/gExZSjOtTPi8radfi/396cbw2j04sd/MpDirluwoK/8a+O3+kzamF6zaEJF262bLP0iA=="], - "@effect/vitest": ["@effect/vitest@https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", { "peerDependencies": { "effect": "^4.0.0-beta.25", "vitest": "^3.0.0 || ^4.0.0" } }], + "@effect/vitest": ["@effect/vitest@4.0.0-beta.25", "", { "peerDependencies": { "effect": "^4.0.0-beta.25", "vitest": "^3.0.0 || ^4.0.0" } }, "sha512-K8Fhs3gvlSvmU2Ye6y4nCLP3Y0qh9M6I0ZRCOb4lvwe9lacR597jROxWcsm3RDOXE/8vHkSye7+9pqqm7Jog0Q=="], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1216,7 +1220,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "effect": ["effect@https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }], + "effect": ["effect@4.0.0-beta.25", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-5dFGfJuJOITAlKnK9tmBNAtTxJt0/G65QO0ouet0QqFsDNDptZf8egYaWAJ/8RnYigzLhieE/A8qvnyKwmAPjw=="], "electron": ["electron@40.6.0", "", { "dependencies": { "@electron/get": "^2.0.0", "@types/node": "^24.9.0", "extract-zip": "^2.0.1" }, "bin": { "electron": "cli.js" } }, "sha512-ett8W+yOFGDuM0vhJMamYSkrbV3LoaffzJd9GfjI96zRAxyrNqUSKqBpf/WGbQCweDxX2pkUCUfrv4wwKpsFZA=="], @@ -2192,13 +2196,7 @@ "@capacitor/cli/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], - "@effect/platform-node/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - - "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - - "@effect/sql-sqlite-bun/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], - - "@effect/vitest/effect": ["effect@4.0.0-beta.33", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-ln9emWPd1SemokSdOV43r2CbH1j8GTe9qbPvttmh9/j2OR0WNmj7UpjbN34llQgF9QV4IdcN6QdV2w8G7B7RyQ=="], + "@effect/platform-node-shared/effect": ["effect@4.0.0-beta.43", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-AJYyDimIwJOn87uUz/JzmgDc5GfjxJbXvEbTvNzMa+M3Uer344bLo/O5mMRkqc1vBleA+Ygs4+dbE3QsqOkKTQ=="], "@electron/get/fs-extra": ["fs-extra@8.1.0", "", { "dependencies": { "graceful-fs": "^4.2.0", "jsonfile": "^4.0.0", "universalify": "^0.1.0" } }, "sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g=="], diff --git a/package.json b/package.json index 4994f3d98..105759360 100644 --- a/package.json +++ b/package.json @@ -9,10 +9,10 @@ "scripts" ], "catalog": { - "effect": "https://pkg.pr.new/Effect-TS/effect-smol/effect@8881a9b", - "@effect/platform-node": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/platform-node@8881a9b", - "@effect/sql-sqlite-bun": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/sql-sqlite-bun@8881a9b", - "@effect/vitest": "https://pkg.pr.new/Effect-TS/effect-smol/@effect/vitest@8881a9b", + "effect": "4.0.0-beta.25", + "@effect/platform-node": "4.0.0-beta.25", + "@effect/sql-sqlite-bun": "4.0.0-beta.25", + "@effect/vitest": "4.0.0-beta.25", "@effect/language-service": "0.75.1", "@types/bun": "^1.3.9", "@types/node": "^24.10.13", @@ -54,7 +54,7 @@ "clean": "rm -rf node_modules apps/*/node_modules packages/*/node_modules apps/*/dist apps/*/dist-electron packages/*/dist .turbo apps/*/.turbo packages/*/.turbo", "sync:vscode-icons": "node scripts/sync-vscode-icons.mjs", "regenerate:brand-assets": "python3 scripts/generate-brand-assets.py", - "prepare": "husky && node scripts/patch-effect-language-service.ts" + "prepare": "husky && node scripts/patch-effect-language-service.ts && node scripts/dedupe-effect-install.mjs" }, "devDependencies": { "@types/node": "catalog:", @@ -66,6 +66,10 @@ "vitest": "catalog:" }, "overrides": { + "@effect/platform-node-shared/effect": "catalog:", + "@effect/platform-node/effect": "catalog:", + "@effect/sql-sqlite-bun/effect": "catalog:", + "@effect/vitest/effect": "catalog:", "vite": "^8.0.0" }, "lint-staged": { diff --git a/scripts/dedupe-effect-install.mjs b/scripts/dedupe-effect-install.mjs new file mode 100644 index 000000000..772077c4e --- /dev/null +++ b/scripts/dedupe-effect-install.mjs @@ -0,0 +1,39 @@ +import { lstat, mkdir, rm, symlink } from "node:fs/promises"; +import path from "node:path"; + +const rootDir = path.resolve(import.meta.dirname, ".."); +const rootEffectDir = path.join(rootDir, "node_modules", "effect"); + +const nestedEffectDirs = [ + path.join(rootDir, "node_modules", "@effect", "platform-node", "node_modules", "effect"), + path.join(rootDir, "node_modules", "@effect", "platform-node-shared", "node_modules", "effect"), + path.join(rootDir, "node_modules", "@effect", "sql-sqlite-bun", "node_modules", "effect"), + path.join(rootDir, "node_modules", "@effect", "vitest", "node_modules", "effect"), +]; + +async function pathExists(target) { + try { + await lstat(target); + return true; + } catch { + return false; + } +} + +async function dedupeNestedEffectDir(target) { + const parentDir = path.dirname(target); + await mkdir(parentDir, { recursive: true }); + + if (await pathExists(target)) { + const stat = await lstat(target); + if (stat.isSymbolicLink()) { + return; + } + await rm(target, { recursive: true, force: true }); + } + + const relativeTarget = path.relative(parentDir, rootEffectDir); + await symlink(relativeTarget, target, process.platform === "win32" ? "junction" : "dir"); +} + +await Promise.all(nestedEffectDirs.map(dedupeNestedEffectDir));