feat(app): add server-keyed session routes (#32570)
This commit is contained in:
parent
40db33c415
commit
0c4f508c50
25 changed files with 630 additions and 378 deletions
|
|
@ -21,6 +21,7 @@ const words = [
|
||||||
"vector",
|
"vector",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const serverKey = "http://127.0.0.1:4096"
|
||||||
const sourceID = "ses_smoke_source"
|
const sourceID = "ses_smoke_source"
|
||||||
const targetID = "ses_smoke_target"
|
const targetID = "ses_smoke_target"
|
||||||
const directory = "C:/OpenCode/SmokeProject"
|
const directory = "C:/OpenCode/SmokeProject"
|
||||||
|
|
@ -240,6 +241,7 @@ function orderedParts(message: Message) {
|
||||||
|
|
||||||
export const fixture = {
|
export const fixture = {
|
||||||
directory,
|
directory,
|
||||||
|
serverKey,
|
||||||
project: {
|
project: {
|
||||||
id: projectID,
|
id: projectID,
|
||||||
worktree: directory,
|
worktree: directory,
|
||||||
|
|
|
||||||
|
|
@ -718,7 +718,8 @@ async function navigateToSession(page: Page, directory: string, sessionId: strin
|
||||||
}
|
}
|
||||||
|
|
||||||
async function switchTitlebarSession(page: Page, sessionID: string, title: string) {
|
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()
|
const tab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`).first()
|
||||||
await expect(tab).toBeVisible()
|
await expect(tab).toBeVisible()
|
||||||
await tab.click()
|
await tab.click()
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ import { Splash } from "@opencode-ai/ui/logo"
|
||||||
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
|
||||||
import { MetaProvider } from "@solidjs/meta"
|
import { MetaProvider } from "@solidjs/meta"
|
||||||
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
|
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 { Effect } from "effect"
|
||||||
import {
|
import {
|
||||||
type Component,
|
type Component,
|
||||||
|
|
@ -30,7 +30,7 @@ import { Dynamic } from "solid-js/web"
|
||||||
import { CommandProvider } from "@/context/command"
|
import { CommandProvider } from "@/context/command"
|
||||||
import { CommentsProvider } from "@/context/comments"
|
import { CommentsProvider } from "@/context/comments"
|
||||||
import { FileProvider } from "@/context/file"
|
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 { ServerSyncProvider } from "@/context/server-sync"
|
||||||
import { GlobalProvider } from "@/context/global"
|
import { GlobalProvider } from "@/context/global"
|
||||||
import { HighlightsProvider } from "@/context/highlights"
|
import { HighlightsProvider } from "@/context/highlights"
|
||||||
|
|
@ -47,11 +47,14 @@ import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
|
||||||
import { SDKProvider, useSDK } from "@/context/sdk"
|
import { SDKProvider, useSDK } from "@/context/sdk"
|
||||||
import { WslServersProvider } from "@/wsl/context"
|
import { WslServersProvider } from "@/wsl/context"
|
||||||
import DirectoryLayout, { DirectoryDataProvider } from "@/pages/directory-layout"
|
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 { ErrorPage } from "./pages/error"
|
||||||
import { useCheckServerHealth } from "./utils/server-health"
|
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 Session = lazy(() => import("@/pages/session"))
|
||||||
const NewSession = lazy(() => import("@/pages/new-session"))
|
const NewSession = lazy(() => import("@/pages/new-session"))
|
||||||
|
|
||||||
|
|
@ -64,6 +67,10 @@ const SessionRoute = Object.assign(
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
|
||||||
|
if (params.id && settings.general.newLayoutDesigns()) {
|
||||||
|
return <Navigate href={sessionHref(server.key, params.id)} />
|
||||||
|
}
|
||||||
|
|
||||||
// When the new layout is enabled, the legacy new-session route (/:dir/session with no id)
|
// 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=…
|
// is replaced by a draft at /new-session?draftId=…
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
@ -82,29 +89,55 @@ const SessionRoute = Object.assign(
|
||||||
{ preload: Session.preload },
|
{ preload: Session.preload },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const TargetSessionRoute = Object.assign(
|
||||||
|
() => {
|
||||||
|
const sdk = useSDK()
|
||||||
|
const serverSDK = useServerSDK()
|
||||||
|
return (
|
||||||
|
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
|
||||||
|
<SessionProviders>
|
||||||
|
<Session />
|
||||||
|
</SessionProviders>
|
||||||
|
</Show>
|
||||||
|
)
|
||||||
|
},
|
||||||
|
{ preload: Session.preload },
|
||||||
|
)
|
||||||
|
|
||||||
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
|
// 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/
|
// server via ServerKey, then provide the server-scoped shell (Permission/Layout/
|
||||||
// Notification/Models + the visual Layout) for that server.
|
// Notification/Models + the visual Layout) for that server.
|
||||||
function SelectedServerLayout(props: ParentProps) {
|
function SelectedServerProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<ServerKey>
|
<ServerKey>
|
||||||
<ServerSDKProvider>
|
<ServerSDKProvider>
|
||||||
<ServerSyncProvider>
|
<ServerSyncProvider>{props.children}</ServerSyncProvider>
|
||||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
|
||||||
</ServerSyncProvider>
|
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
</ServerKey>
|
</ServerKey>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegacyServerLayout(props: ParentProps) {
|
||||||
|
return (
|
||||||
|
<SelectedServerProviders>
|
||||||
|
<LegacyServerScopedShell>{props.children}</LegacyServerScopedShell>
|
||||||
|
</SelectedServerProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Wraps /new-session. It resolves the draft's target server and provides the
|
// 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
|
// server-scoped shell for that server — without ServerKey, so the page never depends
|
||||||
// on the globally "selected" server.
|
// on the globally "selected" server.
|
||||||
function DraftServerLayout(props: ParentProps) {
|
function TargetServerLayout(props: ParentProps) {
|
||||||
const server = useServer()
|
const server = useServer()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
|
const params = useParams<{ serverKey?: string }>()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const conn = createMemo(() => {
|
const conn = createMemo(() => {
|
||||||
|
if (params.serverKey) {
|
||||||
|
const key = requireServerKey(params.serverKey)
|
||||||
|
return server.list.find((item) => ServerConnection.key(item) === key)
|
||||||
|
}
|
||||||
const id = search.draftId
|
const id = search.draftId
|
||||||
if (!id) return undefined
|
if (!id) return undefined
|
||||||
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
|
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === id)
|
||||||
|
|
@ -115,48 +148,98 @@ function DraftServerLayout(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<ServerSDKProvider server={conn}>
|
<ServerSDKProvider server={conn}>
|
||||||
<ServerSyncProvider server={conn}>
|
<ServerSyncProvider server={conn}>
|
||||||
<ServerScopedShell>{props.children}</ServerScopedShell>
|
<TargetDirectoryLayout>{props.children}</TargetDirectoryLayout>
|
||||||
</ServerSyncProvider>
|
</ServerSyncProvider>
|
||||||
</ServerSDKProvider>
|
</ServerSDKProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<string | undefined>((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 (
|
||||||
|
<NewServerScopedShell directory={() => (home() ? undefined : directory())} sessionID={() => params.id}>
|
||||||
|
<Show when={!home()} fallback={props.children}>
|
||||||
|
<Show when={!resolved.error} fallback={<ErrorPage error={resolved.error} />}>
|
||||||
|
<Show when={directory()}>
|
||||||
|
<Show
|
||||||
|
when={!params.serverKey || settings.general.newLayoutDesigns()}
|
||||||
|
fallback={<Navigate href={legacySessionHref(directory()!, params.id!)} />}
|
||||||
|
>
|
||||||
|
<SDKProvider directory={targetDirectory}>
|
||||||
|
<DirectoryDataProvider directory={targetDirectory} server={serverKey}>
|
||||||
|
<Show when={!params.serverKey || (resolved.data && !resolved.isPlaceholderData)}>
|
||||||
|
{props.children}
|
||||||
|
</Show>
|
||||||
|
</DirectoryDataProvider>
|
||||||
|
</SDKProvider>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</Show>
|
||||||
|
</NewServerScopedShell>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function DraftRoute() {
|
function DraftRoute() {
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
return (
|
return (
|
||||||
<Show when={tabs.ready()}>
|
<Show when={tabs.ready()}>
|
||||||
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
<Show when={search.draftId} keyed fallback={<Navigate href="/" />}>
|
||||||
{(draftID) => <ResolvedDraftRoute draftID={draftID} />}
|
<ResolvedDraftRoute />
|
||||||
</Show>
|
</Show>
|
||||||
</Show>
|
</Show>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResolvedDraftRoute(props: { draftID: string }) {
|
function ResolvedDraftRoute() {
|
||||||
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
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Show when={directory()} keyed>
|
<DraftProviders>
|
||||||
{(dir) => (
|
<NewSession />
|
||||||
<SDKProvider directory={dir}>
|
</DraftProviders>
|
||||||
<DirectoryDataProvider directory={dir} draftID={props.draftID}>
|
|
||||||
<DraftProviders>
|
|
||||||
<NewSession />
|
|
||||||
</DraftProviders>
|
|
||||||
</DirectoryDataProvider>
|
|
||||||
</SDKProvider>
|
|
||||||
)}
|
|
||||||
</Show>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,32 +293,51 @@ function BodyDesignClass() {
|
||||||
// shell (router root) so they stay mounted regardless of the active server/route.
|
// shell (router root) so they stay mounted regardless of the active server/route.
|
||||||
function SharedProviders(props: ParentProps) {
|
function SharedProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<SettingsProvider>
|
<>
|
||||||
<BodyDesignClass />
|
<BodyDesignClass />
|
||||||
<CommandProvider>
|
<CommandProvider>
|
||||||
<HighlightsProvider>{props.children}</HighlightsProvider>
|
<HighlightsProvider>{props.children}</HighlightsProvider>
|
||||||
</CommandProvider>
|
</CommandProvider>
|
||||||
</SettingsProvider>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Server-scoped providers plus the visual Layout (tabs/sidebar). These live inside
|
// 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
|
// each per-route server layout so they resolve to that route's server (selected vs
|
||||||
// draft). The Layout remounts when crossing between those groups.
|
// 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 (
|
return (
|
||||||
<PermissionProvider>
|
<PermissionProvider directory={props.directory}>
|
||||||
<LayoutProvider>
|
<LayoutProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider directory={props.directory} sessionID={props.sessionID}>
|
||||||
<ModelsProvider>
|
<ModelsProvider>{props.children}</ModelsProvider>
|
||||||
<Layout>{props.children}</Layout>
|
|
||||||
</ModelsProvider>
|
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</LayoutProvider>
|
</LayoutProvider>
|
||||||
</PermissionProvider>
|
</PermissionProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function LegacyServerScopedShell(props: ServerScopedShellProps) {
|
||||||
|
return (
|
||||||
|
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||||
|
<LegacyLayout>{props.children}</LegacyLayout>
|
||||||
|
</ServerScopedProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewServerScopedShell(props: ServerScopedShellProps) {
|
||||||
|
return (
|
||||||
|
<ServerScopedProviders directory={props.directory} sessionID={props.sessionID}>
|
||||||
|
<NewLayout>{props.children}</NewLayout>
|
||||||
|
</ServerScopedProviders>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
function SessionProviders(props: ParentProps) {
|
function SessionProviders(props: ParentProps) {
|
||||||
return (
|
return (
|
||||||
<TerminalProvider>
|
<TerminalProvider>
|
||||||
|
|
@ -439,28 +541,61 @@ export function AppInterface(props: {
|
||||||
servers={props.servers}
|
servers={props.servers}
|
||||||
>
|
>
|
||||||
<GlobalProvider>
|
<GlobalProvider>
|
||||||
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
<SettingsProvider>
|
||||||
<Dynamic
|
<ConnectionGate disableHealthCheck={props.disableHealthCheck}>
|
||||||
component={props.router ?? Router}
|
<Show when={useSettings().general.newLayoutDesigns().toString()} keyed>
|
||||||
root={(routerProps) => (
|
<Dynamic
|
||||||
<TabsProvider>
|
component={props.router ?? Router}
|
||||||
<ServerShell>{routerProps.children}</ServerShell>
|
root={(routerProps) => (
|
||||||
</TabsProvider>
|
<TabsProvider>
|
||||||
)}
|
<ServerShell>{routerProps.children}</ServerShell>
|
||||||
>
|
</TabsProvider>
|
||||||
<Route component={SelectedServerLayout}>
|
)}
|
||||||
<Route path="/" component={HomeRoute} />
|
>
|
||||||
<Route path="/:dir" component={DirectoryLayout}>
|
<Routes />
|
||||||
<Route path="/" component={() => <Navigate href="session" />} />
|
</Dynamic>
|
||||||
<Route path="/session/:id?" component={SessionRoute} />
|
</Show>
|
||||||
</Route>
|
</ConnectionGate>
|
||||||
</Route>
|
</SettingsProvider>
|
||||||
<Route component={DraftServerLayout}>
|
|
||||||
<Route path="/new-session" component={DraftRoute} />
|
|
||||||
</Route>
|
|
||||||
</Dynamic>
|
|
||||||
</ConnectionGate>
|
|
||||||
</GlobalProvider>
|
</GlobalProvider>
|
||||||
</ServerProvider>
|
</ServerProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Routes() {
|
||||||
|
const settings = useSettings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Route component={LegacyServerLayout}>
|
||||||
|
<Show when={!settings.general.newLayoutDesigns()}>{<Route path="/" component={LegacyHome} />}</Show>
|
||||||
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
|
<Route path="/" component={() => <Navigate href="session" />} />
|
||||||
|
<Route path="/session/:id?" component={SessionRoute} />
|
||||||
|
</Route>
|
||||||
|
</Route>
|
||||||
|
<Route component={TargetServerLayout}>
|
||||||
|
<Show when={settings.general.newLayoutDesigns()}>
|
||||||
|
{
|
||||||
|
<>
|
||||||
|
<Route path="/" component={NewHome} />
|
||||||
|
<Route path="/:dir" component={DirectoryLayout}>
|
||||||
|
<Route
|
||||||
|
path="/session/:id"
|
||||||
|
component={() => {
|
||||||
|
const server = useServer()
|
||||||
|
const { id } = useParams()
|
||||||
|
|
||||||
|
return <Navigate href={`/server/${server.key}/session/${id}`} />
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Route>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
</Show>
|
||||||
|
<Route path="/new-session" component={DraftRoute} />
|
||||||
|
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
|
||||||
|
</Route>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -388,12 +388,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||||
local.session.promote(sessionDirectory, session.id)
|
local.session.promote(sessionDirectory, session.id)
|
||||||
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
|
||||||
const draftID = search.draftId
|
const draftID = search.draftId
|
||||||
if (draftID)
|
if (draftID) tabs.promoteDraft(draftID, { server: server.key, sessionId: session.id })
|
||||||
tabs.promoteDraft(draftID, {
|
|
||||||
server: server.key,
|
|
||||||
dirBase64: base64Encode(sessionDirectory),
|
|
||||||
sessionId: session.id,
|
|
||||||
})
|
|
||||||
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ import { useLanguage } from "@/context/language"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { WindowsAppMenu } from "./windows-app-menu"
|
import { WindowsAppMenu } from "./windows-app-menu"
|
||||||
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
import { applyPath, backPath, forwardPath } from "./titlebar-history"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
import { displayName, getProjectAvatarSource, projectForSession } from "@/pages/layout/helpers"
|
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 { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||||
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { decode64 } from "@/utils/base64"
|
|
||||||
import { ServerConnection, useServer } from "@/context/server"
|
import { ServerConnection, useServer } from "@/context/server"
|
||||||
import { tabHref, useTabs, type Tab } from "@/context/tabs"
|
import { tabHref, useTabs } from "@/context/tabs"
|
||||||
import "./titlebar.css"
|
import "./titlebar.css"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { Session } from "@opencode-ai/sdk/v2"
|
||||||
|
|
||||||
type TauriDesktopWindow = {
|
type TauriDesktopWindow = {
|
||||||
startDragging?: () => Promise<void>
|
startDragging?: () => Promise<void>
|
||||||
|
|
@ -252,7 +252,7 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
<Switch>
|
<Switch>
|
||||||
<Match when={useV2Titlebar()}>
|
<Match when={useV2Titlebar()}>
|
||||||
{(_) => {
|
{(_) => {
|
||||||
const serverSync = useServerSync()
|
const serverSdk = useServerSDK()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
|
|
||||||
|
|
@ -268,6 +268,17 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
|
||||||
const tabs = useTabs()
|
const tabs = useTabs()
|
||||||
const tabsStore = tabs.store
|
const tabsStore = tabs.store
|
||||||
const tabsStoreActions = tabs
|
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) => {
|
const matchRoute = (route: LayoutRoute) => {
|
||||||
if (route.type === "home") return
|
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,
|
item.type === "session" && item.server === route.server && item.sessionId === route.sessionId,
|
||||||
)
|
)
|
||||||
if (main) return main
|
if (main) return main
|
||||||
const sync = serverSync().createDirSyncContext(route.dir)
|
const s = session()
|
||||||
const session = sync.session.get(route.sessionId)
|
if (s?.parentID) {
|
||||||
if (session?.parentID) {
|
const parentID = s.parentID
|
||||||
const parentID = session.parentID
|
|
||||||
const parent = tabsStore.find(
|
const parent = tabsStore.find(
|
||||||
(item) => item.type === "session" && item.server === route.server && item.sessionId === parentID,
|
(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") {
|
if (route.type === "session") {
|
||||||
const sync = serverSync().createDirSyncContext(route.dir)
|
const s = session()
|
||||||
const session = sync.session.get(route.sessionId)
|
if (!s) return
|
||||||
if (!session) return
|
const sessionId = s.parentID ?? s.id
|
||||||
const sessionId = session.parentID ?? session.id
|
const next = { server: route.server ?? server.key, sessionId }
|
||||||
const next = {
|
|
||||||
server: route.server ?? server.key,
|
|
||||||
dirBase64: route.dirBase64,
|
|
||||||
sessionId,
|
|
||||||
}
|
|
||||||
tabsStoreActions.addSessionTab(next)
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
{divider()}
|
{divider()}
|
||||||
<TabNavItem
|
<Show when={session()}>
|
||||||
ref={ref}
|
{(session) => (
|
||||||
href={tabHref(tab)}
|
<TabNavItem
|
||||||
server={tab.server}
|
ref={ref}
|
||||||
directory={decode64(tab.dirBase64)!}
|
href={tabHref(tab)}
|
||||||
sessionId={tab.sessionId}
|
server={tab.server}
|
||||||
onNavigate={() => {
|
sessionId={tab.sessionId}
|
||||||
tabs.select(tab)
|
session={session()}
|
||||||
|
onNavigate={() => {
|
||||||
|
tabs.select(tab)
|
||||||
|
|
||||||
ref.scrollIntoView({ behavior: "instant" })
|
ref.scrollIntoView({ behavior: "instant" })
|
||||||
}}
|
}}
|
||||||
onClose={() => tabsStoreActions.removeTab(i())}
|
onClose={() => tabsStoreActions.removeTab(i())}
|
||||||
active={currentTab() === tab}
|
active={currentTab() === tab}
|
||||||
activeServer={tab.server === server.key}
|
activeServer={tab.server === server.key}
|
||||||
forceTruncate={tabsAreOverflowing()}
|
forceTruncate={tabsAreOverflowing()}
|
||||||
/>
|
/>
|
||||||
|
)}
|
||||||
|
</Show>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
|
|
@ -793,7 +811,6 @@ function TabNavItem(props: {
|
||||||
ref?: HTMLDivElement
|
ref?: HTMLDivElement
|
||||||
href: string
|
href: string
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
directory: string
|
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
hideClose?: boolean
|
hideClose?: boolean
|
||||||
onClose: () => void
|
onClose: () => void
|
||||||
|
|
@ -801,31 +818,19 @@ function TabNavItem(props: {
|
||||||
active?: boolean
|
active?: boolean
|
||||||
activeServer: boolean
|
activeServer: boolean
|
||||||
forceTruncate?: boolean
|
forceTruncate?: boolean
|
||||||
|
session: Session
|
||||||
}) {
|
}) {
|
||||||
const closeTab = (event: MouseEvent) => {
|
const closeTab = (event: MouseEvent) => {
|
||||||
event.preventDefault()
|
event.preventDefault()
|
||||||
event.stopPropagation()
|
event.stopPropagation()
|
||||||
props.onClose()
|
props.onClose()
|
||||||
}
|
}
|
||||||
|
|
||||||
const global = useGlobal()
|
const global = useGlobal()
|
||||||
const serverCtx = createMemo(() => {
|
const serverCtx = createMemo(() => {
|
||||||
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
|
||||||
if (conn) return global.createServerCtx(conn)
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -837,7 +842,7 @@ function TabNavItem(props: {
|
||||||
closeTab(event)
|
closeTab(event)
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={session.latest}>
|
<Show when={props.session}>
|
||||||
{(session) => {
|
{(session) => {
|
||||||
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
const project = createMemo(() => projectForSession(session(), serverCtx()?.projects.list() ?? []))
|
||||||
|
|
||||||
|
|
@ -853,7 +858,7 @@ function TabNavItem(props: {
|
||||||
<span data-slot="project-avatar-slot">
|
<span data-slot="project-avatar-slot">
|
||||||
<ProjectTabAvatar
|
<ProjectTabAvatar
|
||||||
project={project()}
|
project={project()}
|
||||||
directory={props.directory}
|
directory={session().directory}
|
||||||
sessionId={session().id}
|
sessionId={session().id}
|
||||||
activeServer={props.activeServer}
|
activeServer={props.activeServer}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,14 @@ import { batch, createMemo, createRoot, onCleanup } from "solid-js"
|
||||||
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
import { createStore, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
import { createScopedCache } from "@/utils/scoped-cache"
|
import { createScopedCache } from "@/utils/scoped-cache"
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import type { SelectedLineRange } from "@/context/file"
|
import type { SelectedLineRange } from "@/context/file"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
|
||||||
export type LineComment = {
|
export type LineComment = {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -202,6 +204,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const sdk = useSDK()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const cache = createScopedCache(
|
const cache = createScopedCache(
|
||||||
(key) => {
|
(key) => {
|
||||||
|
|
@ -228,7 +231,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
|
||||||
return cache.get(key).value
|
return cache.get(key).value
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() => load(params.dir!, params.id))
|
const session = createMemo(() => load(base64Encode(sdk().directory), params.id))
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ready: () => session().ready(),
|
ready: () => session().ready(),
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { getFilename } from "@opencode-ai/core/util/path"
|
import { getFilename } from "@opencode-ai/core/util/path"
|
||||||
import { useSDK } from "./sdk"
|
import { useSDK } from "./sdk"
|
||||||
import { useSync } from "./sync"
|
import { useSync } from "./sync"
|
||||||
|
|
@ -65,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||||
const scope = createMemo(() => sdk().directory)
|
const scope = createMemo(() => sdk().directory)
|
||||||
const path = createPathHelpers(scope)
|
const path = createPathHelpers(scope)
|
||||||
const tabs = layout.tabs(() =>
|
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<string, Promise<void>>()
|
const inflight = new Map<string, Promise<void>>()
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { createPathHelpers } from "./file/path"
|
||||||
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
|
||||||
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
|
||||||
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
|
||||||
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
|
||||||
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
|
||||||
|
|
||||||
|
|
@ -79,7 +80,7 @@ export type LayoutRoute =
|
||||||
| { type: "home" }
|
| { type: "home" }
|
||||||
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
|
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
|
||||||
| { type: "dir-new-sesssion"; dir: string; dirBase64: 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 {
|
function nextSessionTabsForOpen(current: SessionTabs | undefined, tab: string): SessionTabs {
|
||||||
const all = current?.all ?? []
|
const all = current?.all ?? []
|
||||||
|
|
@ -131,6 +132,14 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||||
return { type: "draft", draftID }
|
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 dirBase64 = parts[0]
|
||||||
const dir = decode64(dirBase64)
|
const dir = decode64(dirBase64)
|
||||||
if (!dir) return { type: "home" }
|
if (!dir) return { type: "home" }
|
||||||
|
|
@ -138,7 +147,7 @@ const currentRoute = (pathname: string, search: string): LayoutRoute => {
|
||||||
if (parts[1] !== "session") return { type: "home" }
|
if (parts[1] !== "session") return { type: "home" }
|
||||||
|
|
||||||
const id = parts[2]
|
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 }
|
return { type: "dir-new-sesssion", dir, dirBase64 }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -154,6 +163,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
const route = createMemo(() => {
|
const route = createMemo(() => {
|
||||||
const value = currentRoute(location.pathname, location.search)
|
const value = currentRoute(location.pathname, location.search)
|
||||||
if (value.type === "home") return value
|
if (value.type === "home") return value
|
||||||
|
if (value.server) return value
|
||||||
return { ...value, server: server.key }
|
return { ...value, server: server.key }
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -572,7 +582,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||||
handoff: {
|
handoff: {
|
||||||
tabs: createMemo(() => store.handoff?.tabs),
|
tabs: createMemo(() => store.handoff?.tabs),
|
||||||
setTabs(dir: string, id: string) {
|
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() {
|
clearTabs() {
|
||||||
if (!store.handoff?.tabs) return
|
if (!store.handoff?.tabs) return
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createStore, reconcile } from "solid-js/store"
|
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 { useParams } from "@solidjs/router"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
|
|
@ -108,7 +108,7 @@ function buildNotificationIndex(list: Notification[]) {
|
||||||
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
|
||||||
name: "Notification",
|
name: "Notification",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: (props: { directory?: Accessor<string | undefined>; sessionID?: Accessor<string | undefined> }) => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
|
@ -119,10 +119,10 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
|
||||||
const empty: Notification[] = []
|
const empty: Notification[] = []
|
||||||
|
|
||||||
const currentDirectory = createMemo(() => {
|
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(
|
const [store, setStore, _, ready] = persisted(
|
||||||
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
|
||||||
|
|
|
||||||
|
|
@ -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 { createStore, produce } from "solid-js/store"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
import type { PermissionRequest } from "@opencode-ai/sdk/v2/client"
|
||||||
|
|
@ -47,13 +47,13 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||||
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
|
||||||
name: "Permission",
|
name: "Permission",
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: (props: { directory?: Accessor<string | undefined> }) => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const serverSync = useServerSync()
|
const serverSync = useServerSync()
|
||||||
|
|
||||||
const permissionsEnabled = createMemo(() => {
|
const permissionsEnabled = createMemo(() => {
|
||||||
const directory = decode64(params.dir)
|
const directory = props.directory?.() ?? decode64(params.dir)
|
||||||
if (!directory) return false
|
if (!directory) return false
|
||||||
const [store] = serverSync().child(directory)
|
const [store] = serverSync().child(directory)
|
||||||
return hasPermissionPromptRules(store.config.permission)
|
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
|
// When config has permission: "allow", auto-enable directory-level auto-accept
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
if (!ready()) return
|
if (!ready()) return
|
||||||
const directory = decode64(params.dir)
|
const directory = props.directory?.() ?? decode64(params.dir)
|
||||||
if (!directory) return
|
if (!directory) return
|
||||||
const [childStore] = serverSync().child(directory)
|
const [childStore] = serverSync().child(directory)
|
||||||
const perm = childStore.config.permission
|
const perm = childStore.config.permission
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
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 { useParams, useSearchParams } from "@solidjs/router"
|
||||||
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
import { batch, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||||
import { createStore, type SetStoreFunction } from "solid-js/store"
|
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 { Persist, persisted } from "@/utils/persist"
|
||||||
import { useServerSDK } from "./server-sdk"
|
import { useServerSDK } from "./server-sdk"
|
||||||
import type { ServerScope } from "@/utils/server-scope"
|
import type { ServerScope } from "@/utils/server-scope"
|
||||||
|
import { useSDK } from "./sdk"
|
||||||
|
|
||||||
interface PartBase {
|
interface PartBase {
|
||||||
content: string
|
content: string
|
||||||
|
|
@ -256,6 +257,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
|
const sdk = useSDK()
|
||||||
const [search] = useSearchParams<{ draftId?: string }>()
|
const [search] = useSearchParams<{ draftId?: string }>()
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const cache = new Map<string, PromptCacheEntry>()
|
const cache = new Map<string, PromptCacheEntry>()
|
||||||
|
|
@ -303,7 +305,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = createMemo(() =>
|
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())
|
const pick = (scope?: Scope) => (scope ? load(scope) : session())
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import type { Session } from "@opencode-ai/sdk/v2/client"
|
import type { Session } from "@opencode-ai/sdk/v2/client"
|
||||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
|
||||||
import { createStore, produce } from "solid-js/store"
|
import { createStore, produce } from "solid-js/store"
|
||||||
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
|
||||||
import { ServerConnection, useServer } from "./server"
|
import { ServerConnection, useServer } from "./server"
|
||||||
|
|
@ -9,11 +8,11 @@ import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||||
import { usePlatform } from "./platform"
|
import { usePlatform } from "./platform"
|
||||||
import { uuid } from "@/utils/uuid"
|
import { uuid } from "@/utils/uuid"
|
||||||
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events"
|
||||||
|
import { sessionHref } from "@/utils/session-route"
|
||||||
|
|
||||||
export type SessionTab = {
|
export type SessionTab = {
|
||||||
type: "session"
|
type: "session"
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
dirBase64: string
|
|
||||||
sessionId: string
|
sessionId: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34,16 +33,12 @@ type RecentTab = {
|
||||||
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
export const draftHref = (draftID: string) => `/new-session?draftId=${encodeURIComponent(draftID)}`
|
||||||
|
|
||||||
export const tabHref = (tab: Tab) =>
|
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 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) {
|
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.sessionId === session.id)
|
||||||
return tabs.some(
|
|
||||||
(tab) =>
|
|
||||||
tab.type === "session" && tab.server === server && tab.dirBase64 === dirBase64 && tab.sessionId === session.id,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
|
|
@ -105,14 +100,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
const navigateTab = (tab: Tab) => {
|
const navigateTab = (tab: Tab) => {
|
||||||
const href = tabHref(tab)
|
const href = tabHref(tab)
|
||||||
setRecentKey(tabKey(tab))
|
setRecentKey(tabKey(tab))
|
||||||
if (tab.server === server.key) {
|
navigate(href)
|
||||||
navigate(href)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
void startTransition(() => {
|
|
||||||
server.setActive(tab.server)
|
|
||||||
navigate(href)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const actions = {
|
const actions = {
|
||||||
|
|
@ -196,10 +184,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
const removed = store
|
const removed = store
|
||||||
.filter(
|
.filter(
|
||||||
(tab) =>
|
(tab) =>
|
||||||
tab.type === "session" &&
|
tab.type === "session" && tab.server === server.key && input.sessionIDs.includes(tab.sessionId),
|
||||||
tab.server === server.key &&
|
|
||||||
atob(tab.dirBase64) === input.directory &&
|
|
||||||
input.sessionIDs.includes(tab.sessionId),
|
|
||||||
)
|
)
|
||||||
.map(tabKey)
|
.map(tabKey)
|
||||||
void startTransition(() => {
|
void startTransition(() => {
|
||||||
|
|
@ -211,7 +196,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
? tabHref({
|
? tabHref({
|
||||||
type: "session",
|
type: "session",
|
||||||
server: server.key,
|
server: server.key,
|
||||||
dirBase64: params.dir,
|
|
||||||
sessionId: params.id,
|
sessionId: params.id,
|
||||||
})
|
})
|
||||||
: undefined
|
: undefined
|
||||||
|
|
@ -224,14 +208,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
|
||||||
const removedCurrent =
|
const removedCurrent =
|
||||||
currentTab?.type === "session" &&
|
currentTab?.type === "session" &&
|
||||||
currentTab.server === server.key &&
|
currentTab.server === server.key &&
|
||||||
atob(currentTab.dirBase64) === input.directory &&
|
|
||||||
sessionIDs.has(currentTab.sessionId)
|
sessionIDs.has(currentTab.sessionId)
|
||||||
|
|
||||||
for (let i = tabs.length - 1; i >= 0; i--) {
|
for (let i = tabs.length - 1; i >= 0; i--) {
|
||||||
const tab = tabs[i]
|
const tab = tabs[i]
|
||||||
if (!tab || tab.type !== "session") continue
|
if (!tab || tab.type !== "session") continue
|
||||||
if (tab.server !== server.key) continue
|
if (tab.server !== server.key) continue
|
||||||
if (atob(tab.dirBase64) !== input.directory) continue
|
|
||||||
if (!sessionIDs.has(tab.sessionId)) continue
|
if (!sessionIDs.has(tab.sessionId)) continue
|
||||||
tabs.splice(i, 1)
|
tabs.splice(i, 1)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,8 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { useSDK, type DirectorySDK } from "./sdk"
|
import { useSDK, type DirectorySDK } from "./sdk"
|
||||||
import type { Platform } from "./platform"
|
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 { defaultTitle, titleNumber } from "./terminal-title"
|
||||||
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
import { Persist, persisted, removePersisted } from "@/utils/persist"
|
||||||
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
|
import { ScopedKey, ServerScope, type ServerScope as ServerScopeValue } from "@/utils/server-scope"
|
||||||
|
|
@ -374,10 +375,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||||
gate: false,
|
gate: false,
|
||||||
init: () => {
|
init: () => {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const server = useServer()
|
const serverSDK = useServerSDK()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const cache = new Map<string, TerminalCacheEntry>()
|
const cache = new Map<string, TerminalCacheEntry>()
|
||||||
const scope = server.scope()
|
const scope = () => serverSDK().scope
|
||||||
|
const directory = createMemo(() => base64Encode(sdk().directory))
|
||||||
|
|
||||||
caches.add(cache)
|
caches.add(cache)
|
||||||
onCleanup(() => caches.delete(cache))
|
onCleanup(() => caches.delete(cache))
|
||||||
|
|
@ -421,11 +423,11 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
|
||||||
return entry.value
|
return entry.value
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = createMemo(() => loadWorkspace(params.dir!, params.id, scope))
|
const workspace = createMemo(() => loadWorkspace(directory(), params.id, scope()))
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ dir: params.dir, id: params.id, scope }),
|
() => ({ dir: directory(), id: params.id, scope: scope() }),
|
||||||
(next, prev) => {
|
(next, prev) => {
|
||||||
if (!prev?.dir) return
|
if (!prev?.dir) return
|
||||||
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
|
if (next.dir === prev.dir && next.id === prev.id && next.scope === prev.scope) return
|
||||||
|
|
|
||||||
|
|
@ -2,26 +2,40 @@ import { DataProvider } from "@opencode-ai/ui/context"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
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 { useLanguage } from "@/context/language"
|
||||||
import { LocalProvider } from "@/context/local"
|
import { LocalProvider } from "@/context/local"
|
||||||
import { SDKProvider } from "@/context/sdk"
|
import { SDKProvider } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { decode64 } from "@/utils/base64"
|
import { decode64 } from "@/utils/base64"
|
||||||
import { Schema } from "effect"
|
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<string>
|
||||||
|
draftID?: string
|
||||||
|
server?: Accessor<ServerConnection.Key | undefined>
|
||||||
|
}>,
|
||||||
|
) {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const sync = useSync()
|
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(() => {
|
createEffect(() => {
|
||||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
// 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
|
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)
|
const path = location.pathname.slice(slug().length + 1)
|
||||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||||
})
|
})
|
||||||
|
|
@ -37,9 +51,9 @@ export function DirectoryDataProvider(props: ParentProps<{ directory: string; dr
|
||||||
return (
|
return (
|
||||||
<DataProvider
|
<DataProvider
|
||||||
data={sync().data}
|
data={sync().data}
|
||||||
directory={props.directory}
|
directory={directory()}
|
||||||
onNavigateToSession={(sessionID: string) => navigate(`/${slug()}/session/${sessionID}`)}
|
onNavigateToSession={(sessionID: string) => navigate(href(sessionID))}
|
||||||
onSessionHref={(sessionID: string) => `/${slug()}/session/${sessionID}`}
|
onSessionHref={href}
|
||||||
>
|
>
|
||||||
<LocalProvider>{props.children}</LocalProvider>
|
<LocalProvider>{props.children}</LocalProvider>
|
||||||
</DataProvider>
|
</DataProvider>
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,6 @@ import { sessionTitle } from "@/utils/session-title"
|
||||||
import { pathKey } from "@/utils/path-key"
|
import { pathKey } from "@/utils/path-key"
|
||||||
import { useGlobal } from "@/context/global"
|
import { useGlobal } from "@/context/global"
|
||||||
import { useCommand } from "@/context/command"
|
import { useCommand } from "@/context/command"
|
||||||
import { useSettings } from "@/context/settings"
|
|
||||||
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
import { ServerRowMenu } from "@/components/server/server-row-menu"
|
||||||
import { ServerHealthIndicator } from "@/components/server/server-row"
|
import { ServerHealthIndicator } from "@/components/server/server-row"
|
||||||
import { type ServerHealth } from "@/utils/server-health"
|
import { type ServerHealth } from "@/utils/server-health"
|
||||||
|
|
@ -113,16 +112,7 @@ function homeSessionSearchKey(record: HomeSessionRecord) {
|
||||||
return `${pathKey(record.session.directory)}:${record.session.id}`
|
return `${pathKey(record.session.directory)}:${record.session.id}`
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Home() {
|
export function NewHome() {
|
||||||
const settings = useSettings()
|
|
||||||
return (
|
|
||||||
<Show when={settings.general.newLayoutDesigns()} fallback={<LegacyHome />}>
|
|
||||||
<HomeDesign />
|
|
||||||
</Show>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function HomeDesign() {
|
|
||||||
const sync = useServerSync()
|
const sync = useServerSync()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
|
|
@ -313,7 +303,7 @@ function HomeDesign() {
|
||||||
const ctx = global.createServerCtx(conn)
|
const ctx = global.createServerCtx(conn)
|
||||||
ctx.projects.open(directory)
|
ctx.projects.open(directory)
|
||||||
ctx.projects.touch(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) {
|
function chooseProject(conn: ServerConnection.Any) {
|
||||||
|
|
@ -416,7 +406,7 @@ function HomeDesign() {
|
||||||
record={record}
|
record={record}
|
||||||
server={state.selection.server}
|
server={state.selection.server}
|
||||||
activeServer={state.selection.server === server.key}
|
activeServer={state.selection.server === server.key}
|
||||||
openSession={openSession}
|
onClick={() => openSession(record.session)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</For>
|
</For>
|
||||||
|
|
@ -1024,7 +1014,7 @@ function HomeSessionRow(props: {
|
||||||
record: HomeSessionRecord
|
record: HomeSessionRecord
|
||||||
server: ServerConnection.Key
|
server: ServerConnection.Key
|
||||||
activeServer: boolean
|
activeServer: boolean
|
||||||
openSession: (session: Session) => void
|
onClick: () => void
|
||||||
}) {
|
}) {
|
||||||
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id)
|
||||||
|
|
||||||
|
|
@ -1033,7 +1023,7 @@ function HomeSessionRow(props: {
|
||||||
type="button"
|
type="button"
|
||||||
data-component="home-session-row"
|
data-component="home-session-row"
|
||||||
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
class={`${HOME_ROW} h-10 gap-2 px-6 py-3 pl-4`}
|
||||||
onClick={() => props.openSession(props.record.session)}
|
onClick={props.onClick}
|
||||||
>
|
>
|
||||||
<HomeSessionLeading
|
<HomeSessionLeading
|
||||||
project={props.record.project}
|
project={props.record.project}
|
||||||
|
|
@ -1093,7 +1083,7 @@ function groupSessions(records: HomeSessionRecord[], language: ReturnType<typeof
|
||||||
].filter((group) => group.sessions.length > 0)
|
].filter((group) => group.sessions.length > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
function LegacyHome() {
|
export function LegacyHome() {
|
||||||
const sync = useServerSync()
|
const sync = useServerSync()
|
||||||
const platform = usePlatform()
|
const platform = usePlatform()
|
||||||
const pickDirectory = useDirectoryPicker()
|
const pickDirectory = useDirectoryPicker()
|
||||||
|
|
|
||||||
38
packages/app/src/pages/layout-new.tsx
Normal file
38
packages/app/src/pages/layout-new.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
|
<Titlebar update={update} />
|
||||||
|
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
||||||
|
{props.children}
|
||||||
|
</main>
|
||||||
|
{import.meta.env.DEV && <DebugBar />}
|
||||||
|
<HelpButton />
|
||||||
|
<ToastRegion v2 />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -13,7 +13,7 @@ import {
|
||||||
type Accessor,
|
type Accessor,
|
||||||
} from "solid-js"
|
} from "solid-js"
|
||||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
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 { useLayout, LocalProject } from "@/context/layout"
|
||||||
import { useServerSync } from "@/context/server-sync"
|
import { useServerSync } from "@/context/server-sync"
|
||||||
import { Persist, persisted } from "@/utils/persist"
|
import { Persist, persisted } from "@/utils/persist"
|
||||||
|
|
@ -92,7 +92,7 @@ import {
|
||||||
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
import { ProjectDragOverlay, SortableProject, type ProjectSidebarContext } from "./layout/sidebar-project"
|
||||||
import { SidebarContent } from "./layout/sidebar-shell"
|
import { SidebarContent } from "./layout/sidebar-shell"
|
||||||
|
|
||||||
export default function Layout(props: ParentProps) {
|
export default function LegacyLayout(props: ParentProps) {
|
||||||
const serverSDK = useServerSDK()
|
const serverSDK = useServerSDK()
|
||||||
const [store, setStore, , ready] = persisted(
|
const [store, setStore, , ready] = persisted(
|
||||||
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
|
Persist.serverGlobal(serverSDK().scope, "layout.page", ["layout.page.v1"]),
|
||||||
|
|
@ -131,10 +131,8 @@ export default function Layout(props: ParentProps) {
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const theme = useTheme()
|
const theme = useTheme()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const newDesign = createMemo(() => settings.general.newLayoutDesigns())
|
createEffect(() => setV2Toast(false))
|
||||||
createEffect(() => setV2Toast(newDesign()))
|
|
||||||
const initialDirectory = decode64(params.dir)
|
const initialDirectory = decode64(params.dir)
|
||||||
const location = useLocation()
|
|
||||||
const route = createMemo(() => {
|
const route = createMemo(() => {
|
||||||
const slug = params.dir
|
const slug = params.dir
|
||||||
if (!slug) return { slug, dir: "" }
|
if (!slug) return { slug, dir: "" }
|
||||||
|
|
@ -158,7 +156,7 @@ export default function Layout(props: ParentProps) {
|
||||||
const currentDir = createMemo(() => route().dir)
|
const currentDir = createMemo(() => route().dir)
|
||||||
|
|
||||||
const [state, setState] = createStore({
|
const [state, setState] = createStore({
|
||||||
autoselect: !initialDirectory && !newDesign(),
|
autoselect: !initialDirectory,
|
||||||
busyWorkspaces: {} as Record<string, boolean>,
|
busyWorkspaces: {} as Record<string, boolean>,
|
||||||
hoverProject: undefined as string | undefined,
|
hoverProject: undefined as string | undefined,
|
||||||
scrollSessionKey: undefined as string | undefined,
|
scrollSessionKey: undefined as string | undefined,
|
||||||
|
|
@ -996,7 +994,7 @@ export default function Layout(props: ParentProps) {
|
||||||
id: "sidebar.toggle",
|
id: "sidebar.toggle",
|
||||||
title: language.t("command.sidebar.toggle"),
|
title: language.t("command.sidebar.toggle"),
|
||||||
category: language.t("command.category.view"),
|
category: language.t("command.category.view"),
|
||||||
keybind: newDesign() ? undefined : "mod+b",
|
keybind: "mod+b",
|
||||||
onSelect: () => layout.sidebar.toggle(),
|
onSelect: () => layout.sidebar.toggle(),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -1134,20 +1132,19 @@ export default function Layout(props: ParentProps) {
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
if (!newDesign())
|
Array.from({ length: 9 }, (_, i) => {
|
||||||
Array.from({ length: 9 }, (_, i) => {
|
const index = i
|
||||||
const index = i
|
const number = index + 1
|
||||||
const number = index + 1
|
commands.push({
|
||||||
commands.push({
|
id: `project.${number}`,
|
||||||
id: `project.${number}`,
|
category: language.t("command.category.project"),
|
||||||
category: language.t("command.category.project"),
|
title: `Open Project {number}`,
|
||||||
title: `Open Project {number}`,
|
keybind: `mod+${number}`,
|
||||||
keybind: `mod+${number}`,
|
disabled: layout.projects.list().length <= index,
|
||||||
disabled: layout.projects.list().length <= index,
|
hidden: true,
|
||||||
hidden: true,
|
onSelect: () => navigateToProjectIndex(index),
|
||||||
onSelect: () => navigateToProjectIndex(index),
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
})
|
||||||
|
|
||||||
for (const [id] of availableThemeEntries()) {
|
for (const [id] of availableThemeEntries()) {
|
||||||
commands.push({
|
commands.push({
|
||||||
|
|
@ -1812,7 +1809,7 @@ export default function Layout(props: ParentProps) {
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
document.documentElement.style.setProperty(
|
document.documentElement.style.setProperty(
|
||||||
"--dialog-left-margin",
|
"--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 (
|
return (
|
||||||
<Show
|
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
||||||
when={!newDesign()}
|
{autoselecting() ?? ""}
|
||||||
fallback={
|
<Titlebar update={titlebarUpdate} />
|
||||||
<div class="relative bg-v2-background-bg-deep flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
<Show when={updateVersion() !== undefined}>
|
||||||
{autoselecting() ?? ""}
|
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
||||||
<Titlebar update={titlebarUpdate} />
|
</Show>
|
||||||
<main class="flex-1 min-h-0 min-w-0 overflow-x-hidden flex flex-col items-start contain-strict">
|
<div class="flex-1 min-h-0 min-w-0 flex">
|
||||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
<div class="flex-1 min-h-0 relative">
|
||||||
{props.children}
|
<div class="size-full relative overflow-x-hidden">
|
||||||
</Show>
|
<nav
|
||||||
</main>
|
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
data-component="sidebar-nav-desktop"
|
||||||
<HelpButton />
|
classList={{
|
||||||
<ToastRegion v2={newDesign()} />
|
"hidden xl:block": true,
|
||||||
</div>
|
"absolute inset-y-0 left-0": true,
|
||||||
}
|
"z-10": true,
|
||||||
>
|
}}
|
||||||
<div class="relative bg-background-base flex-1 min-h-0 min-w-0 flex flex-col select-none [&_input]:select-text [&_textarea]:select-text [&_[contenteditable]]:select-text">
|
style={{ width: `${side()}px` }}
|
||||||
{autoselecting() ?? ""}
|
ref={(el) => {
|
||||||
<Titlebar update={titlebarUpdate} />
|
setState("nav", el)
|
||||||
<Show when={updateVersion() !== undefined}>
|
}}
|
||||||
<UpdateAvailableToast version={updateVersion() ?? ""} install={installUpdate} language={language} />
|
onMouseEnter={() => {
|
||||||
</Show>
|
disarm()
|
||||||
<div class="flex-1 min-h-0 min-w-0 flex">
|
}}
|
||||||
<div class="flex-1 min-h-0 relative">
|
onMouseLeave={() => {
|
||||||
<div class="size-full relative overflow-x-hidden">
|
aim.reset()
|
||||||
<nav
|
if (!sidebarHovering()) return
|
||||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
|
||||||
data-component="sidebar-nav-desktop"
|
|
||||||
classList={{
|
|
||||||
"hidden xl:block": true,
|
|
||||||
"absolute inset-y-0 left-0": true,
|
|
||||||
"z-10": true,
|
|
||||||
}}
|
|
||||||
style={{ width: `${side()}px` }}
|
|
||||||
ref={(el) => {
|
|
||||||
setState("nav", el)
|
|
||||||
}}
|
|
||||||
onMouseEnter={() => {
|
|
||||||
disarm()
|
|
||||||
}}
|
|
||||||
onMouseLeave={() => {
|
|
||||||
aim.reset()
|
|
||||||
if (!sidebarHovering()) return
|
|
||||||
|
|
||||||
arm()
|
arm()
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
<div class="@container w-full h-full contain-strict">{sidebarContent()}</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<Show when={layout.sidebar.opened()}>
|
|
||||||
<div
|
|
||||||
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
|
||||||
style={{ left: `${side()}px` }}
|
|
||||||
onPointerDown={() => setState("sizing", true)}
|
|
||||||
>
|
|
||||||
<ResizeHandle
|
|
||||||
direction="horizontal"
|
|
||||||
size={layout.sidebar.width()}
|
|
||||||
min={244}
|
|
||||||
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
|
||||||
onResize={(w) => {
|
|
||||||
setState("sizing", true)
|
|
||||||
if (sizet !== undefined) clearTimeout(sizet)
|
|
||||||
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
|
||||||
layout.sidebar.resize(w)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Show>
|
|
||||||
|
|
||||||
|
<Show when={layout.sidebar.opened()}>
|
||||||
<div
|
<div
|
||||||
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
class="hidden xl:block absolute inset-y-0 z-30 w-0 overflow-visible"
|
||||||
style={{ left: "calc(4rem + 12px)" }}
|
style={{ left: `${side()}px` }}
|
||||||
/>
|
onPointerDown={() => setState("sizing", true)}
|
||||||
|
>
|
||||||
<div class="xl:hidden">
|
<ResizeHandle
|
||||||
<div
|
direction="horizontal"
|
||||||
classList={{
|
size={layout.sidebar.width()}
|
||||||
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
min={244}
|
||||||
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.3 + 64}
|
||||||
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
onResize={(w) => {
|
||||||
}}
|
setState("sizing", true)
|
||||||
onClick={(e) => {
|
if (sizet !== undefined) clearTimeout(sizet)
|
||||||
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
sizet = window.setTimeout(() => setState("sizing", false), 120)
|
||||||
|
layout.sidebar.resize(w)
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<nav
|
|
||||||
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
|
||||||
data-component="sidebar-nav-mobile"
|
|
||||||
classList={{
|
|
||||||
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
|
||||||
"translate-x-0": layout.mobileSidebar.opened(),
|
|
||||||
"-translate-x-full": !layout.mobileSidebar.opened(),
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{sidebarContent(true)}
|
|
||||||
</nav>
|
|
||||||
</div>
|
</div>
|
||||||
|
</Show>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="hidden xl:block pointer-events-none absolute top-0 right-0 z-0 border-t border-border-weaker-base"
|
||||||
|
style={{ left: "calc(4rem + 12px)" }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="xl:hidden">
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"absolute inset-0": true,
|
"fixed inset-x-0 top-10 bottom-0 z-40 transition-opacity duration-200": true,
|
||||||
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
"opacity-100 pointer-events-auto": layout.mobileSidebar.opened(),
|
||||||
"z-20": true,
|
"opacity-0 pointer-events-none": !layout.mobileSidebar.opened(),
|
||||||
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
|
||||||
!state.sizing,
|
|
||||||
}}
|
}}
|
||||||
style={{
|
onClick={(e) => {
|
||||||
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
if (e.target === e.currentTarget) layout.mobileSidebar.hide()
|
||||||
}}
|
}}
|
||||||
>
|
/>
|
||||||
<main
|
<nav
|
||||||
classList={{
|
aria-label={language.t("sidebar.nav.projectsAndSessions")}
|
||||||
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
data-component="sidebar-nav-mobile"
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
|
||||||
{props.children}
|
|
||||||
</Show>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
classList={{
|
classList={{
|
||||||
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
"@container fixed top-10 bottom-0 left-0 z-50 w-full max-w-[400px] overflow-hidden border-r border-border-weaker-base bg-background-base transition-transform duration-200 ease-out": true,
|
||||||
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
"translate-x-0": layout.mobileSidebar.opened(),
|
||||||
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
"-translate-x-full": !layout.mobileSidebar.opened(),
|
||||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
|
||||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
|
||||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
|
||||||
}}
|
}}
|
||||||
onMouseMove={disarm}
|
onClick={(e) => e.stopPropagation()}
|
||||||
onMouseEnter={() => {
|
>
|
||||||
disarm()
|
{sidebarContent(true)}
|
||||||
aim.reset()
|
</nav>
|
||||||
}}
|
</div>
|
||||||
onPointerDown={disarm}
|
|
||||||
onMouseLeave={() => {
|
<div
|
||||||
arm()
|
classList={{
|
||||||
|
"absolute inset-0": true,
|
||||||
|
"xl:inset-y-0 xl:right-0 xl:left-[var(--main-left)]": true,
|
||||||
|
"z-20": true,
|
||||||
|
"transition-[left] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[left] motion-reduce:transition-none":
|
||||||
|
!state.sizing,
|
||||||
|
}}
|
||||||
|
style={{
|
||||||
|
"--main-left": layout.sidebar.opened() ? `${side()}px` : "4rem",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<main
|
||||||
|
classList={{
|
||||||
|
"size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Show when={peekProject()}>
|
<Show when={!autoselecting.loading} fallback={<div class="size-full" />}>
|
||||||
<SidebarPanel project={peekProject} merged={false} />
|
{props.children}
|
||||||
</Show>
|
</Show>
|
||||||
</div>
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
classList={{
|
classList={{
|
||||||
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
"hidden xl:flex absolute inset-y-0 left-16 z-30": true,
|
||||||
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
"opacity-100 translate-x-0 pointer-events-auto": state.peeked && !layout.sidebar.opened(),
|
||||||
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
"opacity-0 -translate-x-2 pointer-events-none": !state.peeked || layout.sidebar.opened(),
|
||||||
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||||
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||||
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||||
}}
|
}}
|
||||||
style={{ left: `calc(4rem + ${panel()}px)` }}
|
onMouseMove={disarm}
|
||||||
>
|
onMouseEnter={() => {
|
||||||
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
disarm()
|
||||||
</div>
|
aim.reset()
|
||||||
|
}}
|
||||||
|
onPointerDown={disarm}
|
||||||
|
onMouseLeave={() => {
|
||||||
|
arm()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Show when={peekProject()}>
|
||||||
|
<SidebarPanel project={peekProject} merged={false} />
|
||||||
|
</Show>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
classList={{
|
||||||
|
"hidden xl:block pointer-events-none absolute inset-y-0 right-0 z-25 overflow-hidden": true,
|
||||||
|
"opacity-100 translate-x-0": state.peeked && !layout.sidebar.opened(),
|
||||||
|
"opacity-0 -translate-x-2": !state.peeked || layout.sidebar.opened(),
|
||||||
|
"transition-[opacity,transform] motion-reduce:transition-none": true,
|
||||||
|
"duration-180 ease-out": state.peeked && !layout.sidebar.opened(),
|
||||||
|
"duration-120 ease-in": !state.peeked || layout.sidebar.opened(),
|
||||||
|
}}
|
||||||
|
style={{ left: `calc(4rem + ${panel()}px)` }}
|
||||||
|
>
|
||||||
|
<div class="h-full w-px" style={{ "box-shadow": "var(--shadow-sidebar-overlay)" }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
|
||||||
</div>
|
</div>
|
||||||
<HelpButton />
|
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
|
||||||
<ToastRegion v2={newDesign()} />
|
|
||||||
</div>
|
</div>
|
||||||
</Show>
|
<HelpButton />
|
||||||
|
<ToastRegion v2={false} />
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
|
||||||
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge"
|
||||||
import { Button } from "@opencode-ai/ui/button"
|
import { Button } from "@opencode-ai/ui/button"
|
||||||
import { showToast } from "@/utils/toast"
|
import { showToast } from "@/utils/toast"
|
||||||
import { checksum } from "@opencode-ai/core/util/encode"
|
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
|
||||||
import { useLocation, useSearchParams } from "@solidjs/router"
|
import { useLocation, useSearchParams } from "@solidjs/router"
|
||||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||||
import { useComments } from "@/context/comments"
|
import { useComments } from "@/context/comments"
|
||||||
|
|
@ -56,7 +56,6 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline"
|
||||||
import { createTimelineModel } from "@/pages/session/timeline/model"
|
import { createTimelineModel } from "@/pages/session/timeline/model"
|
||||||
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
import { syncSessionModel } from "@/pages/session/session-model-helpers"
|
||||||
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
import { SessionSidePanel } from "@/pages/session/session-side-panel"
|
||||||
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
import { TerminalPanel } from "@/pages/session/terminal-panel"
|
||||||
|
|
@ -92,7 +91,6 @@ export default function Page() {
|
||||||
const prompt = usePrompt()
|
const prompt = usePrompt()
|
||||||
const comments = useComments()
|
const comments = useComments()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
const server = useServer()
|
|
||||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout()
|
||||||
|
|
@ -137,11 +135,11 @@ export default function Page() {
|
||||||
layout.handoff.clearTabs()
|
layout.handoff.clearTabs()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (pending.scope !== server.scope()) return
|
if (pending.scope !== serverSDK().scope) return
|
||||||
|
|
||||||
if (pending.id !== id) return
|
if (pending.id !== id) return
|
||||||
layout.handoff.clearTabs()
|
layout.handoff.clearTabs()
|
||||||
if (pending.dir !== (params.dir ?? "")) return
|
if (pending.dir !== base64Encode(sdk().directory)) return
|
||||||
|
|
||||||
const from = workspaceTabs().tabs()
|
const from = workspaceTabs().tabs()
|
||||||
if (from.all.length === 0 && !from.active) return
|
if (from.all.length === 0 && !from.active) return
|
||||||
|
|
@ -247,7 +245,7 @@ export default function Page() {
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => ({ dir: params.dir, id: params.id }),
|
() => ({ dir: sdk().directory, id: params.id }),
|
||||||
(next, prev) => {
|
(next, prev) => {
|
||||||
if (!prev) return
|
if (!prev) return
|
||||||
if (next.dir === prev.dir && next.id === prev.id) return
|
if (next.dir === prev.dir && next.id === prev.id) return
|
||||||
|
|
@ -575,7 +573,7 @@ export default function Page() {
|
||||||
|
|
||||||
createEffect(
|
createEffect(
|
||||||
on(
|
on(
|
||||||
() => params.dir,
|
() => sdk().directory,
|
||||||
(dir) => {
|
(dir) => {
|
||||||
if (!dir) return
|
if (!dir) return
|
||||||
setStore("newSessionWorktree", "main")
|
setStore("newSessionWorktree", "main")
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ import { useServer } from "@/context/server"
|
||||||
import { useTabs } from "@/context/tabs"
|
import { useTabs } from "@/context/tabs"
|
||||||
import { useDirectoryPicker } from "@/components/directory-picker"
|
import { useDirectoryPicker } from "@/components/directory-picker"
|
||||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
|
|
||||||
export function SessionComposerRegion(props: {
|
export function SessionComposerRegion(props: {
|
||||||
state: SessionComposerState
|
state: SessionComposerState
|
||||||
|
|
@ -200,7 +201,11 @@ export function SessionComposerRegion(props: {
|
||||||
const openParent = () => {
|
const openParent = () => {
|
||||||
const id = parentID()
|
const id = parentID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
navigate(`/${route.params.dir}/session/${id}`)
|
navigate(
|
||||||
|
route.params.serverKey
|
||||||
|
? sessionHref(requireServerKey(route.params.serverKey), id)
|
||||||
|
: legacySessionHref(sdk().directory, id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,19 @@
|
||||||
import { useParams } from "@solidjs/router"
|
import { useParams } from "@solidjs/router"
|
||||||
import { createMemo } from "solid-js"
|
import { createMemo } from "solid-js"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useServer } from "@/context/server"
|
|
||||||
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
|
||||||
export const useSessionKey = () => {
|
export const useSessionKey = () => {
|
||||||
const params = useParams()
|
const params = useParams()
|
||||||
const server = useServer()
|
const sdk = useSDK()
|
||||||
const scope = createMemo(() => server.scope())
|
const serverSDK = useServerSDK()
|
||||||
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir)))
|
const scope = createMemo(() => serverSDK().scope)
|
||||||
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(params.dir, params.id)))
|
const directory = createMemo(() => base64Encode(sdk().directory))
|
||||||
|
const workspaceKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory())))
|
||||||
|
const sessionKey = createMemo(() => SessionStateKey.from(scope(), SessionRouteKey.fromRoute(directory(), params.id)))
|
||||||
return { params, sessionKey, workspaceKey }
|
return { params, sessionKey, workspaceKey }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { useLanguage } from "@/context/language"
|
||||||
import { useLayout } from "@/context/layout"
|
import { useLayout } from "@/context/layout"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
import { useTerminal } from "@/context/terminal"
|
import { useTerminal } from "@/context/terminal"
|
||||||
|
import { useSDK } from "@/context/sdk"
|
||||||
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
import { terminalTabLabel } from "@/pages/session/terminal-label"
|
||||||
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
import { createSizing, focusTerminalById } from "@/pages/session/helpers"
|
||||||
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
|
||||||
|
|
@ -25,10 +26,11 @@ export function TerminalPanel() {
|
||||||
const delays = [120, 240]
|
const delays = [120, 240]
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
|
const sdk = useSDK()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const command = useCommand()
|
const command = useCommand()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const { params, workspaceKey, view } = useSessionLayout()
|
const { workspaceKey, view } = useSessionLayout()
|
||||||
|
|
||||||
const opened = createMemo(() => view().terminal.opened())
|
const opened = createMemo(() => view().terminal.opened())
|
||||||
const size = createSizing()
|
const size = createSizing()
|
||||||
|
|
@ -122,7 +124,7 @@ export function TerminalPanel() {
|
||||||
})
|
})
|
||||||
|
|
||||||
createEffect(() => {
|
createEffect(() => {
|
||||||
const dir = params.dir
|
const dir = sdk().directory
|
||||||
if (!dir) return
|
if (!dir) return
|
||||||
if (!terminal.ready()) return
|
if (!terminal.ready()) return
|
||||||
language.locale()
|
language.locale()
|
||||||
|
|
@ -140,7 +142,7 @@ export function TerminalPanel() {
|
||||||
})
|
})
|
||||||
|
|
||||||
const handoff = createMemo(() => {
|
const handoff = createMemo(() => {
|
||||||
const dir = params.dir
|
const dir = sdk().directory
|
||||||
if (!dir) return []
|
if (!dir) return []
|
||||||
return getTerminalHandoff(workspaceKey()) ?? []
|
return getTerminalHandoff(workspaceKey()) ?? []
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,8 @@ import { useSessionKey } from "@/pages/session/session-layout"
|
||||||
import { useServerSDK } from "@/context/server-sdk"
|
import { useServerSDK } from "@/context/server-sdk"
|
||||||
import { usePlatform } from "@/context/platform"
|
import { usePlatform } from "@/context/platform"
|
||||||
import { useSettings } from "@/context/settings"
|
import { useSettings } from "@/context/settings"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||||
import { useSDK } from "@/context/sdk"
|
import { useSDK } from "@/context/sdk"
|
||||||
import { useSync } from "@/context/sync"
|
import { useSync } from "@/context/sync"
|
||||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||||
|
|
@ -260,6 +262,7 @@ export function MessageTimeline(props: {
|
||||||
const sdk = useSDK()
|
const sdk = useSDK()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
|
const tabs = useTabs()
|
||||||
const dialog = useDialog()
|
const dialog = useDialog()
|
||||||
const language = useLanguage()
|
const language = useLanguage()
|
||||||
const { params, sessionKey } = useSessionKey()
|
const { params, sessionKey } = useSessionKey()
|
||||||
|
|
@ -757,12 +760,18 @@ export function MessageTimeline(props: {
|
||||||
|
|
||||||
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
const navigateAfterSessionRemoval = (sessionID: string, parentID?: string, nextSessionID?: string) => {
|
||||||
if (params.id !== sessionID) return
|
if (params.id !== sessionID) return
|
||||||
|
const href = (id: string) =>
|
||||||
|
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id)
|
||||||
if (parentID) {
|
if (parentID) {
|
||||||
navigate(`/${params.dir}/session/${parentID}`)
|
navigate(href(parentID))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (nextSessionID) {
|
if (nextSessionID) {
|
||||||
navigate(`/${params.dir}/session/${nextSessionID}`)
|
navigate(href(nextSessionID))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (params.serverKey) {
|
||||||
|
tabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
navigate(`/${params.dir}/session`)
|
navigate(`/${params.dir}/session`)
|
||||||
|
|
@ -864,7 +873,9 @@ export function MessageTimeline(props: {
|
||||||
const navigateParent = () => {
|
const navigateParent = () => {
|
||||||
const id = parentID()
|
const id = parentID()
|
||||||
if (!id) return
|
if (!id) return
|
||||||
navigate(`/${params.dir}/session/${id}`)
|
navigate(
|
||||||
|
params.serverKey ? sessionHref(requireServerKey(params.serverKey), id) : legacySessionHref(sdk().directory, id),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DialogDeleteSession(props: { sessionID: string }) {
|
function DialogDeleteSession(props: { sessionID: string }) {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ import { createSessionTabs } from "@/pages/session/helpers"
|
||||||
import { extractPromptFromParts } from "@/utils/prompt"
|
import { extractPromptFromParts } from "@/utils/prompt"
|
||||||
import { UserMessage } from "@opencode-ai/sdk/v2"
|
import { UserMessage } from "@opencode-ai/sdk/v2"
|
||||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||||
|
import { useTabs } from "@/context/tabs"
|
||||||
|
import { requireServerKey } from "@/utils/session-route"
|
||||||
|
|
||||||
export type SessionCommandContext = {
|
export type SessionCommandContext = {
|
||||||
navigateMessageByOffset: (offset: number) => void
|
navigateMessageByOffset: (offset: number) => void
|
||||||
|
|
@ -45,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
const settings = useSettings()
|
const settings = useSettings()
|
||||||
const sync = useSync()
|
const sync = useSync()
|
||||||
const terminal = useTerminal()
|
const terminal = useTerminal()
|
||||||
|
const sessionTabs = useTabs()
|
||||||
const layout = useLayout()
|
const layout = useLayout()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
const { params, tabs, view } = useSessionLayout()
|
const { params, tabs, view } = useSessionLayout()
|
||||||
|
|
@ -381,7 +384,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||||
title: language.t("command.session.new"),
|
title: language.t("command.session.new"),
|
||||||
keybind: "mod+shift+s",
|
keybind: "mod+shift+s",
|
||||||
slash: "new",
|
slash: "new",
|
||||||
onSelect: () => navigate(`/${params.dir}/session`),
|
onSelect: () => {
|
||||||
|
if (params.serverKey) {
|
||||||
|
sessionTabs.newDraft({ server: requireServerKey(params.serverKey), directory: sdk().directory })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
navigate(`/${params.dir}/session`)
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
sessionCommand({
|
sessionCommand({
|
||||||
id: "session.undo",
|
id: "session.undo",
|
||||||
|
|
|
||||||
39
packages/app/src/utils/session-route.test.ts
Normal file
39
packages/app/src/utils/session-route.test.ts
Normal file
|
|
@ -0,0 +1,39 @@
|
||||||
|
import { describe, expect, test } from "bun:test"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { legacySessionHref, requireServerKey, rootSession, sessionHref } from "./session-route"
|
||||||
|
|
||||||
|
describe("session routes", () => {
|
||||||
|
test("builds and decodes a server-keyed session route", () => {
|
||||||
|
const server = ServerConnection.Key.make("https://example.com:4096")
|
||||||
|
const href = sessionHref(server, "session-1")
|
||||||
|
|
||||||
|
expect(href).toBe("/server/aHR0cHM6Ly9leGFtcGxlLmNvbTo0MDk2/session/session-1")
|
||||||
|
expect(requireServerKey(href.split("/")[2])).toBe(server)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("rejects malformed server keys", () => {
|
||||||
|
expect(() => requireServerKey("not-base64")).toThrow("Invalid server route")
|
||||||
|
})
|
||||||
|
|
||||||
|
test("builds the legacy directory-keyed route", () => {
|
||||||
|
expect(legacySessionHref("/Users/example/project", "session-1")).toBe(
|
||||||
|
"/L1VzZXJzL2V4YW1wbGUvcHJvamVjdA/session/session-1",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("resolves the root session", async () => {
|
||||||
|
const sessions: Record<string, { id: string; parentID?: string }> = {
|
||||||
|
child: { id: "child", parentID: "parent" },
|
||||||
|
parent: { id: "parent", parentID: "root" },
|
||||||
|
root: { id: "root" },
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await rootSession(sessions.child, async (id) => {
|
||||||
|
const session = sessions[id]
|
||||||
|
if (!session) throw new Error(`Missing session: ${id}`)
|
||||||
|
return session
|
||||||
|
}),
|
||||||
|
).toBe(sessions.root)
|
||||||
|
})
|
||||||
|
})
|
||||||
25
packages/app/src/utils/session-route.ts
Normal file
25
packages/app/src/utils/session-route.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||||
|
import { ServerConnection } from "@/context/server"
|
||||||
|
import { decode64 } from "@/utils/base64"
|
||||||
|
|
||||||
|
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
|
||||||
|
return `/server/${base64Encode(server)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function legacySessionHref(directory: string, sessionID: string) {
|
||||||
|
return `/${base64Encode(directory)}/session/${sessionID}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function requireServerKey(segment: string | undefined) {
|
||||||
|
const key = decode64(segment)
|
||||||
|
if (!key || base64Encode(key) !== segment) throw new Error("Invalid server route")
|
||||||
|
return ServerConnection.Key.make(key)
|
||||||
|
}
|
||||||
|
|
||||||
|
type SessionParent = { id: string; parentID?: string }
|
||||||
|
|
||||||
|
export async function rootSession(session: SessionParent, get: (sessionID: string) => Promise<SessionParent>) {
|
||||||
|
let current = session
|
||||||
|
while (current.parentID) current = await get(current.parentID)
|
||||||
|
return current
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue