feat(app): improve desktop multi-server support (#30678)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Luke Parker
2026-06-05 06:30:02 +00:00
committed by GitHub
co-authored by Brendan Allan
parent 7ae856a9e9
commit 7f33576f46
62 changed files with 1523 additions and 841 deletions
+267 -176
View File
@@ -1,5 +1,5 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
import { batch, createEffect, createMemo, For, Match, on, onCleanup, onMount, Show, Switch } from "solid-js"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createStore } from "solid-js/store"
import { useQuery } from "@tanstack/solid-query"
@@ -26,7 +26,18 @@ import { useServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { displayName, getProjectAvatarSource, projectForSession, sortedRootSessions } from "@/pages/layout/helpers"
import {
closeHomeProject,
displayName,
getProjectAvatarSource,
homeProjectDirectories,
homeProjectNavigation,
homeSessionServerStatus,
type HomeProjectSelection,
projectForSession,
sortedRootSessions,
toggleHomeProjectSelection,
} from "@/pages/layout/helpers"
import { sessionTitle } from "@/utils/session-title"
import { pathKey } from "@/utils/path-key"
import { messageAgentColor } from "@/utils/agent"
@@ -51,6 +62,8 @@ type HomeSessionRecord = {
projectName: string
}
type HomeSessionSync = Pick<ReturnType<typeof useServerSync>, "child">
type HomeSessionGroup = {
id: "today" | "yesterday" | "older"
title: string
@@ -65,8 +78,10 @@ const HOME_SEARCH_RESULT_TITLE =
const HOME_SEARCH_RESULT_META =
"min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]"
let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined
function buildHomeSessionRecords(input: {
sync: ReturnType<typeof useServerSync>
sync: Pick<ReturnType<typeof useServerSync>, "child">
projectDirectories: () => string[]
projects: () => LocalProject[]
projectByID: () => Map<string, LocalProject>
@@ -95,6 +110,44 @@ function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) {
return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query)
}
function createHomeSessionStatus(input: {
record: () => HomeSessionRecord
sync: () => HomeSessionSync
activeServer: () => boolean
}) {
const notification = useNotification()
const permission = usePermission()
const sessionStore = createMemo(() => input.sync().child(input.record().session.directory, { bootstrap: false })[0])
const unseenCount = createMemo(() => (input.activeServer() ? notification.session.unseenCount(input.record().session.id) : 0))
const hasError = createMemo(() => input.activeServer() && notification.session.unseenHasError(input.record().session.id))
const hasPermissions = createMemo(
() =>
input.activeServer() &&
!!sessionPermissionRequest(sessionStore().session, sessionStore().permission, input.record().session.id, (item) => {
return !permission.autoResponds(item, input.record().session.directory)
}),
)
const serverStatus = createMemo(() =>
homeSessionServerStatus(input.activeServer(), () => ({
working: sessionStore().session_working(input.record().session.id),
tint: messageAgentColor(sessionStore().message[input.record().session.id], sessionStore().agent),
})),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return serverStatus().working
})
const tint = createMemo(() => serverStatus().tint)
return {
unseenCount,
hasError,
hasPermissions,
isWorking,
tint,
show: createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0),
}
}
function homeSessionSearchKey(record: HomeSessionRecord) {
return `${pathKey(record.session.directory)}:${record.session.id}`
}
@@ -122,12 +175,27 @@ function HomeDesign() {
let focusSessionSearch: (() => void) | undefined
const [state, setState] = createStore({
search: "",
project: undefined as string | undefined,
selection: { server: server.key } as HomeProjectSelection,
searchFocused: false,
})
const projects = createMemo(() => layout.projects.list())
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.project))
const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === state.selection.server) ?? server.current,
)
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return
return global.createServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const selectedProject = createMemo(() => projects().find((project) => project.worktree === state.selection.directory))
const newSessionProject = createMemo(
() =>
selectedProject() ??
projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ??
projects()[0],
)
const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])]
const projectDirectories = createMemo(() => {
const project = selectedProject()
@@ -136,9 +204,9 @@ function HomeDesign() {
})
const search = createMemo(() => state.search.trim())
const sessionLoad = useQuery(() => ({
queryKey: ["home", "sessions", ...projectDirectories()] as const,
queryKey: ["home", "sessions", state.selection.server, ...projectDirectories()] as const,
queryFn: async () => {
await Promise.all(projectDirectories().map((directory) => sync.project.loadSessions(directory)))
await Promise.all(projectDirectories().map((directory) => focusedSync().project.loadSessions(directory)))
return null
},
}))
@@ -148,7 +216,7 @@ function HomeDesign() {
)
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sync,
sync: focusedSync(),
projectDirectories,
projects,
projectByID,
@@ -163,6 +231,13 @@ function HomeDesign() {
const searchOpen = createMemo(() => state.searchFocused && search().length > 0)
const groups = createMemo(() => groupSessions(records(), language))
function setSelection(next: HomeProjectSelection) {
batch(() => {
if (state.selection.server !== next.server) setState("selection", "server", next.server)
if (state.selection.directory !== next.directory) setState("selection", "directory", next.directory)
})
}
function closeSearch() {
setState("search", "")
setState("searchFocused", false)
@@ -183,47 +258,76 @@ function HomeDesign() {
},
])
function selectProject(directory: string) {
if (!projects().some((project) => project.worktree === directory)) return
setState("project", state.project === directory ? undefined : directory)
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === state.selection.server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
createEffect(() => {
const pending = pendingHomeNavigation
if (!pending || pending.server !== server.key) return
pendingHomeNavigation = undefined
navigate(pending.href)
})
function focusServer(conn: ServerConnection.Any) {
setSelection({ server: ServerConnection.key(conn) })
}
function addProject(conn: ServerConnection.Any, directory: string) {
const server = global.createServerCtx(conn)
server.projects.open(directory)
server.projects.touch(directory)
setState("project", directory)
function selectProject(conn: ServerConnection.Any, directory: string) {
const key = ServerConnection.key(conn)
if (!global.createServerCtx(conn).projects.list().some((project) => project.worktree === directory)) return
setSelection(toggleHomeProjectSelection(state.selection, key, directory))
}
function addProjects(conn: ServerConnection.Any, directories: string[]) {
const directory = directories[0]
if (!directory) return
const ctx = global.createServerCtx(conn)
directories.forEach(ctx.projects.open)
ctx.projects.touch(directory)
setSelection({ server: ServerConnection.key(conn), directory })
}
function openNewSession() {
const project = selectedProject()
if (!project) {
const conn = server.current
if (conn) void chooseProject(conn)
const conn = focusedServer()
const project = newSessionProject()
if (!conn || !project) return
openProjectNewSession(conn, project.worktree)
}
function navigateOnServer(conn: ServerConnection.Any, href: string) {
const next = homeProjectNavigation(server.key, ServerConnection.key(conn), href)
if (!next.server) {
navigate(next.href)
return
}
layout.projects.open(project.worktree)
server.projects.touch(project.worktree)
navigate(`/${base64Encode(project.worktree)}/session`)
pendingHomeNavigation = next
server.setActive(next.server)
}
function openProjectNewSession(directory: string) {
layout.projects.open(directory)
server.projects.touch(directory)
navigate(`/${base64Encode(directory)}/session`)
function openProjectNewSession(conn: ServerConnection.Any, directory: string) {
const ctx = global.createServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(directory)}/session`)
}
function editProject(project: LocalProject) {
function editProject(conn: ServerConnection.Any, project: LocalProject) {
void import("@/components/dialog-edit-project").then((x) => {
dialog.show(() => <x.DialogEditProject project={project} />)
dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
})
}
function unseenCount(project: LocalProject) {
function unseenCount(conn: ServerConnection.Any, project: LocalProject) {
if (ServerConnection.key(conn) !== server.key) return 0
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
}
function clearNotifications(project: LocalProject) {
function clearNotifications(conn: ServerConnection.Any, project: LocalProject) {
if (ServerConnection.key(conn) !== server.key) return
directories(project)
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
@@ -231,19 +335,18 @@ function HomeDesign() {
function openSession(session: Session) {
const project = projectForSession(session, projects(), projectByID())
layout.projects.open(project?.worktree ?? session.directory)
server.projects.touch(project?.worktree ?? session.directory)
navigate(`/${base64Encode(session.directory)}/session/${session.id}`)
const conn = focusedServer()
if (!conn) return
const directory = project?.worktree ?? session.directory
const ctx = global.createServerCtx(conn)
ctx.projects.open(directory)
ctx.projects.touch(directory)
navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`)
}
async function chooseProject(conn: ServerConnection.Any) {
function resolve(result: string | string[] | null) {
if (Array.isArray(result)) {
result.forEach((r) => addProject(conn, r))
if (result[0]) setState("project", result[0])
return
}
if (result) addProject(conn, result)
addProjects(conn, homeProjectDirectories(result))
}
const server = global.createServerCtx(conn)
@@ -274,14 +377,20 @@ function HomeDesign() {
<div class="mx-auto grid w-full h-full max-w-[1080px] gap-8 px-6 pb-16 lg:grid-cols-[280px_minmax(0,720px)]">
<HomeProjectColumn
projects={projects()}
selected={selectedProject()?.worktree}
selected={state.selection}
focusServer={focusServer}
selectProject={selectProject}
openNewSession={openProjectNewSession}
chooseProject={(conn) => void chooseProject(conn)}
editProject={editProject}
closeProject={(directory) => {
layout.projects.close(directory)
if (state.project === directory) setState("project", undefined)
closeProject={(conn, directory) => {
const next = closeHomeProject(
state.selection,
ServerConnection.key(conn),
global.createServerCtx(conn).projects,
directory,
)
if (next) setSelection(next)
}}
clearNotifications={clearNotifications}
unseenCount={unseenCount}
@@ -297,6 +406,8 @@ function HomeDesign() {
open={searchOpen()}
loading={sessionLoad.isLoading}
results={searchResults()}
sync={focusedSync()}
activeServer={state.selection.server === server.key}
noResultsLabel={language.t("home.sessions.search.noResults", { query: search() })}
bindFocus={(focus) => {
focusSessionSearch = focus
@@ -316,7 +427,10 @@ function HomeDesign() {
when={groups().length > 0}
fallback={
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader title={language.t("home.sessions.empty")} onNewSession={openNewSession} />
<HomeSessionGroupHeader
title={language.t("home.sessions.empty")}
onNewSession={newSessionProject() ? openNewSession : undefined}
/>
</div>
}
>
@@ -325,11 +439,18 @@ function HomeDesign() {
<div class="flex min-w-0 flex-col gap-4">
<HomeSessionGroupHeader
title={group.title}
onNewSession={index() === 0 ? openNewSession : undefined}
onNewSession={index() === 0 && newSessionProject() ? openNewSession : undefined}
/>
<div class="flex min-w-0 flex-col gap-px">
<For each={group.sessions}>
{(record) => <HomeSessionRow record={record} openSession={openSession} />}
{(record) => (
<HomeSessionRow
record={record}
sync={focusedSync()}
activeServer={state.selection.server === server.key}
openSession={openSession}
/>
)}
</For>
</div>
</div>
@@ -347,14 +468,15 @@ function HomeDesign() {
function HomeProjectColumn(props: {
projects: LocalProject[]
selected?: string
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
selected: HomeProjectSelection
focusServer: (server: ServerConnection.Any) => void
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
chooseProject: (server: ServerConnection.Any) => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
unseenCount: (project: LocalProject) => number
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
openSettings: () => void
openHelp: () => void
language: ReturnType<typeof useLanguage>
@@ -378,40 +500,44 @@ function HomeProjectColumn(props: {
</div>
<Show
when={global.servers.list().length > 1}
fallback={<HomeProjectList {...props} chooseProject={() => props.chooseProject(global.servers.list()[0]!)} />}
fallback={<HomeProjectList {...props} server={global.servers.list()[0]!} />}
>
<For each={global.servers.list()}>
{(item) => {
const key = ServerConnection.key(item)
const healthy = () => !!global.servers.health[key]?.healthy
const [state, setState] = createStore({ open: true })
const serverCtx = global.createServerCtx(item)
return (
<div class="flex max-h-[min(572px,calc(100vh_-_300px))] min-w-0 flex-col gap-1 overflow-y-auto [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} disabled:opacity-60`}
disabled={!healthy()}
onClick={() => setState("open", !state.open)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<ServerHealthIndicator health={global.servers.health[key]} />
</div>
<span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
<Show when={healthy()}>
<IconV2
name="outline-chevron-down"
class="shrink-0 text-v2-icon-icon-muted data-[open=false]:-rotate-90"
data-open={state.open}
/>
</Show>
</button>
<Show when={healthy() && state.open}>
<div class="group/server relative flex h-7 min-w-0 items-center rounded-[6px]">
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} pr-16 disabled:opacity-60`}
data-selected={props.selected.server === key && !props.selected.directory ? "" : undefined}
disabled={!healthy()}
onClick={() => props.focusServer(item)}
>
<div class="flex size-4 shrink-0 items-center justify-center">
<ServerHealthIndicator health={global.servers.health[key]} />
</div>
<span class={HOME_PROJECT_NAV_LABEL}>{item.displayName ?? new URL(item.http.url).host}</span>
</button>
<IconButtonV2
data-action="home-add-project"
variant="ghost-muted"
size="small"
class="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 transition-opacity group-hover/server:opacity-100 focus:opacity-100"
icon={<IconV2 name="folder-add-left" />}
aria-label={props.language.t("home.project.add")}
onClick={() => props.chooseProject(item)}
/>
</div>
<Show when={healthy()}>
<div class="mx-3 h-px bg-v2-border-border-base" />
<HomeProjectList
{...props}
server={item}
projects={serverCtx.projects.list()}
chooseProject={() => props.chooseProject(item)}
/>
</Show>
</div>
@@ -442,61 +568,49 @@ function HomeProjectColumn(props: {
}
function HomeProjectList(props: {
server: ServerConnection.Any
projects: LocalProject[]
selected?: string
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
chooseProject: () => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
unseenCount: (project: LocalProject) => number
selected: HomeProjectSelection
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
language: ReturnType<typeof useLanguage>
}) {
return (
<Show
when={props.projects.length > 0}
fallback={
<button
type="button"
class={`${HOME_PROJECT_NAV_ROW} text-v2-text-text-faint [&>[data-slot=icon-svg]]:text-v2-icon-icon-muted`}
onClick={props.chooseProject}
>
<IconV2 name="folder-add-left" size="small" />
<span>{props.language.t("home.project.add")}</span>
</button>
}
>
<div class="flex min-w-0 flex-col gap-1">
<For each={props.projects}>
{(project) => (
<HomeProjectRow
project={project}
selected={props.selected === project.worktree}
unseenCount={props.unseenCount(project)}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</For>
</div>
</Show>
<div class="flex min-w-0 flex-col gap-1">
<For each={props.projects}>
{(project) => (
<HomeProjectRow
project={project}
server={props.server}
selected={props.selected.server === ServerConnection.key(props.server) && props.selected.directory === project.worktree}
unseenCount={props.unseenCount(props.server, project)}
selectProject={props.selectProject}
openNewSession={props.openNewSession}
editProject={props.editProject}
closeProject={props.closeProject}
clearNotifications={props.clearNotifications}
language={props.language}
/>
)}
</For>
</div>
)
}
function HomeProjectRow(props: {
project: LocalProject
server: ServerConnection.Any
selected: boolean
unseenCount: number
selectProject: (directory: string) => void
openNewSession: (directory: string) => void
editProject: (project: LocalProject) => void
closeProject: (directory: string) => void
clearNotifications: (project: LocalProject) => void
selectProject: (server: ServerConnection.Any, directory: string) => void
openNewSession: (server: ServerConnection.Any, directory: string) => void
editProject: (server: ServerConnection.Any, project: LocalProject) => void
closeProject: (server: ServerConnection.Any, directory: string) => void
clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void
language: ReturnType<typeof useLanguage>
}) {
const [state, setState] = createStore({ menuOpen: false })
@@ -508,7 +622,7 @@ function HomeProjectRow(props: {
class={`${HOME_PROJECT_NAV_ROW} pr-16`}
data-selected={props.selected ? "" : undefined}
aria-current={props.selected ? "page" : undefined}
onClick={() => props.selectProject(props.project.worktree)}
onClick={() => props.selectProject(props.server, props.project.worktree)}
>
<HomeProjectAvatar project={props.project} />
<span class={HOME_PROJECT_NAV_LABEL}>{displayName(props.project)}</span>
@@ -523,7 +637,7 @@ function HomeProjectRow(props: {
size="small"
icon={<IconV2 name="edit" />}
aria-label={props.language.t("command.session.new")}
onClick={() => props.openNewSession(props.project.worktree)}
onClick={() => props.openNewSession(props.server, props.project.worktree)}
/>
<MenuV2
gutter={4}
@@ -542,17 +656,17 @@ function HomeProjectRow(props: {
/>
<MenuV2.Portal>
<MenuV2.Content>
<MenuV2.Item onSelect={() => props.openNewSession(props.project.worktree)}>
<MenuV2.Item onSelect={() => props.openNewSession(props.server, props.project.worktree)}>
{props.language.t("command.session.new")}
</MenuV2.Item>
<MenuV2.Item onSelect={() => props.editProject(props.project)}>
<MenuV2.Item onSelect={() => props.editProject(props.server, props.project)}>
{props.language.t("common.edit")}
</MenuV2.Item>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.project)}>
<MenuV2.Item disabled={props.unseenCount === 0} onSelect={() => props.clearNotifications(props.server, props.project)}>
{props.language.t("sidebar.project.clearNotifications")}
</MenuV2.Item>
<MenuV2.Separator />
<MenuV2.Item onSelect={() => props.closeProject(props.project.worktree)}>
<MenuV2.Item onSelect={() => props.closeProject(props.server, props.project.worktree)}>
{props.language.t("common.close")}
</MenuV2.Item>
</MenuV2.Content>
@@ -580,6 +694,8 @@ function HomeSessionSearch(props: {
open: boolean
loading: boolean
results: HomeSessionRecord[]
sync: HomeSessionSync
activeServer: boolean
noResultsLabel: string
bindFocus: (focus: () => void) => void
onInput: (value: string) => void
@@ -694,6 +810,8 @@ function HomeSessionSearch(props: {
{(record) => (
<HomeSessionSearchResultRow
record={record}
sync={props.sync}
activeServer={props.activeServer}
selected={store.active === homeSessionSearchKey(record)}
onHighlight={() => setStore("active", homeSessionSearchKey(record))}
onSelect={(session) => props.onSelect(session)}
@@ -778,29 +896,14 @@ function HomeSessionSearch(props: {
function HomeSessionSearchResultRow(props: {
record: HomeSessionRecord
sync: HomeSessionSync
activeServer: boolean
selected: boolean
onHighlight: () => void
onSelect: (session: Session) => void
}) {
const globalSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
const status = createHomeSessionStatus({ record: () => props.record, sync: () => props.sync, activeServer: () => props.activeServer })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
const hasPermissions = createMemo(
() =>
!!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
return !permission.autoResponds(item, props.record.session.directory)
}),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return sessionStore.session_working(props.record.session.id)
})
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
const key = () => homeSessionSearchKey(props.record)
@@ -820,7 +923,7 @@ function HomeSessionSearchResultRow(props: {
onClick={() => props.onSelect(props.record.session)}
>
<Show
when={showStatus()}
when={status.show()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
@@ -829,19 +932,19 @@ function HomeSessionSearchResultRow(props: {
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={isWorking()}>
<Match when={status.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<Match when={status.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<Match when={status.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={unseenCount() > 0}>
<Match when={status.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
@@ -884,26 +987,14 @@ function HomeSessionGroupHeader(props: { title: string; onNewSession?: () => voi
)
}
function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (session: Session) => void }) {
const globalSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const [sessionStore] = globalSync.child(props.record.session.directory, { bootstrap: false })
function HomeSessionRow(props: {
record: HomeSessionRecord
sync: HomeSessionSync
activeServer: boolean
openSession: (session: Session) => void
}) {
const status = createHomeSessionStatus({ record: () => props.record, sync: () => props.sync, activeServer: () => props.activeServer })
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
const unseenCount = createMemo(() => notification.session.unseenCount(props.record.session.id))
const hasError = createMemo(() => notification.session.unseenHasError(props.record.session.id))
const hasPermissions = createMemo(
() =>
!!sessionPermissionRequest(sessionStore.session, sessionStore.permission, props.record.session.id, (item) => {
return !permission.autoResponds(item, props.record.session.directory)
}),
)
const isWorking = createMemo(() => {
if (hasPermissions()) return false
return sessionStore.session_working(props.record.session.id)
})
const tint = createMemo(() => messageAgentColor(sessionStore.message[props.record.session.id], sessionStore.agent))
const showStatus = createMemo(() => isWorking() || hasPermissions() || hasError() || unseenCount() > 0)
return (
<button
@@ -913,7 +1004,7 @@ function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (sessio
onClick={() => props.openSession(props.record.session)}
>
<Show
when={showStatus()}
when={status.show()}
fallback={
<div class="flex size-4 shrink-0 items-center justify-center">
<TabStateIndicator />
@@ -922,19 +1013,19 @@ function HomeSessionRow(props: { record: HomeSessionRecord; openSession: (sessio
>
<div
class="flex size-4 shrink-0 items-center justify-center"
style={{ color: tint() ?? "var(--icon-interactive-base)" }}
style={{ color: status.tint() ?? "var(--icon-interactive-base)" }}
>
<Switch>
<Match when={isWorking()}>
<Match when={status.isWorking()}>
<Spinner class="size-[15px]" />
</Match>
<Match when={hasPermissions()}>
<Match when={status.hasPermissions()}>
<div class="size-1.5 rounded-full bg-surface-warning-strong" />
</Match>
<Match when={hasError()}>
<Match when={status.hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
<Match when={unseenCount() > 0}>
<Match when={status.unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
+28 -20
View File
@@ -37,7 +37,7 @@ import { useProviders } from "@/hooks/use-providers"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { clearWorkspaceTerminals, getTerminalServerScope } from "@/context/terminal"
import { clearWorkspaceTerminals } from "@/context/terminal"
import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache"
import {
clearSessionPrefetchInflight,
@@ -57,6 +57,7 @@ import { createAim } from "@/utils/aim"
import { setNavigate } from "@/utils/notification-click"
import { Worktree as WorktreeState } from "@/utils/worktree"
import { setSessionHandoff } from "@/pages/session/handoff"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
@@ -64,7 +65,7 @@ import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useServer } from "@/context/server"
import { ServerConnection, useServer } from "@/context/server"
import { useLanguage, type Locale } from "@/context/language"
import { pathKey } from "@/utils/path-key"
import {
@@ -92,8 +93,9 @@ import { SidebarContent } from "./layout/sidebar-shell"
import { runUpdateAndRestart } from "./layout/update"
export default function Layout(props: ParentProps) {
const serverSDK = useServerSDK()
const [store, setStore, , ready] = persisted(
Persist.global("layout.page", ["layout.page.v1"]),
Persist.serverGlobal(serverSDK.scope, "layout.page", ["layout.page.v1"]),
createStore({
lastProjectSession: {} as { [directory: string]: { directory: string; id: string; at: number } },
activeProject: undefined as string | undefined,
@@ -113,7 +115,6 @@ export default function Layout(props: ParentProps) {
let dialogDead = false
const params = useParams()
const serverSDK = useServerSDK()
const serverSync = useServerSync()
const layout = useLayout()
const layoutReady = createMemo(() => layout.ready())
@@ -411,13 +412,17 @@ export default function Layout(props: ParentProps) {
const unsub = serverSDK.event.listen((e) => {
if (e.details?.type === "worktree.ready") {
setBusy(e.name, false)
WorktreeState.ready(e.name)
WorktreeState.ready(serverSDK.scope, e.name)
return
}
if (e.details?.type === "worktree.failed") {
setBusy(e.name, false)
WorktreeState.failed(e.name, e.details.properties?.message ?? language.t("common.requestFailed"))
WorktreeState.failed(
serverSDK.scope,
e.name,
e.details.properties?.message ?? language.t("common.requestFailed"),
)
return
}
@@ -693,7 +698,7 @@ export default function Layout(props: ParentProps) {
serverSDK.url
prefetchToken.value += 1
clearSessionPrefetchInflight()
clearSessionPrefetchInflight(serverSDK.scope)
prefetchQueues.clear()
})
@@ -740,13 +745,14 @@ export default function Layout(props: ParentProps) {
const [store, setStore] = serverSync.child(directory, { bootstrap: false })
return runSessionPrefetch({
scope: serverSDK.scope,
directory,
sessionID,
task: (rev) =>
retry(() => serverSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk }))
.then((messages) => {
if (prefetchToken.value !== token) return
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
const items = (messages.data ?? []).filter((x) => !!x?.info?.id)
const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id)
@@ -761,7 +767,7 @@ export default function Layout(props: ParentProps) {
}
if (stale.length > 0) {
clearSessionPrefetch(directory, stale)
clearSessionPrefetch(serverSDK.scope, directory, stale)
for (const id of stale) {
serverSync.todo.set(id, undefined)
}
@@ -773,7 +779,7 @@ export default function Layout(props: ParentProps) {
sorted,
)
if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return
if (!isSessionPrefetchCurrent(serverSDK.scope, directory, sessionID, rev)) return
batch(() => {
if (stale.length > 0) {
@@ -785,7 +791,7 @@ export default function Layout(props: ParentProps) {
}
setStore("message", sessionID, reconcile(merged, { key: "id" }))
setSessionPrefetch({ directory, sessionID, ...meta })
setSessionPrefetch({ scope: serverSDK.scope, directory, sessionID, ...meta })
for (const message of items) {
const currentParts = store.part[message.info.id] ?? []
@@ -830,7 +836,7 @@ export default function Layout(props: ParentProps) {
const [store] = serverSync.child(directory, { bootstrap: false })
const cached = untrack(() => {
const info = getSessionPrefetch(directory, session.id)
const info = getSessionPrefetch(serverSDK.scope, directory, session.id)
return shouldSkipSessionPrefetch({
message: store.message[session.id] !== undefined,
info,
@@ -1381,7 +1387,9 @@ export default function Layout(props: ParentProps) {
void openProject(link.directory, false)
const slug = base64Encode(link.directory)
if (link.prompt) {
setSessionHandoff(slug, { prompt: link.prompt })
setSessionHandoff(SessionStateKey.from(server.scope(), SessionRouteKey.fromLegacy(slug)), {
prompt: link.prompt,
})
}
const href = link.prompt ? `/${slug}/session?prompt=${encodeURIComponent(link.prompt)}` : `/${slug}/session`
navigateWithSidebarReset(href)
@@ -1456,11 +1464,11 @@ export default function Layout(props: ParentProps) {
layout.sidebar.toggleWorkspaces(project.worktree)
}
const showEditProjectDialog = (project: LocalProject) => {
const showEditProjectDialog = (conn: ServerConnection.Any, project: LocalProject) => {
const run = ++dialogRun
void import("@/components/dialog-edit-project").then((x) => {
if (dialogDead || dialogRun !== run) return
dialog.show(() => <x.DialogEditProject project={project} />)
dialog.show(() => <x.DialogEditProject server={conn} project={project} />)
})
}
@@ -1576,7 +1584,7 @@ export default function Layout(props: ParentProps) {
directory,
sessions.map((s) => s.id),
platform,
getTerminalServerScope(server.current, server.key),
serverSDK.scope,
)
await serverSDK.client.instance.dispose({ directory }).catch(() => undefined)
@@ -1890,7 +1898,7 @@ export default function Layout(props: ParentProps) {
directory && pathKey(directory) !== pathKey(local) && !dirs.some((item) => pathKey(item) === pathKey(directory))
? directory
: undefined
const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false
const pending = extra ? WorktreeState.get(serverSDK.scope, extra)?.status === "pending" : false
const ordered = effectiveWorkspaceOrder(local, dirs, store.workspaceOrder[project.worktree])
if (pending && extra) return [local, extra, ...ordered.filter((item) => item !== local)]
@@ -1962,7 +1970,7 @@ export default function Layout(props: ParentProps) {
const root = pathKey(local)
setBusy(created.directory, true)
WorktreeState.pending(created.directory)
WorktreeState.pending(serverSDK.scope, created.directory)
setStore("workspaceExpanded", key, true)
if (key !== created.directory) {
setStore("workspaceExpanded", created.directory, true)
@@ -2023,7 +2031,7 @@ export default function Layout(props: ParentProps) {
navigateToProject,
openSidebar: () => layout.sidebar.open(),
closeProject,
showEditProjectDialog,
showEditProjectDialog: (proj) => showEditProjectDialog(server.current!, proj),
toggleProjectWorkspaces,
workspacesEnabled: (project) => project.vcs === "git" && layout.sidebar.workspaces(project.worktree)(),
workspaceIds,
@@ -2170,7 +2178,7 @@ export default function Layout(props: ParentProps) {
<DropdownMenu.Content class="mt-1">
<DropdownMenu.Item
onSelect={() => {
showEditProjectDialog(project)
showEditProjectDialog(server.current!, project)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.edit")}</DropdownMenu.ItemLabel>
@@ -9,13 +9,21 @@ import {
import { type Session } from "@opencode-ai/sdk/v2/client"
import {
childSessionOnPath,
closeHomeProject,
displayName,
effectiveWorkspaceOrder,
errorMessage,
hasProjectPermissions,
homeProjectNavigation,
homeProjectDirectories,
homeSessionServerStatus,
latestRootSession,
toggleHomeProjectSelection,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
import { ServerConnection } from "@/context/server"
const serverKey = ServerConnection.Key.make
const session = (input: Partial<Session> & Pick<Session, "id" | "directory">) =>
({
@@ -215,6 +223,84 @@ describe("layout workspace helpers", () => {
test("formats fallback project display name", () => {
expect(displayName({ worktree: "/tmp/app" })).toBe("app")
expect(displayName({ worktree: "/tmp/app", name: "My App" })).toBe("My App")
expect(displayName({ worktree: "/" })).toBe("/")
})
test("scopes home project selection by server", () => {
expect(toggleHomeProjectSelection(undefined, serverKey("https://debian.example"), "/home/luke/repos/amazon")).toEqual({
server: serverKey("https://debian.example"),
directory: "/home/luke/repos/amazon",
})
expect(
toggleHomeProjectSelection(
{ server: serverKey("https://windows.example"), directory: "/home/luke/repos/amazon" },
serverKey("https://debian.example"),
"/home/luke/repos/amazon",
),
).toEqual({ server: serverKey("https://debian.example"), directory: "/home/luke/repos/amazon" })
expect(
toggleHomeProjectSelection(
{ server: serverKey("https://debian.example"), directory: "/home/luke/repos/amazon" },
serverKey("https://debian.example"),
"/home/luke/repos/amazon",
),
).toEqual({ server: serverKey("https://debian.example") })
})
test("closes a home project through its server context", () => {
const closed: string[] = []
expect(
closeHomeProject(
{ server: serverKey("https://windows.example"), directory: "/shared" },
serverKey("https://debian.example"),
{ close: (directory) => closed.push(directory) },
"/shared",
),
).toEqual({ server: serverKey("https://windows.example"), directory: "/shared" })
expect(closed).toEqual(["/shared"])
expect(
closeHomeProject(
{ server: serverKey("https://debian.example"), directory: "/shared" },
serverKey("https://debian.example"),
{ close: (directory) => closed.push(directory) },
"/shared",
),
).toEqual({ server: serverKey("https://debian.example") })
})
test("defers home project navigation until its server is active", () => {
expect(homeProjectNavigation(serverKey("sidecar"), serverKey("https://debian.example"), "/YW1hem9u/session")).toEqual({
server: serverKey("https://debian.example"),
href: "/YW1hem9u/session",
})
expect(homeProjectNavigation(serverKey("https://debian.example"), serverKey("https://debian.example"), "/YW1hem9u/session")).toEqual({
href: "/YW1hem9u/session",
})
})
test("preserves picker order when adding multiple projects", () => {
expect(homeProjectDirectories(["/first", "/second"])).toEqual(["/first", "/second"])
expect(homeProjectDirectories("/only")).toEqual(["/only"])
expect(homeProjectDirectories(null)).toEqual([])
})
test("hides status derived from an inactive server", () => {
let reads = 0
const status = () => {
reads++
return { working: true, tint: "red" }
}
expect(homeSessionServerStatus(false, status)).toEqual({
working: false,
tint: undefined,
})
expect(reads).toBe(0)
expect(homeSessionServerStatus(true, status)).toEqual({
working: true,
tint: "red",
})
expect(reads).toBe(1)
})
test("extracts api error message and fallback", () => {
+39 -1
View File
@@ -1,6 +1,7 @@
import { getFilename } from "@opencode-ai/core/util/path"
import { type Session } from "@opencode-ai/sdk/v2/client"
import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/server"
type SessionStore = {
session?: Session[]
@@ -53,7 +54,44 @@ export const childSessionOnPath = (sessions: Session[] | undefined, rootID: stri
}
export const displayName = (project: { name?: string; worktree: string }) =>
project.name || getFilename(project.worktree)
project.name || getFilename(project.worktree) || project.worktree
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
export function toggleHomeProjectSelection(
current: HomeProjectSelection | undefined,
server: ServerConnection.Key,
directory: string,
): HomeProjectSelection {
if (current?.server === server && current.directory === directory) return { server }
return { server, directory }
}
export function closeHomeProject(
selected: HomeProjectSelection | undefined,
server: ServerConnection.Key,
projects: { close: (directory: string) => void },
directory: string,
) {
projects.close(directory)
if (selected?.server === server && selected.directory === directory) return { server }
return selected
}
export function homeProjectNavigation(active: ServerConnection.Key, server: ServerConnection.Key, href: string) {
if (active === server) return { href }
return { server, href }
}
export function homeProjectDirectories(result: string | string[] | null) {
if (!result) return []
return Array.isArray(result) ? result : [result]
}
export function homeSessionServerStatus(active: boolean, status: () => { working: boolean; tint?: string }) {
if (!active) return { working: false, tint: undefined }
return status()
}
const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750"
@@ -4,18 +4,20 @@ import { useNotification } from "@/context/notification"
import { usePermission } from "@/context/permission"
import { sessionPermissionRequest } from "@/pages/session/composer/session-request-tree"
export function useSessionTabAvatarState(directory: Accessor<string>, sessionId: Accessor<string>) {
export function useSessionTabAvatarState(directory: Accessor<string>, sessionId: Accessor<string>, active: Accessor<boolean> = () => true) {
const globalSync = useServerSync()
const notification = useNotification()
const permission = usePermission()
const hasPermissions = createMemo(() => {
if (!active()) return false
const [store] = globalSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, store.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
})
const unread = createMemo(() => hasPermissions() || notification.session.unseenCount(sessionId()) > 0)
const unread = createMemo(() => active() && (hasPermissions() || notification.session.unseenCount(sessionId()) > 0))
const loading = createMemo(() => {
if (!active()) return false
if (hasPermissions()) return false
const [store] = globalSync.child(directory(), { bootstrap: false })
return store.session_working(sessionId())
+8 -4
View File
@@ -39,6 +39,7 @@ import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
@@ -54,6 +55,7 @@ import {
import { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
import { syncSessionModel } from "@/pages/session/session-model-helpers"
import { SessionSidePanel } from "@/pages/session/session-side-panel"
import { TerminalPanel } from "@/pages/session/terminal-panel"
@@ -190,13 +192,15 @@ export default function Page() {
const dialog = useDialog()
const language = useLanguage()
const sdk = useSDK()
const serverSDK = useServerSDK()
const settings = useSettings()
const prompt = usePrompt()
const comments = useComments()
const terminal = useTerminal()
const server = useServer()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
const location = useLocation()
const { params, sessionKey, tabs, view } = useSessionLayout()
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
createEffect(() => {
@@ -223,7 +227,6 @@ export default function Page() {
const composer = createSessionComposerState()
const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
createEffect(
@@ -239,6 +242,7 @@ export default function Page() {
layout.handoff.clearTabs()
return
}
if (pending.scope !== server.scope()) return
if (pending.id !== id) return
layout.handoff.clearTabs()
@@ -386,7 +390,7 @@ export default function Page() {
})
const [followup, setFollowup] = persisted(
Persist.workspace(sdk.directory, "followup", ["followup.v1"]),
Persist.serverWorkspace(serverSDK.scope, sdk.directory, "followup", ["followup.v1"]),
createStore<{
items: Record<string, FollowupItem[] | undefined>
failed: Record<string, string | undefined>
@@ -633,7 +637,7 @@ export default function Page() {
const stale = !cached
? false
: (() => {
const info = getSessionPrefetch(directory, id)
const info = getSessionPrefetch(serverSDK.scope, directory, id)
if (!info) return true
return Date.now() - info.at > SESSION_PREFETCH_TTL
})()
@@ -10,6 +10,8 @@ import { useLanguage } from "@/context/language"
import { useSDK } from "@/context/sdk"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useServerSDK } from "@/context/server-sdk"
import { ScopedKey } from "@/utils/server-scope"
const cache = new Map<string, { tab: number; answers: QuestionAnswer[]; custom: string[]; customOn: boolean[] }>()
@@ -60,12 +62,14 @@ function Option(props: {
export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit: () => void }> = (props) => {
const sdk = useSDK()
const serverSDK = useServerSDK()
const language = useLanguage()
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
const questions = createMemo(() => props.request.questions)
const total = createMemo(() => questions().length)
const cached = cache.get(props.request.id)
const cached = cache.get(cacheKey)
const [store, setStore] = createStore({
tab: cached?.tab ?? 0,
answers: cached?.answers ?? ([] as QuestionAnswer[]),
@@ -191,7 +195,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
onCleanup(() => {
if (focusFrame !== undefined) cancelAnimationFrame(focusFrame)
if (replied) return
cache.set(props.request.id, {
cache.set(cacheKey, {
tab: store.tab,
answers: store.answers.map((a) => (a ? [...a] : [])),
custom: store.custom.map((s) => s ?? ""),
@@ -211,7 +215,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
cache.delete(cacheKey)
},
onError: fail,
}))
@@ -223,7 +227,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
},
onSuccess: () => {
replied = true
cache.delete(props.request.id)
cache.delete(cacheKey)
},
onError: fail,
}))
@@ -1,19 +1,25 @@
import { useParams } from "@solidjs/router"
import { createMemo } from "solid-js"
import { useLayout } from "@/context/layout"
import { useServer } from "@/context/server"
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
export const useSessionKey = () => {
const params = useParams()
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
return { params, sessionKey }
const server = useServer()
const scope = createMemo(() => server.scope())
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir)))
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id)))
return { params, sessionKey, workspaceKey }
}
export const useSessionLayout = () => {
const layout = useLayout()
const { params, sessionKey } = useSessionKey()
const { params, sessionKey, workspaceKey } = useSessionKey()
return {
params,
sessionKey,
workspaceKey,
tabs: createMemo(() => layout.tabs(sessionKey)),
view: createMemo(() => layout.view(sessionKey)),
}
@@ -26,7 +26,7 @@ export function TerminalPanel() {
const terminal = useTerminal()
const language = useLanguage()
const command = useCommand()
const { params, view } = useSessionLayout()
const { params, workspaceKey, view } = useSessionLayout()
const opened = createMemo(() => view().terminal.opened())
const size = createSizing()
@@ -126,7 +126,7 @@ export function TerminalPanel() {
language.locale()
setTerminalHandoff(
dir,
workspaceKey(),
terminal.all().map((pty) =>
terminalTabLabel({
title: pty.title,
@@ -140,7 +140,7 @@ export function TerminalPanel() {
const handoff = createMemo(() => {
const dir = params.dir
if (!dir) return []
return getTerminalHandoff(dir) ?? []
return getTerminalHandoff(workspaceKey()) ?? []
})
const all = terminal.all