From 0c4f508c507dc34e335f9ce8ff2f12f5fe50c5df Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 23 Jun 2026 14:19:18 +0800 Subject: [PATCH] feat(app): add server-keyed session routes (#32570) --- .../app/e2e/smoke/session-timeline.fixture.ts | 2 + .../app/e2e/smoke/session-timeline.spec.ts | 3 +- packages/app/src/app.tsx | 261 ++++++++++---- .../app/src/components/prompt-input/submit.ts | 7 +- packages/app/src/components/titlebar.tsx | 103 +++--- packages/app/src/context/comments.tsx | 5 +- packages/app/src/context/file.tsx | 3 +- packages/app/src/context/layout.tsx | 16 +- packages/app/src/context/notification.tsx | 8 +- packages/app/src/context/permission.tsx | 8 +- packages/app/src/context/prompt.tsx | 6 +- packages/app/src/context/tabs.tsx | 28 +- packages/app/src/context/terminal.tsx | 12 +- packages/app/src/pages/directory-layout.tsx | 30 +- packages/app/src/pages/home.tsx | 22 +- packages/app/src/pages/layout-new.tsx | 38 +++ packages/app/src/pages/layout.tsx | 319 ++++++++---------- packages/app/src/pages/session.tsx | 12 +- .../composer/session-composer-region.tsx | 7 +- .../app/src/pages/session/session-layout.ts | 14 +- .../app/src/pages/session/terminal-panel.tsx | 8 +- .../session/timeline/message-timeline.tsx | 17 +- .../pages/session/use-session-commands.tsx | 11 +- packages/app/src/utils/session-route.test.ts | 39 +++ packages/app/src/utils/session-route.ts | 25 ++ 25 files changed, 628 insertions(+), 376 deletions(-) create mode 100644 packages/app/src/pages/layout-new.tsx create mode 100644 packages/app/src/utils/session-route.test.ts create mode 100644 packages/app/src/utils/session-route.ts diff --git a/packages/app/e2e/smoke/session-timeline.fixture.ts b/packages/app/e2e/smoke/session-timeline.fixture.ts index 58d50e331..3dce37caf 100644 --- a/packages/app/e2e/smoke/session-timeline.fixture.ts +++ b/packages/app/e2e/smoke/session-timeline.fixture.ts @@ -21,6 +21,7 @@ const words = [ "vector", ] +const serverKey = "http://127.0.0.1:4096" const sourceID = "ses_smoke_source" const targetID = "ses_smoke_target" const directory = "C:/OpenCode/SmokeProject" @@ -240,6 +241,7 @@ function orderedParts(message: Message) { export const fixture = { directory, + serverKey, project: { id: projectID, worktree: directory, diff --git a/packages/app/e2e/smoke/session-timeline.spec.ts b/packages/app/e2e/smoke/session-timeline.spec.ts index a03a75074..dba7eae7e 100644 --- a/packages/app/e2e/smoke/session-timeline.spec.ts +++ b/packages/app/e2e/smoke/session-timeline.spec.ts @@ -718,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin } async function switchTitlebarSession(page: Page, sessionID: string, title: string) { - const href = `/${base64Encode(fixture.directory)}/session/${sessionID}` + console.log(process.env) + const href = `/server/${base64Encode(fixture.serverKey)}/session/${sessionID}` const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first() await expect(tab).toBeVisible() await tab.click() diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 75f0c6b44..e2f6108fd 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo" import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router" -import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" +import { keepPreviousData, QueryClient, QueryClientProvider, useQuery } from "@tanstack/solid-query" import { Effect } from "effect" import { type Component, @@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web" import { CommandProvider } from "@/context/command" import { CommentsProvider } from "@/context/comments" import { FileProvider } from "@/context/file" -import { ServerSDKProvider } from "@/context/server-sdk" +import { ServerSDKProvider, useServerSDK } from "@/context/server-sdk" import { ServerSyncProvider } from "@/context/server-sync" import { GlobalProvider } from "@/context/global" import { HighlightsProvider } from "@/context/highlights" @@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs" import { SDKProvider, useSDK } from "@/context/sdk" import { WslServersProvider } from "@/wsl/context" import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout" -import Layout from "@/pages/layout" +import LegacyLayout from "@/pages/layout" +import NewLayout from "@/pages/layout-new" import { ErrorPage } from "./pages/error" import { useCheckServerHealth } from "./utils/server-health" +import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./utils/session-route" -const HomeRoute = lazy(() => import("@/pages/home")) +const LegacyHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.LegacyHome }))) +const NewHome = lazy(() => import("@/pages/home").then((module) => ({ default: module.NewHome }))) const Session = lazy(() => import("@/pages/session")) const NewSession = lazy(() => import("@/pages/new-session")) @@ -64,6 +67,10 @@ const SessionRoute = Object.assign( const server = useServer() const tabs = useTabs() + if (params.id && settings.general.newLayoutDesigns()) { + return + } + // When the new layout is enabled, the legacy new-session route (/:dir/session with no id) // is replaced by a draft at /new-session?draftId=… createEffect(() => { @@ -82,29 +89,55 @@ const SessionRoute = Object.assign( { preload: Session.preload }, ) +const TargetSessionRoute = Object.assign( + () => { + const sdk = useSDK() + const serverSDK = useServerSDK() + return ( + + + + + + ) + }, + { preload: Session.preload }, +) + // Wraps the non-draft routes. They are gated on (and keyed to) the globally selected // server via ServerKey, then provide the server-scoped shell (Permission/Layout/ // Notification/Models + the visual Layout) for that server. -function SelectedServerLayout(props: ParentProps) { +function SelectedServerProviders(props: ParentProps) { return ( - - {props.children} - + {props.children} ) } +function LegacyServerLayout(props: ParentProps) { + return ( + + {props.children} + + ) +} + // Wraps /new-session. It resolves the draft's target server and provides the // server-scoped shell for that server — without ServerKey, so the page never depends // on the globally "selected" server. -function DraftServerLayout(props: ParentProps) { +function TargetServerLayout(props: ParentProps) { const server = useServer() const tabs = useTabs() + const params = useParams<{ serverKey?: string }>() const [search] = useSearchParams<{ draftId?: string }>() const conn = createMemo(() => { + if (params.serverKey) { + const key = requireServerKey(params.serverKey) + return server.list.find((item) => ServerConnection.key(item) === key) + } const id = search.draftId if (!id) return undefined const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id) @@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) { return ( - {props.children} + {props.children} ) } +function TargetDirectoryLayout(props: ParentProps) { + const params = useParams<{ serverKey?: string; id?: string }>() + const [search] = useSearchParams<{ draftId?: string }>() + const settings = useSettings() + const tabs = useTabs() + const serverSDK = useServerSDK() + const serverKey = createMemo(() => { + if (params.serverKey) return requireServerKey(params.serverKey) + if (!search.draftId) return undefined + return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.server + }) + + const resolved = useQuery(() => ({ + queryKey: [serverSDK().scope, "session-route", params.id] as const, + enabled: !!params.serverKey && !!params.id, + placeholderData: keepPreviousData, + queryFn: async () => { + const session = (await serverSDK().client.session.get({ sessionID: params.id! })).data! + const root = await rootSession(session, (sessionID) => + serverSDK() + .client.session.get({ sessionID }) + .then((result) => result.data!), + ) + return { session, rootID: root.id } + }, + })) + const resolvedDirectory = createMemo(() => { + if (params.serverKey) return resolved.data?.session.directory + if (!search.draftId) return undefined + return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)?.directory + }) + const directory = createMemo((prev) => prev ?? resolvedDirectory()) + const home = () => !params.serverKey && !search.draftId + const targetDirectory = () => directory()! + + createEffect(() => { + const current = resolved.data + const key = serverKey() + if (!current || !key) return + tabs.addSessionTab({ + server: key, + sessionId: current.rootID, + }) + }) + + return ( + (home() ? undefined : directory())} sessionID={() => params.id}> + + }> + + } + > + + + + {props.children} + + + + + + + + + ) +} + function DraftRoute() { const [search] = useSearchParams<{ draftId?: string }>() const tabs = useTabs() return ( }> - {(draftID) => } + ) } -function ResolvedDraftRoute(props: { draftID: string }) { - const tabs = useTabs() - const draft = createMemo(() => - tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === props.draftID), - ) - - // Key on the directory so retargeting the draft's project re-instantiates the - // directory-scoped providers while keeping the same draft id. The draft's target - // server is provided by DraftServerLayout, so changing only the server updates the - // SDK/sync hooks without remounting the composer. - const directory = () => draft()?.directory - +function ResolvedDraftRoute() { return ( - - {(dir) => ( - - - - - - - - )} - + + + ) } @@ -210,32 +293,51 @@ function BodyDesignClass() { // shell (router root) so they stay mounted regardless of the active server/route. function SharedProviders(props: ParentProps) { return ( - + <> {props.children} - + ) } // Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside // each per-route server layout so they resolve to that route's server (selected vs // draft). The Layout remounts when crossing between those groups. -function ServerScopedShell(props: ParentProps) { +type ServerScopedShellProps = ParentProps<{ + directory?: () => string | undefined + sessionID?: () => string | undefined +}> + +function ServerScopedProviders(props: ServerScopedShellProps) { return ( - + - - - {props.children} - + + {props.children} ) } +function LegacyServerScopedShell(props: ServerScopedShellProps) { + return ( + + {props.children} + + ) +} + +function NewServerScopedShell(props: ServerScopedShellProps) { + return ( + + {props.children} + + ) +} + function SessionProviders(props: ParentProps) { return ( @@ -439,28 +541,61 @@ export function AppInterface(props: { servers={props.servers} > - - ( - - {routerProps.children} - - )} - > - - - - } /> - - - - - - - - + + + + ( + + {routerProps.children} + + )} + > + + + + + ) } + +function Routes() { + const settings = useSettings() + + return ( + <> + + {} + + } /> + + + + + + { + <> + + + { + const server = useServer() + const { id } = useParams() + + return + }} + /> + + + } + + + + + + ) +} diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index a95f0a603..621723ae1 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { local.session.promote(sessionDirectory, session.id) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) const draftID = search.draftId - if (draftID) - tabs.promoteDraft(draftID, { - server: server.key, - dirBase64: base64Encode(sessionDirectory), - sessionId: session.id, - }) + if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id }) else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 827714320..331285639 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { WindowsAppMenu } from "./windows-app-menu" import { applyPath, backPath, forwardPath } from "./titlebar-history" -import { useServerSync } from "@/context/server-sync" import { base64Encode } from "@opencode-ai/core/util/encode" import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers" @@ -38,10 +37,11 @@ import { makeEventListener } from "@solid-primitives/event-listener" import { createResizeObserver } from "@solid-primitives/resize-observer" import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events" import { useGlobal } from "@/context/global" -import { decode64 } from "@/utils/base64" import { ServerConnection, useServer } from "@/context/server" -import { tabHref, useTabs, type Tab } from "@/context/tabs" +import { tabHref, useTabs } from "@/context/tabs" import "./titlebar.css" +import { useServerSDK } from "@/context/server-sdk" +import { Session } from "@opencode-ai/sdk/v2" type TauriDesktopWindow = { startDragging?: () => Promise @@ -252,7 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { {(_) => { - const serverSync = useServerSync() + const serverSdk = useServerSDK() const navigate = useNavigate() const layout = useLayout() @@ -268,6 +268,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const tabs = useTabs() const tabsStore = tabs.store const tabsStoreActions = tabs + const [session] = createResource( + () => { + const route = layout.route() + return route.type === "session" ? route : undefined + }, + (route) => + serverSdk() + .client.session.get({ sessionID: route.sessionId }) + .then((x) => x.data) + .catch(() => {}), + ) const matchRoute = (route: LayoutRoute) => { if (route.type === "home") return @@ -280,10 +291,9 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { item.type === "session" && item.server === route.server && item.sessionId === route.sessionId, ) if (main) return main - const sync = serverSync().createDirSyncContext(route.dir) - const session = sync.session.get(route.sessionId) - if (session?.parentID) { - const parentID = session.parentID + const s = session() + if (s?.parentID) { + const parentID = s.parentID const parent = tabsStore.find( (item) => item.type === "session" && item.server === route.server && item.sessionId === parentID, ) @@ -304,15 +314,10 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { } if (route.type === "session") { - const sync = serverSync().createDirSyncContext(route.dir) - const session = sync.session.get(route.sessionId) - if (!session) return - const sessionId = session.parentID ?? session.id - const next = { - server: route.server ?? server.key, - dirBase64: route.dirBase64, - sessionId, - } + const s = session() + if (!s) return + const sessionId = s.parentID ?? s.id + const next = { server: route.server ?? server.key, sessionId } tabsStoreActions.addSessionTab(next) } }) @@ -495,25 +500,38 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { ) } + const [session] = createResource( + () => tab.sessionId, + (sessionID) => + serverSdk() + .client.session.get({ sessionID }) + .then((x) => x.data) + .catch(() => undefined), + ) + return ( <> {divider()} - { - tabs.select(tab) + + {(session) => ( + { + tabs.select(tab) - ref.scrollIntoView({ behavior: "instant" }) - }} - onClose={() => tabsStoreActions.removeTab(i())} - active={currentTab() === tab} - activeServer={tab.server === server.key} - forceTruncate={tabsAreOverflowing()} - /> + ref.scrollIntoView({ behavior: "instant" }) + }} + onClose={() => tabsStoreActions.removeTab(i())} + active={currentTab() === tab} + activeServer={tab.server === server.key} + forceTruncate={tabsAreOverflowing()} + /> + )} + ) }} @@ -793,7 +811,6 @@ function TabNavItem(props: { ref?: HTMLDivElement href: string server: ServerConnection.Key - directory: string sessionId?: string hideClose?: boolean onClose: () => void @@ -801,31 +818,19 @@ function TabNavItem(props: { active?: boolean activeServer: boolean forceTruncate?: boolean + session: Session }) { const closeTab = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() props.onClose() } + const global = useGlobal() const serverCtx = createMemo(() => { const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server) if (conn) return global.createServerCtx(conn) }) - const dirSyncCtx = createMemo(() => serverCtx()?.sync.createDirSyncContext(props.directory)) - - const [session] = createResource( - () => { - const ctx = dirSyncCtx() - if (!ctx || !props.sessionId) return - return [props.sessionId, ctx] as const - }, - async ([sessionId, dirSyncCtx]) => { - await dirSyncCtx.session.sync(sessionId).catch(() => {}) - return dirSyncCtx.session.get(sessionId) - }, - { initialValue: props.sessionId ? dirSyncCtx()?.session.get(props.sessionId) : undefined }, - ) return (
- + {(session) => { const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? [])) @@ -853,7 +858,7 @@ function TabNavItem(props: { diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 71186a55f..afc59b595 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "./server-sdk" import type { ServerScope } from "@/utils/server-scope" import { createScopedCache } from "@/utils/scoped-cache" import { uuid } from "@/utils/uuid" import type { SelectedLineRange } from "@/context/file" +import { useSDK } from "./sdk" export type LineComment = { id: string @@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont gate: false, init: () => { const params = useParams() + const sdk = useSDK() const serverSDK = useServerSDK() const cache = createScopedCache( (key) => { @@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont return cache.get(key).value } - const session = createMemo(() => load(params.dir!, params.id)) + const session = createMemo(() => load(base64Encode(sdk().directory), params.id)) return { ready: () => session().ready(), diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 14ed7466c..f7668c194 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { showToast } from "@/utils/toast" import { useParams } from "@solidjs/router" +import { base64Encode } from "@opencode-ai/core/util/encode" import { getFilename } from "@opencode-ai/core/util/path" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const scope = createMemo(() => sdk().directory) const path = createPathHelpers(scope) const tabs = layout.tabs(() => - SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(params.dir, params.id)), + SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)), ) const inflight = new Map>() diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 53bf40e70..edd58ad7a 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path" import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2" import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope" import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers" +import { requireServerKey } from "@/utils/session-route" export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } @@ -79,7 +80,7 @@ export type LayoutRoute = | { type: "home" } | { type: "draft"; draftID: string; server?: ServerConnection.Key } | { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key } - | { type: "session"; dir: string; dirBase64: string; sessionId: string; server?: ServerConnection.Key } + | { type: "session"; sessionId: string; server?: ServerConnection.Key } function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs { const all = current?.all ?? [] @@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => { return { type: "draft", draftID } } + if (parts[0] === "server" && parts[2] === "session" && parts[3]) { + return { + type: "session", + sessionId: parts[3], + server: requireServerKey(parts[1]), + } + } + const dirBase64 = parts[0] const dir = decode64(dirBase64) if (!dir) return { type: "home" } @@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => { if (parts[1] !== "session") return { type: "home" } const id = parts[2] - if (id) return { type: "session", dir, dirBase64, sessionId: id } + if (id) return { type: "session", sessionId: id } return { type: "dir-new-sesssion", dir, dirBase64 } } @@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( const route = createMemo(() => { const value = currentRoute(location.pathname, location.search) if (value.type === "home") return value + if (value.server) return value return { ...value, server: server.key } }) @@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( handoff: { tabs: createMemo(() => store.handoff?.tabs), setTabs(dir: string, id: string) { - setStore("handoff", "tabs", { scope: server.scope(), dir, id, at: Date.now() }) + setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() }) }, clearTabs() { if (!store.handoff?.tabs) return diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 0814dbce7..ca0ea5f86 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,5 +1,5 @@ import { createStore, reconcile } from "solid-js/store" -import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" import { useParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import { useServerSDK } from "./server-sdk" @@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) { export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", gate: false, - init: () => { + init: (props: { directory?: Accessor; sessionID?: Accessor }) => { const params = useParams() const serverSDK = useServerSDK() const serverSync = useServerSync() @@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const empty: Notification[] = [] const currentDirectory = createMemo(() => { - return decode64(params.dir) + return props.directory?.() ?? decode64(params.dir) }) - const currentSession = createMemo(() => params.id) + const currentSession = createMemo(() => props.sessionID?.() ?? params.id) const [store, setStore, _, ready] = persisted( Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index dce3a4049..ff43638bb 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, onCleanup } from "solid-js" +import { type Accessor, createEffect, createMemo, onCleanup } from "solid-js" import { createStore, produce } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import type { PermissionRequest } from "@opencode-ai/sdk/v2/client" @@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) { export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({ name: "Permission", gate: false, - init: () => { + init: (props: { directory?: Accessor }) => { const params = useParams() const serverSDK = useServerSDK() const serverSync = useServerSync() const permissionsEnabled = createMemo(() => { - const directory = decode64(params.dir) + const directory = props.directory?.() ?? decode64(params.dir) if (!directory) return false const [store] = serverSync().child(directory) return hasPermissionPromptRules(store.config.permission) @@ -85,7 +85,7 @@ export const { use: usePermission, provider: PermissionProvider } = createSimple // When config has permission: "allow", auto-enable directory-level auto-accept createEffect(() => { if (!ready()) return - const directory = decode64(params.dir) + const directory = props.directory?.() ?? decode64(params.dir) if (!directory) return const [childStore] = serverSync().child(directory) const perm = childStore.config.permission diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index 4a62c2a8d..62818550d 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,5 +1,5 @@ import { createSimpleContext } from "@opencode-ai/ui/context" -import { checksum } from "@opencode-ai/core/util/encode" +import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useParams, useSearchParams } from "@solidjs/router" import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js" import { createStore, type SetStoreFunction } from "solid-js/store" @@ -7,6 +7,7 @@ import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" import { useServerSDK } from "./server-sdk" import type { ServerScope } from "@/utils/server-scope" +import { useSDK } from "./sdk" interface PartBase { content: string @@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( gate: false, init: () => { const params = useParams() + const sdk = useSDK() const [search] = useSearchParams<{ draftId?: string }>() const serverSDK = useServerSDK() const cache = new Map() @@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const session = createMemo(() => - load(search.draftId ? { draftID: search.draftId } : { dir: params.dir!, id: params.id }), + load(search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }), ) const pick = (scope?: Scope) => (scope ? load(scope) : session()) diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 393875ff0..1a4a47597 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -1,6 +1,5 @@ import type { Session } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode } from "@opencode-ai/core/util/encode" import { createStore, produce } from "solid-js/store" import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist" import { ServerConnection, useServer } from "./server" @@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router" import { usePlatform } from "./platform" import { uuid } from "@/utils/uuid" import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" +import { sessionHref } from "@/utils/session-route" export type SessionTab = { type: "session" server: ServerConnection.Key - dirBase64: string sessionId: string } @@ -34,16 +33,12 @@ type RecentTab = { export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}` export const tabHref = (tab: Tab) => - tab.type === "draft" ? draftHref(tab.draftID) : `/${tab.dirBase64}/session/${tab.sessionId}` + tab.type === "draft" ? draftHref(tab.draftID) : sessionHref(tab.server, tab.sessionId) export const tabKey = (tab: Tab) => (tab.type === "draft" ? `draft:${tab.draftID}` : `${tab.server}\n${tabHref(tab)}`) export function sessionHasOpenTab(tabs: Tab[], server: ServerConnection.Key, session: Session) { - const dirBase64 = base64Encode(session.directory) - return tabs.some( - (tab) => - tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id, - ) + return tabs.some((tab) => tab.type === "session" && tab.server === server && tab.sessionId === session.id) } export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ @@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const navigateTab = (tab: Tab) => { const href = tabHref(tab) setRecentKey(tabKey(tab)) - if (tab.server === server.key) { - navigate(href) - return - } - void startTransition(() => { - server.setActive(tab.server) - navigate(href) - }) + navigate(href) } const actions = { @@ -196,10 +184,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const removed = store .filter( (tab) => - tab.type === "session" && - tab.server === server.key && - atob(tab.dirBase64) === input.directory && - input.sessionIDs.includes(tab.sessionId), + tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId), ) .map(tabKey) void startTransition(() => { @@ -211,7 +196,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ ? tabHref({ type: "session", server: server.key, - dirBase64: params.dir, sessionId: params.id, }) : undefined @@ -224,14 +208,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const removedCurrent = currentTab?.type === "session" && currentTab.server === server.key && - atob(currentTab.dirBase64) === input.directory && sessionIDs.has(currentTab.sessionId) for (let i = tabs.length - 1; i >= 0; i--) { const tab = tabs[i] if (!tab || tab.type !== "session") continue if (tab.server !== server.key) continue - if (atob(tab.dirBase64) !== input.directory) continue if (!sessionIDs.has(tab.sessionId)) continue tabs.splice(i, 1) } diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index d1aa61c4c..a9c66c483 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli import { useParams } from "@solidjs/router" import { useSDK, type DirectorySDK } from "./sdk" import type { Platform } from "./platform" -import { useServer } from "./server" +import { useServerSDK } from "./server-sdk" +import { base64Encode } from "@opencode-ai/core/util/encode" import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope" @@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont gate: false, init: () => { const sdk = useSDK() - const server = useServer() + const serverSDK = useServerSDK() const params = useParams() const cache = new Map() - const scope = server.scope() + const scope = () => serverSDK().scope + const directory = createMemo(() => base64Encode(sdk().directory)) caches.add(cache) onCleanup(() => caches.delete(cache)) @@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont return entry.value } - const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope)) + const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope())) createEffect( on( - () => ({ dir: params.dir, id: params.id, scope }), + () => ({ dir: directory(), id: params.id, scope: scope() }), (next, prev) => { if (!prev?.dir) return if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index f937c98fa..d9d5a2edc 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" +import { type Accessor, createEffect, createMemo, createResource, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" import { useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" import { Schema } from "effect" +import type { ServerConnection } from "@/context/server" +import { sessionHref } from "@/utils/session-route" -export function DirectoryDataProvider(props: ParentProps<{ directory: string; draftID?: string }>) { +export function DirectoryDataProvider( + props: ParentProps<{ + directory: string | Accessor + draftID?: string + server?: Accessor + }>, +) { const location = useLocation() const navigate = useNavigate() const params = useParams() const sync = useSync() - const slug = createMemo(() => base64Encode(props.directory)) + const directory = () => (typeof props.directory === "function" ? props.directory() : props.directory) + const slug = createMemo(() => base64Encode(directory())) + const href = (sessionID: string) => { + const server = props.server?.() + if (server) return sessionHref(server, sessionID) + return `/${slug()}/session/${sessionID}` + } createEffect(() => { // A draft lives at /new-session?draftId=… and has no directory segment to normalize. - if (props.draftID) return + if (props.draftID || props.server?.()) return const next = sync().data.path.directory - if (!next || next === props.directory) return + if (!next || next === directory()) return const path = location.pathname.slice(slug().length + 1) navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) }) @@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr return ( navigate(`/${slug()}/session/${sessionID}`)} - onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`} + directory={directory()} + onNavigateToSession={(sessionID: string) => navigate(href(sessionID))} + onSessionHref={href} > {props.children} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 24c7e4c3a..e9f036993 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -43,7 +43,6 @@ import { sessionTitle } from "@/utils/session-title" import { pathKey } from "@/utils/path-key" import { useGlobal } from "@/context/global" import { useCommand } from "@/context/command" -import { useSettings } from "@/context/settings" import { ServerRowMenu } from "@/components/server/server-row-menu" import { ServerHealthIndicator } from "@/components/server/server-row" import { type ServerHealth } from "@/utils/server-health" @@ -113,16 +112,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) { return `${pathKey(record.session.directory)}:${record.session.id}` } -export default function Home() { - const settings = useSettings() - return ( - }> - - - ) -} - -function HomeDesign() { +export function NewHome() { const sync = useServerSync() const layout = useLayout() const platform = usePlatform() @@ -313,7 +303,7 @@ function HomeDesign() { const ctx = global.createServerCtx(conn) ctx.projects.open(directory) ctx.projects.touch(directory) - navigateOnServer(conn, `/${base64Encode(session.directory)}/session/${session.id}`) + navigateOnServer(conn, `/server/${base64Encode(ServerConnection.key(conn))}/session/${session.id}`) } function chooseProject(conn: ServerConnection.Any) { @@ -416,7 +406,7 @@ function HomeDesign() { record={record} server={state.selection.server} activeServer={state.selection.server === server.key} - openSession={openSession} + onClick={() => openSession(record.session)} /> )} @@ -1024,7 +1014,7 @@ function HomeSessionRow(props: { record: HomeSessionRecord server: ServerConnection.Key activeServer: boolean - openSession: (session: Session) => void + onClick: () => void }) { const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) @@ -1033,7 +1023,7 @@ function HomeSessionRow(props: { type="button" data-component="home-session-row" class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`} - onClick={() => props.openSession(props.record.session)} + onClick={props.onClick} > group.sessions.length > 0) } -function LegacyHome() { +export function LegacyHome() { const sync = useServerSync() const platform = usePlatform() const pickDirectory = useDirectoryPicker() diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx new file mode 100644 index 000000000..2f8793d8d --- /dev/null +++ b/packages/app/src/pages/layout-new.tsx @@ -0,0 +1,38 @@ +import { createEffect, type ParentProps } from "solid-js" +import { useNavigate } from "@solidjs/router" +import { DebugBar } from "@/components/debug-bar" +import { HelpButton } from "@/components/help-button" +import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" +import { usePlatform } from "@/context/platform" +import { setNavigate } from "@/utils/notification-click" +import { setV2Toast, ToastRegion } from "@/utils/toast" + +export default function NewLayout(props: ParentProps) { + const platform = usePlatform() + const navigate = useNavigate() + setNavigate(navigate) + + createEffect(() => setV2Toast(true)) + + const update: TitlebarUpdate = { + version: () => { + const state = platform.updater?.state() + if (state?.status !== "ready") return + return state.version + }, + installing: () => platform.updater?.state().status === "installing", + install: () => void platform.updater?.install(), + } + + return ( +
+ +
+ {props.children} +
+ {import.meta.env.DEV && } + + +
+ ) +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 4177d4919..cb3a4bf6e 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -13,7 +13,7 @@ import { type Accessor, } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" -import { useLocation, useNavigate, useParams } from "@solidjs/router" +import { useNavigate, useParams } from "@solidjs/router" import { useLayout, LocalProject } from "@/context/layout" import { useServerSync } from "@/context/server-sync" import { Persist, persisted } from "@/utils/persist" @@ -92,7 +92,7 @@ import { import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project" import { SidebarContent } from "./layout/sidebar-shell" -export default function Layout(props: ParentProps) { +export default function LegacyLayout(props: ParentProps) { const serverSDK = useServerSDK() const [store, setStore, , ready] = persisted( Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]), @@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) { const command = useCommand() const theme = useTheme() const language = useLanguage() - const newDesign = createMemo(() => settings.general.newLayoutDesigns()) - createEffect(() => setV2Toast(newDesign())) + createEffect(() => setV2Toast(false)) const initialDirectory = decode64(params.dir) - const location = useLocation() const route = createMemo(() => { const slug = params.dir if (!slug) return { slug, dir: "" } @@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) { const currentDir = createMemo(() => route().dir) const [state, setState] = createStore({ - autoselect: !initialDirectory && !newDesign(), + autoselect: !initialDirectory, busyWorkspaces: {} as Record, hoverProject: undefined as string | undefined, scrollSessionKey: undefined as string | undefined, @@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) { id: "sidebar.toggle", title: language.t("command.sidebar.toggle"), category: language.t("command.category.view"), - keybind: newDesign() ? undefined : "mod+b", + keybind: "mod+b", onSelect: () => layout.sidebar.toggle(), }, { @@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) { }, ] - if (!newDesign()) - Array.from({ length: 9 }, (_, i) => { - const index = i - const number = index + 1 - commands.push({ - id: `project.${number}`, - category: language.t("command.category.project"), - title: `Open Project {number}`, - keybind: `mod+${number}`, - disabled: layout.projects.list().length <= index, - hidden: true, - onSelect: () => navigateToProjectIndex(index), - }) + Array.from({ length: 9 }, (_, i) => { + const index = i + const number = index + 1 + commands.push({ + id: `project.${number}`, + category: language.t("command.category.project"), + title: `Open Project {number}`, + keybind: `mod+${number}`, + disabled: layout.projects.list().length <= index, + hidden: true, + onSelect: () => navigateToProjectIndex(index), }) + }) for (const [id] of availableThemeEntries()) { commands.push({ @@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) { createEffect(() => { document.documentElement.style.setProperty( "--dialog-left-margin", - newDesign() ? "0px" : `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, + `${layout.sidebar.opened() ? layout.sidebar.width() : 48}px`, ) }) @@ -2355,176 +2352,158 @@ export default function Layout(props: ParentProps) { ) return ( - - {autoselecting() ?? ""} - -
- }> - {props.children} - -
- {import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && } - - -
- } - > -
- {autoselecting() ?? ""} - - - - -
-
-
- +