feedback n stuff?

This commit is contained in:
LukeParkerDev
2026-04-19 13:21:48 +10:00
parent e3d2a9ddbb
commit bc84698428
24 changed files with 5660 additions and 143 deletions
+3 -1
View File
@@ -235,7 +235,9 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo
})
return {
url: currentServer.http.url,
get url() {
return server.current?.http.url ?? currentServer.http.url
},
client: sdk,
event: {
on: emitter.on.bind(emitter),
+16 -2
View File
@@ -171,6 +171,13 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
if (state.active !== input) setState("active", input)
}
function nextActiveKey(exclude?: ServerConnection.Key) {
const available = allServers().filter((conn) => ServerConnection.key(conn) !== exclude)
const preferred = available.find((conn) => ServerConnection.key(conn) === props.defaultServer)
const next = preferred ?? available[0]
return next ? ServerConnection.key(next) : props.defaultServer
}
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
@@ -192,8 +199,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
batch(() => {
setStore("list", list)
if (state.active === key) {
const next = list[0]
setState("active", next ? ServerConnection.Key.make(url(next)) : props.defaultServer)
setState("active", nextActiveKey(key))
}
})
}
@@ -239,6 +245,14 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
createEffect(() => {
const list = allServers()
if (!list.length) return
if (list.some((conn) => ServerConnection.key(conn) === state.active)) return
setState("active", nextActiveKey(state.active))
})
const isLocal = createMemo(() => {
const c = current()
return c?.type === "sidecar" || (c?.type === "http" && isLocalHost(c.http.url))
+4 -3
View File
@@ -1,6 +1,6 @@
import { beforeAll, describe, expect, mock, test } from "bun:test"
let getWorkspaceTerminalCacheKey: (dir: string) => string
let getWorkspaceTerminalCacheKey: (dir: string, serverKey: string) => string
let getLegacyTerminalStorageKeys: (dir: string, legacySessionID?: string) => string[]
let migrateTerminalState: (value: unknown) => unknown
@@ -22,8 +22,9 @@ beforeAll(async () => {
})
describe("getWorkspaceTerminalCacheKey", () => {
test("uses workspace-only directory cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo")).toBe("/repo:__workspace__")
test("includes the server in the workspace cache key", () => {
expect(getWorkspaceTerminalCacheKey("/repo", "local:windows")).toBe("/repo:local:windows:__workspace__")
expect(getWorkspaceTerminalCacheKey("/repo", "wsl:Debian")).toBe("/repo:wsl:Debian:__workspace__")
})
})
+137 -21
View File
@@ -3,6 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "solid-js"
import { useParams } from "@solidjs/router"
import { useSDK } from "./sdk"
import { useServer } from "./server"
import type { Platform } from "./platform"
import { defaultTitle, titleNumber } from "./terminal-title"
import { Persist, persisted, removePersisted } from "@/utils/persist"
@@ -21,6 +22,11 @@ export type LocalPTY = {
const WORKSPACE_KEY = "__workspace__"
const MAX_TERMINAL_SESSIONS = 20
const debugTerminal = (phase: string, input: Record<string, unknown>) => {
if (!import.meta.env.DEV) return
console.log(`[terminal context] ${JSON.stringify({ phase, ...input })}`)
}
function record(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
@@ -82,8 +88,8 @@ export function migrateTerminalState(value: unknown) {
}
}
export function getWorkspaceTerminalCacheKey(dir: string) {
return `${dir}:${WORKSPACE_KEY}`
export function getWorkspaceTerminalCacheKey(dir: string, serverKey: string) {
return `${dir}:${serverKey}:${WORKSPACE_KEY}`
}
export function getLegacyTerminalStorageKeys(dir: string, legacySessionID?: string) {
@@ -111,10 +117,11 @@ const trimTerminal = (pty: LocalPTY) => {
}
export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], platform?: Platform) {
const key = getWorkspaceTerminalCacheKey(dir)
for (const cache of caches) {
const entry = cache.get(key)
entry?.value.clear()
for (const [key, entry] of cache.entries()) {
if (!key.startsWith(`${dir}:`) || !key.endsWith(`:${WORKSPACE_KEY}`)) continue
entry.value.clear()
}
}
void removePersisted(Persist.workspace(dir, "terminal"), platform)
@@ -130,14 +137,25 @@ export function clearWorkspaceTerminals(dir: string, sessionIDs?: string[], plat
}
}
function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: string, legacySessionID?: string) {
function createWorkspaceTerminalSession(
sdk: ReturnType<typeof useSDK>,
dir: string,
serverKey: string,
legacySessionID?: string,
) {
const legacy = getLegacyTerminalStorageKeys(dir, legacySessionID)
const target = {
...Persist.workspace(dir, `${serverKey}:terminal`, legacy),
migrate: migrateTerminalState,
}
// Scope persisted terminal state by server so switching servers behaves
// like switching projects: a fresh session for the new server+dir pair,
// while the other server's state stays intact until you swap back. PTY
// ids, scrollback, and WebSocket connections are all server-scoped, so
// cross-server persistence was showing stale output on swap.
const [store, setStore, _, ready] = persisted(
{
...Persist.workspace(dir, "terminal", legacy),
migrate: migrateTerminalState,
},
target,
createStore<{
active?: string
all: LocalPTY[]
@@ -146,6 +164,14 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
}),
)
debugTerminal("session.create", {
dir,
serverKey,
storage: target.storage,
key: target.key,
legacySessionID: legacySessionID ?? null,
})
const pickNextTerminalNumber = () => {
const existingTitleNumbers = new Set(
store.all.flatMap((pty) => {
@@ -186,6 +212,16 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
onCleanup(unsub)
const update = (client: ReturnType<typeof useSDK>["client"], pty: Partial<LocalPTY> & { id: string }) => {
debugTerminal("session.update", {
dir,
serverKey,
id: pty.id,
title: pty.title ?? null,
hasBuffer: typeof pty.buffer === "string",
bufferLength: typeof pty.buffer === "string" ? pty.buffer.length : 0,
cursor: pty.cursor ?? null,
scrollY: pty.scrollY ?? null,
})
const index = store.all.findIndex((x) => x.id === pty.id)
const previous = index >= 0 ? store.all[index] : undefined
if (index >= 0) {
@@ -202,11 +238,18 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
const currentIndex = store.all.findIndex((item) => item.id === pty.id)
if (currentIndex >= 0) setStore("all", currentIndex, previous)
}
console.error("Failed to update terminal", error)
console.error(
`Failed to update terminal ${JSON.stringify({
ptyID: pty.id,
title: pty.title,
error: error instanceof Error ? { name: error.name, message: error.message, stack: error.stack } : error,
})}`,
)
})
}
const clone = async (client: ReturnType<typeof useSDK>["client"], id: string) => {
debugTerminal("session.clone.start", { dir, serverKey, id })
const index = store.all.findIndex((x) => x.id === id)
const pty = store.all[index]
if (!pty) return
@@ -220,6 +263,14 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
})
if (!next?.data) return
debugTerminal("session.clone.done", {
dir,
serverKey,
id,
nextID: next.data.id ?? null,
title: next.data.title ?? pty.title,
})
const active = store.active === pty.id
batch(() => {
@@ -252,11 +303,19 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
new() {
const nextNumber = pickNextTerminalNumber()
debugTerminal("session.new", { dir, serverKey, nextNumber })
sdk.client.pty
.create({ title: defaultTitle(nextNumber) })
.then((pty: { data?: { id?: string; title?: string } }) => {
const id = pty.data?.id
if (!id) return
debugTerminal("session.new.done", {
dir,
serverKey,
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
})
const newTerminal = {
id,
title: pty.data?.title ?? defaultTitle(nextNumber),
@@ -289,6 +348,12 @@ function createWorkspaceTerminalSession(sdk: ReturnType<typeof useSDK>, dir: str
},
bind() {
const client = sdk.client
debugTerminal("session.bind", {
dir,
serverKey,
active: store.active ?? null,
all: store.all.map((item) => item.id),
})
return {
trim(id: string) {
const index = store.all.findIndex((x) => x.id === id)
@@ -357,6 +422,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
gate: false,
init: () => {
const sdk = useSDK()
const server = useServer()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
@@ -364,7 +430,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
onCleanup(() => caches.delete(cache))
const disposeAll = () => {
// Snapshot disposers, then defer them to a microtask. When this runs
// Snapshot disposers, then defer them to a macrotask. When this runs
// from onCleanup during a parent remount (e.g. switching servers),
// calling dispose() synchronously starts a nested cleanNode cascade on
// a sibling root while the outer cascade is mid-traversal, corrupting
@@ -372,7 +438,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
// null (reading '1')` at chunk-*.js:992.
const pending = Array.from(cache.values(), (entry) => entry.dispose)
cache.clear()
if (pending.length) queueMicrotask(() => pending.forEach((d) => d()))
if (pending.length) setTimeout(() => pending.forEach((d) => d()), 0)
}
onCleanup(disposeAll)
@@ -387,18 +453,33 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
}
}
const loadWorkspace = (dir: string, legacySessionID?: string) => {
// Terminals are workspace-scoped so tabs persist while switching sessions in the same directory.
const key = getWorkspaceTerminalCacheKey(dir)
const loadWorkspace = (dir: string, serverKey: string, legacySessionID?: string) => {
// Session ids, PTY ids, and terminal buffers are server-scoped. Project
// swaps remount this subtree, but server swaps do not, so the in-memory
// cache must be partitioned by server as well as directory.
const key = getWorkspaceTerminalCacheKey(dir, serverKey)
const existing = cache.get(key)
if (existing) {
debugTerminal("workspace.cache.hit", {
dir,
serverKey,
key,
legacySessionID: legacySessionID ?? null,
})
cache.delete(key)
cache.set(key, existing)
return existing.value
}
debugTerminal("workspace.cache.miss", {
dir,
serverKey,
key,
legacySessionID: legacySessionID ?? null,
})
const entry = createRoot((dispose) => ({
value: createWorkspaceTerminalSession(sdk, dir, legacySessionID),
value: createWorkspaceTerminalSession(sdk, dir, serverKey, legacySessionID),
dispose,
}))
@@ -407,16 +488,51 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
return entry.value
}
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id))
const unsupported = createMemo(() => {
const current = server.current
return current?.type === "sidecar" && current.variant === "wsl" && params.dir?.startsWith("/mnt/")
})
const unsupportedWorkspace = {
ready: () => true,
all: () => [] as LocalPTY[],
active: () => undefined as string | undefined,
clear() {},
new() {},
update(_pty: Partial<LocalPTY> & { id: string }) {},
trim(_id: string) {},
trimAll() {},
clone: async (_id: string) => {},
bind() {
return {
trim(_id: string) {},
update(_pty: Partial<LocalPTY> & { id: string }) {},
clone: async (_id: string) => {},
}
},
open(_id: string) {},
close: async (_id: string) => {},
move(_id: string, _to: number) {},
next() {},
previous() {},
} as unknown as ReturnType<typeof createWorkspaceTerminalSession>
const workspace = createMemo(() => {
if (unsupported()) return unsupportedWorkspace
const key = server.key
if (!key) return unsupportedWorkspace
return loadWorkspace(params.dir!, key, params.id)
})
createEffect(
on(
() => ({ dir: params.dir, id: params.id }),
(next, prev) => {
if (!prev?.dir) return
const prevKey = server.key
if (!prev?.dir || !prevKey) return
if (next.dir === prev.dir && next.id === prev.id) return
if (next.dir === prev.dir && next.id) return
loadWorkspace(prev.dir, prev.id).trimAll()
loadWorkspace(prev.dir, prevKey, prev.id).trimAll()
},
{ defer: true },
),
@@ -431,7 +547,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
trim: (id: string) => workspace().trim(id),
trimAll: () => workspace().trimAll(),
clone: (id: string) => workspace().clone(id),
bind: () => workspace(),
bind: () => workspace().bind(),
open: (id: string) => workspace().open(id),
close: (id: string) => workspace().close(id),
move: (id: string, to: number) => workspace().move(id, to),