Compare commits

..

38 Commits

Author SHA1 Message Date
Brendan Allan 10c8268c8b giant refactor 2026-08-12 23:31:22 +08:00
Kit Langton dc82a446b9 fix(core): guard Deferred waiter cleanup in vendored effect patch (#41858) 2026-08-11 20:16:21 -04:00
Luke Parker c4bea9d558 fix(app): stabilize markdown worker startup (#41487) 2026-08-12 10:05:32 +10:00
Luke Parker 1190ef3818 feat(desktop): support local server builds (#41486) 2026-08-12 09:56:47 +10:00
opencode-agent[bot] 055a380e66 fix(core): parameterize v1 migration messages (#41877)
Co-authored-by: Dax <826656+thdxr@users.noreply.github.com>
2026-08-11 19:51:37 -04:00
Dax Raad 456c26a012 feat(tui): show localhost on pair screen 2026-08-11 19:49:33 -04:00
opencode-agent[bot] 6072fc5c1b chore: generate 2026-08-11 22:44:47 +00:00
Brendan Allan 20df2fdba6 feat(cli): embed web ui (#41525)
Co-authored-by: Dax Raad <d@ironbay.co>
2026-08-11 15:43:07 -07:00
opencode-agent[bot] f1399dcb65 fix(desktop): orchestrate update restarts (#41865)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
2026-08-12 08:38:18 +10:00
Dax Raad 3507bd10b6 fix(www): match OG images to docs theme 2026-08-11 18:28:51 -04:00
Dax Raad 492ae1fdfa feat(www): match legacy docs theme 2026-08-11 18:20:58 -04:00
Kit Langton b00d8d65fe feat(tui): hierarchical slot tree for plugin UI placement (#41189) 2026-08-11 22:00:04 +00:00
Kit Langton f1366d80c8 fix(core): default #sqlite resolution to the node driver (#41834) 2026-08-11 17:50:09 -04:00
opencode-agent[bot] 549a8f826b fix(core): log Plan reminder failures (#41856)
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
2026-08-11 16:39:26 -05:00
Kit Langton 59970699d0 test(core): narrow plugin test layer (#41852) 2026-08-11 17:23:47 -04:00
Aiden Cline b35c5fc985 fix(core): detect copilot PDF input support (#41854) 2026-08-11 16:05:36 -05:00
Kit Langton 627e67323c docs: remove ideal-pseudocode project skill (#41851) 2026-08-11 17:03:05 -04:00
opencode-agent[bot] b97d964e5c chore: generate 2026-08-11 20:53:50 +00:00
Aiden Cline 3fd3e28690 fix(core): classify copilot utility requests (#41840) 2026-08-11 15:51:55 -05:00
Kit Langton b4abeb4788 test(core): narrow effect flock harness (#41837) 2026-08-11 16:51:42 -04:00
Kit Langton 14deb6baf7 docs: clarify skill source precedence (#41844) 2026-08-11 20:46:14 +00:00
Aiden Cline ab6e18e4ff fix(core): route Vertex Anthropic natively (#41836) 2026-08-11 15:29:02 -05:00
Kit Langton 2b6dd2559c fix(core): prefer nearest skill source (#41788) 2026-08-11 16:13:32 -04:00
Kit Langton 2b7bd84867 test(core): speed up subagent tool suite (#41835) 2026-08-11 16:13:04 -04:00
Kit Langton baa54a5150 test(core): speed up shell tool suite (#41831) 2026-08-11 16:03:50 -04:00
Kit Langton c5704d3e3b feat(core): sqlite driver for durable object storage (#41659) 2026-08-11 15:45:23 -04:00
Kit Langton e3336725db fix(core): normalize provider endpoint templates (#41816) 2026-08-11 15:42:54 -04:00
opencode-agent[bot] ff3ecefd24 fix(cli): sign macOS preview binaries (#40662)
Co-authored-by: Ryan Vogel <89211796+R44VC0RP@users.noreply.github.com>
2026-08-11 15:37:44 -04:00
Kit Langton 8486bbcdc1 refactor(core): unify fff runtime adapters (#41827) 2026-08-11 15:22:03 -04:00
Kit Langton 2621dde1c9 refactor(core): unify config collection decoding (#41826) 2026-08-11 15:21:52 -04:00
Kit Langton a253c0437e test(core): await shell tool registration (#41825) 2026-08-11 15:21:35 -04:00
opencode-agent[bot] d8b3e528e8 fix(core): use standard web search key dialogs (#41821)
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
2026-08-11 14:13:58 -05:00
Aiden Cline a5d6969f6f feat(core): extract plan agent into a plugin (#41665) 2026-08-11 13:49:24 -05:00
opencode-agent[bot] 3954d7979d chore: generate 2026-08-11 18:11:13 +00:00
Kit Langton a69a372f27 fix(util): defer module-scope randomness and import.meta path derivation (#41765) 2026-08-11 14:09:18 -04:00
Kit Langton 7300e7e10f core: write-ahead execution claims replace shutdown-hook suspension (#41800) 2026-08-11 14:09:04 -04:00
James Long eeb870b0cc feat(sdk): configure embedded logging (#41809) 2026-08-11 14:03:55 -04:00
Kit Langton 136bad9f17 feat(core): discover AGENTS.md up to the home directory (#41652) 2026-08-11 13:57:10 -04:00
230 changed files with 4841 additions and 4137 deletions
+56 -1
View File
@@ -124,12 +124,66 @@ jobs:
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
name: opencode-preview-cli-unsigned
path: packages/cli/dist/cli-*
outputs:
version: ${{ needs.version.outputs.version }}
sign-cli-macos:
needs: build-cli
runs-on: macos-26
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: apple-actions/import-codesign-certs@8f3fb608891dd2244cdab3d69cd68c0d37a7fe93 # v2.0.0
with:
keychain: build
p12-file-base64: ${{ secrets.APPLE_CERTIFICATE }}
p12-password: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }}
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-preview-cli-unsigned
path: packages/cli/dist
- name: Sign macOS CLI binaries
run: |
identity=$(security find-identity -v -p codesigning build.keychain | sed -n 's/.*"\(Developer ID Application:.*\)"/\1/p' | head -n 1)
if [ -z "$identity" ]; then
echo "Developer ID Application identity not found"
exit 1
fi
found=0
for file in packages/cli/dist/cli-darwin-*/bin/opencode2; do
if [ ! -f "$file" ]; then
continue
fi
found=1
codesign \
--force \
--timestamp \
--options runtime \
--entitlements packages/cli/script/entitlements.plist \
--sign "$identity" \
"$file"
codesign --verify --deep --strict --verbose=4 "$file"
codesign --display --requirements - "$file"
done
if [ "$found" -eq 0 ]; then
echo "No macOS CLI binaries found"
exit 1
fi
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-preview-cli
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
needs: version
if: github.repository == 'anomalyco/opencode' && github.ref_name != 'beta'
@@ -468,6 +522,7 @@ jobs:
needs:
- version
- build-cli
- sign-cli-macos
- build-node-cli
- sign-cli-windows
- build-electron
@@ -1,68 +0,0 @@
---
name: ideal-pseudocode
description: Function-by-function refactoring loop driven by ideal pseudocode. Use when the user says "ideal pseudocode", asks to make a function read like its pseudocode, or wants a dense module cleaned up one function at a time.
---
# Ideal Pseudocode
Clean up one function at a time by writing the pseudocode it _should_ read as, naming every delta between that and the real code, and closing only the gaps the user approves.
## Loop
One function per round. Never touch code before the user picks a direction.
1. **Pick the target** with the user — usually the next function up or down the call chain from the last round.
2. **Read the current code** fresh from disk. It may have unsaved or parallel edits; ask before overwriting anything unexpected.
3. **Distill.** Write the function's ideal pseudocode in a `ts`-fenced code block — TypeScript-flavored for syntax highlighting, but pseudocode: comments over mechanics, one line per idea, every arm of a loop visible as an arm. For a dense or unfamiliar function, first show the _current_ structure as pseudocode, then the ideal.
4. **Name the deltas.** A numbered list; each delta is one concrete gap: control flow smuggled through combinators, a flag-typed parameter, colliding names, side-effect plumbing drowning a loop body, twin functions differing only by a filter. For each, give an honest cost/benefit and a decisive recommendation — including "leave it" when the density has reasons (state the reasons).
5. **Verify before proposing.** Any API the proposal builds on (a combinator, schedule, matcher) gets checked against the installed version's source, not memory.
6. **Get buy-in, then apply.** Minimal edits. Quarantine irreducible ugliness inside a small helper whose name is the pseudocode line it implements. Collapse shallow sibling functions into one deep function only when the distinguishing argument is a domain noun, not a behavior switch.
7. **Validate.** Typecheck and the focused test suites from the package directory; rerun timing-sensitive tests repeatedly. Flag any semantic change the restructure introduced, even when tests pass.
8. **Offer the next target** and stop.
## Style
- Keep each round tight: pseudocode block, deltas, recommendation, one question.
- State mutation belongs in one visible place (usually the loop tail), never spread across error channels or callback closures.
- Prefer early return and `if` chains for control flow; matchers are for producing values.
- Compare names against the question each caller is actually asking; rename toward the domain noun.
## Example round
Every round should read like this (abridged from a real one, on an Effect step-runner):
> `runStep` it is. The ideal pseudocode:
>
> ```ts
> // One logical step: one settled model call, however many attempts it takes.
> // An attempt can end without settling in two ways:
> // transient provider failure -> retry the same call, same assistant message
> // compaction rewrote history -> rebuild the request and call again
> function runStep(session, promotable, step) {
> while (true) {
> const result = callModel(session, promotable, step)
>
> if (result.completed) return result
>
> if (result.retryable) {
> wait(retrySchedule) // exhausted -> publish Step.Failed, fail drain
> promotable = none // never re-promote on a second attempt
> continue
> }
>
> // compaction restarted the step: fresh request from rewritten history
> promotable = none
> step = result.step
> }
> }
> ```
>
> Comparing against the real thing, three deltas:
>
> 1. **The retry arm doesn't live in the loop.** In the pseudocode, retry is just another loop arm. In the real code it's smuggled through the error channel: `Effect.tapError` mutating loop variables via `Effect.sync` closures, then `Effect.retryOrElse` re-running a suspended call. Four mutable variables mutated from three different places — the state flow is invisible unless you already know the trick.
> 2. **`recoverOverflow: typeof compaction.compact | undefined`** — a function-typed parameter used as a one-shot armed/disarmed flag. The callee already closes over `compaction`; a boolean says what it is.
> 3. **`assistantMessageID`** exists solely to resume the same durable message across retries — fine, but only legible once the retry arm is visible in the loop.
>
> The unifying move for (1): a schedule driver, so a retryable failure becomes a caught value handled in the loop body exactly like the compaction arm. One honest trade: it's more manual than the blessed combinator — but the manual version is the loop the combinator was hiding, and the loop already exists for restarts. Two restart mechanisms, one control structure.
>
> Want me to apply it — unified loop, simplified schedule input, boolean `recoverOverflow`?
+66 -251
View File
@@ -5,44 +5,33 @@ import { DialogProvider } from "@opencode-ai/ui/context/dialog"
import { FileComponentProvider } from "@opencode-ai/ui/context/file"
import { File } from "@opencode-ai/session-ui/file"
import { Font } from "@opencode-ai/ui/font"
import { Splash } from "@opencode-ai/ui/logo"
import { ThemeProvider } from "@opencode-ai/ui/theme/context"
import { MetaProvider } from "@solidjs/meta"
import { type BaseRouterProps, Navigate, Route, Router, useParams, useSearchParams } from "@solidjs/router"
import { QueryClient, QueryClientProvider } from "@tanstack/solid-query"
import { Effect } from "effect"
import {
type Component,
createEffect,
createMemo,
createRenderEffect,
createResource,
createSignal,
ErrorBoundary,
For,
type JSX,
lazy,
onCleanup,
type ParentProps,
Show,
} from "solid-js"
import { Dynamic } from "solid-js/web"
import { makeEventListener } from "@solid-primitives/event-listener"
import { CommandProvider, useCommand, type CommandOption } from "@/context/command"
import { CommentsProvider } from "@/context/comments"
import { FileProvider } from "@/context/file"
import { ServerSDKProvider } from "@/context/server-sdk"
import { ServerSyncProvider } from "@/context/server-sync"
import { GlobalProvider, useGlobal } from "@/context/global"
import { HighlightsProvider } from "@/context/highlights"
import { LanguageProvider, type Locale, useLanguage } from "@/context/language"
import { LayoutProvider } from "@/context/layout"
import { ModelsProvider } from "@/context/models"
import { NotificationProvider } from "@/context/notification"
import { PermissionProvider } from "@/context/permission"
import { usePlatform } from "@/context/platform"
import { PromptProvider } from "@/context/prompt"
import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server"
import { ServerConnection, ServersProvider, useServers } from "@/context/servers"
import { SettingsProvider } from "@/context/settings"
import { TabsProvider, useTabs, type DraftTab } from "@/context/tabs"
import { SDKProvider } from "@/context/sdk"
@@ -50,26 +39,26 @@ import { WslServersProvider } from "@/wsl/context"
import { DirectoryDataProvider } from "@/pages/directory-layout"
import Layout from "@/pages/layout"
import { ErrorPage } from "./pages/error"
import { useCheckServerHealth } from "./utils/server-health"
import { legacySessionServer, requireServerKey, sessionHref } from "./utils/session-route"
import { decode64 } from "@/utils/base64"
import { TargetSessionRouteContent } from "@/pages/session"
import { Home } from "@/pages/home"
import { ServerProvider } from "./context/server"
const NewSession = lazy(() => import("@/pages/new-session"))
const DirectoryDraftRedirect = () => {
const params = useParams()
const [search] = useSearchParams<{ draftId?: string; prompt?: string }>()
const server = useServer()
const server = useServers()
const tabs = useTabs()
createEffect(() => {
if (search.draftId || !tabs.ready()) return
const directory = decode64(params.dir)
if (!directory) return
tabs.newDraft({ server: server.key, directory }, search.prompt)
tabs.newDraft({ server: ServerConnection.key(server.list[0])!, directory }, search.prompt)
})
return null
@@ -86,31 +75,13 @@ function TargetServerRoute(props: ParentProps) {
return (
// Owns the server-identity remount. Session changes must not remount this subtree.
<Show when={requireServerKey(params.serverKey)} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
<Show when={conn()} keyed>
{(conn) => <ServerProvider conn={conn}>{props.children}</ServerProvider>}
</Show>
</Show>
)
}
const TargetSessionRoute = () => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)
// Wraps the non-draft routes. They are gated on (and keyed to) the globally selected
// server via ServerKey, then provide the server-scoped shell for that server.
function SelectedServerProviders(props: ParentProps) {
return (
<ServerKey>
<ServerSDKProvider>
<ServerSyncProvider>{props.children}</ServerSyncProvider>
</ServerSDKProvider>
</ServerKey>
)
}
function DraftRoute() {
const [search] = useSearchParams<{ draftId?: string }>()
const tabs = useTabs()
@@ -135,19 +106,21 @@ function ResolvedDraftRoute(props: { draft: DraftTab }) {
return (
<Show when={`${props.draft.server}\0${props.draft.directory}`} keyed>
<ServerSDKProvider server={conn}>
<ServerSyncProvider server={conn}>
<ModelsProvider directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</ModelsProvider>
</ServerSyncProvider>
</ServerSDKProvider>
<Show when={conn()} keyed>
{(conn) => (
<ServerProvider conn={conn}>
<ModelsProvider directory={directory}>
<SDKProvider directory={directory}>
<DirectoryDataProvider directory={directory} server={serverKey}>
<DraftProviders>
<NewSession draftId={props.draft.draftID} />
</DraftProviders>
</DirectoryDataProvider>
</SDKProvider>
</ModelsProvider>
</ServerProvider>
)}
</Show>
</Show>
)
}
@@ -180,8 +153,8 @@ function QueryProvider(props: ParentProps) {
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
},
},
})
@@ -202,15 +175,7 @@ function BodyDesignClass() {
// Server-agnostic providers shared across every route. These live in the shared
// shell (router root) so they stay mounted regardless of the active server/route.
function SharedProviders(props: ParentProps) {
return (
<>
<BodyDesignClass />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>{props.children}</HighlightsProvider>
</CommandProvider>
</>
)
return <></>
}
function DesktopCommands() {
@@ -236,30 +201,14 @@ function DesktopCommands() {
return null
}
type ServerScopedShellProps = ParentProps<{
directory?: () => string | undefined
serverScoped?: JSX.Element
}>
function ServerScopedProviders(props: ServerScopedShellProps) {
function AppLayout(props: ParentProps) {
return (
<LayoutProvider>
{props.serverScoped}
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
<Layout>{props.children}</Layout>
</LayoutProvider>
)
}
function AppLayout(props: ParentProps<{ serverScoped?: JSX.Element }>) {
return (
<SelectedServerProviders>
<ServerScopedProviders serverScoped={props.serverScoped}>
<Layout>{props.children}</Layout>
</ServerScopedProviders>
</SelectedServerProviders>
)
}
// The draft page only renders the prompt composer, so it drops TerminalProvider.
// FileProvider and CommentsProvider stay because PromptInput uses file search and comment context.
function DraftProviders(props: ParentProps) {
@@ -309,133 +258,6 @@ export function AppBaseProviders(
)
}
function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean; startup?: Promise<void> }>) {
const server = useServer()
const checkServerHealth = useCheckServerHealth()
const [checkMode, setCheckMode] = createSignal<"blocking" | "background">("blocking")
// performs repeated health check with a grace period for
// non-http connections, otherwise fails instantly
const [startupHealthCheck, healthCheckActions] = createResource(() =>
props.disableHealthCheck
? true
: Effect.gen(function* () {
if (!server.current) return true
const { http, type } = server.current
while (true) {
const res = yield* Effect.promise(() => checkServerHealth(http))
if (res.healthy) return true
if (checkMode() === "background" || type === "http") return false
}
}).pipe(
Effect.timeoutOrElse({ duration: "10 seconds", orElse: () => Effect.succeed(false) }),
Effect.ensuring(Effect.sync(() => setCheckMode("background"))),
Effect.runPromise,
),
)
const checking = createMemo(
() => checkMode() === "blocking" && ["unresolved", "pending"].includes(startupHealthCheck.state),
)
const [startup] = createResource(async () => {
if (!props.startup) return true
await props.startup.catch((error) => {
console.error("[startup] startup gate failed", error)
})
return true
})
const startupChecking = createMemo(
() => startupHealthCheck.latest === true && ["unresolved", "pending"].includes(startup.state),
)
const loading = createMemo(() => checking() || startupChecking())
return (
<>
<Show when={!checking()}>
<Show
when={startupHealthCheck.latest}
fallback={
<ConnectionError
onRetry={() => {
if (checkMode() === "background") void healthCheckActions.refetch()
}}
onServerSelected={(key) => {
setCheckMode("blocking")
server.setActive(key)
void healthCheckActions.refetch()
}}
/>
}
>
{props.children}
</Show>
</Show>
<Show when={loading()}>
<div class="fixed inset-0 z-[9999] flex flex-col items-center justify-center bg-background-base">
<Splash class="w-16 h-20 opacity-50 animate-pulse" />
</div>
</Show>
</>
)
}
function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) {
const language = useLanguage()
const server = useServer()
const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key)
const name = createMemo(() => server.name || server.key)
const serverToken = "\u0000server\u0000"
const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken))
const timer = setInterval(() => props.onRetry?.(), 1000)
onCleanup(() => clearInterval(timer))
return (
<div class="h-dvh w-screen flex flex-col items-center justify-center bg-background-base gap-6 p-6">
<div class="flex flex-col items-center max-w-md text-center">
<Splash class="w-12 h-15 mb-4" />
<p class="text-14-regular text-text-base">
{unreachable()[0]}
<span class="text-text-strong font-medium">{name()}</span>
{unreachable()[1]}
</p>
<p class="mt-1 text-12-regular text-text-weak">{language.t("app.server.retrying")}</p>
</div>
<Show when={others().length > 0}>
<div class="flex flex-col gap-2 w-full max-w-sm">
<span class="text-12-regular text-text-base text-center">{language.t("app.server.otherServers")}</span>
<div class="flex flex-col gap-1 bg-surface-base rounded-lg p-2">
<For each={others()}>
{(conn) => {
const key = ServerConnection.key(conn)
return (
<button
type="button"
class="flex items-center gap-3 w-full px-3 py-2 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left"
onClick={() => props.onServerSelected?.(key)}
>
<span class="text-14-regular text-text-strong truncate">{serverName(conn)}</span>
</button>
)
}}
</For>
</div>
</div>
</Show>
</div>
)
}
function ServerKey(props: ParentProps) {
const server = useServer()
return (
<Show when={server.key} keyed>
{props.children}
</Show>
)
}
export function AppInterface(props: {
children?: JSX.Element
defaultServer: ServerConnection.Key
@@ -444,67 +266,60 @@ export function AppInterface(props: {
router?: Component<BaseRouterProps>
disableHealthCheck?: boolean
startup?: Promise<void>
serverScoped?: JSX.Element
}) {
// The visual layout lives in the router root so it remains mounted across
// route changes. Draft and session routes override only their server-bound data
// providers beneath it.
const ServerShell = (shellProps: ParentProps) => (
<QueryProvider>
<SharedProviders>
{props.children}
{shellProps.children}
</SharedProviders>
</QueryProvider>
const Root = (rootProps: ParentProps) => (
<SettingsProvider>
<GlobalProvider>
<TabsProvider>
<BodyDesignClass />
<CommandProvider>
<DesktopCommands />
<HighlightsProvider>
{props.children}
{rootProps.children}
</HighlightsProvider>
</CommandProvider>
</TabsProvider>
</GlobalProvider>
</SettingsProvider>
)
return (
<ServerProvider
<ServersProvider
defaultServer={props.defaultServer}
canonicalLocalServer={props.canonicalLocalServer}
servers={props.servers}
>
<GlobalProvider>
<SettingsProvider>
<ConnectionGate disableHealthCheck={props.disableHealthCheck} startup={props.startup}>
<Dynamic
component={props.router ?? Router}
root={(routerProps) => (
<TabsProvider>
<PermissionProvider>
<NotificationProvider>
<ServerShell>
<AppLayout serverScoped={props.serverScoped}>{routerProps.children}</AppLayout>
</ServerShell>
</NotificationProvider>
</PermissionProvider>
</TabsProvider>
)}
>
<Routes />
</Dynamic>
</ConnectionGate>
</SettingsProvider>
</GlobalProvider>
</ServerProvider>
)
}
function Routes() {
return (
<>
<Route path="/" component={Home} />
<Route path="/:dir" component={DirectoryDraftRedirect} />
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
<Route path="/server/:serverKey/session/:id" component={TargetSessionRoute} />
<Route path="/new-session" component={DraftRoute} />
</>
<Dynamic component={props.router ?? Router} root={Root}>
{/* Proper Routes */}
<Route component={AppLayout}>
<Route path="/" component={Home} />
<Route
path="/server/:serverKey/session/:id"
component={() => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
)}
/>
<Route path="/new-session" component={DraftRoute} />
</Route>
{/* Legacy Routes */}
<Route>
<Route path="/:dir" component={DirectoryDraftRedirect} />
<Route path="/:dir/session" component={DirectoryDraftRedirect} />
<Route path="/:dir/session/:id" component={LegacySessionRedirect} />
</Route>
</Dynamic>
</ServersProvider>
)
}
function LegacySessionRedirect() {
const server = useServer()
const server = useServers()
const tabs = useTabs()
const params = useParams<{ id: string }>()
@@ -515,7 +330,7 @@ function LegacySessionRedirect() {
legacySessionServer(
tabs.store.filter((item) => item.type === "session"),
params.id,
server.key,
ServerConnection.key(server.list[0]!),
),
params.id,
)}
@@ -8,12 +8,13 @@ import { useFile } from "@/context/file"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useLayout, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useTabs } from "@/context/tabs"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { createSessionTabs } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { useServer } from "@/context/server"
export type CommandPaletteEntry = {
id: string
@@ -83,7 +84,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on
const language = useLanguage()
const file = useFile()
const dialog = useDialog()
const serverSDK = useServerSDK()()
const serverSDK = useServerSDK()
const serverCtx = global.ensureServerCtx(serverSDK.server)
const appTabs = useTabs()
const { tabs: sessionTabs } = useSessionLayout()
@@ -10,7 +10,7 @@ import { createEffect, createMemo, createResource, createSignal, For, Match, onC
import { commandPaletteOptions, formatKeybindParts, useCommand } from "@/context/command"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { useTabs } from "@/context/tabs"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import { getRelativeTime } from "@/utils/time"
@@ -378,7 +378,7 @@ function ProviderConnection(props: {
const directory = () => props.directory?.() ?? decode64(params.dir)
const provider = createMemo(
() => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!,
() => providers.all().get(props.provider) ?? serverSync.data.provider.all.get(props.provider)!,
)
const controller = createProviderConnectionController({
provider: () => props.provider,
@@ -116,8 +116,8 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
const output = validateCustomProvider({
form,
t: language.t,
disabledProviders: serverSync().data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync().data.provider.all.keys()),
disabledProviders: serverSync.data.config.disabled_providers ?? [],
existingProviderIDs: new Set(serverSync.data.provider.all.keys()),
})
batch(() => {
setForm("err", output.err)
@@ -9,7 +9,7 @@ import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2"
import { For, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { getProjectAvatarVariant, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { getProjectAvatarSource } from "@/pages/layout/helpers"
import { createEditProjectModel } from "./edit-project"
@@ -7,7 +7,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createEffect, createMemo, createResource, createSignal, For, onCleanup, onMount, Show } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import type { Path } from "@/types"
import {
absoluteTreePath,
@@ -6,7 +6,7 @@ import type { ListRef } from "@opencode-ai/ui/list"
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
import { createMemo, createResource, createSignal } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { useGlobal } from "@/context/global"
import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain"
import type { Path } from "@/types"
@@ -9,7 +9,6 @@ import { formatKeybind } from "@/context/command"
import { useServerSDK } from "@/context/server-sdk"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
import { getRelativeTime } from "@/utils/time"
@@ -29,14 +28,13 @@ type DialogSelectFileMode = "all" | "files"
export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFile?: (path: string) => void }) {
const platform = usePlatform()
const settings = useSettings()
const filesOnly = () => props.mode === "files"
if (!filesOnly() && settings.general.newLayoutDesigns()) {
if (!filesOnly()) {
return <DialogCommandPaletteV2 onOpenFile={props.onOpenFile} />
}
if (filesOnly() && platform.platform === "desktop" && settings.general.newLayoutDesigns()) {
if (filesOnly() && platform.platform === "desktop") {
return <DialogSelectFileDesktopV2 onOpenFile={props.onOpenFile} />
}
@@ -52,7 +50,7 @@ function DialogSelectFileDesktopV2(props: { onOpenFile?: (path: string) => void
return (
<DialogSelectFileV2
server={serverSDK().server}
server={serverSDK.server}
mode="file"
start={projectDirectory()}
title={language.t("session.header.searchFiles")}
@@ -1,17 +1,13 @@
import { Button } from "@opencode-ai/ui/button"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { Dialog } from "@opencode-ai/ui/dialog"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { List } from "@opencode-ai/ui/list"
import { TextField } from "@opencode-ai/ui/text-field"
import { Show } from "solid-js"
import { ServerHealthIndicator, ServerRow } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { useSettings } from "@/context/settings"
import { type ServerDomainController } from "@/components/server/server-management-controller"
import { ServerConnection } from "@/context/servers"
import { ServerCollectionController } from "@/components/server/server-management-controller"
type ServerConnectionFormController = {
state: {
@@ -117,12 +113,11 @@ function ServerForm(props: ServerFormProps) {
}
export function ServerConnectionList(props: {
domain: ServerDomainController
domain: ServerCollectionController
onAdd: () => void
onEdit: (server: ServerConnection.Http) => void
}) {
const language = useLanguage()
const settings = useSettings()
return (
<div class="flex flex-1 min-h-0 flex-col gap-4">
@@ -136,9 +131,6 @@ export function ServerConnectionList(props: {
emptyMessage={language.t("dialog.server.empty")}
items={props.domain.collection.items}
key={(x) => x.http.url}
onSelect={(x) => {
if (x && !settings.general.newLayoutDesigns()) void props.domain.selection.select(x)
}}
divider={true}
>
{(i) => {
@@ -163,15 +155,6 @@ export function ServerConnectionList(props: {
showCredentials
/>
<div class="flex items-center justify-center gap-4 pl-4">
<Show
when={
props.domain.collection.current() &&
ServerConnection.key(props.domain.collection.current()!) === key
}
>
<Icon name="check" class="h-6" />
</Show>
<Show when={i.type === "http"}>
<DropdownMenu>
<DropdownMenu.Trigger
@@ -1,4 +1,4 @@
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import type { Platform } from "@/context/platform"
export function directoryPickerKind(platform: Platform["platform"], server: ServerConnection.Any) {
@@ -1,5 +1,5 @@
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { usePlatform } from "@/context/platform"
import { useSettings } from "@/context/settings"
import { lazy } from "solid-js"
+1 -1
View File
@@ -6,7 +6,7 @@ import { createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useGlobal } from "@/context/global"
import { type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
export function createEditProjectModel(props: { project: LocalProject; server: ServerConnection.Any }) {
const supported = !props.project.id || props.project.id === "global"
@@ -45,7 +45,6 @@ import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { useCommand } from "@/context/command"
import { usePermission } from "@/context/permission"
import { useLanguage } from "@/context/language"
@@ -195,14 +195,6 @@ beforeAll(async () => {
usePrompt: () => prompt,
}))
mock.module("@/context/layout", () => ({
useLayout: () => ({
handoff: {
setTabs: () => undefined,
},
}),
}))
mock.module("@/context/sdk", () => ({
useSDK: () => {
const sdk = {
@@ -8,7 +8,6 @@ import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local"
import { usePermission } from "@/context/permission"
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
@@ -258,7 +257,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const local = useLocal()
const permission = usePermission()
const prompt = input.prompt
const layout = useLayout()
const language = useLanguage()
const params = useParams()
const [search] = useSearchParams<{ draftId?: string }>()
@@ -279,7 +277,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sessionID = params.id
if (!sessionID) return Promise.resolve()
serverSync().session.set("todo", sessionID, [])
serverSync.session.set("todo", sessionID, [])
input.onAbort?.()
@@ -320,8 +318,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
const seed = (dir: string, info: SessionInfo) => {
serverSync().session.remember(info)
const [, setStore] = serverSync().child(dir)
serverSync.session.remember(info)
const [, setStore] = serverSync.child(dir)
setStore("session", (list: SessionInfo[]) => {
const result = Binary.search(list, info.id, (item) => item.id)
const next = [...list]
@@ -370,7 +368,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
input.resetHistoryNavigation()
const projectDirectory = sdk().directory
const permissionState = permission.currentServerState()
const isNewSession = !params.id
const shouldAutoAccept = isNewSession && input.autoAccept()
const worktreeSelection = input.newSessionWorktree?.() || "main"
@@ -402,7 +399,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
}
if (sessionDirectory !== projectDirectory) {
serverSync().child(sessionDirectory)
serverSync.child(sessionDirectory)
}
input.onNewSessionWorktreeReset?.()
@@ -428,13 +425,12 @@ export function createPromptSubmit(input: PromptSubmitInput) {
session = created
await startTransition(() => {
if (!session) return
if (shouldAutoAccept) permissionState.enableAutoAccept(session.id, sessionDirectory)
if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id, {
agent: currentAgent.name,
model: { providerID: currentModel.provider.id, modelID: currentModel.id },
variant: variant ?? null,
})
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
@@ -523,7 +519,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (customCommand) {
clearInput()
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
serverSync.session.set("session_status", session.id, { type: "busy" })
sdk()
.api.session.command({
sessionID: session.id,
@@ -540,7 +536,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
),
})
.catch((err) => {
serverSync().session.set("session_status", session.id, { type: "idle" })
serverSync.session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
description: formatServerError(err, language.t, language.t("common.requestFailed")),
@@ -626,7 +622,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
void sendFollowupDraft({
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
serverSync: serverSync,
session: () => input.info() ?? session,
draft,
messageID,
@@ -27,8 +27,8 @@ export function createProviderConnectionController(options: {
const [integration] = createResource(
() => ({ provider: options.provider(), directory: options.directory() }),
(input) =>
serverSDK()
.api.integration.get({ integrationID: input.provider, location: location() })
serverSDK.api.integration
.get({ integrationID: input.provider, location: location() })
.then((result) => result.data),
)
const methods = createMemo<ProviderConnectMethod[]>(() => {
@@ -120,14 +120,14 @@ export function createProviderConnectionController(options: {
cancelPolling()
const directory = options.directory()
await queryClient
.refetchQueries(serverSync().queryOptions.providers(directory ? pathKey(directory) : null))
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
.catch(() => undefined)
if (polling.disposed) return
options.onComplete()
}
const poll = async (authorization: Authorization, generation: number) => {
const result = await serverSDK()
.api.integration.oauth.status({
const result = await serverSDK.api.integration.oauth
.status({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
@@ -175,8 +175,8 @@ export function createProviderConnectionController(options: {
return
}
dispatch({ type: "auth.pending" })
const result = await serverSDK()
.api.integration.oauth.connect({
const result = await serverSDK.api.integration.oauth
.connect({
integrationID: options.provider(),
methodID: selected.id,
...(answer ? { answer } : {}),
@@ -197,7 +197,7 @@ export function createProviderConnectionController(options: {
dispatch({ type: "method.reset" })
}
const connectKey = async (key: string) => {
await serverSDK().api.integration.connect.key({
await serverSDK.api.integration.connect.key({
integrationID: options.provider(),
location: location(),
key,
@@ -208,8 +208,8 @@ export function createProviderConnectionController(options: {
const completeCode = async (code: string) => {
const authorization = store.authorization
if (!authorization) return language.t("provider.connect.oauth.code.invalid")
const result = await serverSDK()
.api.integration.oauth.complete({
const result = await serverSDK.api.integration.oauth
.complete({
integrationID: options.provider(),
attemptID: authorization.attemptID,
location: location(),
@@ -3,7 +3,7 @@ import { createMemo, createResource } from "solid-js"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server"
import { ServerConnection, useServers } from "@/context/servers"
import { useSettings } from "@/context/settings"
import { useTabs } from "@/context/tabs"
import { type ServerHealth } from "@/utils/server-health"
@@ -49,7 +49,7 @@ function useDefaultServer() {
}
export function useServerActionsController() {
const server = useServer()
const server = useServers()
const tabs = useTabs()
const platform = usePlatform()
const language = useLanguage()
@@ -72,28 +72,16 @@ export function useServerActionsController() {
export type ServerActionsController = ReturnType<typeof useServerActionsController>
export function useServerCollectionController() {
const server = useServer()
const server = useServers()
const global = useGlobal()
const settings = useSettings()
const actions = useServerActionsController()
const items = createMemo(() => {
const current = server.current
const list = server.list
if (!current) return list
if (!list.includes(current)) return [current, ...list]
return [current, ...list.filter((item) => item !== current)]
})
const current = createMemo<ServerConnection.Any | undefined>(() =>
settings.general.newLayoutDesigns()
? undefined
: (items().find((item) => ServerConnection.key(item) === server.key) ?? items()[0]),
)
const items = createMemo(() => server.list)
const sorted = createMemo(() => {
const raw = items()
const list = raw
if (!list.length) return list
const active = current()
const order = new Map(list.map((item, index) => [item, index] as const))
const rank = (value?: ServerHealth) => {
if (value?.healthy === true) return 0
@@ -101,8 +89,6 @@ export function useServerCollectionController() {
return 1
}
return list.slice().sort((a, b) => {
if (a === active) return -1
if (b === active) return 1
const diff =
rank(global.servers.health[ServerConnection.key(a)]) - rank(global.servers.health[ServerConnection.key(b)])
if (diff !== 0) return diff
@@ -113,7 +99,6 @@ export function useServerCollectionController() {
return {
collection: {
items: sorted,
current,
health: () => global.servers.health,
},
...actions,
@@ -121,21 +106,3 @@ export function useServerCollectionController() {
}
export type ServerCollectionController = ReturnType<typeof useServerCollectionController>
export function useServerDomainController(options: { onSelect?: () => void } = {}) {
const navigate = useNavigate()
const server = useServer()
const global = useGlobal()
const collection = useServerCollectionController()
const select = async (connection: ServerConnection.Any) => {
if (global.servers.health[ServerConnection.key(connection)]?.healthy === false) return
options.onSelect?.()
navigate("/")
queueMicrotask(() => server.setActive(ServerConnection.key(connection)))
}
return { ...collection, selection: { select } }
}
export type ServerDomainController = ReturnType<typeof useServerDomainController>
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { createServerHealthPreview, replaceServerConnection, type ServerFormValues } from "./server-management"
function deferred<T>() {
@@ -65,20 +65,17 @@ describe("replaceServerConnection", () => {
const calls: string[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: (key) => calls.push(`tabs:${key}`),
add: (server) => {
calls.push(`add:${ServerConnection.key(server)}`)
return server
},
setActive: (key) => calls.push(`active:${key}`),
remove: (key) => calls.push(`remove:${key}`),
})
expect(calls).toEqual([
"tabs:https://old.example.com",
"add:https://new.example.com",
"active:https://new.example.com",
"remove:https://old.example.com",
])
})
@@ -87,10 +84,8 @@ describe("replaceServerConnection", () => {
const removed: ServerConnection.Key[] = []
replaceServerConnection(ServerConnection.key(original), next, {
active: () => ServerConnection.key(original),
removeTabs: () => {},
add: () => undefined,
setActive: () => {},
remove: (key) => removed.push(key),
})
@@ -1,4 +1,4 @@
import { normalizeServerUrl, ServerConnection } from "@/context/server"
import { normalizeServerUrl, ServerConnection } from "@/context/servers"
import type { ServerHealth } from "@/utils/server-health"
export type ServerFormValues = {
@@ -42,18 +42,13 @@ export function replaceServerConnection(
originalKey: ServerConnection.Key,
next: ServerConnection.Http,
operations: {
active: () => ServerConnection.Key | undefined
removeTabs: (key: ServerConnection.Key) => void
add: (server: ServerConnection.Http) => ServerConnection.Any | undefined
setActive: (key: ServerConnection.Key) => void
remove: (key: ServerConnection.Key) => void
},
) {
const active = operations.active()
operations.removeTabs(originalKey)
const added = operations.add(next)
if (!added) return
const nextActive = active === originalKey ? ServerConnection.key(added) : active
if (nextActive) operations.setActive(nextActive)
operations.remove(originalKey)
}
@@ -4,7 +4,7 @@ import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { type Component, Show } from "solid-js"
import type { ServerActionsController } from "@/components/server/server-management-controller"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
export const ServerRowMenu: Component<{
server: ServerConnection.Any
@@ -11,7 +11,7 @@ import {
Show,
} from "solid-js"
import { useLanguage } from "@/context/language"
import { type ServerConnection, serverName } from "@/context/server"
import { type ServerConnection, serverName } from "@/context/servers"
import type { ServerHealth } from "@/utils/server-health"
interface ServerRowProps extends ParentProps {
@@ -2,9 +2,9 @@ import { createEffect, createMemo } from "solid-js"
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
import { useServer } from "@/context/server"
export const OPEN_APPS = [
"vscode",
@@ -169,7 +169,7 @@ export function useOpenInApp(input: { directory: () => string }) {
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal)
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
@@ -1,32 +1,12 @@
import { AppIcon } from "@opencode-ai/ui/app-icon"
import { Button } from "@opencode-ai/ui/button"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Icon } from "@opencode-ai/ui/icon"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Keybind } from "@opencode-ai/ui/keybind"
import { Spinner } from "@opencode-ai/ui/spinner"
import { showToast } from "@/utils/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { getFilename } from "@opencode-ai/core/util/path"
import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { createMemo, Show } from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { useSessionLayout } from "@/pages/session/session-layout"
import { messageAgentColor } from "@/utils/agent"
import { decode64 } from "@/utils/base64"
import { fileManagerApp } from "@/utils/file-manager"
import { Persist, persisted } from "@/utils/persist"
import { StatusPopover, StatusPopoverV2 } from "../status-popover"
import { StatusPopoverV2 } from "../status-popover"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
@@ -34,206 +14,15 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount } from "../titlebar"
const OPEN_APPS = [
"vscode",
"cursor",
"zed",
"textmate",
"antigravity",
"finder",
"terminal",
"iterm2",
"ghostty",
"warp",
"xcode",
"android-studio",
"powershell",
"sublime-text",
] as const
type OpenApp = (typeof OPEN_APPS)[number]
type OS = "macos" | "windows" | "linux" | "unknown"
const MAC_APPS = [
{
id: "vscode",
label: "session.header.open.app.vscode",
icon: "vscode",
openWith: "Visual Studio Code",
},
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" },
{ id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" },
{
id: "antigravity",
label: "session.header.open.app.antigravity",
icon: "antigravity",
openWith: "Antigravity",
},
{ id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" },
{ id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" },
{ id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" },
{ id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" },
{ id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" },
{
id: "android-studio",
label: "session.header.open.app.androidStudio",
icon: "android-studio",
openWith: "Android Studio",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const WINDOWS_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "powershell",
label: "session.header.open.app.powershell",
icon: "powershell",
openWith: "powershell",
},
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const LINUX_APPS = [
{ id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" },
{ id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" },
{ id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" },
{
id: "sublime-text",
label: "session.header.open.app.sublimeText",
icon: "sublime-text",
openWith: "Sublime Text",
},
] as const
const detectOS = (platform: ReturnType<typeof usePlatform>): OS => {
if (platform.platform === "desktop" && platform.os) return platform.os
if (typeof navigator !== "object") return "unknown"
const value = navigator.platform || navigator.userAgent
if (/Mac/i.test(value)) return "macos"
if (/Win/i.test(value)) return "windows"
if (/Linux/i.test(value)) return "linux"
return "unknown"
}
const showRequestError = (language: ReturnType<typeof useLanguage>, err: unknown) => {
showToast({
variant: "error",
title: language.t("common.requestFailed"),
description: err instanceof Error ? err.message : String(err),
})
}
export function SessionHeader() {
const layout = useLayout()
const command = useCommand()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const settings = useSettings()
const sync = useSync()
const terminal = useTerminal()
const { params, view } = useSessionLayout()
const { view } = useSessionLayout()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
if (!directory) return
return layout.projects.list().find((p) => p.worktree === directory || p.sandboxes?.includes(directory))
})
const name = createMemo(() => {
const current = project()
if (current) return current.name || getFilename(current.worktree)
return getFilename(projectDirectory())
})
const hotkey = createMemo(() => command.keybind("file.open"))
const os = createMemo(() => detectOS(platform))
const isV2 = settings.general.newLayoutDesigns
const search = settings.visibility.search
const status = settings.visibility.status
const isDesktop = createMediaQuery("(min-width: 768px)")
const [exists, setExists] = createStore<Partial<Record<OpenApp, boolean>>>({
finder: true,
})
const apps = createMemo(() => {
if (os() === "macos") return MAC_APPS
if (os() === "windows") return WINDOWS_APPS
return LINUX_APPS
})
const fileManager = createMemo(() => fileManagerApp(os()))
createEffect(() => {
if (platform.platform !== "desktop") return
if (!platform.checkAppExists) return
const list = apps()
setExists(Object.fromEntries(list.map((app) => [app.id, undefined])) as Partial<Record<OpenApp, boolean>>)
void Promise.all(
list.map((app) =>
Promise.resolve(platform.checkAppExists?.(app.openWith))
.then((value) => Boolean(value))
.catch(() => false)
.then((ok) => [app.id, ok] as const),
),
).then((entries) => {
setExists(Object.fromEntries(entries) as Partial<Record<OpenApp, boolean>>)
})
})
const options = createMemo(() => {
return [
{ id: "finder", label: language.t(fileManager().label), icon: fileManager().icon },
...apps()
.filter((app) => exists[app.id])
.map((app) => ({ ...app, label: language.t(app.label) })),
] as const
})
const toggleTerminal = () => {
const next = !view().terminal.opened()
view().terminal.toggle()
if (!next) return
const id = terminal.active()
if (!id) return
focusTerminalById(id)
}
const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({
app: undefined as OpenApp | undefined,
})
const canOpen = createMemo(() => platform.platform === "desktop" && !!platform.openPath && server.isLocal())
const current = createMemo(
() =>
options().find((o) => o.id === prefs.app) ??
options()[0] ??
({ id: "finder", label: fileManager().label, icon: fileManager().icon } as const),
)
const opening = createMemo(() => openRequest.app !== undefined)
const tint = createMemo(() =>
messageAgentColor(params.id ? sync().data.message[params.id] : undefined, sync().data.agent),
)
const v2ActionsState = createMemo<SessionHeaderV2ActionsState>(() => ({
statusVisible: status(),
statusLabel: language.t("status.popover.trigger"),
@@ -244,275 +33,16 @@ export function SessionHeader() {
onReviewToggle: () => view().reviewPanel.toggle(),
}))
const selectApp = (app: OpenApp) => {
if (!options().some((item) => item.id === app)) return
setPrefs("app", app)
}
const openDir = (app: OpenApp) => {
if (opening() || !canOpen() || !platform.openPath) return
const directory = projectDirectory()
if (!directory) return
const item = options().find((o) => o.id === app)
const openWith = item && "openWith" in item ? item.openWith : undefined
setOpenRequest("app", app)
platform
.openPath(directory, openWith)
.catch((err: unknown) => showRequestError(language, err))
.finally(() => {
setOpenRequest("app", undefined)
})
}
const copyPath = () => {
const directory = projectDirectory()
if (!directory) return
navigator.clipboard
.writeText(directory)
.then(() => {
showToast({
variant: "success",
icon: "circle-check",
title: language.t("session.share.copy.copied"),
description: directory,
})
})
.catch((err: unknown) => showRequestError(language, err))
}
const [centerMount, setCenterMount] = createSignal<HTMLElement | null>(null)
const rightMount = useTitlebarRightMount()
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
})
return (
<>
<Show when={search() && centerMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<Button
type="button"
variant="ghost"
size="small"
class="hidden md:flex w-[240px] max-w-full min-w-0 items-center gap-2 justify-between rounded-md border border-border-weak-base bg-surface-panel shadow-none cursor-default"
onClick={() => command.trigger("file.open")}
aria-label={language.t("session.header.searchFiles")}
>
<div class="flex min-w-0 flex-1 items-center overflow-visible">
<span class="flex-1 min-w-0 text-12-regular text-text-weak truncate text-left">
{language.t("session.header.search.placeholder", {
project: name(),
})}
</span>
</div>
<Show when={hotkey()} keyed>
{(keybind) => (
<Keybind class="shrink-0 !border-0 !bg-transparent !shadow-none px-0 text-text-weaker">
{keybind}
</Keybind>
)}
</Show>
</Button>
</Portal>
)}
</Show>
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<Show
when={isV2}
fallback={
<div class="flex items-center gap-2">
<Show when={projectDirectory()}>
<div class="hidden xl:flex items-center">
<Show
when={canOpen()}
fallback={
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full py-0 pr-3 pl-0.5 gap-1.5 border-none shadow-none"
onClick={copyPath}
aria-label={language.t("session.header.open.copyPath")}
>
<Icon name="copy" size="small" class="text-icon-base" />
<span class="text-12-regular text-text-strong">
{language.t("session.header.open.copyPath")}
</span>
</Button>
</div>
}
>
<div class="flex items-center">
<div class="flex h-[24px] box-border items-center rounded-md border border-border-weak-base bg-surface-panel overflow-hidden">
<Button
variant="ghost"
class="rounded-none h-full px-0.5 border-none shadow-none disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
onClick={() => openDir(current().id)}
disabled={opening()}
aria-label={language.t("session.header.open.ariaLabel", { app: current().label })}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<Show when={opening()} fallback={<AppIcon id={current().icon} />}>
<Spinner class="size-3.5" style={{ color: tint() ?? "var(--icon-base)" }} />
</Show>
</div>
</Button>
<DropdownMenu
gutter={4}
placement="bottom-end"
open={menu.open}
onOpenChange={(open) => setMenu("open", open)}
>
<DropdownMenu.Trigger
as={IconButton}
icon="chevron-down"
variant="ghost"
disabled={opening()}
class="rounded-none h-full w-[20px] p-0 border-none shadow-none data-[expanded]:bg-surface-raised-base-active disabled:!cursor-default"
classList={{
"bg-surface-raised-base-active": opening(),
}}
aria-label={language.t("session.header.open.menu")}
/>
<DropdownMenu.Portal>
<DropdownMenu.Content class="[&_[data-slot=dropdown-menu-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]]:pl-1 [&_[data-slot=dropdown-menu-radio-item]+[data-slot=dropdown-menu-radio-item]]:mt-1">
<DropdownMenu.Group>
<DropdownMenu.GroupLabel class="!px-1 !py-1">
{language.t("session.header.openIn")}
</DropdownMenu.GroupLabel>
<DropdownMenu.RadioGroup
class="mt-1"
value={current().id}
onChange={(value) => {
if (!OPEN_APPS.includes(value as OpenApp)) return
selectApp(value as OpenApp)
}}
>
<For each={options()}>
{(o) => (
<DropdownMenu.RadioItem
value={o.id}
disabled={opening()}
onSelect={() => {
setMenu("open", false)
openDir(o.id)
}}
>
<div class="flex size-5 shrink-0 items-center justify-center [&_[data-component=app-icon]]:size-5">
<AppIcon id={o.icon} />
</div>
<DropdownMenu.ItemLabel>{o.label}</DropdownMenu.ItemLabel>
<DropdownMenu.ItemIndicator>
<Icon name="check-small" size="small" class="text-icon-weak" />
</DropdownMenu.ItemIndicator>
</DropdownMenu.RadioItem>
)}
</For>
</DropdownMenu.RadioGroup>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => {
setMenu("open", false)
copyPath()
}}
>
<div class="flex size-5 shrink-0 items-center justify-center">
<Icon name="copy" size="small" class="text-icon-weak" />
</div>
<DropdownMenu.ItemLabel>
{language.t("session.header.open.copyPath")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
</div>
</Show>
</div>
</Show>
<div class="flex items-center gap-1">
<Show when={status()}>
<Tooltip placement="bottom" value={language.t("status.popover.trigger")}>
<StatusPopover />
</Tooltip>
</Show>
<TooltipKeybind
title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")}
>
<Button
variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel"
>
<Icon size="small" name={view().terminal.opened() ? "terminal-active" : "terminal"} />
</Button>
</TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind
title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")}
>
<Button
variant="ghost"
class="group/review-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => view().reviewPanel.toggle()}
aria-label={language.t("command.review.toggle")}
aria-expanded={view().reviewPanel.opened()}
aria-controls="review-panel"
>
<Icon size="small" name={view().reviewPanel.opened() ? "review-active" : "review"} />
</Button>
</TooltipKeybind>
<TooltipKeybind
title={language.t("command.fileTree.toggle")}
keybind={command.keybind("fileTree.toggle")}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
onClick={() => layout.fileTree.toggle()}
aria-label={language.t("command.fileTree.toggle")}
aria-expanded={layout.fileTree.opened()}
aria-controls="file-tree-panel"
>
<div class="relative flex items-center justify-center size-4">
<Icon
size="small"
name={layout.fileTree.opened() ? "file-tree-active" : "file-tree"}
classList={{
"text-icon-strong": layout.fileTree.opened(),
"text-icon-weak": !layout.fileTree.opened(),
}}
/>
</div>
</Button>
</TooltipKeybind>
</div>
</div>
</div>
}
>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Show>
</Portal>
)}
</Show>
</>
<Show when={rightMount()} keyed>
{(mount) => (
<Portal mount={mount}>
<SessionHeaderV2Actions state={v2ActionsState()} />
</Portal>
)}
</Show>
)
}
@@ -527,8 +57,6 @@ type SessionHeaderV2ActionsState = {
}
function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
const language = useLanguage()
return (
<div class="flex items-center gap-2">
<Show when={props.state.statusVisible}>
@@ -410,7 +410,7 @@ export function createKeybindSettingsController(
}
}
function SettingsKeybindsV2() {
export function SettingsKeybinds() {
const command = useCommand()
const settings = useSettings()
const controller = createKeybindSettingsController({
@@ -528,254 +528,3 @@ function SettingsKeybindsV2View(props: {
</>
)
}
export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => {
if (props.v2) return <SettingsKeybindsV2 />
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const [store, setStore] = createStore({
active: null as string | null,
filter: "",
})
const stop = () => {
if (!store.active) return
setStore("active", null)
command.keybinds(true)
}
const start = (id: string) => {
if (store.active === id) {
stop()
return
}
if (store.active) stop()
setStore("active", id)
command.keybinds(false)
}
const map = createMemo(() => keybinds(settings.current.keybinds))
const hasOverrides = createMemo(() => Object.values(map()).some((x) => typeof x === "string"))
const resetAll = () => {
stop()
settings.keybinds.resetAll()
showToast({
title: language.t("settings.shortcuts.reset.toast.title"),
description: language.t("settings.shortcuts.reset.toast.description"),
})
}
const list = createMemo(() => {
language.locale()
return listFor(command, map(), language.t("command.palette"))
})
const title = (id: string) => list().get(id)?.title ?? ""
const grouped = createMemo(() => groupedFor(list()))
const filtered = createMemo(() => {
return filteredFor(store.filter, list(), grouped(), (id) => command.keybind(id) || "")
})
const hasResults = createMemo(() => {
for (const group of GROUPS) {
const ids = filtered().get(group) ?? []
if (ids.length > 0) return true
}
return false
})
const used = createMemo(() => {
const map = new Map<string, { id: string; title: string }[]>()
const add = (key: string, value: { id: string; title: string }) => {
const list = map.get(key)
if (!list) {
map.set(key, [value])
return
}
list.push(value)
}
const palette = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND
for (const sig of signatures(palette)) {
add(sig, { id: PALETTE_ID, title: title(PALETTE_ID) })
}
const valueFor = (id: string) => {
const custom = settings.keybinds.get(id)
if (typeof custom === "string") return custom
const live = command.options.find((x) => x.id === id)
if (live?.keybind) return live.keybind
const meta = command.catalog.find((x) => x.id === id)
return meta?.keybind
}
for (const id of list().keys()) {
if (id === PALETTE_ID) continue
for (const sig of signatures(valueFor(id))) {
add(sig, { id, title: title(id) })
}
}
return map
})
const setKeybind = (id: string, keybind: string) => settings.keybinds.set(id, keybind)
useKeyCapture({
active: () => store.active,
stop,
set: setKeybind,
used,
language,
})
onCleanup(() => {
if (store.active) command.keybinds(true)
})
const emptyResults = (
<Show when={store.filter && !hasResults()}>
<div
classList={{
"flex flex-col items-center justify-center py-12 text-center": !props.v2,
"settings-v2-shortcuts-status": props.v2,
}}
>
<span
classList={{
"text-14-regular text-text-weak": !props.v2,
}}
>
{language.t("settings.shortcuts.search.empty")}
</span>
<Show when={store.filter}>
<span
classList={{
"text-14-regular text-text-strong mt-1": !props.v2,
"settings-v2-shortcuts-status-filter": props.v2,
}}
>
&quot;{store.filter}&quot;
</span>
</Show>
</div>
</Show>
)
const List = props.v2 ? SettingsListV2 : SettingsList
const groups = (
<div
classList={{
"settings-v2-shortcuts flex flex-col gap-8": props.v2,
"flex flex-col gap-8 max-w-[720px]": !props.v2,
}}
>
<For each={GROUPS}>
{(group) => (
<Show when={(filtered().get(group) ?? []).length > 0}>
<div
classList={{
"settings-v2-section": props.v2,
"flex flex-col gap-1": !props.v2,
}}
>
<h3
classList={{
"settings-v2-section-title": props.v2,
"text-14-medium text-text-strong pb-2": !props.v2,
}}
>
{language.t(groupKey[group])}
</h3>
<List>
<For each={filtered().get(group) ?? []}>
{(id) => (
<div class="flex items-center justify-between gap-4 py-3 border-b border-border-weak-base last:border-none">
<span
classList={{
"text-14-regular text-text-strong": !props.v2,
}}
>
{title(id)}
</span>
<button
type="button"
data-keybind-id={id}
classList={{
"settings-v2-keybind-button": props.v2,
"settings-v2-keybind-button--active": props.v2 && store.active === id,
"h-8 px-3 rounded-md text-12-regular": !props.v2,
"bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active":
!props.v2 && store.active !== id,
"border border-border-weak-base bg-surface-inset-base text-text-weak":
!props.v2 && store.active === id,
}}
onClick={() => start(id)}
>
<Show
when={store.active === id}
fallback={command.keybind(id) || language.t("settings.shortcuts.unassigned")}
>
{language.t("settings.shortcuts.pressKeys")}
</Show>
</button>
</div>
)}
</For>
</List>
</div>
</Show>
)}
</For>
{emptyResults}
</div>
)
return (
<div class="flex flex-col h-full overflow-y-auto no-scrollbar px-4 pb-10 sm:px-10 sm:pb-10">
<div class="sticky top-0 z-10 bg-[linear-gradient(to_bottom,var(--surface-stronger-non-alpha)_calc(100%_-_24px),transparent)]">
<div class="flex flex-col gap-4 pt-6 pb-6 max-w-[720px]">
<div class="flex items-center justify-between gap-4">
<h2 class="text-16-medium text-text-strong">{language.t("settings.shortcuts.title")}</h2>
<Button size="small" variant="secondary" onClick={resetAll} disabled={!hasOverrides()}>
{language.t("settings.shortcuts.reset.button")}
</Button>
</div>
<div class="flex items-center gap-2 px-3 h-9 rounded-lg bg-surface-base">
<Icon name="magnifying-glass" class="text-icon-weak-base flex-shrink-0" />
<TextField
variant="ghost"
type="text"
value={store.filter}
onChange={(v) => setStore("filter", v)}
placeholder={language.t("settings.shortcuts.search.placeholder")}
spellcheck={false}
autocorrect="off"
autocomplete="off"
autocapitalize="off"
class="flex-1"
/>
<Show when={store.filter}>
<IconButton icon="circle-x" variant="ghost" onClick={() => setStore("filter", "")} />
</Show>
</div>
</div>
</div>
{groups}
</div>
)
}
@@ -13,7 +13,7 @@ import {
} from "@/components/server/server-management"
import { useGlobal } from "@/context/global"
import { useLanguage } from "@/context/language"
import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server"
import { normalizeServerUrl, ServerConnection, useServers } from "@/context/servers"
import { useTabs } from "@/context/tabs"
import { useCheckServerHealth } from "@/utils/server-health"
import "./settings-v2.css"
@@ -146,7 +146,7 @@ export const DialogServerV2: Component<{
}
function createFormController(options: { onSelect?: () => void } = {}) {
const server = useServer()
const server = useServers()
const tabs = useTabs()
const global = useGlobal()
const language = useLanguage()
@@ -173,8 +173,7 @@ function createFormController(options: { onSelect?: () => void } = {}) {
})
}
const allServers = () => {
if (!server.current || server.list.includes(server.current)) return server.list
return [server.current, ...server.list]
return server.list
}
const editing = createMemo(() =>
allServers().find((item) => item.type === "http" && item.http.url === store.originalUrl),
@@ -182,10 +181,8 @@ function createFormController(options: { onSelect?: () => void } = {}) {
const add = (connection: ServerConnection.Http) => server.add(connection)
const replace = (originalKey: ServerConnection.Key, next: ServerConnection.Http) =>
replaceServerConnection(originalKey, next, {
active: () => server.key,
removeTabs: (key) => tabs.removeServer(key),
add,
setActive: (key) => server.setActive(key),
remove: (key) => server.remove(key),
})
@@ -1,10 +1,10 @@
import { Component, createMemo, createSignal, startTransition } from "solid-js"
import { Component, createMemo, createSignal, Show, startTransition } from "solid-js"
import { Dialog } from "@opencode-ai/ui/v2/dialog-v2"
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
import { Icon } from "@opencode-ai/ui/icon"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { SettingsGeneralV2 } from "./general"
import { SettingsGeneral } from "./general"
import { SettingsKeybinds } from "../settings-keybinds"
import { SettingsProvidersV2 } from "./providers"
import { SettingsModelsV2 } from "./models"
@@ -13,7 +13,10 @@ import { SettingsServersV2 } from "./servers"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useLayout } from "@/context/layout"
import { useTabs } from "@/context/tabs"
import { useServerSync } from "@/context/server-sync"
import { ServerProvider } from "@/context/server"
import { ServerConnection, useServers } from "@/context/servers"
import { useServerCtx } from "@/context/global"
import { ModelsProvider } from "@/context/models"
export const DialogSettings: Component<{
sessionID?: string
@@ -23,18 +26,36 @@ export const DialogSettings: Component<{
const platform = usePlatform()
const dialog = useDialog()
const layout = useLayout()
const servers = useServers()
const tabs = useTabs()
const serverSync = useServerSync()
const [tab, setTab] = createSignal(props.defaultValue ?? "general")
const server = createMemo(() => {
const route = layout.route()
switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return servers.list.find((item) => ServerConnection.key(item) === draft?.server)
}
case "session": {
return servers.list.find((item) => ServerConnection.key(item) === route.server)
}
case "home": {
return servers.list.find((item) => ServerConnection.key(item) === layout.home.selection().server)
}
}
})
const serverCtx = useServerCtx(server)
const directory = createMemo(() => {
const route = layout.route()
if (route.type === "dir-new-sesssion") return route.dir
if (route.type === "draft") {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
switch (route.type) {
case "draft": {
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
return draft?.type === "draft" ? draft.directory : undefined
}
case "session": {
return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
}
}
if (route.type === "session") return serverSync().session.get(route.sessionId)?.location.directory
return undefined
})
const showProviders = () => {
@@ -94,20 +115,28 @@ export const DialogSettings: Component<{
</div>
</TabsV2.List>
<TabsV2.Content value="general" class="settings-v2-panel">
<SettingsGeneralV2 sessionID={props.sessionID} />
<SettingsGeneral server={server()} sessionID={props.sessionID} />
</TabsV2.Content>
<TabsV2.Content value="shortcuts" class="settings-v2-panel">
<SettingsKeybinds v2 />
<SettingsKeybinds />
</TabsV2.Content>
<TabsV2.Content value="servers" class="settings-v2-panel">
<SettingsServersV2 />
</TabsV2.Content>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<SettingsModelsV2 />
</TabsV2.Content>
<Show when={server()} keyed>
{(server) => (
<ServerProvider conn={server}>
<TabsV2.Content value="providers" class="settings-v2-panel">
<SettingsProvidersV2 directory={directory} onBack={showProviders} />
</TabsV2.Content>
<TabsV2.Content value="models" class="settings-v2-panel">
<ModelsProvider directory={directory}>
<SettingsModelsV2 />
</ModelsProvider>
</TabsV2.Content>
</ServerProvider>
)}
</Show>
</TabsV2>
</Dialog>
)
@@ -17,17 +17,24 @@ import {
} from "@/context/settings"
import { playSoundById, SOUND_OPTIONS } from "@/utils/sound"
import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior"
import { ServerConnection } from "@/context/servers"
import { useGlobal, useServerCtx } from "@/context/global"
export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior"
export type { ShellOption, ShellSelectOption } from "./general-controller-behavior"
export function createPermissionScopeController(sessionID: Accessor<string | undefined>) {
const permission = usePermission()
const serverSync = useServerSync()
export function createPermissionScopeController(
server: Accessor<ServerConnection.Any | undefined>,
sessionID: Accessor<string | undefined>,
) {
const serverCtx = useServerCtx(server)
const permission = () => serverCtx()?.permission
const directory = createMemo(() => {
const s = server()
const id = sessionID()
if (!id) return undefined
return serverSync().session.lineage.peek(id)?.session.location.directory
if (!s || !id) return undefined
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
})
return {
@@ -35,21 +42,21 @@ export function createPermissionScopeController(sessionID: Accessor<string | und
const id = sessionID()
const dir = directory()
if (!id || !dir) return false
return permission.isAutoAccepting(id, dir)
return permission()?.isAutoAccepting(id, dir)
}),
enabled: createMemo(() => !!directory()),
set: (checked: boolean) => {
const id = sessionID()
const dir = directory()
if (!id || !dir) return
if (checked) return permission.enableAutoAccept(id, dir)
permission.disableAutoAccept(id, dir)
if (checked) return permission()?.enableAutoAccept(id, dir)
permission()?.disableAutoAccept(id, dir)
},
}
}
export function createShellSettingsController() {
const serverSync = useServerSync()
export function createShellSettingsController(server: Accessor<ServerConnection.Any | undefined>) {
const serverCtx = useServerCtx(server)
const [shells] = createResource(
async () => {
// TODO: Dax is considering the V2 shell discovery and config update APIs.
@@ -58,7 +65,7 @@ export function createShellSettingsController() {
},
{ initialValue: [] as ShellOption[] },
)
const current = createMemo(() => serverSync().data.config.shell ?? "")
const current = createMemo(() => serverCtx()?.sync.data.config.shell ?? "")
return {
shells: () => shells.latest,
@@ -66,7 +73,7 @@ export function createShellSettingsController() {
select: (value: string) => {
if (value === current()) return
// TODO: Dax is considering the V2 shell discovery and config update APIs.
// void serverSync().updateConfig({ shell: value })
// void serverSync.updateConfig({ shell: value })
},
}
}
@@ -24,6 +24,7 @@ import {
type SoundSettingsController,
} from "./general-controllers"
import "./settings-v2.css"
import { ServerConnection } from "@/context/servers"
const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"]
const fontSettings = {
@@ -269,16 +270,20 @@ const LanguageSetting = () => {
)
}
export const SettingsGeneralV2: Component<{
export const SettingsGeneral: Component<{
sessionID?: string
server?: ServerConnection.Any
}> = (props) => {
const language = useLanguage()
const platform = usePlatform()
const settings = useSettings()
const mobile = createMediaQuery("(max-width: 767px)")
const updater = useUpdaterAction()
const permissionScope = createPermissionScopeController(() => props.sessionID)
const shell = createShellSettingsController()
const permissionScope = createPermissionScopeController(
() => props.server,
() => props.sessionID,
)
const shell = createShellSettingsController(() => props.server)
const appearance = createAppearanceSettingsController()
const sounds = createSoundSettingsController()
const desktop = createMemo(() => platform.platform === "desktop")
@@ -512,7 +517,6 @@ export const SettingsGeneralV2: Component<{
<div class="settings-v2-tab-header">
<h2 class="settings-v2-tab-title">{language.t("settings.tab.general")}</h2>
</div>
<div class="settings-v2-tab-body">
<GeneralSection />
@@ -24,7 +24,7 @@ export const SettingsModelsV2: Component = () => {
const models = useModels()
const serverSdk = useServerSDK()
const [store, setStore] = persisted(
Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"),
Persist.serverGlobal(serverSdk.scope, "settings-v2.models.providers"),
createStore({ collapsed: {} as Record<string, boolean> }),
)
@@ -85,7 +85,7 @@ export const SettingsProvidersV2: Component<{
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
const isConfigCustom = (providerID: string) => {
const provider = serverSync().data.config.provider?.[providerID]
const provider = serverSync.data.config.provider?.[providerID]
if (!provider) return false
if (provider.npm !== "@ai-sdk/openai-compatible") return false
if (!provider.models || Object.keys(provider.models).length === 0) return false
@@ -94,11 +94,11 @@ export const SettingsProvidersV2: Component<{
const disableProvider = async (providerID: string, name: string) => {
return
const before = serverSync().data.config.disabled_providers ?? []
const before = serverSync.data.config.disabled_providers ?? []
const next = before.includes(providerID) ? before : [...before, providerID]
serverSync().set("config", "disabled_providers", next)
serverSync.set("config", "disabled_providers", next)
await serverSync()
await serverSync
.updateConfig({ disabled_providers: next })
.then(() => {
showToast({
@@ -109,7 +109,7 @@ export const SettingsProvidersV2: Component<{
})
})
.catch((err: unknown) => {
serverSync().set("config", "disabled_providers", before)
serverSync.set("config", "disabled_providers", before)
const message = err instanceof Error ? err.message : String(err)
showToast({ title: language.t("common.requestFailed"), description: message })
})
@@ -117,13 +117,13 @@ export const SettingsProvidersV2: Component<{
const disconnect = async (providerID: string, name: string) => {
const location = props.directory() ? { directory: props.directory() } : undefined
await serverSdk()
.api.integration.get({ integrationID: providerID, location })
await serverSdk.api.integration
.get({ integrationID: providerID, location })
.then(async (integration) => {
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSdk().api.credential.remove({ credentialID: credential.id, location })),
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
)
showToast({
variant: "success",
@@ -9,7 +9,7 @@ import { createStore } from "solid-js/store"
import { ServerRowMenu } from "@/components/server/server-row-menu"
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { ServerConnection, serverName } from "@/context/servers"
import { useServerCollectionController } from "../server/server-management-controller"
import { DialogServerV2 } from "./dialog-server-v2"
import { SettingsListV2 } from "./parts/list"
@@ -14,12 +14,13 @@ import {
import { createStore } from "solid-js/store"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection, useServer } from "@/context/server"
import { ServerConnection, useServers } from "@/context/servers"
import { useSync } from "@/context/sync"
import { type ServerHealth } from "@/utils/server-health"
import { useGlobal } from "@/context/global"
import { useMcpToggle } from "@/context/mcp"
import { useSDK } from "@/context/sdk"
import { useServer } from "@/context/server"
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
const parts = value.split(file)
@@ -112,9 +113,6 @@ type ServerStatusItem = {
export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
const sync = useSync()
const sdk = useSDK()
const global = useGlobal()
const server = useServer()
const platform = usePlatform()
const language = useLanguage()
const fail = (err: unknown) => {
@@ -130,17 +128,10 @@ export function StatusPopoverBody(props: { shown: Accessor<boolean> }) {
})
let dialogRun = 0
let dialogDead = false
onCleanup(() => {
dialogDead = true
dialogRun += 1
})
const sortedServers = createMemo(() => {
const list = global.servers.list()
return listServersByHealth(list, server.key, global.servers.health)
})
const toggleMcp = useMcpToggle()
const defaultServer = useDefaultServerKey(platform.getDefaultServer)
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
@@ -5,7 +5,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { Suspense, createMemo, createSignal, lazy, Show, type JSX } from "solid-js"
import { useLanguage } from "@/context/language"
import { ServerConnection, useServer } from "@/context/server"
import { ServerConnection, useServers } from "@/context/servers"
import { useServerSDK } from "@/context/server-sdk"
import { useSync } from "@/context/sync"
import { useGlobal } from "@/context/global"
@@ -14,6 +14,7 @@ import {
hasServiceNeedingAttention,
serverStatusDotClass,
} from "./status-popover-indicator"
import { useServer } from "@/context/server"
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
@@ -82,16 +83,12 @@ export function StatusPopover() {
}
export function StatusPopoverV2() {
return <DirectoryStatusPopover />
}
function DirectoryStatusPopover() {
const language = useLanguage()
const server = useServerSDK()
const server = useServer()
const global = useGlobal()
const sync = useSync()
const [shown, setShown] = createSignal(false)
const serverHealth = () => global.servers.health[ServerConnection.key(server().server)]?.healthy
const serverHealth = () => global.servers.health[server.key]?.healthy
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
const attention = createMemo(() =>
hasServiceNeedingAttention({
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
import type { ServerConnection } from "@/context/servers"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "./titlebar-session-events"
const remote = "remote" as ServerConnection.Key
@@ -1,9 +1,9 @@
import type { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
export const SESSION_TABS_REMOVED_EVENT = "opencode:session-tabs-removed"
export type SessionTabsRemovedDetail = {
server?: ServerConnection.Key
server: ServerConnection.Key
directory: string
sessionIDs: string[]
}
@@ -19,16 +19,15 @@ export function readSessionTabsRemovedDetail(event: Event): SessionTabsRemovedDe
if (!detail || typeof detail !== "object") return undefined
if (!("directory" in detail)) return undefined
if (!("sessionIDs" in detail)) return undefined
if (!("server" in detail) || typeof detail.server !== "string") return undefined
if (typeof detail.directory !== "string") return undefined
if (!Array.isArray(detail.sessionIDs)) return undefined
if ("server" in detail && detail.server !== undefined && typeof detail.server !== "string") return undefined
const sessionIDs = detail.sessionIDs.filter((id): id is string => typeof id === "string")
if (sessionIDs.length === 0) return undefined
return {
server:
"server" in detail && typeof detail.server === "string" ? (detail.server as ServerConnection.Key) : undefined,
server: ServerConnection.Key.make(detail.server),
directory: detail.directory,
sessionIDs,
}
@@ -4,9 +4,9 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { createMutation } from "@tanstack/solid-query"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { useGlobal } from "@/context/global"
import { useGlobal, useServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { ServerConnection, serverName } from "@/context/server"
import { ServerConnection, serverName, useServers } from "@/context/servers"
import { displayName, projectForSession } from "@/pages/layout/helpers"
import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar"
import type { SessionInfo } from "@opencode-ai/client/promise"
@@ -46,11 +46,8 @@ export function TabNavItem(props: {
event.stopPropagation()
props.onClose()
}
const global = useGlobal()
const serverCtx = createMemo(() => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (conn) return global.ensureServerCtx(conn)
})
const servers = useServers()
const serverCtx = useServerCtx(() => servers.list.find((item) => ServerConnection.key(item) === props.server))
const project = createMemo(() => {
const session = props.session()
if (!session) return
@@ -74,8 +71,8 @@ export function TabNavItem(props: {
})
// Only label the server when multiple servers are connected.
const serverLabel = createMemo(() => {
if (global.servers.list().length <= 1) return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === props.server)
if (servers.list.length <= 1) return
const conn = servers.list.find((item) => ServerConnection.key(item) === props.server)
return conn ? serverName(conn) : undefined
})
@@ -8,9 +8,9 @@ import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
import { RestrictToElement } from "@dnd-kit/dom/modifiers"
import { arrayMove } from "@dnd-kit/helpers"
import { tabHref, tabKey, type SessionTab, type Tab } from "@/context/tabs"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { DraftTabItem, TabNavItem } from "@/components/titlebar-tab-nav"
import { useGlobal, type ServerCtx } from "@/context/global"
import { useGlobal, useServerCtx, type ServerCtx } from "@/context/global"
import { useLanguage } from "@/context/language"
import { useCommand } from "@/context/command"
import { useTabs } from "@/context/tabs"
@@ -339,10 +339,9 @@ export function TitlebarTabStrip(props: {
let ref!: HTMLDivElement
const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id)
useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref))
const serverCtx = createMemo(() => {
const serverCtx = useServerCtx(() => {
if (tab.type !== "session") return
const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
if (conn) return global.ensureServerCtx(conn)
return global.servers.list().find((item) => ServerConnection.key(item) === tab.server)
})
if (tab.type === "session") {
+39 -219
View File
@@ -32,8 +32,8 @@ import { TitlebarTabStrip } from "@/components/titlebar-tab-strip"
import { makeEventListener } from "@solid-primitives/event-listener"
import { createMediaQuery } from "@solid-primitives/media"
import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/components/titlebar-session-events"
import { useGlobal } from "@/context/global"
import { ServerConnection, useServer } from "@/context/server"
import { useGlobal, useServerCtx } from "@/context/global"
import { ServerConnection, useServers } from "@/context/servers"
import { tabKey, useTabs } from "@/context/tabs"
import type { PromptSession } from "@/context/prompt"
import "./titlebar.css"
@@ -61,15 +61,13 @@ export function useTitlebarRightMount() {
}
export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visible: boolean; toggle: () => void } }) {
const layout = useLayout()
const platform = usePlatform()
const command = useCommand()
const language = useLanguage()
const settings = useSettings()
const server = useServer()
const servers = useServers()
const navigate = useNavigate()
const location = useLocation()
const params = useParams()
const useV2Titlebar = createMemo(() => settings.general.newLayoutDesigns())
const mobile = createMediaQuery("(max-width: 767px)")
const bottom = createMemo(() => useV2Titlebar() && mobile() && settings.general.mobileTitlebarPosition() === "bottom")
@@ -77,11 +75,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos")
const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows")
const linux = createMemo(() => platform.platform === "desktop" && platform.os === "linux")
const web = createMemo(() => platform.platform === "web")
const macTrafficLights = createMemo(() => mac() && !platform.windowFullscreen?.())
const zoom = () => platform.webviewZoom?.() ?? 1
const titlebarZoom = () => (windows() ? Math.max(zoom(), minTitlebarZoom) : zoom())
const counterZoom = () => (windows() && titlebarZoom() < 1 ? 1 / titlebarZoom() : 1)
const minHeight = () => {
const height = useV2Titlebar() ? v2TitlebarHeight : legacyTitlebarHeight
if (mac()) return `${height / zoom()}px`
@@ -97,14 +93,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
})
const path = () => `${location.pathname}${location.search}${location.hash}`
const creating = createMemo(() => {
const route = layout.route()
if (route.type === "draft" || route.type === "dir-new-sesssion") return true
if (!params.dir) return false
if (params.id) return false
const parts = location.pathname.replace(/\/+$/, "").split("/")
return parts.at(-1) === "session"
})
createEffect(() => {
const current = path()
@@ -116,10 +104,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
})
})
const canBack = createMemo(() => history.index > 0)
const canForward = createMemo(() => history.index < history.stack.length - 1)
const hasProjects = createMemo(() => layout.projects.list().length > 0)
const nav = createMemo(() => (useV2Titlebar() ? settings.general.showNavigation() : true))
const updateState = createMemo<TitlebarUpdatePillState>(() => {
const installing = props.update?.installing() ?? false
const version = props.update?.version()
@@ -188,7 +172,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
data-tauri-drag-region
>
<Switch>
<Match when={useV2Titlebar()}>
<Match when>
{(_) => {
const layout = useLayout()
const global = useGlobal()
@@ -200,9 +184,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
() => {
const route = layout.route()
if (route.type !== "session") return undefined
const conn = global.servers
.list()
.find((item) => ServerConnection.key(item) === (route.server ?? server.key))
const conn = global.servers.list().find((item) => ServerConnection.key(item) === route.server)
return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined
},
({ route, sdk }) => sdk.api.session.get({ sessionID: route.sessionId }).catch(() => {}),
@@ -245,7 +227,7 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const s = session()
if (!s) return
const sessionId = s.parentID ?? s.id
const next = { server: route.server ?? server.key, sessionId }
const next = { server: route.server, sessionId }
tabsStoreActions.addSessionTab(next)
}
})
@@ -258,53 +240,43 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
const openNewTab = () => {
const route = layout.route()
const activeSession = session()
if (route.type === "session" && activeSession) {
const sessionTab = {
type: "session" as const,
server: route.server ?? server.key,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
return
}
switch (route.type) {
case "session": {
const activeSession = session()
if (!activeSession) return
const activeTab = currentTab()
if (activeTab?.type === "draft") {
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
if (route.type === "home") {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
const sessionTab = {
type: "session" as const,
server: route.server,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.location.directory }, "", model)
return
}
case "draft": {
const activeTab = currentTab()
if (activeTab?.type !== "draft") return
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
return
}
case "home": {
const selection = layout.home.selection()
const conn = global.servers.list().find((item) => ServerConnection.key(item) === selection.server)
const project = conn
? global
.ensureServerCtx(conn)
.projects.list()
.find((item) => item.worktree === selection.directory)
: undefined
if (conn && project) {
tabs.newDraft({ server: ServerConnection.key(conn), directory: project.worktree }, "")
return
}
}
}
const current = layout.projects.list()[0]
if (current) {
tabs.newDraft({ server: server.key, directory: current.worktree }, "")
return
}
const fallback = global.servers.list().flatMap((conn) => {
const project = global.ensureServerCtx(conn).projects.list()[0]
return project ? [{ server: ServerConnection.key(conn), project }] : []
})[0]
if (!fallback) return
tabs.newDraft({ server: fallback.server, directory: fallback.project.worktree }, "")
}
const toggleHome = () => tabs.toggleHome({ home: layout.route().type === "home", current: currentTab() })
@@ -430,158 +402,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl
)
}}
</Match>
<Match when>
<div
class="grid h-full min-h-full w-full grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)] items-center"
style={{ zoom: counterZoom() }}
>
<div
classList={{
"flex items-center min-w-0": true,
"pl-2": !macTrafficLights(),
}}
>
<Show when={windows() || linux()}>
<WindowsAppMenu command={command} platform={platform} />
</Show>
<Show when={mac()}>
<div class="xl:hidden w-10 shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<Show when={!mac()}>
<div class="xl:hidden w-[48px] shrink-0 flex items-center justify-center">
<IconButton
icon="menu"
variant="ghost"
class="titlebar-icon rounded-md"
onClick={layout.mobileSidebar.toggle}
aria-label={language.t("sidebar.menu.toggle")}
aria-expanded={layout.mobileSidebar.opened()}
/>
</div>
</Show>
<div class="flex items-center gap-1 shrink-0">
<TooltipKeybind
class={web() ? "hidden xl:flex shrink-0 ml-14" : "hidden xl:flex shrink-0 ml-2"}
placement="bottom"
title={language.t("command.sidebar.toggle")}
keybind={command.keybind("sidebar.toggle")}
>
<Button
variant="ghost"
class="group/sidebar-toggle titlebar-icon w-8 h-6 p-0 box-border"
onClick={layout.sidebar.toggle}
aria-label={language.t("command.sidebar.toggle")}
aria-expanded={layout.sidebar.opened()}
>
<Icon size="small" name={layout.sidebar.opened() ? "sidebar-active" : "sidebar"} />
</Button>
</TooltipKeybind>
<div class="hidden xl:flex items-center shrink-0">
<Show when={params.dir}>
<div
class="flex items-center shrink-0 w-8 mr-1"
aria-hidden={layout.sidebar.opened() ? "true" : undefined}
>
<div
class="transition-opacity"
classList={{
"opacity-100 duration-120 ease-out": !layout.sidebar.opened(),
"opacity-0 duration-120 ease-in delay-0 pointer-events-none": layout.sidebar.opened(),
}}
>
<TooltipKeybind
placement="bottom"
title={language.t("command.session.new")}
keybind={command.keybind("session.new")}
openDelay={800}
>
<Button
variant="ghost"
class="titlebar-icon w-8 h-6 p-0 box-border"
disabled={layout.sidebar.opened()}
tabIndex={layout.sidebar.opened() ? -1 : undefined}
onClick={() => {
if (!params.dir) return
navigate(`/${params.dir}/session`)
}}
aria-label={language.t("command.session.new")}
aria-current={creating() ? "page" : undefined}
>
<IconV2 name="edit" size="small" />
</Button>
</TooltipKeybind>
</div>
</div>
</Show>
<div
class="flex items-center shrink-0"
classList={{
"ltr:-translate-x-[36px] rtl:translate-x-[36px]": layout.sidebar.opened() && !!params.dir,
"duration-180 ease-out": !layout.sidebar.opened(),
"duration-180 ease-in": layout.sidebar.opened(),
}}
>
<Show when={hasProjects() && nav()}>
<div class="flex items-center gap-0 transition-transform">
<Tooltip placement="bottom" value={language.t("common.goBack")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-left"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canBack()}
onClick={back}
aria-label={language.t("common.goBack")}
/>
</Tooltip>
<Tooltip placement="bottom" value={language.t("common.goForward")} openDelay={800}>
<Button
variant="ghost"
icon="chevron-right"
class="titlebar-icon w-6 h-6 p-0 box-border"
disabled={!canForward()}
onClick={forward}
aria-label={language.t("common.goForward")}
/>
</Tooltip>
</div>
</Show>
<div id="opencode-titlebar-left" class="flex items-center gap-3 min-w-0 px-2" />
</div>
</div>
<ChannelIndicator debugTools={props.debugTools} />
</div>
</div>
<div class="min-w-0 flex items-center justify-center pointer-events-none">
<div
id="opencode-titlebar-center"
class="pointer-events-auto min-w-0 flex justify-center w-fit max-w-full"
/>
</div>
<div
classList={{
"flex items-center min-w-0 justify-end": true,
"pr-2": !windows(),
}}
data-tauri-drag-region
>
<div id="opencode-titlebar-right" class="flex items-center gap-1 shrink-0 justify-end" />
<Show when={windows()}>
<div class="shrink-0" style={{ width: windowsControlsWidth() }} />
</Show>
</div>
</div>
</Match>
</Switch>
</header>
)
@@ -3,6 +3,7 @@ import type { UpdaterState } from "@/updater"
import { usePlatform } from "@/context/platform"
import { useLanguage } from "@/context/language"
import { showToast } from "@/utils/toast"
import { formatServerError } from "@/utils/server-errors"
export function updaterAction(state: UpdaterState | undefined) {
if (!state) return { label: "settings.updates.action.checkNow" as const }
@@ -31,7 +32,14 @@ export function useUpdaterAction() {
action,
async run() {
const run = action().run
if (run === "install") return platform.updater?.install()
if (run === "install") {
return platform.updater?.install().catch((error) => {
showToast({
title: language.t("common.requestFailed"),
description: formatServerError(error, language.t, language.t("common.requestFailed")),
})
})
}
if (run !== "check") return
const state = await platform.updater?.check()
+1 -1
View File
@@ -219,7 +219,7 @@ export const { use: useComments, provider: CommentsProvider } = createSimpleCont
const decoded = decodeSessionKey(key)
return createRoot((dispose) => ({
value: createCommentSession(
serverSDK().scope,
serverSDK.scope,
decoded.dir,
decoded.id === WORKSPACE_KEY ? undefined : decoded.id,
),
+4 -4
View File
@@ -66,7 +66,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
const scope = createMemo(() => sdk().directory)
const path = createPathHelpers(scope)
const tabs = layout.tabs(() =>
SessionStateKey.from(serverSDK().scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
SessionStateKey.from(serverSDK.scope, SessionRouteKey.fromRoute(base64Encode(sdk().directory), params.id)),
)
const inflight = new Map<string, Promise<void>>()
@@ -123,7 +123,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
})
})
const viewCache = createFileViewCache(serverSDK().scope)
const viewCache = createFileViewCache(serverSDK.scope)
const view = createMemo(() => viewCache.load(scope(), params.id))
const ensure = (file: string) => {
@@ -211,8 +211,8 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
}
const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) =>
serverSDK()
.api.file.find(
serverSDK.api.file
.find(
{
location: { directory: sdk().directory },
query,
+34 -18
View File
@@ -1,7 +1,7 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createEffect, createMemo, createRoot } from "solid-js"
import { Accessor, createEffect, createMemo, createRoot } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./servers"
import { pathKey } from "@/utils/path-key"
import { useServerHealth } from "@/utils/server-health"
import { createServerSdkContext } from "./server-sdk"
@@ -9,11 +9,13 @@ import { createServerSyncContext } from "./server-sync"
import { getOwner } from "solid-js/web"
import { QueryClient } from "@tanstack/solid-query"
import type { ServerScope } from "@/utils/server-scope"
import { createServerPermissionState } from "./permission"
import { createServerNotificationState } from "./notification"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
init: () => {
const server = useServer()
const server = useServers()
const serverHealth = useServerHealth(
() => server.list,
() => true,
@@ -35,23 +37,21 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
if (store.settings.serverKey !== key) setStore("settings", "serverKey", key)
})
const serverCtxs = new Map<
ServerConnection.Key,
{ dispose: () => void; serverCtx: ReturnType<typeof createServerCtx> }
>()
const serverCtxs = new Map<ServerConnection.Key, ReturnType<typeof createServerController>>()
const serverCtxDisposers = new Map<ServerConnection.Key, () => void>()
const owner = getOwner()
const ensureServerCtx = (conn: ServerConnection.Any) => {
const key = ServerConnection.key(conn)
const existing = serverCtxs.get(key)
if (existing) return existing.serverCtx
const root = createRoot((dispose) => {
const serverCtx = createServerCtx(conn, server.scope(key), server.projects.forServer(key))
return { dispose, serverCtx }
if (existing) return existing
const serverCtx = createRoot((dispose) => {
serverCtxDisposers.set(key, dispose)
return createServerController(conn, server.scope(key), server.projects.forServer(key))
}, owner as any)
serverCtxs.set(key, root)
return root.serverCtx
serverCtxs.set(key, serverCtx)
return serverCtx
}
createMemo(() => {
@@ -63,8 +63,8 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
createEffect(() => {
for (const [key] of serverCtxs) {
if (!server.list.find((conn) => ServerConnection.key(conn) === key)) {
const { dispose } = serverCtxs.get(key)!
dispose()
serverCtxDisposers.get(key)?.()
serverCtxDisposers.delete(key)
serverCtxs.delete(key)
}
}
@@ -93,22 +93,26 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
function createServerCtx(
function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
projects: ReturnType<typeof createServerProjects>,
) {
const connKey = ServerConnection.key(conn)
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnReconnect: false,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnMount: false,
},
},
})
const sdk = createServerSdkContext(conn, scope)
const sync = createServerSyncContext(sdk)
const permission = createServerPermissionState({ sdk, sync })
const notification = createServerNotificationState({ sdk, sync, key: connKey })
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = sync.child(project.worktree, { bootstrap: false })
@@ -150,10 +154,22 @@ function createServerCtx(
list: projectsList,
recentlyClosed: recentlyClosedList,
},
permission,
notification,
}
}
export type ServerCtx = ReturnType<typeof createServerCtx>
export function useServerCtx(server: Accessor<ServerConnection.Any>): Accessor<ServerCtx>
export function useServerCtx(server: Accessor<ServerConnection.Any | undefined>): Accessor<ServerCtx | undefined>
export function useServerCtx(server: Accessor<ServerConnection.Any | undefined>) {
const global = useGlobal()
return () => {
const s = server()
if (s) return global.ensureServerCtx(s)
}
}
export type ServerCtx = ReturnType<typeof createServerController>
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
+102 -247
View File
@@ -4,13 +4,10 @@ import { useLocation } from "@solidjs/router"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { makeEventListener } from "@solid-primitives/event-listener"
import { useServerSync } from "./server-sync"
import { useServerSDK } from "./server-sdk"
import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server"
import { ServerConnection, useServers } from "./servers"
import { usePlatform } from "./platform"
import type { Project } from "@/types"
import { normalizeProjectInfo } from "./global-sync/utils"
import { Persist, persisted, removePersisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { decode64 } from "@/utils/base64"
import { same } from "@/utils/same"
import { createScrollPersistence, type SessionScroll } from "./layout-scroll"
@@ -19,7 +16,6 @@ import type { ProjectAvatarVariant } from "@opencode-ai/ui/v2/project-avatar-v2"
import { migrateLegacySessionStateKeys, ServerScope, SessionStateKey } from "@/utils/server-scope"
import { createSessionKeyReader, ensureSessionKey, pruneSessionKeys } from "./layout-helpers"
import { requireServerKey } from "@/utils/session-route"
import { type DraftTab, useTabs } from "./tabs"
import { closeSessionTab, openSessionTab, previewSessionTab, type SessionTabs } from "./layout-tabs"
export { createSessionKeyReader, ensureSessionKey, pruneSessionKeys }
@@ -75,13 +71,6 @@ type SessionView = {
todoCollapsed?: boolean
}
type TabHandoff = {
scope: ServerScope
dir: string
id: string
at: number
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
export type HomeProjectSelection = { server: ServerConnection.Key; directory?: string }
@@ -91,9 +80,8 @@ export type ReviewPanelSource = "context-button" | "other"
export type LayoutRoute =
| { type: "home" }
| { type: "draft"; draftID: string; server?: ServerConnection.Key }
| { type: "dir-new-sesssion"; dir: string; dirBase64: string; server?: ServerConnection.Key }
| { type: "session"; sessionId: string; server?: ServerConnection.Key }
| { type: "draft"; draftID: string }
| { type: "session"; sessionId: string; server: ServerConnection.Key }
const sessionPath = (key: string) => {
const dir = SessionStateKey.route(key).split("/")[0]
@@ -149,33 +137,21 @@ export const currentRoute = (pathname: string, search: string): LayoutRoute => {
const dir = decode64(dirBase64)
if (!dir) return { type: "home" }
if (parts[1] !== "session") return { type: "home" }
throw new Error("Unrecognised route!")
}
const id = parts[2]
if (id) return { type: "session", sessionId: id }
return { type: "dir-new-sesssion", dir, dirBase64 }
export const useCurrentRoute = () => {
const location = useLocation()
return createMemo(() => currentRoute(location.pathname, location.search))
}
export const { use: useLayout, provider: LayoutProvider } = createSimpleContext({
name: "Layout",
gate: false,
init: () => {
const serverSdk = useServerSDK()
const serverSync = useServerSync()
const server = useServer()
const tabs = useTabs()
// const serverSync = useServerSync()
const servers = useServers()
const platform = usePlatform()
const location = useLocation()
const route = createMemo(() => {
const value = currentRoute(location.pathname, location.search)
if (value.type === "home") return value
if (value.server) return value
if (value.type === "draft") {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === value.draftID)
if (draft) return { ...value, server: draft.server }
}
return { ...value, server: server.key }
})
const isRecord = (value: unknown): value is Record<string, unknown> =>
typeof value === "object" && value !== null && !Array.isArray(value)
@@ -267,9 +243,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
}
}
const target = Persist.serverGlobal(serverSdk().scope, "layout", ["layout.v6"])
const [store, setStore, _, ready] = persisted(
{ ...target, migrate },
{ ...Persist.global("layout", ["layout.v6"]), migrate },
createStore({
sidebar: {
opened: false,
@@ -298,11 +273,8 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
handoff: {
tabs: undefined as TabHandoff | undefined,
},
home: {
selection: { server: server.key } as HomeProjectSelection,
selection: { server: ServerConnection.key(servers.list[0]) } as HomeProjectSelection,
},
}),
)
@@ -442,168 +414,116 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return available[Math.floor(Math.random() * available.length)]
}
function enrich(project: { worktree: string; expanded: boolean }) {
const [childStore] = serverSync().child(project.worktree, { bootstrap: false })
const projectID = childStore.project
const metadata = projectID
? serverSync().data.project.find((x) => x.id === projectID)
: serverSync().data.project.find((x) => x.worktree === project.worktree)
// function enrich(project: { worktree: string; expanded: boolean }) {
// const [childStore] = serverSync.child(project.worktree, { bootstrap: false })
// const projectID = childStore.project
// const metadata = projectID
// ? serverSync.data.project.find((x) => x.id === projectID)
// : serverSync.data.project.find((x) => x.worktree === project.worktree)
// Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// Without this, different subdirectories of the same git repo would share the same
// icon from the database instead of using their individual overrides.
const base = { ...metadata, ...project }
if (childStore.icon) {
return { ...base, icon: { ...base.icon, override: childStore.icon } }
}
return base
}
// // Preserve local icon override from per-workspace localStorage cache (childStore.icon).
// // Without this, different subdirectories of the same git repo would share the same
// // icon from the database instead of using their individual overrides.
// const base = { ...metadata, ...project }
// if (childStore.icon) {
// return { ...base, icon: { ...base.icon, override: childStore.icon } }
// }
// return base
// }
const roots = createMemo(() => {
const map = new Map<string, string>()
for (const project of serverSync().data.project) {
const sandboxes = project.sandboxes ?? []
for (const sandbox of sandboxes) {
map.set(sandbox, project.worktree)
}
}
return map
})
// const roots = createMemo(() => {
// const map = new Map<string, string>()
// for (const project of serverSync.data.project) {
// const sandboxes = project.sandboxes ?? []
// for (const sandbox of sandboxes) {
// map.set(sandbox, project.worktree)
// }
// }
// return map
// })
const rootFor = (directory: string) => {
const map = roots()
if (map.size === 0) return directory
// const rootFor = (directory: string) => {
// const map = roots()
// if (map.size === 0) return directory
const visited = new Set<string>()
const chain = [directory]
// const visited = new Set<string>()
// const chain = [directory]
while (chain.length) {
const current = chain[chain.length - 1]
if (!current) return directory
// while (chain.length) {
// const current = chain[chain.length - 1]
// if (!current) return directory
const next = map.get(current)
if (!next) return current
// const next = map.get(current)
// if (!next) return current
if (visited.has(next)) return directory
visited.add(next)
chain.push(next)
}
// if (visited.has(next)) return directory
// visited.add(next)
// chain.push(next)
// }
return directory
}
// return directory
// }
createEffect(() => {
const projects = server.projects.list()
const seen = new Set(projects.map((project) => project.worktree))
const enriched = createMemo(() => [] /* servers.projects.list().map(enrich) */)
batch(() => {
for (const project of projects) {
const root = rootFor(project.worktree)
if (root === project.worktree) continue
// createEffect(() => {
// const projects = enriched()
// if (projects.length === 0) return
// if (!serverSync.ready) return
server.projects.remove(project.worktree)
// for (const project of projects) {
// if (!project.id) continue
// if (project.id === "global") continue
// serverSync.project.icon(project.worktree, project.icon?.override)
// }
// })
if (!seen.has(root)) {
server.projects.open(root)
seen.add(root)
}
// createEffect(() => {
// const projects = enriched()
// if (projects.length === 0) return
if (project.expanded) server.projects.expand(root)
}
})
})
// for (const project of projects) {
// if (project.icon?.color) colorRequested.delete(project.worktree)
// }
const enriched = createMemo(() => server.projects.list().map(enrich))
const list = createMemo(() => {
const projects = enriched()
return projects.map((project) => {
const color = project.icon?.color ?? colors[project.worktree]
if (!color) return project
const icon = project.icon ? { ...project.icon, color } : { color }
return { ...project, icon }
})
})
// const used = new Set<string>()
// for (const project of projects) {
// const color = project.icon?.color ?? colors[project.worktree]
// if (color) used.add(color)
// }
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
if (!serverSync().ready) return
// for (const project of projects) {
// if (project.icon?.color || project.icon?.override || project.icon?.url) continue
// const worktree = project.worktree
// const existing = colors[worktree]
// const color = existing ?? pickAvailableColor(used)
// if (!existing) {
// used.add(color)
// setColors(worktree, color)
// }
// if (!project.id) continue
for (const project of projects) {
if (!project.id) continue
if (project.id === "global") continue
serverSync().project.icon(project.worktree, project.icon?.override)
}
})
// const requested = colorRequested.get(worktree)
// if (requested === color) continue
// colorRequested.set(worktree, color)
createEffect(() => {
const projects = enriched()
if (projects.length === 0) return
// if (project.id === "global") {
// serverSync.project.meta(worktree, { icon: { color } })
// continue
// }
for (const project of projects) {
if (project.icon?.color) colorRequested.delete(project.worktree)
}
const used = new Set<string>()
for (const project of projects) {
const color = project.icon?.color ?? colors[project.worktree]
if (color) used.add(color)
}
for (const project of projects) {
if (project.icon?.color || project.icon?.override || project.icon?.url) continue
const worktree = project.worktree
const existing = colors[worktree]
const color = existing ?? pickAvailableColor(used)
if (!existing) {
used.add(color)
setColors(worktree, color)
}
if (!project.id) continue
const requested = colorRequested.get(worktree)
if (requested === color) continue
colorRequested.set(worktree, color)
if (project.id === "global") {
serverSync().project.meta(worktree, { icon: { color } })
continue
}
const projectID = project.id
void (async () => {
// TODO: Restore project color updates when the V2 client exposes a project update API.
void projectID
})().catch(() => {
if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
})
}
})
let sessionFrame: number | undefined
let sessionTimer: number | undefined
onMount(() => {
sessionFrame = requestAnimationFrame(() => {
sessionFrame = undefined
sessionTimer = window.setTimeout(() => {
sessionTimer = undefined
void Promise.all(
server.projects.list().map((project) => {
return serverSync().project.loadSessions(project.worktree)
}),
)
}, 0)
})
})
onCleanup(() => {
if (sessionFrame !== undefined) cancelAnimationFrame(sessionFrame)
if (sessionTimer !== undefined) window.clearTimeout(sessionTimer)
})
// const projectID = project.id
// void (async () => {
// // TODO: Restore project color updates when the V2 client exposes a project update API.
// void projectID
// })().catch(() => {
// if (colorRequested.get(worktree) === color) colorRequested.delete(worktree)
// })
// }
// })
return {
route,
route: useCurrentRoute(),
ready,
home: {
selection: createMemo(() => store.home.selection),
@@ -611,71 +531,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("home", "selection", reconcile(selection))
},
},
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { scope: serverSdk().scope, dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
setStore("handoff", "tabs", undefined)
},
},
projects: {
list,
recentlyClosed: createMemo(() => {
const known = new Set(serverSync().data.project.map((project) => pathKey(project.worktree)))
return server.projects
.recentlyClosed()
.filter((worktree) => known.has(pathKey(worktree)))
.slice(0, RECENTLY_CLOSED_DISPLAY_LIMIT)
.map((worktree) => enrich({ worktree, expanded: false }))
}),
open(directory: string) {
const root = rootFor(directory)
if (server.projects.list().find((x) => x.worktree === root)) return
void serverSync().project.loadSessions(root)
server.projects.open(root)
},
close(directory: string) {
server.projects.close(directory)
},
expand(directory: string) {
server.projects.expand(directory)
},
collapse(directory: string) {
server.projects.collapse(directory)
},
move(directory: string, toIndex: number) {
server.projects.move(directory, toIndex)
},
},
sidebar: {
opened: createMemo(() => store.sidebar.opened),
open() {
setStore("sidebar", "opened", true)
},
close() {
setStore("sidebar", "opened", false)
},
toggle() {
setStore("sidebar", "opened", (x) => !x)
},
width: createMemo(() => store.sidebar.width),
resize(width: number) {
setStore("sidebar", "width", width)
},
workspaces(directory: string) {
return () => store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
},
setWorkspaces(directory: string, value: boolean) {
setStore("sidebar", "workspaces", directory, value)
},
toggleWorkspaces(directory: string) {
const current = store.sidebar.workspaces[directory] ?? store.sidebar.workspacesDefault ?? false
setStore("sidebar", "workspaces", directory, !current)
},
},
terminal: {
height: createMemo(() => store.terminal.height),
resize(height: number) {
+5 -5
View File
@@ -73,7 +73,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [saved, setSaved, , savedReady] = persisted(
{
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
...Persist.serverWorkspace(serverSDK.scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
},
createStore<Saved>({
@@ -127,14 +127,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const scope = createMemo<State | undefined>(() => {
const session = id()
if (!session) return store.draft ?? store.promoting
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
return saved.session[session] ?? handoff.get(handoffKey(serverSDK.scope, sdk().directory, session))
})
createEffect(() => {
const session = id()
if (!session) return
const key = handoffKey(serverSDK().scope, sdk().directory, session)
const key = handoffKey(serverSDK.scope, sdk().directory, session)
const next = handoff.get(key)
if (!next) return
if (saved.session[session] !== undefined) {
@@ -385,7 +385,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
if (!next) return
const key = handoffKey(serverSDK().scope, dir, session)
const key = handoffKey(serverSDK.scope, dir, session)
handoff.set(key, next)
if (dir === sdk().directory) {
@@ -400,7 +400,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
if (!session) return
if (msg.sessionID !== session) return
if (saved.session[session] !== undefined) return
if (handoff.has(handoffKey(serverSDK().scope, sdk().directory, session))) return
if (handoff.has(handoffKey(serverSDK.scope, sdk().directory, session))) return
setSaved("session", session, {
agent: msg.agent,
+153 -143
View File
@@ -22,152 +22,162 @@ function modelKey(model: ModelKey) {
return `${model.providerID}:${model.modelID}`
}
const createModelsPersistedState = () => {
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
createStore<Store>({
user: [],
recent: [],
variant: {},
}),
)
return [store, setStore, ready] as const
}
const createModelsController = (directory: Accessor<string | undefined>) => {
const providers = useProviders(() => directory())
const [store, setStore, ready] = createModelsPersistedState()
const available = createMemo(() =>
providers.connected().flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
provider: p,
})),
),
)
const release = createMemo(
() =>
new Map(
available().map((model) => {
const parsed = DateTime.fromISO(model.release_date)
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
}),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter(
(x) =>
Math.abs(
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
.diffNow()
.as("months"),
) < 6,
),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
models,
groupBy((x) => x.family),
values(),
(groups) =>
groups.flatMap((g) => {
const first = firstBy(g, [(x) => x.release_date, "desc"])
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
}),
),
),
values(),
flat(),
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
const visibility = createMemo(() => {
const map = new Map<string, Visibility>()
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
return map
})
const list = createMemo(() =>
available().map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
})),
)
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
function update(model: ModelKey, state: Visibility) {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, (current) => ({ ...current, visibility: state }))
return
}
setStore("user", store.user.length, { ...model, visibility: state })
}
const visible = (model: ModelKey) => {
const key = modelKey(model)
const state = visibility().get(key)
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
const date = release().get(key)
if (!date?.isValid) return true
return false
}
const setVisibility = (model: ModelKey, state: boolean) => {
update(model, state ? "show" : "hide")
}
const push = (model: ModelKey) => {
const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
if (uniq.length > RECENT_LIMIT) uniq.pop()
setStore("recent", uniq)
}
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value })
return
}
setStore("variant", key, value)
}
const [recentModels] = createResource(
async () => {
const recent = store.recent
await ready.promise
return recent
},
(p) => p,
{ initialValue: [] },
)
return {
ready,
list,
find,
visible,
setVisibility,
recent: {
list: () => recentModels()!,
push,
},
variant: {
get: getVariant,
set: setVariant,
},
}
}
export const { use: useModels, provider: ModelsProvider } = createSimpleContext({
name: "Models",
gate: false,
init: (props: { directory?: Accessor<string | undefined> } = {}) => {
const providers = useProviders(() => props.directory?.())
const [store, setStore, _, ready] = persisted(
Persist.global("model", ["model.v1"]),
createStore<Store>({
user: [],
recent: [],
variant: {},
}),
)
const available = createMemo(() =>
providers.connected().flatMap((p) =>
Object.values(p.models).map((m) => ({
...m,
provider: p,
})),
),
)
const release = createMemo(
() =>
new Map(
available().map((model) => {
const parsed = DateTime.fromISO(model.release_date)
return [modelKey({ providerID: model.provider.id, modelID: model.id }), parsed] as const
}),
),
)
const latest = createMemo(() =>
pipe(
available(),
filter(
(x) =>
Math.abs(
(release().get(modelKey({ providerID: x.provider.id, modelID: x.id })) ?? DateTime.invalid("invalid"))
.diffNow()
.as("months"),
) < 6,
),
groupBy((x) => x.provider.id),
mapValues((models) =>
pipe(
models,
groupBy((x) => x.family),
values(),
(groups) =>
groups.flatMap((g) => {
const first = firstBy(g, [(x) => x.release_date, "desc"])
return first ? [{ modelID: first.id, providerID: first.provider.id }] : []
}),
),
),
values(),
flat(),
),
)
const latestSet = createMemo(() => new Set(latest().map((x) => modelKey(x))))
const visibility = createMemo(() => {
const map = new Map<string, Visibility>()
for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility)
return map
})
const list = createMemo(() =>
available().map((m) => ({
...m,
name: m.name.replace("(latest)", "").trim(),
latest: m.name.includes("(latest)"),
})),
)
const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID)
function update(model: ModelKey, state: Visibility) {
const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID)
if (index >= 0) {
setStore("user", index, (current) => ({ ...current, visibility: state }))
return
}
setStore("user", store.user.length, { ...model, visibility: state })
}
const visible = (model: ModelKey) => {
const key = modelKey(model)
const state = visibility().get(key)
if (state === "hide") return false
if (state === "show") return true
if (latestSet().has(key)) return true
const date = release().get(key)
if (!date?.isValid) return true
return false
}
const setVisibility = (model: ModelKey, state: boolean) => {
update(model, state ? "show" : "hide")
}
const push = (model: ModelKey) => {
const uniq = uniqueBy([model, ...store.recent], (x) => `${x.providerID}:${x.modelID}`)
if (uniq.length > RECENT_LIMIT) uniq.pop()
setStore("recent", uniq)
}
const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}`
const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)]
const setVariant = (model: ModelKey, value: string | undefined) => {
const key = variantKey(model)
if (!store.variant) {
setStore("variant", { [key]: value })
return
}
setStore("variant", key, value)
}
const [recentModels] = createResource(
async () => {
const recent = store.recent
await ready.promise
return recent
},
(p) => p,
{ initialValue: [] },
)
return {
ready,
list,
find,
visible,
setVisibility,
recent: {
list: () => recentModels()!,
push,
},
variant: {
get: getVariant,
set: setVariant,
},
}
return createModelsController(() => props.directory?.())
},
})
+27 -138
View File
@@ -13,10 +13,12 @@ import type { EventSessionError } from "@/types"
import { Persist, persisted } from "@/utils/persist"
import { playSoundById } from "@/utils/sound"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { ServerConnection, useServers } from "./servers"
import { type DraftTab, useTabs } from "./tabs"
import { requireServerKey } from "@/utils/session-route"
import type { ServerScope } from "@/utils/server-scope"
import { useCurrentRoute } from "./layout"
import { useServer } from "./server"
type NotificationBase = {
directory?: string
@@ -109,131 +111,18 @@ function buildNotificationIndex(list: Notification[]) {
return index
}
export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({
name: "Notification",
gate: false,
init: () => {
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const global = useGlobal()
const server = useServer()
const tabs = useTabs()
const navigate = useNavigate()
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const owner = getOwner()
const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>()
const activeServer = createMemo(() => {
if (params.serverKey) return requireServerKey(params.serverKey)
if (search.draftId) {
const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
if (draft) return draft.server
}
return server.key
})
const activeDirectory = createMemo(() => decode64(params.dir))
const activeSession = createMemo(() => params.id)
const ensure = (key: ServerConnection.Key) => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn) throw new Error(`Notification server not found: ${key}`)
const ctx = global.ensureServerCtx(conn)
const existing = states.get(ctx.sdk.scope)
if (existing) return existing.state
const root = createRoot(
(dispose) => ({
dispose,
state: createServerNotificationState({
sdk: ctx.sdk,
sync: ctx.sync,
active: () => server.scope(activeServer()) === ctx.sdk.scope,
directory: activeDirectory,
sessionID: activeSession,
platform,
settings,
language,
navigate,
}),
}),
owner ?? undefined,
)
states.set(ctx.sdk.scope, root)
return root.state
}
createEffect(() => {
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
})
createEffect(() => {
const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn))))
states.forEach((value, scope) => {
if (scopes.has(scope)) return
value.dispose()
states.delete(scope)
})
})
onCleanup(() => states.forEach((value) => value.dispose()))
const selected = () => {
const list = global.servers.list()
const key = activeServer()
if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key)
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
if (!conn) throw new Error("Notification server not found")
return ensure(ServerConnection.key(conn))
}
return {
ready: () => selected().ready(),
ensureServerState: ensure,
session: {
all: (session: string) => selected().session.all(session),
unseen: (session: string) => selected().session.unseen(session),
unseenCount: (session: string) => selected().session.unseenCount(session),
unseenHasError: (session: string) => selected().session.unseenHasError(session),
markViewed: (session: string) => selected().session.markViewed(session),
},
project: {
all: (directory: string) => selected().project.all(directory),
unseen: (directory: string) => selected().project.unseen(directory),
unseenCount: (directory: string) => selected().project.unseenCount(directory),
unseenHasError: (directory: string) => selected().project.unseenHasError(directory),
markViewed: (directory: string) => selected().project.markViewed(directory),
},
}
},
})
type NotificationState = ReturnType<typeof createServerNotificationState>
function createServerNotificationState(input: {
sdk: ServerSDK
sync: ServerSync
active: Accessor<boolean>
directory: Accessor<string | undefined>
sessionID: Accessor<string | undefined>
platform: ReturnType<typeof usePlatform>
settings: ReturnType<typeof useSettings>
language: ReturnType<typeof useLanguage>
navigate: (href: string) => void
}) {
const serverSDK = () => input.sdk
const serverSync = () => input.sync
const platform = input.platform
const settings = input.settings
const language = input.language
export function createServerNotificationState(input: { sdk: ServerSDK; sync: ServerSync; key: ServerConnection.Key }) {
const platform = usePlatform()
const settings = useSettings()
const language = useLanguage()
const navigate = useNavigate()
const empty: Notification[] = []
const currentDirectory = input.directory
const currentSession = input.sessionID
const route = useCurrentRoute()
const [store, setStore, _, ready] = persisted(
Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]),
Persist.serverGlobal(input.sdk.scope, "notification", ["notification.v1"]),
createStore({
list: [] as Notification[],
}),
@@ -316,7 +205,7 @@ function createServerNotificationState(input: {
const lookup = async (directory: string, sessionID?: string) => {
if (!sessionID) return undefined
const sync = serverSync().ensureDirSyncContext(directory)
const sync = input.sync.ensureDirSyncContext(directory)
const session = sync.session.get(sessionID)
if (session) return session
return sync.session
@@ -325,17 +214,12 @@ function createServerNotificationState(input: {
.catch(() => undefined)
}
const viewedInCurrentSession = (directory: string, sessionID?: string) => {
if (!input.active()) return false
const activeDirectory = currentDirectory()
const activeSession = currentSession()
if (!activeSession) return false
if (!sessionID) return false
if (activeDirectory && directory !== activeDirectory) return false
return sessionID === activeSession
const viewedInCurrentSession = (sessionID: string) => {
const currentRoute = route()
return currentRoute.type === "session" && currentRoute.server === input.key && currentRoute.sessionId === sessionID
}
const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => {
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
const sessionID = event.properties.sessionID
void lookup(directory, sessionID).then((session) => {
if (meta.disposed) return
@@ -349,15 +233,15 @@ function createServerNotificationState(input: {
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
viewed: viewedInCurrentSession(sessionID),
type: "turn-complete",
session: sessionID,
})
const href = `/${base64Encode(directory)}/session/${sessionID}`
const href = `/server/${input.key}/session/${sessionID}`
if (settings.notifications.agent()) {
void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
input.navigate(href),
navigate(href),
)
}
})
@@ -381,7 +265,7 @@ function createServerNotificationState(input: {
append({
directory,
time,
viewed: viewedInCurrentSession(directory, sessionID),
viewed: viewedInCurrentSession(sessionID),
type: "error",
session: sessionID ?? "global",
error,
@@ -389,14 +273,14 @@ function createServerNotificationState(input: {
const description =
session?.title ??
(typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}`
const href = `/server/${input.key}/session/${sessionID}`
if (settings.notifications.errors()) {
void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href))
void platform.notify(language.t("notification.session.error.title"), description, () => navigate(href))
}
})
}
const unsub = serverSDK().event.listen((e) => {
const unsub = input.sdk.event.listen((e) => {
const event = e.details
if (event.type !== "session.idle" && event.type !== "session.execution.failed") return
@@ -481,3 +365,8 @@ function createServerNotificationState(input: {
},
}
}
export const useNotification = () => {
const server = useServer()
return server.ctx.notification
}
+8 -135
View File
@@ -8,7 +8,7 @@ import type { ServerSync } from "./server-sync"
import { useParams, useSearchParams } from "@solidjs/router"
import { decode64 } from "@/utils/base64"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { ServerConnection, useServers } from "./servers"
import { type DraftTab, useTabs } from "./tabs"
import { useSettings } from "./settings"
import { requireServerKey } from "@/utils/session-route"
@@ -20,6 +20,7 @@ import {
autoRespondsPermission,
sessionAutoAccept,
} from "./permission-auto-respond"
import { useServer } from "./server"
type PermissionRespondFn = (input: {
sessionID: string
@@ -51,142 +52,9 @@ function hasPermissionPromptRules(permission: unknown) {
return Object.values(config).some(isNonAllowRule)
}
export const { use: usePermission, provider: PermissionProvider } = createSimpleContext({
name: "Permission",
gate: false,
init: () => {
const params = useParams<{ serverKey?: string; dir?: string; id?: string }>()
const [search] = useSearchParams<{ draftId?: string }>()
const global = useGlobal()
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const owner = getOwner()
const states = new Map<ServerScope, { key: ServerConnection.Key; dispose: () => void; state: PermissionState }>()
const activeDraft = createMemo(() => {
if (!search.draftId) return
return tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId)
})
const activeServer = createMemo(() => {
if (params.serverKey && settings.general.newLayoutDesigns()) return requireServerKey(params.serverKey)
return activeDraft()?.server ?? server.key
})
const ensure = (key: ServerConnection.Key) => {
const conn = global.servers.list().find((item) => ServerConnection.key(item) === key)
if (!conn) throw new Error(`Permission server not found: ${key}`)
const ctx = global.ensureServerCtx(conn)
const existing = states.get(ctx.sdk.scope)
if (existing && global.servers.list().some((item) => ServerConnection.key(item) === existing.key)) {
return existing.state
}
if (existing) {
existing.dispose()
states.delete(ctx.sdk.scope)
}
const root = createRoot(
(dispose) => ({
key,
dispose,
state: createServerPermissionState({ sdk: ctx.sdk, sync: ctx.sync }),
}),
owner ?? undefined,
)
states.set(ctx.sdk.scope, root)
return root.state
}
createEffect(() => {
global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn)))
})
createEffect(() => {
const list = global.servers.list()
const keys = new Set(list.map(ServerConnection.key))
states.forEach((value, scope) => {
if (keys.has(value.key)) return
value.dispose()
states.delete(scope)
const replacement = list.find((conn) => server.scope(ServerConnection.key(conn)) === scope)
if (replacement) ensure(ServerConnection.key(replacement))
})
})
onCleanup(() => states.forEach((value) => value.dispose()))
let lastSelected: PermissionState | undefined
const selected = () => {
const key = activeServer()
if (global.servers.list().some((conn) => ServerConnection.key(conn) === key)) {
lastSelected = ensure(key)
}
if (lastSelected) return lastSelected
return ensure(server.key)
}
const activeDirectory = createMemo(() => {
const directory = decode64(params.dir)
if (directory) return directory
const draft = activeDraft()
if (draft) return draft.directory
if (!params.id) return
if (!global.servers.list().some((conn) => ServerConnection.key(conn) === activeServer())) return
return selected().sync.session.lineage.peek(params.id)?.session.location.directory
})
createEffect(() => {
const directory = activeDirectory()
if (!directory) return
selected().enableConfiguredDirectory(directory)
})
const permissionsEnabled = createMemo(() => {
const directory = activeDirectory()
if (!directory) return false
return selected().permissionsEnabled(directory)
})
return {
ready: () => selected().ready(),
ensureServerState: (key: ServerConnection.Key) => ensure(key).api,
currentServerState: () => selected().api,
respond(input: Parameters<PermissionRespondFn>[0]) {
selected().respond(input)
},
autoResponds(permission: PermissionRequest, directory?: string) {
return selected().autoResponds(permission, directory)
},
isAutoAccepting(sessionID: string, directory?: string) {
return selected().isAutoAccepting(sessionID, directory)
},
isAutoAcceptingDirectory(directory: string) {
return selected().isAutoAcceptingDirectory(directory)
},
toggleAutoAccept(sessionID: string, directory: string) {
selected().toggleAutoAccept(sessionID, directory)
},
toggleAutoAcceptDirectory(directory: string) {
selected().toggleAutoAcceptDirectory(directory)
},
enableAutoAccept(sessionID: string, directory: string) {
selected().enableAutoAccept(sessionID, directory)
},
disableAutoAccept(sessionID: string, directory?: string) {
selected().disableAutoAccept(sessionID, directory)
},
permissionsEnabled,
isPermissionAllowAll(directory: string) {
return selected().isPermissionAllowAll(directory)
},
}
},
})
type PermissionState = ReturnType<typeof createServerPermissionState>
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
const [store, setStore, _, ready] = persisted(
{
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
@@ -474,3 +342,8 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }
},
}
}
export const usePermission = () => {
const server = useServer()
return server.ctx.permission
}
+1 -1
View File
@@ -2,7 +2,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import type { AsyncStorage, SyncStorage } from "@solid-primitives/storage"
import type { Accessor } from "solid-js"
import type { DesktopMenuAction } from "../desktop-menu"
import { ServerConnection } from "./server"
import { ServerConnection } from "./servers"
import type { WslServersPlatform } from "../wsl/types"
import type { UpdaterPlatform } from "../updater"
import type { DraftStore } from "@/utils/draft-store"
+4 -4
View File
@@ -3,7 +3,7 @@ import { createSimpleContext } from "@opencode-ai/ui/context"
import { useParams, useSearchParams } from "@solidjs/router"
import { createMemo, createResource, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "./server"
import { ServerConnection } from "./servers"
import { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings"
import { useSDK } from "./sdk"
@@ -101,12 +101,12 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const owner = getOwner()
const serverKey = () =>
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK.server)
const scope = (): PromptScope =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope) => {
const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined
if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope)
if (current) return createTabPromptState(tabs, current, serverSDK.scope, scope)
const key = scopeKey(scope)
const existing = cache.get(key)
@@ -118,7 +118,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const entry = createRoot(
(dispose) => ({
value: createPromptSession(serverSDK().scope, scope),
value: createPromptSession(serverSDK.scope, scope),
dispose,
}),
owner,
View File
+1 -1
View File
@@ -10,7 +10,7 @@ const context = createSimpleContext({
const serverSDK = useServerSDK()
return createMemo(() => {
const directory = typeof props.directory === "function" ? props.directory() : props.directory
return serverSDK().ensureDirSdkContext(directory)
return serverSDK.ensureDirSdkContext(directory)
})
},
})
+6 -17
View File
@@ -7,10 +7,11 @@ import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js"
import { createApiForServer, type ServerApi } from "@/utils/server"
import { useLanguage } from "./language"
import { usePlatform } from "./platform"
import { ServerConnection, useServer } from "./server"
import { ServerConnection, useServers } from "./servers"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerScope } from "@/utils/server-scope"
import { useServer } from "./server"
const isAbortError = (error: unknown) =>
error !== null && typeof error === "object" && "name" in error && error.name === "AbortError"
@@ -266,22 +267,10 @@ export function createServerSdkContext(server: ServerConnection.Any, scope: Serv
})
}
export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleContext({
name: "ServerSDK",
// Returns an accessor so the resolved server can change reactively (e.g. a
// /new-session draft retargeting its server) without re-instantiating the subtree.
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
return createMemo<ServerSDK>(() => {
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.ensureServerCtx(conn).sdk
})
},
})
export const useServerSDK = () => {
const server = useServer()
return server.ctx.sdk
}
type SDKEventMap = {
[key in Event["type"]]: Extract<ServerEvent, { type: key }>
+9 -23
View File
@@ -1,8 +1,8 @@
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
import { showToast } from "@/utils/toast"
import { getFilename } from "@opencode-ai/core/util/path"
import { type Accessor, batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, produce, reconcile } from "solid-js/store"
import { batch, createMemo, getOwner, onCleanup, onMount, untrack } from "solid-js"
import { createStore, reconcile } from "solid-js/store"
import { useLanguage } from "@/context/language"
import type { InitError } from "../pages/error"
import { ServerSDK } from "./server-sdk"
@@ -31,12 +31,9 @@ import { createRefreshQueue } from "./global-sync/queue"
import { directoryKey } from "./global-sync/utils"
import { PathKey } from "@/utils/path-key"
import { createDirSyncContext } from "./directory-sync"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
import { createRefCountMap } from "@/utils/refcount"
import { useGlobal } from "./global"
import { ServerConnection, useServer } from "./server"
import { retry } from "@opencode-ai/core/util/retry"
import { ServerConnection } from "./servers"
import type { ServerScope } from "@/utils/server-scope"
import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
import { persisted } from "@/utils/persist"
@@ -54,6 +51,7 @@ import type {
import { toggleMcp } from "./global-sync/mcp"
import { createServerSession, type ServerSession } from "./server-session"
import { usePlatform } from "./platform"
import { useServer } from "./server"
type GlobalStore = {
ready: boolean
@@ -695,24 +693,12 @@ export function createServerSyncContext(serverSDK: ServerSDK) {
export type ServerSync = ReturnType<typeof createServerSyncContext>
export const { use: useServerSync, provider: ServerSyncProvider } = createSimpleContext({
name: "ServerSync",
// Returns an accessor so the resolved server can change reactively without
// re-instantiating the subtree (mirrors useServerSDK).
init: (props: { server?: Accessor<ServerConnection.Any | undefined> }) => {
const global = useGlobal()
const language = useLanguage()
const server = useServer()
return createMemo<ServerSync>(() => {
const conn = props.server?.() ?? server.current
if (!conn) throw new Error(language.t("error.serverSDK.noServerAvailable"))
return global.ensureServerCtx(conn).sync
})
},
})
export const useServerSync = () => {
const server = useServer()
return server.ctx.sync
}
export function useQueryOptions() {
const sync = useServerSync()
return createMemo(() => sync().queryOptions)
return sync.queryOptions
}
+1 -107
View File
@@ -1,13 +1,11 @@
import { describe, expect, test } from "bun:test"
import { createRoot, createSignal } from "solid-js"
import { createStore } from "solid-js/store"
import {
createServerProjects,
migrateCanonicalLocalServerState,
nextServerAfterRemoval,
resolveServerList,
ServerConnection,
} from "./server"
} from "./servers"
import { ServerScope } from "@/utils/server-scope"
describe("resolveServerList", () => {
@@ -95,110 +93,6 @@ test("active server removal falls back across built-in and persisted servers", (
).toBe(ServerConnection.Key.make("sidecar"))
})
describe("createServerProjects", () => {
test("keeps active and explicit server buckets in one reactive store", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const active = createServerProjects({ scope, store, setStore })
const remote = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
remote.open("/repo")
expect(remote.list()).toEqual([{ worktree: "/repo", expanded: true }])
expect(active.list()).toEqual([])
const adopted = createServerProjects({ scope: () => "https://debian.example" as ServerScope, store, setStore })
expect(adopted.list()).toEqual([{ worktree: "/repo", expanded: true }])
adopted.close("/repo")
expect(remote.list()).toEqual([])
dispose()
})
})
test("tracks recently closed projects and drops them when reopened", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.open("/a")
projects.open("/b")
projects.close("/a")
expect(projects.recentlyClosed()).toEqual(["/a"])
projects.close("/b")
expect(projects.recentlyClosed()).toEqual(["/b", "/a"])
projects.open("/a")
expect(projects.recentlyClosed()).toEqual(["/b"])
expect(projects.list()).toEqual([{ worktree: "/a", expanded: true }])
dispose()
})
})
test("remove drops a project without recording it as recently closed", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.open("/repo/subdir")
projects.remove("/repo/subdir")
expect(projects.list()).toEqual([])
expect(projects.recentlyClosed()).toEqual([])
dispose()
})
})
test("retains recently closed history beyond the visible display limit", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
// Closing 6 projects keeps all 6 in the store even though only 5 are displayed;
// this prevents display-filtered entries from evicting still-visible ones.
for (const dir of ["/1", "/2", "/3", "/4", "/5", "/6"]) {
projects.open(dir)
projects.close(dir)
}
expect(projects.recentlyClosed()).toEqual(["/6", "/5", "/4", "/3", "/2", "/1"])
dispose()
})
})
test("caps recently closed history at the store limit", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
for (let i = 1; i <= 20; i++) {
projects.open(`/p${i}`)
projects.close(`/p${i}`)
}
expect(projects.recentlyClosed()).toHaveLength(16)
expect(projects.recentlyClosed()[0]).toBe("/p20")
expect(projects.recentlyClosed().at(-1)).toBe("/p5")
dispose()
})
})
test("dedupes recently closed entries by normalized path", () => {
createRoot((dispose) => {
const [scope] = createSignal(ServerScope.local)
const [store, setStore] = createStore({ projects: {}, lastProject: {}, recentlyClosed: {} })
const projects = createServerProjects({ scope, store, setStore })
projects.close("/repo")
projects.close("/repo/")
expect(projects.recentlyClosed()).toEqual(["/repo/"])
dispose()
})
})
})
describe("migrateCanonicalLocalServerState", () => {
test("moves an existing canonical web bucket into local scope", () => {
expect(
+13 -365
View File
@@ -1,375 +1,23 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { ServerScope } from "@/utils/server-scope"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = {
projects: Record<string, StoredProject[]>
lastProject: Record<string, string>
recentlyClosed: Record<string, string[]>
}
const HEALTH_POLL_INTERVAL_MS = 10_000
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
// filtered out do not evict still-visible ones from the persisted store.
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
if (!trimmed) return
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
return withProtocol.replace(/\/+$/, "")
}
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
if (!conn) return ""
if (conn.displayName && !ignoreDisplayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
if (!isRecord(value)) return value
const projects = isRecord(value.projects) ? value.projects : undefined
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
const previousProjects = projects?.[canonicalLocalServer]
const previousLastProject = lastProject?.[canonicalLocalServer]
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
const next = { ...value }
if (projects && Array.isArray(previousProjects)) {
const local = Array.isArray(projects.local) ? projects.local : []
const worktrees = new Set(
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
)
const migrated = previousProjects.filter((project) => {
if (!isRecord(project) || typeof project.worktree !== "string") return true
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
})
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
delete nextProjects[canonicalLocalServer]
next.projects = nextProjects
}
if (lastProject && typeof previousLastProject === "string") {
const nextLastProject = { ...lastProject }
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
delete nextLastProject[canonicalLocalServer]
next.lastProject = nextLastProject
}
return next
}
export function createServerProjects<T extends ServerProjectState>(input: {
scope: Accessor<ServerScope>
store: Store<T>
setStore: SetStoreFunction<T>
}) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const current = () => input.store.projects[input.scope()] ?? []
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
const remove = (directory: string) => {
setStore(
"projects",
input.scope(),
current().filter((project) => project.worktree !== directory),
)
}
return {
list: current,
recentlyClosed: currentClosed,
remove,
open(directory: string) {
const scope = input.scope()
const key = pathKey(directory)
const closed = currentClosed()
if (closed.some((worktree) => pathKey(worktree) === key)) {
setStore(
"recentlyClosed",
scope,
closed.filter((worktree) => pathKey(worktree) !== key),
)
}
if (current().some((project) => project.worktree === directory)) return
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
},
// User-initiated close: removes the project and records it in recently closed.
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
close(directory: string) {
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
setStore("recentlyClosed", input.scope(), closed)
},
expand(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
},
collapse(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
},
move(directory: string, toIndex: number) {
const fromIndex = current().findIndex((project) => project.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const next = [...current()]
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
setStore("projects", input.scope(), next)
},
last() {
return input.store.lastProject[input.scope()]
},
touch(directory: string) {
setStore("lastProject", input.scope(), directory)
},
}
}
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
)
for (const value of input.stored) {
const conn: ServerConnection.Http =
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: "http" in value
? value
: { type: "http", http: value }
const key = ServerConnection.key(conn)
const existing = deduped.get(key)
if (existing)
deduped.set(key, {
...existing,
...conn,
http: { ...existing.http, ...conn.http },
})
else deduped.set(key, conn)
}
return [...deduped.values()]
}
export function canRemoveServer(input: {
key: ServerConnection.Key
provided?: Array<ServerConnection.Any>
stored: StoredServer[]
}) {
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
return input.stored.some((server) =>
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
)
}
export namespace ServerConnection {
type Base = { displayName?: string; label?: string }
export type HttpBase = {
url: string
username?: string
password?: string
}
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
authToken?: boolean
} & Base
export type Sidecar = {
type: "sidecar"
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
distro: string
}
) &
Base
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
} & Base
export type Any =
| Http
// All these are desktop-only
| (Sidecar | Ssh)
export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
export const local = (conn?: Any) =>
!!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
}
export function nextServerAfterRemoval(
servers: ServerConnection.Any[],
removed: ServerConnection.Key,
fallback: ServerConnection.Key,
) {
const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
return next ? ServerConnection.key(next) : fallback
}
import { ServerConnection } from "./servers"
import { useGlobal } from "./global"
// Must be keyed for now
export const { use: useServer, provider: ServerProvider } = createSimpleContext({
name: "Server",
gate: true,
init: (props: {
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
const [store, setStore, _, ready] = persisted(
{
...Persist.global("server", ["server.v3"]),
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
},
createStore({
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
recentlyClosed: {} as Record<string, string[]>,
}),
)
init: (props: { conn: ServerConnection.Any }) => {
const conn = props.conn
const key = ServerConnection.key(conn)
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers })
})
const [state, setState] = createStore({
active: props.defaultServer,
})
function setActive(input: ServerConnection.Key) {
if (state.active !== input) setState("active", input)
}
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {
setStore("list", existing, conn)
} else {
setStore("list", store.list.length, conn)
}
setState("active", ServerConnection.key(conn))
return conn
})
}
function remove(key: ServerConnection.Key) {
const next = nextServerAfterRemoval(allServers(), key, props.defaultServer)
const list = store.list.filter((x) => url(x) !== key)
batch(() => {
setStore("list", list)
if (state.active === key) setState("active", next)
})
}
function canRemove(key: ServerConnection.Key) {
return canRemoveServer({ key, provided: props.servers, stored: store.list })
}
const isReady = Object.assign(
createMemo(() => ready() && !!state.active),
{ promise: ready.promise },
)
const scope = (key = state.active) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
const projects = createServerProjects({ scope, store, setStore })
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
const projectsForServer = (key: ServerConnection.Key) => {
const existing = projectStores.get(key)
if (existing) return existing
const next = createServerProjects({ scope: () => scope(key), store, setStore })
projectStores.set(key, next)
return next
}
const current: Accessor<ServerConnection.Any | undefined> = createMemo(
() => allServers().find((s) => ServerConnection.key(s) === state.active) ?? allServers()[0],
)
const isLocal = createMemo(() => ServerConnection.local(current()))
const global = useGlobal()
return {
ready: isReady,
isLocal,
get key() {
return state.active
},
get name() {
return serverName(current())
},
get list() {
return allServers()
},
get current() {
return current()
},
setActive,
add,
remove,
canRemove,
scope,
projects: {
...projects,
forServer: projectsForServer,
conn,
key,
isLocal: ServerConnection.local(props.conn),
ctx: global.ensureServerCtx(conn),
get health() {
return global.servers.health[key]
},
}
},
+342
View File
@@ -0,0 +1,342 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { type Accessor, batch, createMemo } from "solid-js"
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import { Persist, persisted } from "@/utils/persist"
import { pathKey } from "@/utils/path-key"
import { ServerScope } from "@/utils/server-scope"
type StoredProject = { worktree: string; expanded: boolean }
type StoredServer = string | ServerConnection.HttpBase | ServerConnection.Http
type ServerProjectState = {
projects: Record<string, StoredProject[]>
lastProject: Record<string, string>
recentlyClosed: Record<string, string[]>
}
const HEALTH_POLL_INTERVAL_MS = 10_000
// The store retains more history than is displayed. Consumers filter recently closed entries
// against the live project list (dropping deleted projects) and then cap the visible count via
// RECENTLY_CLOSED_DISPLAY_LIMIT. Retaining extra history ensures entries that are temporarily
// filtered out do not evict still-visible ones from the persisted store.
const RECENTLY_CLOSED_HISTORY_LIMIT = 16
export const RECENTLY_CLOSED_DISPLAY_LIMIT = 5
export function normalizeServerUrl(input: string) {
const trimmed = input.trim()
if (!trimmed) return
const withProtocol = /^https?:\/\//.test(trimmed) ? trimmed : `http://${trimmed}`
return withProtocol.replace(/\/+$/, "")
}
export function serverName(conn?: ServerConnection.Any, ignoreDisplayName = false) {
if (!conn) return ""
if (conn.displayName && !ignoreDisplayName) return conn.displayName
return conn.http.url.replace(/^https?:\/\//, "").replace(/\/+$/, "")
}
function isLocalHost(url: string) {
const host = url.replace(/^https?:\/\//, "").split(":")[0]
if (host === "localhost" || host === "127.0.0.1") return "local"
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value)
}
export function migrateCanonicalLocalServerState(value: unknown, canonicalLocalServer?: ServerConnection.Key) {
if (!canonicalLocalServer || canonicalLocalServer === "local") return value
if (!isRecord(value)) return value
const projects = isRecord(value.projects) ? value.projects : undefined
const lastProject = isRecord(value.lastProject) ? value.lastProject : undefined
const previousProjects = projects?.[canonicalLocalServer]
const previousLastProject = lastProject?.[canonicalLocalServer]
if (!Array.isArray(previousProjects) && typeof previousLastProject !== "string") return value
const next = { ...value }
if (projects && Array.isArray(previousProjects)) {
const local = Array.isArray(projects.local) ? projects.local : []
const worktrees = new Set(
local.flatMap((project) => (isRecord(project) && typeof project.worktree === "string" ? [project.worktree] : [])),
)
const migrated = previousProjects.filter((project) => {
if (!isRecord(project) || typeof project.worktree !== "string") return true
if (worktrees.has(project.worktree)) return false
worktrees.add(project.worktree)
return true
})
const nextProjects: Record<string, unknown> = { ...projects, local: [...local, ...migrated] }
delete nextProjects[canonicalLocalServer]
next.projects = nextProjects
}
if (lastProject && typeof previousLastProject === "string") {
const nextLastProject = { ...lastProject }
if (typeof nextLastProject.local !== "string") nextLastProject.local = previousLastProject
delete nextLastProject[canonicalLocalServer]
next.lastProject = nextLastProject
}
return next
}
export function createServerProjects<T extends ServerProjectState>(input: {
scope: () => ServerScope
store: Store<T>
setStore: SetStoreFunction<T>
}) {
const setStore = input.setStore as unknown as SetStoreFunction<ServerProjectState>
const current = () => input.store.projects[input.scope()] ?? []
const currentClosed = () => input.store.recentlyClosed?.[input.scope()] ?? []
const remove = (directory: string) => {
setStore(
"projects",
input.scope(),
current().filter((project) => project.worktree !== directory),
)
}
return {
list: current,
recentlyClosed: currentClosed,
remove,
open(directory: string) {
const scope = input.scope()
const key = pathKey(directory)
const closed = currentClosed()
if (closed.some((worktree) => pathKey(worktree) === key)) {
setStore(
"recentlyClosed",
scope,
closed.filter((worktree) => pathKey(worktree) !== key),
)
}
if (current().some((project) => project.worktree === directory)) return
setStore("projects", scope, [{ worktree: directory, expanded: true }, ...current()])
},
// User-initiated close: removes the project and records it in recently closed.
// Internal, non-user removals (e.g. sandbox/worktree normalization) should use remove().
close(directory: string) {
remove(directory)
const key = pathKey(directory)
const closed = [directory, ...currentClosed().filter((worktree) => pathKey(worktree) !== key)].slice(
0,
RECENTLY_CLOSED_HISTORY_LIMIT,
)
setStore("recentlyClosed", input.scope(), closed)
},
expand(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", true)
},
collapse(directory: string) {
const index = current().findIndex((project) => project.worktree === directory)
if (index !== -1) setStore("projects", input.scope(), index, "expanded", false)
},
move(directory: string, toIndex: number) {
const fromIndex = current().findIndex((project) => project.worktree === directory)
if (fromIndex === -1 || fromIndex === toIndex) return
const next = [...current()]
const [item] = next.splice(fromIndex, 1)
next.splice(toIndex, 0, item)
setStore("projects", input.scope(), next)
},
last() {
return input.store.lastProject[input.scope()]
},
touch(directory: string) {
setStore("lastProject", input.scope(), directory)
},
}
}
export function resolveServerList(input: {
props?: Array<ServerConnection.Any>
stored: StoredServer[]
}): Array<ServerConnection.Any> {
const deduped = new Map<ServerConnection.Key, ServerConnection.Any>(
input.props?.map((v) => [ServerConnection.key(v), v]) ?? [],
)
for (const value of input.stored) {
const conn: ServerConnection.Http =
typeof value === "string"
? {
type: "http" as const,
http: { url: value },
}
: "http" in value
? value
: { type: "http", http: value }
const key = ServerConnection.key(conn)
const existing = deduped.get(key)
if (existing)
deduped.set(key, {
...existing,
...conn,
http: { ...existing.http, ...conn.http },
})
else deduped.set(key, conn)
}
return [...deduped.values()]
}
export function canRemoveServer(input: {
key: ServerConnection.Key
provided?: Array<ServerConnection.Any>
stored: StoredServer[]
}) {
if (input.provided?.some((server) => ServerConnection.key(server) === input.key)) return false
return input.stored.some((server) =>
typeof server === "string" ? server === input.key : ("type" in server ? server.http.url : server.url) === input.key,
)
}
export namespace ServerConnection {
type Base = { displayName?: string; label?: string }
export type HttpBase = {
url: string
username?: string
password?: string
}
// Regular web connections
export type Http = {
type: "http"
http: HttpBase
authToken?: boolean
} & Base
export type Sidecar = {
type: "sidecar"
http: HttpBase
} & (
| // Regular desktop server
{ variant: "base" }
// WSL server (windows only)
| {
variant: "wsl"
distro: string
}
) &
Base
// Remote server desktop can SSH into
export type Ssh = {
type: "ssh"
host: string
// SSH client exposes an HTTP server for the app to use as a proxy
http: HttpBase
} & Base
export type Any =
| Http
// All these are desktop-only
| (Sidecar | Ssh)
export const key = (conn: Any): Key => {
switch (conn.type) {
case "http":
return Key.make(conn.http.url)
case "sidecar": {
if (conn.variant === "wsl") return Key.make(`wsl:${conn.distro}`)
return Key.make("sidecar")
}
case "ssh":
return Key.make(`ssh:${conn.host}`)
}
}
export type Key = string & { _brand: "Key" }
export const Key = { make: (v: string) => v as Key }
export const builtin = (conn: Any) => conn.type === "sidecar" && conn.variant === "base"
export const local = (conn?: Any) =>
!!conn && (builtin(conn) || (conn.type === "http" && isLocalHost(conn.http.url) === "local"))
}
export function nextServerAfterRemoval(
servers: ServerConnection.Any[],
removed: ServerConnection.Key,
fallback: ServerConnection.Key,
) {
const remaining = servers.filter((server) => ServerConnection.key(server) !== removed)
const next = remaining.find((server) => ServerConnection.key(server) === fallback) ?? remaining[0]
return next ? ServerConnection.key(next) : fallback
}
export const { use: useServers, provider: ServersProvider } = createSimpleContext({
name: "Server",
gate: true,
init: (props: {
defaultServer: ServerConnection.Key
canonicalLocalServer?: ServerConnection.Key
servers?: Array<ServerConnection.Any>
}) => {
const [store, setStore, _] = persisted(
{
...Persist.global("server", ["server.v3"]),
migrate: (value) => migrateCanonicalLocalServerState(value, props.canonicalLocalServer),
},
createStore({
list: [] as StoredServer[],
projects: {} as Record<string, StoredProject[]>,
lastProject: {} as Record<string, string>,
recentlyClosed: {} as Record<string, string[]>,
}),
)
const url = (x: StoredServer) => (typeof x === "string" ? x : "type" in x ? x.http.url : x.url)
const allServers = createMemo((): Array<ServerConnection.Any> => {
return resolveServerList({ stored: store.list, props: props.servers })
})
function add(input: ServerConnection.Http) {
const url_ = normalizeServerUrl(input.http.url)
if (!url_) return
const conn: ServerConnection.Http = { ...input, authToken: undefined, http: { ...input.http, url: url_ } }
return batch(() => {
const existing = store.list.findIndex((x) => url(x) === url_)
if (existing !== -1) {
setStore("list", existing, conn)
} else {
setStore("list", store.list.length, conn)
}
return conn
})
}
function remove(key: ServerConnection.Key) {
const list = store.list.filter((x) => url(x) !== key)
batch(() => {
setStore("list", list)
})
}
function canRemove(key: ServerConnection.Key) {
return canRemoveServer({ key, provided: props.servers, stored: store.list })
}
const scope = (key: ServerConnection.Key) => ServerScope.fromServerKey(key, props.canonicalLocalServer)
const projectStores = new Map<ServerConnection.Key, ReturnType<typeof createServerProjects>>()
const projectsForServer = (key: ServerConnection.Key) => {
const existing = projectStores.get(key)
if (existing) return existing
const next = createServerProjects({ scope: () => scope(key), store, setStore })
projectStores.set(key, next)
return next
}
return {
get list() {
return allServers()
},
add,
remove,
canRemove,
scope,
projects: {
forServer: projectsForServer,
},
}
},
})
+1 -1
View File
@@ -114,7 +114,7 @@ export const useSync = () => {
const serverSync = useServerSync()
const sdk = useSDK()
return createMemo(() => serverSync().ensureDirSyncContext(sdk().directory))
return createMemo(() => serverSync.ensureDirSyncContext(sdk().directory))
}
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ServerConnection } from "./server"
import type { ServerConnection } from "./servers"
import type { Tab } from "./tabs"
export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] {
+1 -1
View File
@@ -4,7 +4,7 @@ import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import type { SessionTab, Tab } from "./tabs"
import { migrateTabs } from "./tab-migration"
import type { ServerConnection } from "./server"
import type { ServerConnection } from "./servers"
const server = "local\nhttp://localhost:4096" as ServerConnection.Key
+18 -21
View File
@@ -2,7 +2,7 @@ import type { SessionInfo } from "@opencode-ai/client/promise"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { createStore, produce } from "solid-js/store"
import { Persist, persisted, removePersisted, draftPersistedKeys } from "@/utils/persist"
import { ServerConnection, useServer } from "./server"
import { ServerConnection, useServers } from "./servers"
import { createEffect, getOwner, onCleanup, startTransition } from "solid-js"
import { useLocation, useNavigate, useParams } from "@solidjs/router"
import { usePlatform } from "./platform"
@@ -13,6 +13,7 @@ import { createTabMemory } from "./tab-memory"
import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs"
import { createDraftPromptSession, type PromptModel } from "./prompt-state"
import { migrateTabs } from "./tab-migration"
import { useCurrentRoute } from "./layout"
export type SessionTab = {
type: "session"
@@ -54,13 +55,12 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
name: "Tabs",
gate: false,
init: () => {
const server = useServer()
const servers = useServers()
const platform = usePlatform()
const fallback = server.key
const [store, setStore, _, ready] = persisted(
{
...Persist.window("tabs"),
migrate: (value: unknown) => migrateTabs(value, fallback),
migrate: (value: unknown) => migrateTabs(value, ServerConnection.key(servers.list[0])),
},
createStore<Tab[]>([]),
)
@@ -72,6 +72,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
const navigate = useNavigate()
const location = useLocation()
const memory = createTabMemory(getOwner())
const currentRoute = useCurrentRoute()
const closing = new Set<string>()
let recentWrite = 0
@@ -120,11 +121,11 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createEffect(() => {
if (!ready() || !recentReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = store.filter((tab) => servers.has(tab.server))
const serversSet = new Set(servers.list.map(ServerConnection.key))
const next = store.filter((tab) => serversSet.has(tab.server))
if (next.length !== store.length) {
for (const tab of store) {
if (!servers.has(tab.server)) {
if (!serversSet.has(tab.server)) {
const key = tabKey(tab)
memory.remove(key)
removeInfo(key)
@@ -141,8 +142,8 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
createEffect(() => {
if (!closedReady()) return
const servers = new Set(server.list.map(ServerConnection.key))
const next = closed.filter((entry) => servers.has(entry.tab.server))
const serversSet = new Set(servers.list.map(ServerConnection.key))
const next = closed.filter((entry) => serversSet.has(entry.tab.server))
if (next.length !== closed.length) setClosed(() => next)
})
@@ -293,10 +294,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
for (const key of removed) removeInfo(key)
if (recent.key && removed.includes(recent.key)) setRecentKey(undefined)
for (const draftID of drafts) removeDraftPersisted(draftID)
if (server.key === key) navigate("/")
},
removeSessions: (input: SessionTabsRemovedDetail) => {
const targetServer = input.server ?? server.key
const targetServer = input.server
updateClosed((stack) => removeClosedTabs(stack, targetServer, input.sessionIDs))
const removed = store
.filter(
@@ -307,17 +307,14 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
setStore(
produce((tabs) => {
const sessionIDs = new Set(input.sessionIDs)
const currentHref =
targetServer === server.key && params.dir && params.id
? tabHref({
type: "session",
server: targetServer,
sessionId: params.id,
})
: undefined
const currentIndex = currentHref
const route = currentRoute()
const sessionRoute = route.type === "session" ? route : undefined
const currentIndex = sessionRoute
? tabs.findIndex(
(tab) => tab.type === "session" && tab.server === targetServer && tabHref(tab) === currentHref,
(tab) =>
tab.type === "session" &&
tab.server === sessionRoute.server &&
tab.sessionId === sessionRoute.sessionId,
)
: -1
const currentTab = tabs[currentIndex]
+1 -1
View File
@@ -440,7 +440,7 @@ export const { use: useTerminal, provider: TerminalProvider } = createSimpleCont
const serverSDK = useServerSDK()
const params = useParams()
const cache = new Map<string, TerminalCacheEntry>()
const scope = () => serverSDK().scope
const scope = () => serverSDK.scope
const directory = createMemo(() => base64Encode(sdk().directory))
caches.add(cache)
+1 -1
View File
@@ -10,7 +10,7 @@ import { dict as en } from "@/i18n/en"
import { dict as zh } from "@/i18n/zh"
import { authFromToken } from "@/utils/server"
import pkg from "../package.json"
import { ServerConnection } from "./context/server"
import { ServerConnection } from "./context/servers"
const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl"
+1 -1
View File
@@ -1,6 +1,6 @@
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
export const emptyProviderCatalog: NormalizedProviderListResponse = { all: new Map(), connected: [], default: {} }
type DirectoryCatalog = {
ready: boolean
+12 -24
View File
@@ -1,9 +1,9 @@
import { useServerSync } from "@/context/server-sync"
import { decode64 } from "@/utils/base64"
import { useParams } from "@solidjs/router"
import { useQueryOptions } from "@/context/server-sync"
import { Iterable, pipe } from "effect"
import { createEffect, createMemo, type Accessor } from "solid-js"
import { selectProviderCatalog } from "./provider-catalog"
import { type Accessor } from "solid-js"
import { emptyProviderCatalog } from "./provider-catalog"
import { useQuery } from "@tanstack/solid-query"
import { pathKey } from "@/utils/path-key"
export const popularProviders = [
"opencode",
@@ -18,25 +18,13 @@ export const popularProviders = [
const popularProviderSet = new Set(popularProviders)
export function useProviders(directory: Accessor<string | undefined>) {
const serverSync = useServerSync()
const params = useParams()
const dir = () => (directory ? directory() : decode64(params.dir))
const providers = () => {
const value = dir()
const projectStore = value ? serverSync().child(value)[0] : undefined
if (value)
return selectProviderCatalog({
explicit: true,
directory: value,
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
})
return selectProviderCatalog({
explicit: false,
directory: value,
catalog: projectStore && { ready: projectStore.provider_ready, providers: projectStore.provider },
global: serverSync().data.provider,
})
}
const providersQuery = useQuery(() => {
const queryOpts = useQueryOptions()
const dir = directory()
return queryOpts.providers(dir ? pathKey(dir) : null)
})
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
return {
all: () => providers().all,
+2 -2
View File
@@ -2,7 +2,7 @@ export { AppBaseProviders, AppInterface } from "./app"
export { useLayout } from "./context/layout"
export { useServerSDK } from "./context/server-sdk"
export { useServerSync } from "./context/server-sync"
export { useServer } from "./context/server"
export { useServers as useServers } from "./context/servers"
export { useSettings } from "./context/settings"
export { useTabs } from "./context/tabs"
export { useProviders } from "./hooks/use-providers"
@@ -26,5 +26,5 @@ export {
type WslServersPlatform,
type WslServersState,
} from "./wsl/types"
export { ServerConnection } from "./context/server"
export { ServerConnection } from "./context/servers"
export { createDraftStore, type DraftStore } from "./utils/draft-store"
+3 -3
View File
@@ -9,7 +9,7 @@ import { SDKProvider } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { decode64 } from "@/utils/base64"
import { Schema } from "effect"
import type { ServerConnection } from "@/context/server"
import type { ServerConnection } from "@/context/servers"
import { sessionHref } from "@/utils/session-route"
import { useServerSync } from "@/context/server-sync"
@@ -53,8 +53,8 @@ export function DirectoryDataProvider(
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
serverSync().session.pin(sessionID)
onCleanup(() => serverSync().session.unpin(sessionID))
serverSync.session.pin(sessionID)
onCleanup(() => serverSync.session.unpin(sessionID))
})
return (
@@ -1,7 +1,7 @@
import { expect, test } from "bun:test"
import { SESSION_TABS_REMOVED_EVENT, readSessionTabsRemovedDetail } from "@/components/titlebar-session-events"
import { archiveHomeSession } from "./home-session-archive"
import type { ServerConnection } from "@/context/server"
import type { ServerConnection } from "@/context/servers"
const remote = "remote" as ServerConnection.Key
@@ -1,5 +1,5 @@
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
import type { ServerConnection } from "@/context/server"
import type { ServerConnection } from "@/context/servers"
import type { SessionInfo } from "@opencode-ai/client/promise"
type HomeSession = Pick<SessionInfo, "id" | "location">
+12 -19
View File
@@ -1,32 +1,25 @@
import { useGlobal } from "@/context/global"
import { useGlobal, useServerCtx } from "@/context/global"
import { type HomeProjectSelection, useLayout } from "@/context/layout"
import { ServerConnection, useServer } from "@/context/server"
import { useServerSync } from "@/context/server-sync"
import { ServerConnection } from "@/context/servers"
import { useTabs } from "@/context/tabs"
import { toggleHomeProjectSelection } from "@/pages/layout/helpers"
import { createEffect, createMemo } from "solid-js"
export function createHomeController() {
const sync = useServerSync()
const layout = useLayout()
const server = useServer()
const global = useGlobal()
const tabs = useTabs()
const selection = layout.home.selection
const focusedServer = createMemo(
() => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current,
const focusedServer = createMemo<ServerConnection.Any | undefined>(
() =>
global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ??
global.servers.list()[0],
)
const focusedServerCtx = createMemo(() => {
const conn = focusedServer()
if (!conn) return undefined
return global.ensureServerCtx(conn)
})
const focusedSync = () => focusedServerCtx()?.sync ?? sync()
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list())
const recentlyClosed = createMemo(
() => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(),
)
const homedir = createMemo(() => focusedSync().data.path.home ?? "")
const focusedServerCtx = useServerCtx(focusedServer)
const focusedSync = () => focusedServerCtx()?.sync
const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? [])
const recentlyClosed = createMemo(() => focusedServerCtx()?.projects.recentlyClosed() ?? [])
const homedir = createMemo(() => focusedSync()?.data.path.home ?? "")
const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory))
const newSessionProject = createMemo(
() =>
@@ -38,7 +31,7 @@ export function createHomeController() {
createEffect(() => {
const list = global.servers.list()
if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return
const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0]
const conn = list[0]
if (conn) setSelection({ server: ServerConnection.key(conn) })
})
@@ -6,7 +6,7 @@ import { type LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { useNotification } from "@/context/notification"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers"
import { Persist, persisted } from "@/utils/persist"
import { showToast } from "@/utils/toast"
@@ -14,15 +14,16 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createResource } from "solid-js"
import { createStore } from "solid-js/store"
import type { HomeController } from "./home-controller"
import { useGlobal } from "@/context/global"
export function createHomeProjectsController(home: HomeController) {
const platform = usePlatform()
const pickDirectory = useDirectoryPicker()
const dialog = useDialog()
const language = useLanguage()
const notification = useNotification()
const openSettings = useSettingsCommand()
const serverManagement = useServerActionsController()
const global = useGlobal()
const [_state, setState, _, ready] = persisted(
Persist.global("home.servers", ["home.servers.v1"]),
createStore({ collapsed: {} as Record<string, boolean> }),
@@ -78,14 +79,14 @@ export function createHomeProjectsController(home: HomeController) {
})
},
unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
const state = notification.ensureServerState(ServerConnection.key(conn))
return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0)
const notification = global.ensureServerCtx(conn).notification
return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
},
clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => {
const state = notification.ensureServerState(ServerConnection.key(conn))
const notification = global.ensureServerCtx(conn).notification
directories(project)
.filter((directory) => state.project.unseenCount(directory) > 0)
.forEach((directory) => state.project.markViewed(directory))
.filter((directory) => notification.project.unseenCount(directory) > 0)
.forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (home.server.health(conn)?.healthy === false) return
@@ -12,7 +12,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
@@ -1,6 +1,6 @@
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { serverName } from "@/context/server"
import { serverName } from "@/context/servers"
import { displayName } from "@/pages/layout/helpers"
import { sessionLabel } from "@/utils/session-title"
import { makeEventListener } from "@solid-primitives/event-listener"
@@ -1,7 +1,7 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { useQuery } from "@tanstack/solid-query"
import { skipToken, useQuery } from "@tanstack/solid-query"
import { DateTime } from "luxon"
import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js"
import { produce } from "solid-js/store"
@@ -13,7 +13,7 @@ import {
} from "@/context/global-sync/home-session-index"
import type { LocalProject } from "@/context/layout"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { sessionHasOpenTab, useTabs } from "@/context/tabs"
import { compareSessionTime, displayName, errorMessage, projectForSession } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
@@ -51,41 +51,46 @@ export function createHomeSessionsController(home: HomeController) {
const projectByID = createMemo(
() => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))),
)
const homeSessions = () => home.server.focusedSync().homeSessions
const sessionEventLoad = useQuery(() => ({
queryKey: homeSessions().eventsKey,
queryFn: async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }),
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
enabled: false,
}))
const sessionLoad = useQuery(() => ({
queryKey: homeSessions().indexKey,
enabled: !!home.server.focusedContext(),
queryFn: async ({ signal }) => {
const ctx = home.server.focusedContext()
if (!ctx) return { sessions: [], eventSequence: 0 }
const cache = homeSessions()
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.api.session.list(input, options),
eventSequence,
signal,
)
cache.complete(eventSequence)
return index
},
retry: false,
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
}))
const indexedSessions = createMemo(() =>
retainHomeSessions(
homeSessions().sessions(sessionLoad.data, sessionEventLoad.data),
HOME_SESSION_LIMIT,
Date.now(),
),
)
const homeSessions = () => home.server.focusedSync()?.homeSessions
const sessionEventLoad = useQuery(() => {
const cache = homeSessions()
return {
queryKey: cache?.eventsKey ?? (["home", "session-events", "unselected"] as const),
queryFn: cache ? async (): Promise<HomeSessionEvents> => ({ sequence: 0, entries: [] }) : skipToken,
initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents,
enabled: false,
}
})
const sessionLoad = useQuery(() => {
const cache = homeSessions()
return {
queryKey: cache?.indexKey ?? (["home", "session-index", "unselected"] as const),
enabled: !!home.server.focusedContext(),
queryFn: cache
? async ({ signal }) => {
const ctx = home.server.focusedContext()
if (!ctx) return { sessions: [], eventSequence: 0 }
const eventSequence = cache.eventSequence()
const index = await loadHomeSessionIndex(
(input, options) => ctx.sdk.api.session.list(input, options),
eventSequence,
signal,
)
cache.complete(eventSequence)
return index
}
: skipToken,
retry: false,
staleTime: 30_000,
refetchOnMount: true,
refetchOnReconnect: true,
}
})
const indexedSessions = createMemo(() => {
const cache = homeSessions()
if (!cache) return []
return retainHomeSessions(cache.sessions(sessionLoad.data, sessionEventLoad.data), HOME_SESSION_LIMIT, Date.now())
})
const allRecords = createMemo(() =>
buildHomeSessionRecords({
sessions: indexedSessions,
@@ -190,17 +195,18 @@ export function createHomeSessionsController(home: HomeController) {
) ?? projectForSession(session, home.project.list(), projectByID())
const conn = home.server.focused()
if (!conn) return
const connKey = ServerConnection.key(conn)
const directory = project?.worktree ?? session.location.directory
const ctx = home.server.focusedContext()
if (!ctx) return
ctx.projects.open(directory)
if (options?.background) {
tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
tabs.addSessionTab({ server: connKey, sessionId: session.id })
return
}
ctx.projects.touch(directory)
void startTransition(() => {
const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id })
const tab = tabs.addSessionTab({ server: connKey, sessionId: session.id })
tabs.select(tab)
})
},
@@ -7,7 +7,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useLanguage } from "@/context/language"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar"
import { sessionLabel } from "@/utils/session-title"
import { shouldOpenSessionInBackground } from "../home-session-open"
@@ -23,7 +23,7 @@ import {
toggleHomeProjectSelection,
} from "./helpers"
import { pathKey } from "@/utils/path-key"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
const serverKey = ServerConnection.Key.make
+3 -3
View File
@@ -1,7 +1,7 @@
import { getFilename } from "@opencode-ai/core/util/path"
import type { SessionInfo } from "@opencode-ai/client/promise"
import { pathKey } from "@/utils/path-key"
import type { ServerConnection } from "@/context/server"
import type { ServerConnection } from "@/context/servers"
import type { HomeProjectSelection } from "@/context/layout"
type SessionStore = {
@@ -61,10 +61,10 @@ export function toggleHomeProjectSelection(
export function closeHomeProject(
selected: HomeProjectSelection | undefined,
server: ServerConnection.Key,
projects: { close: (directory: string) => void },
projects: { close: (serverKey: ServerConnection.Key, directory: string) => void },
directory: string,
) {
projects.close(directory)
projects.close(server, directory)
if (selected?.server === server && selected.directory === directory) return { server }
return selected
}
@@ -1,9 +1,8 @@
import { createMemo, type Accessor } from "solid-js"
import { useGlobal } from "@/context/global"
import { useNotification } from "@/context/notification"
import { useGlobal, useServerCtx } from "@/context/global"
import { usePermission } from "@/context/permission"
import { sessionPermissionRequest, sessionQuestionRequest } from "@/pages/session/composer/session-request-tree"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
export function useSessionTabAvatarState(
server: Accessor<ServerConnection.Key>,
@@ -11,20 +10,17 @@ export function useSessionTabAvatarState(
sessionId: Accessor<string>,
) {
const global = useGlobal()
const notification = useNotification()
const permission = usePermission()
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
const sync = createMemo(() => {
const conn = connection()
if (conn) return global.ensureServerCtx(conn).sync
})
const serverCtx = useServerCtx(connection)
const sync = () => serverCtx()?.sync
const hasPermissions = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
const permissionState = permission.ensureServerState(server())
const [store] = serverSync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => {
return !permissionState.autoResponds(item, directory())
const ctx = serverCtx()
if (!ctx) return false
const sync = ctx.sync
const permission = ctx.permission
const [store] = sync.child(directory(), { bootstrap: false })
return !!sessionPermissionRequest(store.session, sync.session.data.permission, sessionId(), (item) => {
return !permission.autoResponds(item, directory())
})
})
const hasQuestions = createMemo(() => {
@@ -34,11 +30,9 @@ export function useSessionTabAvatarState(
return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId())
})
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
const notificationState = createMemo(() => {
if (!connection()) return
return notification.ensureServerState(server())
})
const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0)
const unread = createMemo(
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
)
const loading = createMemo(() => {
const serverSync = sync()
if (!serverSync) return false
@@ -1,6 +1,6 @@
import type { LocalProject } from "@/context/layout"
import { getProjectAvatarVariant } from "@/context/layout"
import type { ServerConnection } from "@/context/server"
import type { ServerConnection } from "@/context/servers"
import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state"
import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2"
+2 -1
View File
@@ -8,13 +8,14 @@ import { createNewSessionWorkspaceController } from "./new-session/new-session-w
import { useNewSessionCommands } from "./new-session/use-new-session-commands"
/** The draft-only V2 session page. Submitting promotes the draft into a real session. */
export default function NewSessionPage() {
export default function NewSessionPage(props: { draftId: string }) {
const settings = useSettings()
const rightMount = useTitlebarRightMount()
const workspace = createNewSessionWorkspaceController()
const draft = createNewSessionDraftController({
worktree: workspace.selection.value,
resetWorktree: workspace.selection.reset,
draftId: props.draftId,
})
const project = createPromptProjectController({
controls: draft.project.controls,
@@ -10,7 +10,11 @@ import { createPromptModelSelection } from "@/pages/session/composer/prompt-mode
import { useSessionKey } from "@/pages/session/session-layout"
import { useComposerCommands } from "@/pages/session/use-composer-commands"
export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) {
export function createNewSessionDraftController(props: {
worktree: () => string
resetWorktree: () => void
draftId: string
}) {
const prompt = usePrompt()
const serverSync = useServerSync()
const comments = useComments()
@@ -24,18 +28,20 @@ export function createNewSessionDraftController(workspace: { worktree: () => str
const controls = createPromptInputController({
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
queryOptions: serverSync.queryOptions,
model,
})
const projectControls = createPromptProjectControls()
const projectControls = createPromptProjectControls({
draftId: props.draftId,
})
const input = usePromptInputV2Controller({
get controls() {
return controls()
},
get newSessionWorktree() {
return workspace.worktree()
return props.worktree()
},
onNewSessionWorktreeReset: workspace.resetWorktree,
onNewSessionWorktreeReset: props.resetWorktree,
onSubmit: comments.clear,
})
@@ -104,7 +104,7 @@ function ProviderTip() {
)
const visible = createMemo(
() =>
serverSync().child(sdk().directory)[0].provider_ready &&
serverSync.child(sdk().directory)[0].provider_ready &&
persistedReady() &&
providers.paid().length === 0 &&
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
@@ -46,12 +46,12 @@ export function createNewSessionWorkspaceController() {
}),
)
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
const localBranch = createMemo(() => serverSync.child(projectRoot())[0].vcs?.branch)
const branch = createMemo(() =>
resolveNewSessionBranch({
worktree: value(),
local: localBranch(),
worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch,
worktreeBranch: (worktree) => serverSync.child(worktree)[0].vcs?.branch,
}),
)
+44 -106
View File
@@ -38,7 +38,7 @@ import { createAutoScroll } from "@opencode-ai/ui/hooks"
import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-bridge"
import { Button } from "@opencode-ai/ui/button"
import { showToast } from "@/utils/toast"
import { base64Encode, checksum } from "@opencode-ai/core/util/encode"
import { checksum } from "@opencode-ai/core/util/encode"
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { NewSessionView, SessionHeader } from "@/components/session"
import { ErrorPage } from "@/pages/error"
@@ -54,7 +54,7 @@ import { PromptProvider, usePrompt } from "@/context/prompt"
import { usePlatform } from "@/context/platform"
import { SDKProvider, useSDK } from "@/context/sdk"
import { useServerSDK } from "@/context/server-sdk"
import { ServerConnection, serverName, useServer } from "@/context/server"
import { ServerConnection, serverName, useServers } from "@/context/servers"
import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
@@ -144,14 +144,6 @@ async function runPromptRollbackMutation<T, R>(input: {
})
}
export function SessionPage() {
return (
<SessionProviders>
<Page />
</SessionProviders>
)
}
// Rendered under app.tsx's TargetSessionRoute, which owns the per-server keyed
// remount around the server-scoped providers. Nothing here may key on the
// session ID: session tabs on the same server share this route instance, and
@@ -159,16 +151,19 @@ export function SessionPage() {
export function TargetSessionRouteContent() {
const params = useParams<{ serverKey: string; id: string }>()
const serverSync = useServerSync()
const directory = createMemo(() => serverSync().session.lineage.peek(params.id)?.session.location.directory)
const directory = createMemo(() => serverSync.session.lineage.peek(params.id)?.session.location.directory)
return (
// Settings must keep the target-server SDK, sync, and models context and remain registered
// when session content falls back to the route error boundary.
<TargetServerScopedProviders directory={directory} sessionID={() => params.id}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</TargetServerScopedProviders>
<>
<MarkSessionNotificationsViewed sessionID={() => params.id} />
<ModelsProvider directory={directory}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</ModelsProvider>
</>
)
}
@@ -180,20 +175,15 @@ function TargetSessionSettingsCommand() {
export function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
) {
const settings = useSettings()
return (
<ErrorBoundary
fallback={(error) =>
settings.general.newLayoutDesigns() ? (
<SessionRouteFrame padded={props.padded}>
<SessionPanelFrame newLayout raised={!!props.sessionID}>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionPanelFrame>
</SessionRouteFrame>
) : (
<ErrorPage error={error} />
)
}
fallback={(error) => (
<SessionRouteFrame padded={props.padded}>
<SessionPanelFrame newLayout raised={!!props.sessionID}>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionPanelFrame>
</SessionRouteFrame>
)}
>
{props.children}
</ErrorBoundary>
@@ -202,16 +192,17 @@ export function SessionRouteErrorBoundary(
function SessionErrorFallback(props: { error: unknown; sessionID?: string; serverKey?: ServerConnection.Key }) {
const language = useLanguage()
const server = useServer()
const server = useServers()
const tabs = useTabs()
const displayServer = createMemo(() => {
const key = props.serverKey ?? server.key
const key = props.serverKey
const conn = server.list.find((item) => ServerConnection.key(item) === key)
return conn ? serverName(conn) : key
})
const closeTab = () => {
if (!props.sessionID) return
tabs.removeSessionTab({ server: props.serverKey ?? server.key, sessionId: props.sessionID })
if (!props.sessionID || !props.serverKey) return
tabs.removeSessionTab({ server: props.serverKey, sessionId: props.sessionID })
}
if (isCurrentSessionNotFoundError(props.error, props.sessionID)) {
return (
@@ -250,7 +241,7 @@ function ResolvedTargetSessionRoute() {
const serverKey = createMemo(() => requireServerKey(params.serverKey))
const current = createSessionLineage(
() => params.id,
() => sync().session.lineage,
() => sync.session.lineage,
)
const directory = createMemo(() => current()?.session.location.directory)
const targetDirectory = () => directory()!
@@ -286,23 +277,20 @@ function TargetSessionPage() {
const sdk = useSDK()
const serverSDK = useServerSDK()
return (
<Show when={`${serverSDK().scope}\0${sdk().directory}`} keyed>
<SessionPage />
<Show when={`${serverSDK.scope}\0${sdk().directory}`} keyed>
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>
<Page />
</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
</Show>
)
}
function TargetServerScopedProviders(
props: ParentProps<{ directory?: () => string | undefined; sessionID?: () => string | undefined }>,
) {
return (
<>
<MarkSessionNotificationsViewed sessionID={props.sessionID} />
<ModelsProvider directory={props.directory}>{props.children}</ModelsProvider>
</>
)
}
function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) {
const notification = useNotification()
createEffect(() => {
@@ -314,18 +302,6 @@ function MarkSessionNotificationsViewed(props: { sessionID?: () => string | unde
return null
}
function SessionProviders(props: ParentProps) {
return (
<TerminalProvider>
<FileProvider>
<PromptProvider>
<CommentsProvider>{props.children}</CommentsProvider>
</PromptProvider>
</FileProvider>
</TerminalProvider>
)
}
function SessionRouteFrame(props: ParentProps<{ padded?: boolean }>) {
return (
<div class="relative size-full overflow-hidden flex flex-col" classList={{ "p-2": props.padded }}>
@@ -406,49 +382,11 @@ export default function Page() {
const inputController = createPromptInputController({
sessionKey: controller.identity.sessionKey,
sessionID: () => controller.identity.params.id,
queryOptions: serverSync().queryOptions,
queryOptions: serverSync.queryOptions,
})
const workspaceTabs = createMemo(() => layout.tabs(controller.identity.workspaceKey))
const sessionPanelKey = createMemo(() =>
controller.identity.params.id ? `${serverSDK().scope}\0${controller.identity.params.id}` : undefined,
)
createEffect(
on(
() => controller.identity.params.id,
(id, prev) => {
if (!id) return
if (prev) return
const pending = layout.handoff.tabs()
if (!pending) return
if (Date.now() - pending.at > 60_000) {
layout.handoff.clearTabs()
return
}
if (pending.scope !== serverSDK().scope) return
if (pending.id !== id) return
layout.handoff.clearTabs()
if (pending.dir !== base64Encode(sdk().directory)) return
const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return
const current = controller.layout.tabs().tabs()
if (current.all.length > 0 || current.active) return
const all = controller.tabs.normalizeAll(from.all)
const active = from.active ? controller.tabs.normalize(from.active) : undefined
controller.layout.tabs().setAll(all)
controller.layout.tabs().setActive(active && all.includes(active) ? active : all[0])
workspaceTabs().setAll([])
workspaceTabs().setActive(undefined)
},
{ defer: true },
),
controller.identity.params.id ? `${serverSDK.scope}\0${controller.identity.params.id}` : undefined,
)
const size = createSizing()
@@ -579,7 +517,7 @@ export default function Page() {
})
const [followup, setFollowup] = persisted(
Persist.serverWorkspace(serverSDK().scope, sdk().directory, "followup", ["followup.v1"]),
Persist.serverWorkspace(serverSDK.scope, sdk().directory, "followup", ["followup.v1"]),
createStore<{
items: Record<string, FollowupItem[] | undefined>
failed: Record<string, string | undefined>
@@ -721,7 +659,7 @@ export default function Page() {
const request = (scope: string, context?: number) =>
queryClient
.fetchQuery({
queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const,
queryKey: [serverSDK.scope, ...vcsKey(), mode, "directory", scope, context, version] as const,
staleTime: Number.POSITIVE_INFINITY,
retry: 2,
queryFn: () =>
@@ -816,11 +754,11 @@ export default function Page() {
}
function upsert(next: Project) {
const list = serverSync().data.project
const list = serverSync.data.project
sync().set("project", next.id)
const idx = list.findIndex((item) => item.id === next.id)
if (idx >= 0) {
serverSync().set(
serverSync.set(
"project",
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
)
@@ -828,10 +766,10 @@ export default function Page() {
}
const at = list.findIndex((item) => item.id > next.id)
if (at >= 0) {
serverSync().set("project", [...list.slice(0, at), next, ...list.slice(at)])
serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
return
}
serverSync().set("project", [...list, next])
serverSync.set("project", [...list, next])
}
const gitMutation = useMutation(() => ({
@@ -1696,7 +1634,7 @@ export default function Page() {
const ok = await sendFollowupDraft({
api: sdk().api.session,
sync: sync(),
serverSync: serverSync(),
serverSync: serverSync,
session: () => sync().session.get(input.sessionID),
draft: item,
optimisticBusy: item.sessionDirectory === sdk().directory,
@@ -5,12 +5,12 @@ import { type Accessor, createMemo } from "solid-js"
import type { PromptInputControls } from "@/components/prompt-input/contracts"
import type { PromptProjectControls } from "@/components/prompt-project-selector"
import { useDirectoryPicker } from "@/components/directory-picker"
import { useGlobal } from "@/context/global"
import { useGlobal, useServerCtx } from "@/context/global"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { serverName, ServerConnection, useServer } from "@/context/server"
import { serverName, ServerConnection, useServers } from "@/context/servers"
import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useTabs } from "@/context/tabs"
@@ -60,21 +60,18 @@ export function createPromptInputController(input: {
})
}
export function createPromptProjectControls() {
const navigate = useNavigate()
const layout = useLayout()
const server = useServer()
export function createPromptProjectControls(props: { draftId: string }) {
const server = useServers()
const serverSDK = useServerSDK()
const sdk = useSDK()
const tabs = useTabs()
const global = useGlobal()
const pickDirectory = useDirectoryPicker()
const [search] = useSearchParams<{ draftId?: string }>()
const projectServer = () => serverSDK().server
const projectServerCtx = createMemo(() => global.ensureServerCtx(projectServer()))
const projectServer = () => serverSDK.server
const projectServerCtx = useServerCtx(projectServer)
const projects = createMemo(() => {
if (server.list.length <= 1) {
return search.draftId ? projectServerCtx().projects.list() : layout.projects.list()
return projectServerCtx().projects.list()
}
return server.list.flatMap((conn) => {
const item = { key: ServerConnection.key(conn), name: serverName(conn) }
@@ -86,28 +83,12 @@ export function createPromptProjectControls() {
})
const selectProject = (worktree: string, serverKey?: string) => {
const conn = serverKey ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) : projectServer()
if (search.draftId) {
if (!conn) return
const target = global.ensureServerCtx(conn)
target.projects.open(worktree)
target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return
}
if (!serverKey) {
layout.projects.open(worktree)
server.projects.touch(worktree)
navigate(`/${base64Encode(worktree)}/session`)
return
}
if (!conn) return
const target = global.ensureServerCtx(conn)
target.projects.open(worktree)
target.projects.touch(worktree)
server.setActive(ServerConnection.key(conn))
navigate(`/${base64Encode(worktree)}/session`)
tabs.updateDraft(props.draftId, { server: ServerConnection.key(conn), directory: worktree })
}
const addProject = (title: string, serverKey?: string) => {
@@ -53,7 +53,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
const todos = createMemo((): Todo[] => {
const id = params.id
if (!id) return []
return serverSync().session.data.todo[id] ?? []
return serverSync.session.data.todo[id] ?? []
})
const done = createMemo(
@@ -65,7 +65,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit
const sdk = useSDK()
const serverSDK = useServerSDK()
const language = useLanguage()
const cacheKey = ScopedKey.from(serverSDK().scope, props.request.id)
const cacheKey = ScopedKey.from(serverSDK.scope, props.request.id)
const questions = createMemo(() => props.request.questions)
const total = createMemo(() => questions().length)
@@ -10,7 +10,7 @@ export const useSessionKey = () => {
const params = useParams()
const sdk = useSDK()
const serverSDK = useServerSDK()
const scope = createMemo(() => serverSDK().scope)
const scope = createMemo(() => serverSDK.scope)
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)))
@@ -21,6 +21,7 @@ import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from
import { showToast } from "@/utils/toast"
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
import { createTimelineProjection } from "./projection"
import { useServer } from "@/context/server"
const emptyMessages: Message[] = []
const emptyParts: Part[] = []
@@ -46,6 +47,7 @@ export function createTimelineController(input: {
const navigate = useNavigate()
const sdk = useSDK()
const sync = useSync()
const server = useServer()
const settings = useSettings()
const tabs = useTabs()
const dialog = useDialog()
@@ -195,7 +197,7 @@ export function createTimelineController(input: {
void navigateAfterRemoval(id, session.parentID, next?.id)
sync().set(produce((draft) => void (draft.session = draft.session.filter((item) => !removed.has(item.id)))))
removed.forEach((sessionID) => sync().session.evict(sessionID))
notifySessionTabsRemoved({ directory: sdk().directory, sessionIDs: [...removed] })
notifySessionTabsRemoved({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
return true
}
@@ -24,7 +24,7 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
if (!id) return
const cached = untrack(() => sync().data.message[id] !== undefined)
const stale = cached && !serverSync().session.fresh(id, sessionFreshness)
const stale = cached && !serverSync.session.fresh(id, sessionFreshness)
refreshFrame = requestAnimationFrame(() => {
refreshFrame = undefined
@@ -42,7 +42,7 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
return !id || isTimelineReady(sync().data.message[id], serverSync().session.history.loading(id))
return !id || isTimelineReady(sync().data.message[id], serverSync.session.history.loading(id))
})
const more = createMemo(() => {
const id = input.session.identity.sessionID()
@@ -13,6 +13,7 @@ import { useSessionLayout } from "@/pages/session/session-layout"
import { SessionFileView } from "@/pages/session/file-tabs"
import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2"
import { pathKey } from "@/utils/path-key"
import { useServer } from "@/context/server"
const emptyFiles: string[] = []
@@ -38,6 +39,7 @@ export function SessionFileBrowserTab(props: {
const language = useLanguage()
const layout = useLayout()
const sdk = useSDK()
const server = useServer()
const { workspaceKey } = useSessionLayout()
const resultsID = `session-file-browser-results-${createUniqueId()}`
const [filter, setFilter] = createSignal("")
@@ -63,10 +65,11 @@ export function SessionFileBrowserTab(props: {
if (explicit && values.includes(explicit)) return explicit
return values[0]
})
const loading = createMemo(() => query().length > 0 && search.isPending)
const project = createMemo(() => {
const directory = pathKey(sdk().directory)
return layout.projects
return server.ctx.projects
.list()
.find(
(item) =>
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import type { ServerConnection } from "@/context/server"
import type { ServerConnection } from "@/context/servers"
import { checkServerHealth } from "./server-health"
const server: ServerConnection.HttpBase = {
+1 -1
View File
@@ -1,5 +1,5 @@
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { authTokenFromCredentials } from "./server"
import { ClientError, OpenCode } from "@opencode-ai/client"
import { Accessor, createEffect, onCleanup } from "solid-js"
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ServerConnection } from "@/context/server"
import type { ServerConnection } from "@/context/servers"
export type ServerScope = string & { readonly __brand: "ServerScope" }
export type SessionRouteKey = string & { readonly __brand: "SessionRouteKey" }
+1 -1
View File
@@ -1,5 +1,5 @@
import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise"
import type { ServerConnection } from "@/context/server"
import type { ServerConnection } from "@/context/servers"
import { decode64 } from "@/utils/base64"
export function authTokenFromCredentials(input: { username?: string; password: string }) {
+1 -1
View File
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { legacySessionHref, legacySessionServer, requireServerKey, rootSession, sessionHref } from "./session-route"
describe("session routes", () => {
+1 -1
View File
@@ -1,5 +1,5 @@
import { base64Encode } from "@opencode-ai/core/util/encode"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { decode64 } from "@/utils/base64"
export function sessionHref(server: ServerConnection.Key, sessionID: string) {
+1 -1
View File
@@ -11,7 +11,7 @@ import type { ServerCollectionController } from "@/components/server/server-mana
import { ServerHealthIndicator } from "@/components/server/server-row"
import { useLanguage } from "@/context/language"
import { usePlatform } from "@/context/platform"
import { ServerConnection } from "@/context/server"
import { ServerConnection } from "@/context/servers"
import { showToast } from "@/utils/toast"
import { DialogAddWslServer } from "./dialog-add-server"
import { useWslServers } from "./context"

Some files were not shown because too many files have changed in this diff Show More