Compare commits

...

2 Commits

Author SHA1 Message Date
Brendan Allan 2baa9ed376 test(app): remove cross-worktree pane regressions 2026-07-28 16:32:23 +08:00
Brendan Allan 4406292ff2 fix(app): prevent pane flicker across worktrees 2026-07-28 16:27:07 +08:00
4 changed files with 54 additions and 52 deletions
+40 -19
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { batch, createEffect, createMemo, createRoot, onCleanup, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
import { useSettings } from "@/context/settings"
@@ -26,6 +26,13 @@ type Saved = {
session: Record<string, State | undefined>
}
type SavedEntry = {
saved: Saved
setSaved: ReturnType<typeof createStore<Saved>>[1]
ready: ReturnType<typeof persisted<Saved>>[3]
dispose: VoidFunction
}
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
@@ -71,15 +78,29 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
const [saved, setSaved, , savedReady] = persisted(
{
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
session: {},
}),
)
const savedCache = new Map<string, SavedEntry>()
const loadSaved = (serverScope: ServerScope, directory: string) => {
const key = ScopedKey.from(serverScope, directory, "model-selection")
const existing = savedCache.get(key)
if (existing) return existing
const entry = createRoot((dispose) => {
const [saved, setSaved, , ready] = persisted(
{
...Persist.serverWorkspace(serverScope, directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({ session: {} }),
)
return { saved, setSaved, ready, dispose }
})
savedCache.set(key, entry)
return entry
}
const saved = createMemo(() => loadSaved(serverSDK().scope, sdk().directory))
onCleanup(() => {
for (const entry of savedCache.values()) entry.dispose()
savedCache.clear()
})
const [store, setStore] = createStore<{
current?: string
@@ -127,7 +148,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft ?? store.promoting
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
return saved().saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
})
createEffect(() => {
@@ -137,13 +158,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const key = handoffKey(serverSDK().scope, sdk().directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
if (saved().saved.session[session] !== undefined) {
handoff.delete(key)
setStore("promoting", undefined)
return
}
setSaved("session", session, clone(next))
saved().setSaved("session", session, clone(next))
handoff.delete(key)
setStore("promoting", undefined)
})
@@ -209,7 +230,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
} satisfies State
const session = id()
if (session) {
setSaved("session", session, next)
saved().setSaved("session", session, next)
return
}
setStore("draft", next)
@@ -270,7 +291,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const session = id()
if (session) {
setSaved("session", session, state)
saved().setSaved("session", session, state)
return
}
setStore("draft", state)
@@ -378,7 +399,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model,
agent,
session: {
ready: savedReady,
ready: () => saved().ready(),
reset() {
setStore({ draft: undefined, promoting: undefined })
},
@@ -389,7 +410,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
handoff.set(key, next)
if (dir === sdk().directory) {
setSaved("session", session, next)
saved().setSaved("session", session, next)
}
setStore("promoting", next)
@@ -399,10 +420,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const session = id()
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (saved().saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
setSaved("session", session, {
saved().setSaved("session", session, {
agent: msg.agent,
model: msg.model,
variant: msg.model?.variant ?? null,
+8 -12
View File
@@ -58,18 +58,14 @@ export function DirectoryDataProvider(
})
return (
<Show when={directory()} keyed>
{(directory) => (
<DataProvider
data={sync().data}
directory={directory}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)}
</Show>
<DataProvider
data={sync().data}
directory={directory}
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
onSessionHref={href}
>
<LocalProvider>{props.children}</LocalProvider>
</DataProvider>
)
}
+3 -19
View File
@@ -252,7 +252,7 @@ function ResolvedTargetSessionRoute() {
() => params.id,
() => sync().session.lineage,
)
const directory = createMemo(() => current()?.session.directory)
const directory = createMemo<string | undefined>((previous) => current()?.session.directory ?? previous)
const targetDirectory = () => directory()!
createEffect(() => {
@@ -265,33 +265,17 @@ function ResolvedTargetSessionRoute() {
})
return (
// Non-keyed: closes only while the target's directory is unknown (uncached
// lineage mid-resolution), which tears down the workspace subtree including
// the terminal. Same-workspace tab switches keep it open because warm
// targets resolve synchronously from the sync cache.
// Keep the previous workspace mounted while an uncached target resolves.
<Show when={directory()}>
<SDKProvider directory={targetDirectory}>
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
<TargetSessionPage />
<SessionPage />
</DirectoryDataProvider>
</SDKProvider>
</Show>
)
}
// Owns the workspace-identity remount. Must not include the session ID in the
// key: SessionPage handles session changes reactively, and remounting here
// destroys workspace-scoped state (terminal PTYs, file/prompt providers).
function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionPage />
</Show>
)
}
function TargetServerScopedProviders(
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
) {
+3 -2
View File
@@ -2,6 +2,7 @@ import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import type { Accessor } from "solid-js"
export type NormalizedProviderListResponse = {
all: Map<string, Provider>
@@ -46,7 +47,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
name: "Data",
init: (props: {
data: Data
directory: string
directory: string | Accessor<string>
onNavigateToSession?: NavigateToSessionFn
onSessionHref?: SessionHrefFn
}) => {
@@ -55,7 +56,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
return props.data
},
get directory() {
return props.directory
return typeof props.directory === "function" ? props.directory() : props.directory
},
navigateToSession: props.onNavigateToSession,
sessionHref: props.onSessionHref,