feat(app): add draft tab support to tabs store (#31343)

This commit is contained in:
Brendan Allan 2026-06-09 10:49:18 +08:00 committed by GitHub
parent 161247c70d
commit f565ff3c09
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 100 additions and 5 deletions

View File

@ -447,6 +447,8 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
refreshTabsAreOverflowing()
})
if (tab.type !== "session") return null
return (
<>
{i() !== 0 && (

View File

@ -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 const tabHref = (tab: Tab) => `/${tab.dirBase64}/session/${tab.sessionId}`
export const tabKey = (tab: Tab) => `${tab.server}\n${tabHref(tab)}`
export type Tab = SessionTab | DraftTab
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<string>()
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<DraftTab, "type" | "draftID">, 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<Omit<DraftTab, "type" | "draftID">>) {
setStore(
(tab) => tab.type === "draft" && tab.draftID === draftID,
produce((tab) => Object.assign(tab, draft)),
)
},
promoteDraft(draftID: string, session: Omit<SessionTab, "type">) {
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(

View File

@ -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")

View File

@ -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<string>()
@ -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) }