diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 371cf87c5cbd..019f4f47089b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -447,6 +447,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { refreshTabsAreOverflowing() }) + if (tab.type !== "session") return null + return ( <> {i() !== 0 && ( diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 17374983799c..2a1e1cd7004c 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -2,10 +2,12 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { base64Encode } from "@opencode-ai/core/util/encode" import { createStore, produce } from "solid-js/store" -import { Persist, persisted } from "@/utils/persist" +import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { ServerConnection, useServer } from "./server" import { createEffect, startTransition } from "solid-js" import { useNavigate, useParams } from "@solidjs/router" +import { usePlatform } from "./platform" +import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" export type SessionTab = { @@ -15,10 +17,23 @@ export type SessionTab = { sessionId: string } -export type Tab = SessionTab +export type DraftTab = { + type: "draft" + draftID: string + server: ServerConnection.Key + directory: string + worktree?: string +} + +export type Tab = SessionTab | DraftTab -export const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}` -export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}` +export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` + +export const tabHref = (tab: Tab) => + tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` + +export const tabKey = (tab: Tab) => + tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}` export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { const dirBase64 = base64Encode(session.directory) @@ -33,6 +48,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ gate: false, init: () => { const server = useServer() + const platform = usePlatform() const fallback = server.key const [store, setStore, _, ready] = persisted( { @@ -53,6 +69,10 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const closing = new Set() + const removeDraftPersisted = (draftID: string) => { + for (const key of draftPersistedKeys()) removePersisted(Persist.draft(draftID, key), platform) + } + createEffect(() => { if (!ready()) return const servers = new Set(server.list.map(ServerConnection.key)) @@ -83,10 +103,42 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ }), ) }, + draft(draftID: string) { + const tab = store.find((item) => item.type === "draft" && item.draftID === draftID) + if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) + return tab + }, + newDraft(draft: Omit, prompt?: string) { + const draftID = uuid() + setStore( + produce((tabs) => { + tabs.push({ type: "draft", draftID, ...draft }) + }), + ) + navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) + }, + updateDraft(draftID: string, draft: Partial>) { + setStore( + (tab) => tab.type === "draft" && tab.draftID === draftID, + produce((tab) => Object.assign(tab, draft)), + ) + }, + promoteDraft(draftID: string, session: Omit) { + const active = `${location.pathname}${location.search}` === draftHref(draftID) + setStore( + produce((tabs) => { + const index = tabs.findIndex((tab) => tab.type === "draft" && tab.draftID === draftID) + if (index !== -1) tabs[index] = { type: "session", ...session } + }), + ) + if (active) navigateTab({ type: "session", ...session }) + removeDraftPersisted(draftID) + }, removeTab: (index: number) => { const tab = store[index] if (!tab) return const key = tabKey(tab) + const draftID = tab.type === "draft" ? tab.draftID : undefined const nextTab = store[index + 1] ?? store[index - 1] closing.add(key) void startTransition(() => { @@ -98,9 +150,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (nextTab) navigateTab(nextTab) else navigate("/") }).finally(() => closing.delete(key)) + if (draftID) removeDraftPersisted(draftID) }, removeServer(key: ServerConnection.Key) { + const drafts = store.flatMap((tab) => (tab.type === "draft" && tab.server === key ? [tab.draftID] : [])) setStore((tabs) => tabs.filter((tab) => tab.server !== key)) + for (const draftID of drafts) removeDraftPersisted(draftID) if (server.key === key) navigate("/") }, removeSessions: (input: SessionTabsRemovedDetail) => { @@ -110,7 +165,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const sessionIDs = new Set(input.sessionIDs) const currentHref = params.dir && params.id - ? tabHref({ type: "session", server: server.key, dirBase64: params.dir, sessionId: params.id }) + ? tabHref({ + type: "session", + server: server.key, + dirBase64: params.dir, + sessionId: params.id, + }) : undefined const currentIndex = currentHref ? tabs.findIndex( diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts index 90a8d01dc89b..d8b822d856bb 100644 --- a/packages/app/src/utils/persist.test.ts +++ b/packages/app/src/utils/persist.test.ts @@ -166,6 +166,24 @@ describe("persist localStorage resilience", () => { expect(storage.getItem(`${target.legacyStorageNames![0]}:${target.key}`)).toBeNull() }) + test("draft target isolates storage per draft and namespaces keys", () => { + const a = Persist.draft("draft-a", "prompt") + const b = Persist.draft("draft-b", "prompt") + + expect(a.key).toBe("draft:prompt") + expect(a.storage).not.toBe(b.storage) + expect(a.storage).not.toBe(Persist.workspace("/home/luke/repo", "prompt").storage) + }) + + test("removes draft storage when removing persisted target", () => { + const target = Persist.draft("draft-a", "prompt") + storage.setItem(`${target.storage}:${target.key}`, '{"value":1}') + + removePersisted(target) + + expect(storage.getItem(`${target.storage}:${target.key}`)).toBeNull() + }) + test("server workspace target preserves local storage and isolates remote storage", () => { const local = Persist.serverWorkspace(ServerScope.local, "/home/luke/repo", "prompt") const windows = Persist.serverWorkspace("https://windows.example" as ServerScope, "/home/luke/repo", "prompt") diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 590d19e96975..7c0fe28fbd5e 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -341,6 +341,12 @@ function workspaceStorage(dir: string) { return `opencode.workspace.${head}.${sum}.dat` } +function draftStorage(draftID: string) { + const head = (draftID.slice(0, 12) || "draft").replace(/[^a-zA-Z0-9._-]/g, "-") + const sum = checksum(draftID) ?? "0" + return `opencode.draft.${head}.${sum}.dat` +} + function legacyWorkspaceStorage(dir: string) { const storage = workspaceStorage(pathKey(dir)) const result = new Set() @@ -450,6 +456,12 @@ function localStorageDirect(): SyncStorage { } } +const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"] + +export function draftPersistedKeys() { + return DRAFT_PERSISTED_KEYS +} + export const PersistTesting = { localStorageDirect, localStorageWithPrefix, @@ -462,6 +474,9 @@ export const Persist = { global(key: string, legacy?: string[]): PersistTarget { return { storage: GLOBAL_STORAGE, key, legacy } }, + draft(draftID: string, key: string, legacy?: string[]): PersistTarget { + return { storage: draftStorage(draftID), key: `draft:${key}`, legacy } + }, serverGlobal(scope: ServerScopeValue, key: string, legacy?: string[]): PersistTarget { if (scope === ServerScope.local) return Persist.global(key, legacy) return { storage: GLOBAL_STORAGE, key: ScopedKey.from(scope, key) }