feat(app): improve desktop multi-server support (#30678)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
co-authored by
Brendan Allan
parent
7ae856a9e9
commit
7f33576f46
+267
-176
@@ -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>
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user