mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-17 01:48:29 -04:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f44f5cea68 | |||
| 3a2eeebca2 | |||
| fbbb043d4a | |||
| a9c3d484ba |
@@ -188,12 +188,15 @@
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101",
|
||||
"solid-js": ">=1.9.0",
|
||||
},
|
||||
"optionalPeers": [
|
||||
"effect",
|
||||
"solid-js",
|
||||
],
|
||||
},
|
||||
"packages/codemode": {
|
||||
|
||||
@@ -16,7 +16,6 @@ import { type Component, createMemo, createUniqueId, For, Match, onMount, Show,
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
@@ -362,7 +361,6 @@ function ProviderConnection(props: {
|
||||
setBack: (handler: () => void) => void
|
||||
}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const params = useParams()
|
||||
const language = useLanguage()
|
||||
const settings = useSettings()
|
||||
@@ -386,10 +384,7 @@ function ProviderConnection(props: {
|
||||
const provider = createMemo(() => ({
|
||||
id: props.provider,
|
||||
name:
|
||||
providers.all().get(props.provider)?.name ??
|
||||
serverSync.data.provider.all.get(props.provider)?.name ??
|
||||
controller.integration()?.name ??
|
||||
props.provider,
|
||||
providers.all().get(props.provider)?.name ?? controller.integration()?.name ?? props.provider,
|
||||
}))
|
||||
const methodLabel = (value?: { type?: string; label?: string }) => {
|
||||
if (!value) return ""
|
||||
|
||||
@@ -9,7 +9,7 @@ import { showToast } from "@/utils/toast"
|
||||
import { batch, For } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { ExternalLink } from "@/components/external-link"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form"
|
||||
|
||||
@@ -41,7 +41,7 @@ export function DialogCustomProvider(props: Props) {
|
||||
|
||||
export function CustomProviderForm(props: { autofocus?: boolean } = {}) {
|
||||
const dialog = useDialog()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
|
||||
const [form, setForm] = createStore<FormState>({
|
||||
@@ -116,8 +116,9 @@ 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()),
|
||||
// TODO: Restore disabled-provider validation when V2 exposes config reads.
|
||||
disabledProviders: [],
|
||||
existingProviderIDs: new Set((data.location.provider.list() ?? []).map((provider) => provider.id)),
|
||||
})
|
||||
batch(() => {
|
||||
setForm("err", output.err)
|
||||
|
||||
@@ -1,16 +1,15 @@
|
||||
import { Component, createMemo } from "solid-js"
|
||||
import { useNavigate, useParams } from "@solidjs/router"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useData } from "@/context/server"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { TextPart as SDKTextPart } from "@/types"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
|
||||
interface ForkableMessage {
|
||||
id: string
|
||||
@@ -25,9 +24,9 @@ function formatTime(date: Date): string {
|
||||
export const DialogFork: Component = () => {
|
||||
const params = useParams()
|
||||
const navigate = useNavigate()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const prompt = usePrompt()
|
||||
const serverSDK = useServerSDK()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -35,19 +34,15 @@ export const DialogFork: Component = () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return []
|
||||
|
||||
const msgs = sync().data.message[sessionID] ?? []
|
||||
const msgs = data.session.message.list(sessionID)
|
||||
const result: ForkableMessage[] = []
|
||||
|
||||
for (const message of msgs) {
|
||||
if (message.role !== "user") continue
|
||||
|
||||
const parts = sync().data.part[message.id] ?? []
|
||||
const textPart = parts.find((x): x is SDKTextPart => x.type === "text" && !x.synthetic && !x.ignored)
|
||||
if (!textPart) continue
|
||||
if (message.type !== "user" || !message.text) continue
|
||||
|
||||
result.push({
|
||||
id: message.id,
|
||||
text: textPart.text.replace(/\n/g, " ").slice(0, 200),
|
||||
text: message.text.replace(/\n/g, " ").slice(0, 200),
|
||||
time: formatTime(new Date(message.time.created)),
|
||||
})
|
||||
}
|
||||
@@ -61,19 +56,11 @@ export const DialogFork: Component = () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const parts = sync().data.part[item.id] ?? []
|
||||
const restored = extractPromptFromParts(parts, {
|
||||
directory: sdk().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
const dir = base64Encode(sdk().directory)
|
||||
|
||||
sdk()
|
||||
.api.session.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
|
||||
.then((forked) => {
|
||||
dialog.close()
|
||||
prompt.set(restored, undefined, { dir, id: forked.id })
|
||||
navigate(`/${dir}/session/${forked.id}`)
|
||||
navigate(sessionHref(ServerConnection.key(serverSDK.server), forked.id))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { Component, createMemo, Show } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { Dialog } from "@opencode-ai/ui/dialog"
|
||||
import { List } from "@opencode-ai/ui/list"
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
@@ -14,12 +15,13 @@ const statusLabels = {
|
||||
} as const
|
||||
|
||||
export const DialogSelectMcp: Component = () => {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const items = createMemo(() =>
|
||||
Object.entries(sync().data.mcp ?? {})
|
||||
.map(([name, status]) => ({ name, status: status.status }))
|
||||
(data.location.mcp.server.list({ directory: sdk().directory }) ?? [])
|
||||
.map((server) => ({ name: server.name, status: server.status.status }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name)),
|
||||
)
|
||||
|
||||
@@ -47,7 +49,8 @@ export const DialogSelectMcp: Component = () => {
|
||||
}}
|
||||
>
|
||||
{(i) => {
|
||||
const mcpStatus = () => sync().data.mcp[i.name]
|
||||
const mcpStatus = () =>
|
||||
data.location.mcp.server.list({ directory: sdk().directory })?.find((server) => server.name === i.name)?.status
|
||||
const status = () => mcpStatus()?.status
|
||||
const statusLabel = () => {
|
||||
const key = status() ? statusLabels[status() as keyof typeof statusLabels] : undefined
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { type Component, For, Show, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { type Component, For, Show, createEffect, createMemo, createResource, createSignal } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { ExternalLink } from "./external-link"
|
||||
|
||||
type SkillItem = {
|
||||
@@ -15,7 +14,6 @@ type SkillItem = {
|
||||
location: string
|
||||
}
|
||||
|
||||
const pluginName = (item: string | [string, Record<string, unknown>]) => (typeof item === "string" ? item : item[0])
|
||||
const skillKey = (item: SkillItem) => `${item.name}\n${item.location}`
|
||||
|
||||
const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||
@@ -23,9 +21,8 @@ const ExtensionCard: Component<{ children: unknown }> = (props) => (
|
||||
)
|
||||
|
||||
const ExtensionRow: Component<{
|
||||
icon: "mcp" | "cube" | "post-skill" | "code"
|
||||
icon: "mcp" | "cube" | "post-skill"
|
||||
name: string
|
||||
status?: string
|
||||
children?: unknown
|
||||
}> = (props) => (
|
||||
<div class="project-settings-extension-row">
|
||||
@@ -33,14 +30,6 @@ const ExtensionRow: Component<{
|
||||
<Icon name={props.icon} class="project-settings-extension-row-icon" />
|
||||
<span class="project-settings-extension-row-name">{props.name}</span>
|
||||
</div>
|
||||
<Show when={props.status}>
|
||||
{(status) => (
|
||||
<span class="project-settings-extension-row-status">
|
||||
<span class="project-settings-extension-row-status-dot" />
|
||||
{status()}
|
||||
</span>
|
||||
)}
|
||||
</Show>
|
||||
{props.children as any}
|
||||
</div>
|
||||
)
|
||||
@@ -76,55 +65,54 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const directorySDK = useSDK()
|
||||
const serverSync = useServerSync()
|
||||
const sync = useSync()
|
||||
const toggleMcp = useMcpToggle()
|
||||
const data = useData()
|
||||
const toggleMcp = useMcpToggle(() => directorySDK().directory)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
const ref = { directory: directorySDK().directory }
|
||||
void Promise.all([
|
||||
data.location.mcp.server.sync(),
|
||||
data.location.skill.sync(),
|
||||
data.location.mcp.server.sync(ref),
|
||||
data.location.skill.sync(ref),
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
|
||||
const [serverMcp] = createResource(
|
||||
serverSDK,
|
||||
(sdk) =>
|
||||
sdk.api.mcp
|
||||
.list()
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
.catch(() => ({})),
|
||||
{ initialValue: {} },
|
||||
)
|
||||
const globalMcpNames = createMemo(() =>
|
||||
[...new Set([...Object.keys(serverSync.data.config.mcp ?? {}), ...Object.keys(serverMcp.latest)])].sort(),
|
||||
[...new Set((data.location.mcp.server.list() ?? []).map((server) => server.name))].sort(),
|
||||
)
|
||||
const projectMcpNames = createMemo(() => {
|
||||
const shared = new Set(globalMcpNames())
|
||||
const configured = Object.keys(sync().data.config.mcp ?? {}).filter((name) => !shared.has(name))
|
||||
if (configured.length > 0) return configured.sort()
|
||||
return Object.keys(sync().data.mcp ?? {})
|
||||
return (data.location.mcp.server.list({ directory: directorySDK().directory }) ?? [])
|
||||
.map((server) => server.name)
|
||||
.filter((name) => !shared.has(name))
|
||||
.sort()
|
||||
})
|
||||
const mcpEnabled = (name: string) => sync().data.mcp?.[name]?.status === "connected"
|
||||
const mcpEnabled = (name: string) =>
|
||||
data.location.mcp.server.list({ directory: directorySDK().directory })?.find((server) => server.name === name)?.status
|
||||
.status === "connected"
|
||||
|
||||
const globalPlugins = createMemo(() => (serverSync.data.config.plugin ?? []).map(pluginName))
|
||||
const [globalPluginList] = createResource(
|
||||
() => serverSDK.connection.status() === "connected",
|
||||
() => serverSDK.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const [projectPluginList] = createResource(
|
||||
() => (serverSDK.connection.status() === "connected" ? directorySDK().directory : undefined),
|
||||
(directory) => serverSDK.api.plugin.list({ location: { directory } }).then((result) => result.data),
|
||||
)
|
||||
const globalPlugins = createMemo(() => (globalPluginList.latest ?? []).map((item) => item.id))
|
||||
const projectPlugins = createMemo(() => {
|
||||
const shared = new Set(globalPlugins())
|
||||
return (sync().data.config.plugin ?? []).map(pluginName).filter((name) => !shared.has(name))
|
||||
return (projectPluginList.latest ?? []).map((item) => item.id).filter((name) => !shared.has(name))
|
||||
})
|
||||
|
||||
const [serverSkills] = createResource(
|
||||
serverSDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill.list().then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const [directorySkills] = createResource(
|
||||
directorySDK,
|
||||
(sdk): Promise<SkillItem[]> =>
|
||||
sdk.api.skill
|
||||
.list({ location: { directory: sdk.directory } })
|
||||
.then((result) => result.data.map((item) => ({ name: item.name, location: item.location }))),
|
||||
{ initialValue: [] },
|
||||
)
|
||||
const serverSkills = createMemo(() => data.location.skill.list() ?? [])
|
||||
const projectSkills = createMemo(() => {
|
||||
const shared = new Set(serverSkills.latest.map(skillKey))
|
||||
return directorySkills.latest.filter((item) => !shared.has(skillKey(item)))
|
||||
const shared = new Set(serverSkills().map(skillKey))
|
||||
return (data.location.skill.list({ directory: directorySDK().directory }) ?? []).filter(
|
||||
(item) => !shared.has(skillKey(item)),
|
||||
)
|
||||
})
|
||||
|
||||
const mcpRows = (items: string[]) => (
|
||||
@@ -165,7 +153,7 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
<TabsV2.Trigger value="mcps">{language.t("settings.extensions.tab.mcps")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="plugins">{language.t("status.popover.tab.plugins")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="skills">{language.t("settings.extensions.tab.skills")}</TabsV2.Trigger>
|
||||
<TabsV2.Trigger value="lsps">{language.t("project.settings.extensions.tab.lsps")}</TabsV2.Trigger>
|
||||
{/* TODO: Restore LSP status when V2 exposes it. */}
|
||||
</TabsV2.List>
|
||||
|
||||
<TabsV2.Content value="mcps">
|
||||
@@ -205,33 +193,10 @@ export const ProjectSettingsExtensions: Component = () => {
|
||||
<Show when={projectSkills().length > 0}>
|
||||
<ExtensionCard>{skillRows(projectSkills())}</ExtensionCard>
|
||||
</Show>
|
||||
<SharedSection count={serverSkills.latest.length}>{skillRows(serverSkills.latest)}</SharedSection>
|
||||
<SharedSection count={serverSkills().length}>{skillRows(serverSkills())}</SharedSection>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
|
||||
<TabsV2.Content value="lsps">
|
||||
<div class="project-settings-extension-section">
|
||||
<div class="project-settings-extension-section-header">
|
||||
<span>{language.t("project.settings.extensions.lsp.detected")}</span>
|
||||
<span>{language.t("project.settings.extensions.lsp.description")}</span>
|
||||
</div>
|
||||
<Show when={sync().data.lsp.length > 0}>
|
||||
<ExtensionCard>
|
||||
<For each={sync().data.lsp}>
|
||||
{(item) => (
|
||||
<ExtensionRow
|
||||
icon="code"
|
||||
name={item.name || item.id}
|
||||
status={
|
||||
item.status === "error" ? language.t("project.settings.extensions.setupRequired") : undefined
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</ExtensionCard>
|
||||
</Show>
|
||||
</div>
|
||||
</TabsV2.Content>
|
||||
</TabsV2>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -23,7 +23,7 @@ import { usePermission } from "@/context/permission"
|
||||
import { type ImageAttachmentPart, usePrompt } from "@/context/prompt"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { PromptInputV2, type PromptInputV2Suggestion } from "@opencode-ai/session-ui/v2/prompt-input"
|
||||
@@ -82,7 +82,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) {
|
||||
|
||||
export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): PromptInputV2ComposerController {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const files = useFile()
|
||||
const layout = useLayout()
|
||||
const comments = useComments()
|
||||
@@ -113,8 +113,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
return [...result, path]
|
||||
}, [])
|
||||
})
|
||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
||||
const attachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -226,8 +226,8 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
const referenceDescription = (reference: ReferenceInfo) =>
|
||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||
const references = createMemo(() =>
|
||||
sync()
|
||||
.data.reference.filter((reference) => !reference.hidden)
|
||||
(data.location.reference.list({ directory: sdk().directory }) ?? [])
|
||||
.filter((reference) => !reference.hidden)
|
||||
.map((reference) => ({
|
||||
id: `reference:${reference.name}`,
|
||||
kind: "reference" as const,
|
||||
@@ -246,7 +246,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
})),
|
||||
)
|
||||
const resources = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map((resource) => ({
|
||||
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map((resource) => ({
|
||||
id: `resource:${resource.server}:${resource.uri}`,
|
||||
kind: "resource" as const,
|
||||
label: `@${resource.name}`,
|
||||
@@ -292,7 +292,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
})),
|
||||
])
|
||||
const slashCommands = createMemo(() => [
|
||||
...sync().data.command.map((item) => ({
|
||||
...(data.location.command.list({ directory: sdk().directory }) ?? []).map((item) => ({
|
||||
id: `custom.${item.name}`,
|
||||
trigger: item.name,
|
||||
title: item.name,
|
||||
@@ -352,7 +352,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
|
||||
dialog.show(() => <ImagePreview src={attachment.blob.url} alt={attachment.filename} />),
|
||||
openContext(key) {
|
||||
const item = controller.contextItem(key)
|
||||
if (item) openComment(item, props, sync, layout, files, comments)
|
||||
if (item) openComment(item, props, layout, files, comments)
|
||||
},
|
||||
onEditor(element) {
|
||||
editor = element as HTMLDivElement
|
||||
@@ -555,7 +555,6 @@ function PromptInputV2ModelControl(props: {
|
||||
function openComment(
|
||||
item: { path: string; commentID?: string; commentOrigin?: "review" | "file" },
|
||||
props: PromptInputV2ControllerProps,
|
||||
sync: ReturnType<typeof useSync>,
|
||||
layout: ReturnType<typeof useLayout>,
|
||||
files: ReturnType<typeof useFile>,
|
||||
comments: ReturnType<typeof useComments>,
|
||||
@@ -573,9 +572,7 @@ function openComment(
|
||||
})
|
||||
})
|
||||
}
|
||||
const diffs = props.controls.session.id ? sync().data.session_diff[props.controls.session.id] : undefined
|
||||
const review =
|
||||
item.commentOrigin === "review" || (item.commentOrigin !== "file" && diffs?.some((diff) => diff.file === item.path))
|
||||
const review = item.commentOrigin === "review"
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
if (review) {
|
||||
layout.fileTree.setTab("changes")
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
} from "@/context/prompt"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
|
||||
@@ -115,8 +115,8 @@ const EXAMPLES = [
|
||||
|
||||
export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const sdk = useSDK()
|
||||
const data = useData()
|
||||
|
||||
const sync = useSync()
|
||||
const files = useFile()
|
||||
const prompt = props.state ?? usePrompt()
|
||||
const layout = useLayout()
|
||||
@@ -184,15 +184,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab),
|
||||
}).activeFileTab
|
||||
|
||||
const commentInReview = (path: string) => {
|
||||
const sessionID = props.controls.session.id
|
||||
if (!sessionID) return false
|
||||
|
||||
const diffs = sync().data.session_diff[sessionID]
|
||||
if (!diffs) return false
|
||||
return diffs.some((diff) => diff.file === path)
|
||||
}
|
||||
|
||||
const openComment = (item: { path: string; commentID?: string; commentOrigin?: "review" | "file" }) => {
|
||||
if (!item.commentID) return
|
||||
|
||||
@@ -216,7 +207,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
schedule(attempts)
|
||||
}
|
||||
|
||||
const wantsReview = item.commentOrigin === "review" || (item.commentOrigin !== "file" && commentInReview(item.path))
|
||||
const wantsReview = item.commentOrigin === "review"
|
||||
if (wantsReview) {
|
||||
if (!props.controls.session.reviewPanel.opened()) props.controls.session.reviewPanel.open()
|
||||
layout.fileTree.setTab("changes")
|
||||
@@ -250,8 +241,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
|
||||
return paths
|
||||
})
|
||||
const info = createMemo(() => (props.controls.session.id ? sync().session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => sync().data.session_working(props.controls.session.id ?? ""))
|
||||
const info = createMemo(() => (props.controls.session.id ? data.session.get(props.controls.session.id) : undefined))
|
||||
const working = createMemo(() => data.session.status(props.controls.session.id ?? "") === "running")
|
||||
const imageAttachments = createMemo(() =>
|
||||
prompt.current().filter((part): part is ImageAttachmentPart => part.type === "image"),
|
||||
)
|
||||
@@ -310,9 +301,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
const hasUserPrompt = createMemo(() => {
|
||||
const sessionID = props.controls.session.id
|
||||
if (!sessionID) return false
|
||||
const messages = sync().data.message[sessionID]
|
||||
if (!messages) return false
|
||||
return messages.some((m) => m.role === "user")
|
||||
return data.session.message.list(sessionID).some((message) => message.type === "user")
|
||||
})
|
||||
|
||||
const history = props.history ?? createPersistedPromptInputHistory()
|
||||
@@ -565,8 +554,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
reference.source.type === "git" ? reference.source.repository : reference.source.path
|
||||
|
||||
const referenceList = createMemo(() =>
|
||||
sync()
|
||||
.data.reference.filter((reference) => !reference.hidden)
|
||||
(data.location.reference.list({ directory: sdk().directory }) ?? [])
|
||||
.filter((reference) => !reference.hidden)
|
||||
.map(
|
||||
(reference): AtOption => ({
|
||||
type: "reference",
|
||||
@@ -585,7 +574,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
)
|
||||
|
||||
const mcpResourceList = createMemo(() =>
|
||||
Object.values(sync().data.mcp_resource).map(
|
||||
(data.location.mcp.resource.list({ directory: sdk().directory }) ?? []).map(
|
||||
(resource): AtOption => ({
|
||||
type: "resource",
|
||||
name: resource.name,
|
||||
@@ -702,7 +691,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
|
||||
type: "builtin" as const,
|
||||
}))
|
||||
|
||||
const custom = sync().data.command.map((cmd) => ({
|
||||
const custom = (data.location.command.list({ directory: sdk().directory }) ?? []).map((cmd) => ({
|
||||
id: `custom.${cmd.name}`,
|
||||
trigger: cmd.name,
|
||||
title: cmd.name,
|
||||
|
||||
@@ -11,16 +11,6 @@ type SessionCreateInput = {
|
||||
model?: { id: string; providerID: string; variant?: string }
|
||||
location?: { directory: string }
|
||||
}
|
||||
const optimistic: Array<{
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: {
|
||||
agent: string
|
||||
model: { providerID: string; modelID: string }
|
||||
variant?: string
|
||||
}
|
||||
}> = []
|
||||
const storedSessions: Record<string, Array<{ id: string; title?: string }>> = {}
|
||||
const sentShell: Array<{ sessionID: string; id?: string; command: string }> = []
|
||||
const sentShellDirectories: string[] = []
|
||||
const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = []
|
||||
@@ -34,8 +24,6 @@ const switchedModels: Array<{
|
||||
}> = []
|
||||
const sessionRequestOrder: string[] = []
|
||||
const updatedDrafts: Array<{ draftID: string; worktree?: string }> = []
|
||||
const syncedServers: string[] = []
|
||||
const optimisticServers: string[] = []
|
||||
const promptCaptures: Array<{ scope?: unknown; target?: unknown }> = []
|
||||
let serverSessionSyncs = 0
|
||||
|
||||
@@ -49,8 +37,6 @@ let worktreeFailure: Error | undefined
|
||||
let locationFailure: Error | undefined
|
||||
let worktreeCreates = 0
|
||||
let activeSDK = "server-a"
|
||||
let activeServerSync = "server-a"
|
||||
let activeDirectorySync = "server-a"
|
||||
let commands: Array<{ name: string }> = []
|
||||
let worktreeDirectory = "/repo/new-0"
|
||||
let worktreeID = 0
|
||||
@@ -229,61 +215,19 @@ beforeAll(async () => {
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/sync", () => ({
|
||||
useSync: () => () => {
|
||||
const server = activeDirectorySync
|
||||
return {
|
||||
data: { command: commands, project: "project" },
|
||||
session: {
|
||||
optimistic: {
|
||||
add: (value: {
|
||||
directory?: string
|
||||
sessionID?: string
|
||||
message: { agent: string; model: { providerID: string; modelID: string; variant?: string } }
|
||||
}) => {
|
||||
optimisticServers.push(server)
|
||||
optimistic.push(value)
|
||||
},
|
||||
remove: () => undefined,
|
||||
},
|
||||
mock.module("@/context/server", () => ({
|
||||
useData: () => ({
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
setStatus: () => undefined,
|
||||
},
|
||||
location: {
|
||||
info: () => ({ project: { id: "project", directory: "/repo/main" } }),
|
||||
command: {
|
||||
list: () => commands,
|
||||
},
|
||||
set: () => undefined,
|
||||
project: { worktree: server === "server-a" ? "/repo/main" : "/repo/other" },
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
mock.module("@/context/server-sync", () => ({
|
||||
useServerSync: () => {
|
||||
const server = activeServerSync
|
||||
return {
|
||||
session: {
|
||||
remember: () => undefined,
|
||||
set: () => undefined,
|
||||
sync: async () => {
|
||||
serverSessionSyncs++
|
||||
},
|
||||
},
|
||||
child: (directory: string) => {
|
||||
syncedServers.push(server)
|
||||
storedSessions[directory] ??= []
|
||||
return [
|
||||
{ session: storedSessions[directory] },
|
||||
(...args: unknown[]) => {
|
||||
if (args[0] !== "session") return
|
||||
const next = args[1]
|
||||
if (typeof next === "function") {
|
||||
storedSessions[directory] = next(storedSessions[directory]) as Array<{ id: string; title?: string }>
|
||||
return
|
||||
}
|
||||
if (Array.isArray(next)) {
|
||||
storedSessions[directory] = next as Array<{ id: string; title?: string }>
|
||||
}
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}),
|
||||
}))
|
||||
|
||||
mock.module("@/context/platform", () => ({
|
||||
@@ -304,7 +248,6 @@ beforeAll(async () => {
|
||||
|
||||
beforeEach(() => {
|
||||
createdSessions.length = 0
|
||||
optimistic.length = 0
|
||||
promotedDrafts.length = 0
|
||||
updatedDrafts.length = 0
|
||||
sentCommands.length = 0
|
||||
@@ -313,8 +256,6 @@ beforeEach(() => {
|
||||
switchedAgents.length = 0
|
||||
switchedModels.length = 0
|
||||
sessionRequestOrder.length = 0
|
||||
syncedServers.length = 0
|
||||
optimisticServers.length = 0
|
||||
promptCaptures.length = 0
|
||||
params = {}
|
||||
search = {}
|
||||
@@ -323,8 +264,6 @@ beforeEach(() => {
|
||||
selected = "/repo/worktree-a"
|
||||
variant = undefined
|
||||
activeSDK = "server-a"
|
||||
activeServerSync = "server-a"
|
||||
activeDirectorySync = "server-a"
|
||||
commands = []
|
||||
promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }]
|
||||
worktreeDirectory = `/repo/new-${++worktreeID}`
|
||||
@@ -336,7 +275,6 @@ beforeEach(() => {
|
||||
worktreeCreates = 0
|
||||
for (const key of Object.keys(draftServers)) delete draftServers[key]
|
||||
for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key]
|
||||
for (const key of Object.keys(storedSessions)) delete storedSessions[key]
|
||||
})
|
||||
|
||||
const event = { preventDefault: () => undefined } as unknown as Event
|
||||
@@ -411,8 +349,6 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
const result = submit.handleSubmit(event)
|
||||
activeSDK = "server-b"
|
||||
activeServerSync = "server-b"
|
||||
activeDirectorySync = "server-b"
|
||||
search.draftId = "draft-2"
|
||||
release()
|
||||
await result
|
||||
@@ -420,8 +356,6 @@ describe("prompt submit worktree selection", () => {
|
||||
|
||||
expect(updatedDrafts).toEqual([{ draftID: "draft-1", worktree: undefined }])
|
||||
expect(promotedDrafts).toEqual([{ draftID: "draft-1", server: "project-server-a", sessionId: "session-1" }])
|
||||
expect(syncedServers.every((server) => server === "server-a")).toBe(true)
|
||||
expect(optimisticServers).toEqual(["server-a"])
|
||||
expect(promptCaptures.at(-1)?.target).toEqual({ server: "project-server-a", scope: ServerScope.local })
|
||||
expect(submitted).toBe(0)
|
||||
})
|
||||
@@ -441,13 +375,6 @@ describe("prompt submit worktree selection", () => {
|
||||
await submit.handleSubmit(event)
|
||||
await Bun.sleep(0)
|
||||
|
||||
expect(optimistic).toHaveLength(1)
|
||||
expect(optimistic[0]).toMatchObject({
|
||||
message: {
|
||||
agent: "agent",
|
||||
model: { providerID: "provider", modelID: "model", variant: "high" },
|
||||
},
|
||||
})
|
||||
expect(sentPrompts).toEqual(["/repo/main"])
|
||||
expect(switchedAgents).toEqual([{ sessionID: "session-1", agent: "agent" }])
|
||||
expect(switchedModels).toEqual([
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import type { Message } from "@/types"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { batch, startTransition, type Accessor } from "solid-js"
|
||||
import { startTransition, type Accessor } from "solid-js"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useServerSync, type ServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLocal, type ModelSelection } from "@/context/local"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt"
|
||||
import { useSDK, type DirectorySDK } from "@/context/sdk"
|
||||
import { useSync, type DirectorySync } from "@/context/sync"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { getDirectory } from "@opencode-ai/core/util/path"
|
||||
import { buildRequestParts } from "./build-request-parts"
|
||||
@@ -37,8 +34,7 @@ export type FollowupDraft = {
|
||||
|
||||
type FollowupSendInput = {
|
||||
api: DirectorySDK["api"]["session"]
|
||||
serverSync: ServerSync
|
||||
sync: DirectorySync
|
||||
data: Data
|
||||
session: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
|
||||
draft: FollowupDraft
|
||||
messageID?: string
|
||||
@@ -54,17 +50,17 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
const images = draftImages(input.draft.prompt)
|
||||
const setBusy = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
|
||||
input.data.session.setStatus(input.draft.sessionID, "running")
|
||||
}
|
||||
|
||||
const setIdle = () => {
|
||||
if (!input.optimisticBusy) return
|
||||
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
|
||||
input.data.session.setStatus(input.draft.sessionID, "idle")
|
||||
}
|
||||
|
||||
const [head, ...tail] = text.split(" ")
|
||||
const cmd = head?.startsWith("/") ? head.slice(1) : undefined
|
||||
if (cmd && input.sync.data.command.find((item) => item.name === cmd)) {
|
||||
if (cmd && input.data.location.command.list({ directory: input.draft.sessionDirectory })?.some((item) => item.name === cmd)) {
|
||||
setBusy()
|
||||
try {
|
||||
const messageID = Identifier.ascending("message")
|
||||
@@ -100,7 +96,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
dataUrl: await blobDataUrl(attachment.blob, attachment.mime),
|
||||
})),
|
||||
)
|
||||
const { requestParts, optimisticParts } = buildRequestParts({
|
||||
const { requestParts } = buildRequestParts({
|
||||
prompt: input.draft.prompt,
|
||||
context: input.draft.context,
|
||||
images: encodedImages,
|
||||
@@ -110,34 +106,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
sessionDirectory: input.draft.sessionDirectory,
|
||||
})
|
||||
|
||||
const message: Message = {
|
||||
id: messageID,
|
||||
sessionID: input.draft.sessionID,
|
||||
role: "user",
|
||||
time: { created: Date.now() },
|
||||
agent: input.draft.agent,
|
||||
model: { ...input.draft.model, variant: input.draft.variant },
|
||||
}
|
||||
|
||||
const add = () =>
|
||||
input.sync.session.optimistic.add({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
message,
|
||||
parts: optimisticParts,
|
||||
})
|
||||
|
||||
const remove = () =>
|
||||
input.sync.session.optimistic.remove({
|
||||
directory: input.draft.sessionDirectory,
|
||||
sessionID: input.draft.sessionID,
|
||||
messageID,
|
||||
})
|
||||
|
||||
batch(() => {
|
||||
setBusy()
|
||||
add()
|
||||
})
|
||||
setBusy()
|
||||
|
||||
try {
|
||||
const session = input.session()
|
||||
@@ -189,10 +158,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
|
||||
})
|
||||
return true
|
||||
} catch (err) {
|
||||
batch(() => {
|
||||
setIdle()
|
||||
remove()
|
||||
})
|
||||
setIdle()
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -226,8 +192,7 @@ type PromptSubmitInput = {
|
||||
export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const local = useLocal()
|
||||
const permission = usePermission()
|
||||
const prompt = input.prompt
|
||||
@@ -248,8 +213,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const abort = async () => {
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return Promise.resolve()
|
||||
serverSync.session.set("todo", sessionID, [])
|
||||
|
||||
input.onAbort?.()
|
||||
|
||||
return sdk()
|
||||
@@ -280,21 +243,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
}
|
||||
|
||||
const seed = (target: ServerSync, dir: string, info: SessionInfo) => {
|
||||
target.session.remember(info)
|
||||
const [, setStore] = target.child(dir)
|
||||
setStore("session", (list: SessionInfo[]) => {
|
||||
const result = Binary.search(list, info.id, (item) => item.id)
|
||||
const next = [...list]
|
||||
if (result.found) {
|
||||
next[result.index] = info
|
||||
return next
|
||||
}
|
||||
next.splice(result.index, 0, info)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const handleSubmit = async (event: Event) => {
|
||||
event.preventDefault()
|
||||
|
||||
@@ -327,8 +275,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
}
|
||||
|
||||
const submissionSDK = sdk()
|
||||
const submissionSync = sync()
|
||||
const submissionServerSync = serverSync
|
||||
const submissionData = data
|
||||
const submissionScope = submissionSDK.scope
|
||||
const projectDirectory = submissionSDK.directory
|
||||
const sessionID = params.id
|
||||
@@ -359,9 +306,11 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (worktreeSelection === "create") {
|
||||
const createdWorktree = await submissionSDK.api.worktree
|
||||
.create({
|
||||
projectID: submissionSync.data.project,
|
||||
projectID: submissionData.location.info({ directory: projectDirectory })?.project.id ?? "",
|
||||
strategy: "git",
|
||||
directory: getDirectory(submissionSync.project?.worktree ?? projectDirectory),
|
||||
directory: getDirectory(
|
||||
submissionData.location.info({ directory: projectDirectory })?.project.directory ?? projectDirectory,
|
||||
),
|
||||
})
|
||||
.then(async (created) => {
|
||||
await submissionSDK.api.location.get({ location: { directory: created.directory } })
|
||||
@@ -381,10 +330,6 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (worktreeSelection !== "main" && worktreeSelection !== "create") {
|
||||
sessionDirectory = worktreeSelection
|
||||
}
|
||||
|
||||
if (sessionDirectory !== projectDirectory) {
|
||||
submissionServerSync.child(sessionDirectory)
|
||||
}
|
||||
}
|
||||
|
||||
let session = currentSession
|
||||
@@ -403,7 +348,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
return undefined
|
||||
})
|
||||
if (created) {
|
||||
seed(submissionServerSync, sessionDirectory, created)
|
||||
submissionData.session.remember(created)
|
||||
session = created
|
||||
await startTransition(() => {
|
||||
if (!session) return
|
||||
@@ -503,11 +448,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
if (text.startsWith("/")) {
|
||||
const [cmdName, ...args] = text.split(" ")
|
||||
const commandName = cmdName.slice(1)
|
||||
const customCommand = submissionSync.data.command.find((c) => c.name === commandName)
|
||||
const customCommand = submissionData.location.command
|
||||
.list({ directory: sessionDirectory })
|
||||
?.find((command) => command.name === commandName)
|
||||
if (customCommand) {
|
||||
clearInput()
|
||||
const messageID = Identifier.ascending("message")
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "busy" })
|
||||
submissionData.session.setStatus(session.id, "running")
|
||||
void submissionSDK.api.session
|
||||
.command({
|
||||
sessionID: session.id,
|
||||
@@ -524,7 +471,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
),
|
||||
})
|
||||
.catch((err) => {
|
||||
submissionServerSync.session.set("session_status", session.id, { type: "idle" })
|
||||
submissionData.session.setStatus(session.id, "idle")
|
||||
showToast({
|
||||
title: language.t("prompt.toast.commandSendFailed.title"),
|
||||
description: formatServerError(err, language.t, language.t("common.requestFailed")),
|
||||
@@ -538,34 +485,24 @@ export function createPromptSubmit(input: PromptSubmitInput) {
|
||||
const commentItems = context.filter((item) => item.type === "file" && !!item.comment?.trim())
|
||||
const messageID = Identifier.ascending("message")
|
||||
|
||||
const removeOptimisticMessage = () => {
|
||||
submissionSync.session.optimistic.remove({
|
||||
directory: sessionDirectory,
|
||||
sessionID: session.id,
|
||||
messageID,
|
||||
})
|
||||
}
|
||||
|
||||
for (const item of commentItems) submission.target().context.remove(item.key)
|
||||
clearInput()
|
||||
|
||||
void sendFollowupDraft({
|
||||
api: submissionSDK.api.session,
|
||||
sync: submissionSync,
|
||||
serverSync: submissionServerSync,
|
||||
data: submissionData,
|
||||
session: () => session,
|
||||
draft,
|
||||
messageID,
|
||||
optimisticBusy: sessionDirectory === projectDirectory,
|
||||
}).catch((err) => {
|
||||
if (sessionDirectory === projectDirectory) {
|
||||
submissionSync.set("session_status", session.id, { type: "idle" })
|
||||
submissionData.session.setStatus(session.id, "idle")
|
||||
}
|
||||
showToast({
|
||||
title: language.t("prompt.toast.promptSendFailed.title"),
|
||||
description: errorMessage(err),
|
||||
})
|
||||
removeOptimisticMessage()
|
||||
if (restoreInput()) restoreCommentItems(submission.target(), commentItems)
|
||||
})
|
||||
} finally {
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { FormAnswer, IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise"
|
||||
import { useQueryClient } from "@tanstack/solid-query"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useData } from "@/context/server"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
|
||||
@@ -18,8 +16,7 @@ export function createProviderConnectionController(options: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const queryClient = useQueryClient()
|
||||
const data = useData()
|
||||
const location = () => {
|
||||
const directory = options.directory()
|
||||
return directory ? { directory } : undefined
|
||||
@@ -118,10 +115,17 @@ export function createProviderConnectionController(options: {
|
||||
}
|
||||
const finish = async () => {
|
||||
cancelPolling()
|
||||
const directory = options.directory()
|
||||
await queryClient
|
||||
.refetchQueries(serverSync.queryOptions.providers(directory ? pathKey(directory) : null))
|
||||
.catch(() => undefined)
|
||||
const ref = location()
|
||||
data.location.integration.invalidate(ref)
|
||||
data.location.provider.invalidate(ref)
|
||||
data.location.model.invalidate(ref)
|
||||
data.location.model.invalidateDefault(ref)
|
||||
await Promise.all([
|
||||
data.location.integration.sync(ref),
|
||||
data.location.provider.sync(ref),
|
||||
data.location.model.sync(ref),
|
||||
data.location.model.syncDefault(ref),
|
||||
]).catch(() => undefined)
|
||||
if (polling.disposed) return
|
||||
options.onComplete()
|
||||
}
|
||||
|
||||
@@ -8,11 +8,10 @@ import { createMediaQuery } from "@solid-primitives/media"
|
||||
|
||||
import { useFile } from "@/context/file"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { getSessionContext } from "@/components/session/session-context-metrics"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { createSessionTabs } from "@/pages/session/helpers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -43,7 +42,7 @@ function openSessionContext(args: {
|
||||
}
|
||||
|
||||
export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const file = useFile()
|
||||
const layout = useLayout()
|
||||
const language = useLanguage()
|
||||
@@ -61,8 +60,8 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab),
|
||||
fileBrowser: () => settings.general.newLayoutDesigns() && isDesktop() && !!params.id,
|
||||
})
|
||||
const messages = createMemo(() => (params.id ? (sync().data.message[params.id] ?? []) : []))
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const messages = createMemo(() => (params.id ? data.session.message.list(params.id) : []))
|
||||
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
@@ -72,7 +71,21 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
|
||||
}),
|
||||
)
|
||||
|
||||
const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const context = createMemo(() => {
|
||||
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
|
||||
if (message?.type !== "assistant" || !message.tokens) return
|
||||
const model = providers.all().get(message.model.providerID)?.models[message.model.id]
|
||||
const total =
|
||||
message.tokens.input +
|
||||
message.tokens.output +
|
||||
message.tokens.reasoning +
|
||||
message.tokens.cache.read +
|
||||
message.tokens.cache.write
|
||||
return {
|
||||
total,
|
||||
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
const cost = createMemo(() => {
|
||||
return usd().format(info()?.cost ?? 0)
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { createSignal, For, Show, type ComponentProps, type JSX } from "solid-js
|
||||
import type { Project } from "@/types"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useSettingsDialog } from "@/components/settings-dialog"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { showToast } from "@/utils/toast"
|
||||
@@ -26,11 +26,11 @@ export function SessionWorkspaceMenu(props: {
|
||||
}) {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const openWorkspaces = useSettingsDialog("workspaces")
|
||||
const [store, setStore] = createStore({ selected: undefined as string | undefined })
|
||||
const [directories, setDirectories] = createSignal(workspaceDirectories(props.project))
|
||||
const blocked = () => props.eligible === false || serverSync.session.data.session_working(props.sessionID)
|
||||
const blocked = () => props.eligible === false || data.session.status(props.sessionID) === "running"
|
||||
const currentWorkspace = () => directories().find((workspace) => containsDirectory(workspace, props.directory))
|
||||
const workspaces = () =>
|
||||
directories().filter((workspace) => pathKey(workspace) !== pathKey(currentWorkspace() ?? props.directory))
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { createMemo, createEffect, on, onCleanup, For, Show } from "solid-js"
|
||||
import type { JSX } from "solid-js"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { findLast } from "@opencode-ai/core/util/array"
|
||||
import { same } from "@/utils/same"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -11,25 +10,15 @@ import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
|
||||
import { File } from "@opencode-ai/session-ui/file"
|
||||
import { Markdown } from "@opencode-ai/session-ui/markdown"
|
||||
import { ScrollView } from "@opencode-ai/ui/scroll-view"
|
||||
import type { Message, Part, UserMessage } from "@/types"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSessionLayout } from "@/pages/session/session-layout"
|
||||
import { getSessionContext } from "./session-context-metrics"
|
||||
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
|
||||
import { createSessionContextFormatter } from "./session-context-format"
|
||||
|
||||
const BREAKDOWN_COLOR: Record<SessionContextBreakdownKey, string> = {
|
||||
system: "var(--syntax-info)",
|
||||
user: "var(--syntax-success)",
|
||||
assistant: "var(--syntax-property)",
|
||||
tool: "var(--syntax-warning)",
|
||||
other: "var(--syntax-comment)",
|
||||
}
|
||||
|
||||
function Stat(props: { label: string; value: JSX.Element }) {
|
||||
return (
|
||||
<div class="flex flex-col gap-1">
|
||||
@@ -39,12 +28,11 @@ function Stat(props: { label: string; value: JSX.Element }) {
|
||||
)
|
||||
}
|
||||
|
||||
function RawMessageContent(props: { message: Message; getParts: (id: string) => Part[]; onRendered: () => void }) {
|
||||
function RawMessageContent(props: { message: SessionMessageInfo; onRendered: () => void }) {
|
||||
const file = createMemo(() => {
|
||||
const parts = props.getParts(props.message.id)
|
||||
const contents = JSON.stringify({ message: props.message, parts }, null, 2)
|
||||
const contents = JSON.stringify(props.message, null, 2)
|
||||
return {
|
||||
name: `${props.message.role}-${props.message.id}.json`,
|
||||
name: `${props.message.type}-${props.message.id}.json`,
|
||||
contents,
|
||||
cacheKey: checksum(contents),
|
||||
}
|
||||
@@ -62,8 +50,7 @@ function RawMessageContent(props: { message: Message; getParts: (id: string) =>
|
||||
}
|
||||
|
||||
function RawMessage(props: {
|
||||
message: Message
|
||||
getParts: (id: string) => Part[]
|
||||
message: SessionMessageInfo
|
||||
onRendered: () => void
|
||||
time: (value: number | undefined) => string
|
||||
}) {
|
||||
@@ -73,7 +60,7 @@ function RawMessage(props: {
|
||||
<Accordion.Trigger>
|
||||
<div class="flex items-center justify-between gap-2 w-full">
|
||||
<div class="min-w-0 truncate">
|
||||
{props.message.role} <span class="text-text-base">• {props.message.id}</span>
|
||||
{props.message.type} <span class="text-text-base">• {props.message.id}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="shrink-0 text-12-regular text-text-weak">{props.time(props.message.time.created)}</div>
|
||||
@@ -84,52 +71,34 @@ function RawMessage(props: {
|
||||
</StickyAccordionHeader>
|
||||
<Accordion.Content class="bg-background-base">
|
||||
<div class="p-3">
|
||||
<RawMessageContent message={props.message} getParts={props.getParts} onRendered={props.onRendered} />
|
||||
<RawMessageContent message={props.message} onRendered={props.onRendered} />
|
||||
</div>
|
||||
</Accordion.Content>
|
||||
</Accordion.Item>
|
||||
)
|
||||
}
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const emptyUserMessages: UserMessage[] = []
|
||||
const emptyMessages: SessionMessageInfo[] = []
|
||||
|
||||
export function SessionContextTab() {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const sdk = useSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const { params, view } = useSessionLayout()
|
||||
|
||||
const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined))
|
||||
const info = createMemo(() => (params.id ? data.session.get(params.id) : undefined))
|
||||
|
||||
const messages = createMemo(
|
||||
() => {
|
||||
const id = params.id
|
||||
if (!id) return emptyMessages
|
||||
return (sync().data.message[id] ?? []) as Message[]
|
||||
return data.session.message.list(id)
|
||||
},
|
||||
emptyMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const userMessages = createMemo(
|
||||
() => messages().filter((m) => m.role === "user") as UserMessage[],
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const visibleUserMessages = createMemo(
|
||||
() => {
|
||||
const revert = info()?.revert?.messageID
|
||||
if (!revert) return userMessages()
|
||||
const boundary = userMessages().findIndex((message) => message.id === revert)
|
||||
return boundary < 0 ? userMessages() : userMessages().slice(0, boundary)
|
||||
},
|
||||
emptyUserMessages,
|
||||
{ equals: same },
|
||||
)
|
||||
|
||||
const usd = createMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(language.intl(), {
|
||||
@@ -138,7 +107,28 @@ export function SessionContextTab() {
|
||||
}),
|
||||
)
|
||||
|
||||
const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()]))
|
||||
const ctx = createMemo(() => {
|
||||
const message = messages().findLast((item) => item.type === "assistant" && !!item.tokens)
|
||||
if (message?.type !== "assistant" || !message.tokens) return
|
||||
const provider = providers.all().get(message.model.providerID)
|
||||
const model = provider?.models[message.model.id]
|
||||
const total =
|
||||
message.tokens.input +
|
||||
message.tokens.output +
|
||||
message.tokens.reasoning +
|
||||
message.tokens.cache.read +
|
||||
message.tokens.cache.write
|
||||
return {
|
||||
message,
|
||||
tokens: message.tokens,
|
||||
providerLabel: provider?.name ?? message.model.providerID,
|
||||
modelLabel: model?.name ?? message.model.id,
|
||||
limit: model?.limit.context,
|
||||
input: message.tokens.input,
|
||||
total,
|
||||
usage: model?.limit.context ? Math.round((total / model.limit.context) * 100) : null,
|
||||
}
|
||||
})
|
||||
const formatter = createMemo(() => createSessionContextFormatter(language.intl()))
|
||||
|
||||
const cost = createMemo(() => {
|
||||
@@ -147,8 +137,8 @@ export function SessionContextTab() {
|
||||
|
||||
const counts = createMemo(() => {
|
||||
const all = messages()
|
||||
const user = all.reduce((count, x) => count + (x.role === "user" ? 1 : 0), 0)
|
||||
const assistant = all.reduce((count, x) => count + (x.role === "assistant" ? 1 : 0), 0)
|
||||
const user = all.reduce((count, message) => count + (message.type === "user" ? 1 : 0), 0)
|
||||
const assistant = all.reduce((count, message) => count + (message.type === "assistant" ? 1 : 0), 0)
|
||||
return {
|
||||
all: all.length,
|
||||
user,
|
||||
@@ -157,8 +147,7 @@ export function SessionContextTab() {
|
||||
})
|
||||
|
||||
const systemPrompt = createMemo(() => {
|
||||
const msg = findLast(visibleUserMessages(), (m) => !!m.system)
|
||||
const system = msg?.system
|
||||
const system = messages().findLast((message) => message.type === "system")?.text
|
||||
if (!system) return
|
||||
const trimmed = system.trim()
|
||||
if (!trimmed) return
|
||||
@@ -177,30 +166,6 @@ export function SessionContextTab() {
|
||||
return c.modelLabel
|
||||
})
|
||||
|
||||
const breakdown = createMemo(
|
||||
on(
|
||||
() => [ctx()?.message.id, ctx()?.input, messages().length, systemPrompt()],
|
||||
() => {
|
||||
const c = ctx()
|
||||
if (!c?.input) return []
|
||||
return estimateSessionContextBreakdown({
|
||||
messages: messages(),
|
||||
parts: sync().data.part as Record<string, Part[] | undefined>,
|
||||
input: c.input,
|
||||
systemPrompt: systemPrompt(),
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
const breakdownLabel = (key: SessionContextBreakdownKey) => {
|
||||
if (key === "system") return language.t("context.breakdown.system")
|
||||
if (key === "user") return language.t("context.breakdown.user")
|
||||
if (key === "assistant") return language.t("context.breakdown.assistant")
|
||||
if (key === "tool") return language.t("context.breakdown.tool")
|
||||
return language.t("context.breakdown.other")
|
||||
}
|
||||
|
||||
const stats = [
|
||||
{ label: "context.stats.session", value: () => info()?.title ?? params.id ?? "—" },
|
||||
{ label: "context.stats.messages", value: () => counts().all.toLocaleString(language.intl()) },
|
||||
@@ -210,12 +175,12 @@ export function SessionContextTab() {
|
||||
{ label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) },
|
||||
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
|
||||
{ label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) },
|
||||
{ label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.tokens.output) },
|
||||
{ label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.tokens.reasoning) },
|
||||
{
|
||||
label: "context.stats.cacheTokens",
|
||||
value: () =>
|
||||
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
|
||||
`${formatter().number(ctx()?.tokens.cache.read)} / ${formatter().number(ctx()?.tokens.cache.write)}`,
|
||||
},
|
||||
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
|
||||
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
|
||||
@@ -252,8 +217,6 @@ export function SessionContextTab() {
|
||||
let scroll: HTMLDivElement | undefined
|
||||
let frame: number | undefined
|
||||
let pending: { x: number; y: number } | undefined
|
||||
const getParts = (id: string) => (sync().data.part[id] ?? []) as Part[]
|
||||
|
||||
const restoreScroll = () => {
|
||||
const el = scroll
|
||||
if (!el) return
|
||||
@@ -314,37 +277,6 @@ export function SessionContextTab() {
|
||||
</For>
|
||||
</div>
|
||||
|
||||
<Show when={breakdown().length > 0}>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="text-12-regular text-text-weak">{language.t("context.breakdown.title")}</div>
|
||||
<div class="h-2 w-full rounded-full bg-surface-base overflow-hidden flex">
|
||||
<For each={breakdown()}>
|
||||
{(segment) => (
|
||||
<div
|
||||
class="h-full"
|
||||
style={{
|
||||
width: `${segment.width}%`,
|
||||
"background-color": BREAKDOWN_COLOR[segment.key],
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-x-3 gap-y-1">
|
||||
<For each={breakdown()}>
|
||||
{(segment) => (
|
||||
<div class="flex items-center gap-1 text-11-regular text-text-weak">
|
||||
<div class="size-2 rounded-sm" style={{ "background-color": BREAKDOWN_COLOR[segment.key] }} />
|
||||
<div>{breakdownLabel(segment.key)}</div>
|
||||
<div class="text-text-weaker">{segment.percent.toLocaleString(language.intl())}%</div>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</div>
|
||||
<div class="hidden text-11-regular text-text-weaker">{language.t("context.breakdown.note")}</div>
|
||||
</div>
|
||||
</Show>
|
||||
|
||||
<Show when={systemPrompt()}>
|
||||
{(prompt) => (
|
||||
<div class="flex flex-col gap-2">
|
||||
@@ -372,7 +304,7 @@ export function SessionContextTab() {
|
||||
<Accordion multiple>
|
||||
<For each={messages()}>
|
||||
{(message) => (
|
||||
<RawMessage message={message} getParts={getParts} onRendered={restoreScroll} time={formatter().time} />
|
||||
<RawMessage message={message} onRendered={restoreScroll} time={formatter().time} />
|
||||
)}
|
||||
</For>
|
||||
</Accordion>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import { Show, createMemo } from "solid-js"
|
||||
import { DateTime } from "luxon"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Mark } from "@opencode-ai/ui/logo"
|
||||
import { getDirectory, getFilename } from "@opencode-ai/core/util/path"
|
||||
@@ -16,28 +16,32 @@ interface NewSessionViewProps {
|
||||
}
|
||||
|
||||
export function NewSessionView(props: NewSessionViewProps) {
|
||||
const sync = useSync()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
const data = useData()
|
||||
|
||||
const sandboxes = createMemo(() => sync().project?.sandboxes ?? [])
|
||||
const project = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
return projectID ? data.project.get(projectID) : undefined
|
||||
})
|
||||
const sandboxes = createMemo(() => project()?.sandboxes ?? [])
|
||||
const options = createMemo(() => [MAIN_WORKTREE, ...sandboxes(), CREATE_WORKTREE])
|
||||
const current = createMemo(() => {
|
||||
const selection = props.worktree
|
||||
if (options().includes(selection)) return selection
|
||||
return MAIN_WORKTREE
|
||||
})
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const projectRoot = createMemo(() => project()?.canonical ?? sdk().directory)
|
||||
const isWorktree = createMemo(() => {
|
||||
const project = sync().project
|
||||
if (!project) return false
|
||||
return sdk().directory !== project.worktree
|
||||
const current = project()
|
||||
if (!current) return false
|
||||
return sdk().directory !== current.canonical
|
||||
})
|
||||
|
||||
const label = (value: string) => {
|
||||
if (value === MAIN_WORKTREE) {
|
||||
if (isWorktree()) return language.t("session.new.worktree.main")
|
||||
const branch = sync().data.vcs?.branch
|
||||
const branch = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
||||
if (branch) return language.t("session.new.worktree.mainWithBranch", { branch })
|
||||
return language.t("session.new.worktree.main")
|
||||
}
|
||||
@@ -69,7 +73,7 @@ export function NewSessionView(props: NewSessionViewProps) {
|
||||
{label(current())}
|
||||
</div>
|
||||
</div>
|
||||
<Show when={sync().project}>
|
||||
<Show when={project()}>
|
||||
{(project) => (
|
||||
<div class="flex items-start justify-center gap-3 min-h-5">
|
||||
<div class="text-12-medium text-text-weak leading-5 min-w-0 max-w-160 break-words text-center">
|
||||
|
||||
@@ -63,7 +63,7 @@ export const DialogSettings: Component<{
|
||||
const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID)
|
||||
return draft?.type === "draft" ? draft.directory : undefined
|
||||
}
|
||||
if (route.type === "session") return serverCtx()?.sync.session.get(route.sessionId)?.location.directory
|
||||
if (route.type === "session") return serverCtx()?.data.session.get(route.sessionId)?.location.directory
|
||||
return undefined
|
||||
})
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { Component, For, createMemo, createResource } from "solid-js"
|
||||
import { Component, For, createEffect, createMemo, createResource } from "solid-js"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Switch } from "@opencode-ai/ui/v2/switch-v2"
|
||||
import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useMcpToggle } from "@/context/mcp"
|
||||
import { ExternalLink } from "../external-link"
|
||||
import { InlineServerSelect } from "./parts/server-select"
|
||||
import "./settings-v2.css"
|
||||
@@ -21,35 +22,35 @@ interface PluginRowItem {
|
||||
export const SettingsExtensionsV2: Component = () => {
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const [mcpList, { refetch: refetchMcp }] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.mcp.list().then((result) => result.data),
|
||||
)
|
||||
const toggleMcp = useMcpToggle(() => undefined, refetchMcp)
|
||||
const mcps = createMemo<McpRowItem[]>(() => {
|
||||
const configMcp = serverSync.data.config.mcp ?? {}
|
||||
return Object.entries(configMcp).map(([name, config]) => ({
|
||||
name,
|
||||
enabled: typeof config !== "object" || config === null || !("enabled" in config) || config.enabled !== false,
|
||||
return (mcpList.latest ?? []).map((server) => ({
|
||||
name: server.name,
|
||||
enabled: server.status.status === "connected",
|
||||
}))
|
||||
})
|
||||
|
||||
const handleMcpToggle = (item: McpRowItem, checked: boolean) => {
|
||||
const before = serverSync.data.config.mcp ?? {}
|
||||
const config = before[item.name]
|
||||
if (typeof config !== "object" || config === null) return
|
||||
const next = { ...before, [item.name]: { ...config, enabled: checked } }
|
||||
serverSync.set("config", "mcp", next)
|
||||
void serverSync.updateConfig({ mcp: next }).catch(() => serverSync.set("config", "mcp", before))
|
||||
if (item.enabled === checked || toggleMcp.isPending) return
|
||||
toggleMcp.mutate(item.name)
|
||||
}
|
||||
|
||||
const plugins = createMemo<PluginRowItem[]>(() => {
|
||||
const raw = serverSync.data.config.plugin ?? []
|
||||
return raw.map((item) => {
|
||||
const name = typeof item === "string" ? item : item[0]
|
||||
return { name }
|
||||
})
|
||||
})
|
||||
const [pluginList] = createResource(
|
||||
() => serverSdk.connection.status() === "connected",
|
||||
() => serverSdk.api.plugin.list().then((result) => result.data),
|
||||
)
|
||||
const plugins = createMemo<PluginRowItem[]>(() => (pluginList.latest ?? []).map((item) => ({ name: item.id })))
|
||||
|
||||
const [skills] = createResource(serverSdk, (sdk) => sdk.api.skill.list().then((result) => result.data), {
|
||||
initialValue: [],
|
||||
createEffect(() => {
|
||||
if (serverSdk.connection.status() !== "connected") return
|
||||
void data.location.skill.sync().catch(() => undefined)
|
||||
})
|
||||
const skills = () => data.location.skill.list() ?? []
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@@ -2,7 +2,6 @@ import { createMemo, createResource, onMount, type Accessor } from "solid-js"
|
||||
import type { ColorScheme } from "@opencode-ai/ui/theme/context"
|
||||
import { useTheme } from "@opencode-ai/ui/theme/context"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import {
|
||||
monoDefault,
|
||||
monoFontFamily,
|
||||
@@ -34,7 +33,7 @@ export function createPermissionScopeController(
|
||||
const s = server()
|
||||
const id = sessionID()
|
||||
if (!s || !id) return undefined
|
||||
return serverCtx()?.sync.session.lineage.peek(id)?.session.location.directory
|
||||
return serverCtx()?.data.session.lineage.peek(id)?.session.location.directory
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { popularProviders, useProviders } from "@/hooks/use-providers"
|
||||
import { createMemo, type Component, For, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { DialogConnectProvider, useProviderConnectController } from "../dialog-connect-provider"
|
||||
import { DialogCustomProvider } from "../dialog-custom-provider"
|
||||
import { SettingsServerScope } from "../settings-server-picker"
|
||||
@@ -38,7 +37,6 @@ export const SettingsProvidersV2: Component<{
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSdk = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => props.directory)
|
||||
const providerConnect = useProviderConnectController({ onBack: props.onBack })
|
||||
|
||||
@@ -84,26 +82,18 @@ export const SettingsProvidersV2: Component<{
|
||||
const current = source(item)
|
||||
if (current === "env") return language.t("settings.providers.tag.environment")
|
||||
if (current === "api") return language.t("provider.connect.method.apiKey")
|
||||
if (current === "config") {
|
||||
if (isConfigCustom(item.id)) return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.config")
|
||||
}
|
||||
if (current === "config") return language.t("settings.providers.tag.config")
|
||||
if (current === "custom") return language.t("settings.providers.tag.custom")
|
||||
return language.t("settings.providers.tag.other")
|
||||
}
|
||||
|
||||
const canDisconnect = (item: ProviderItem) => source(item) !== "env" && !isConfigCustom(item.id)
|
||||
const canDisconnect = (item: ProviderItem) => {
|
||||
const current = source(item)
|
||||
return current !== "env" && current !== "config"
|
||||
}
|
||||
|
||||
const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key
|
||||
|
||||
const isConfigCustom = (providerID: string) => {
|
||||
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
|
||||
return true
|
||||
}
|
||||
|
||||
const disconnect = async (providerID: string, name: string) => {
|
||||
const location = props.directory ? { directory: props.directory } : undefined
|
||||
await serverSdk.api.integration
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
@@ -13,7 +13,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { getRelativeTime } from "@/utils/time"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
} from "@/utils/workspace"
|
||||
import { listAllSessions } from "@/utils/session"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
import "./settings-v2.css"
|
||||
|
||||
type Workspace = {
|
||||
@@ -48,7 +49,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const tabs = useTabs()
|
||||
const platform = usePlatform()
|
||||
const [store, setStore] = createStore({
|
||||
@@ -56,9 +57,22 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
transaction: undefined as "confirm" | "running" | undefined,
|
||||
})
|
||||
|
||||
const workspaces = createMemo(() => workspaceInventory(serverSync.data.project))
|
||||
const projectQuery = useQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "settings-workspace-projects"] as const,
|
||||
queryFn: async () =>
|
||||
Promise.all(
|
||||
(await serverSDK.api.project.list()).map(async (project) => {
|
||||
const worktrees = await serverSDK.api.worktree
|
||||
.list({ projectID: project.id })
|
||||
.catch(() => [{ directory: project.canonical }, ...project.sandboxes.map((directory) => ({ directory }))])
|
||||
return normalizeProjectInfo({ ...project, worktrees })
|
||||
}),
|
||||
),
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const workspaces = createMemo(() => workspaceInventory(projectQuery.data ?? []))
|
||||
const projects = createMemo(() =>
|
||||
serverSync.data.project.filter((project) => managedWorkspaceDirectories(project).length > 0),
|
||||
(projectQuery.data ?? []).filter((project) => managedWorkspaceDirectories(project).length > 0),
|
||||
)
|
||||
const projectName = (project: Project) => project.name || getFilename(project.worktree)
|
||||
const projectOptions = createMemo(() => [
|
||||
@@ -71,13 +85,18 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
const filtered = createMemo(() => filterWorkspaceInventory(workspaces(), selectedProject()))
|
||||
const captureDeleteContext = () => {
|
||||
const sdk = serverSDK
|
||||
return { sdk, sync: serverSync, server: ServerConnection.key(sdk.server), activeDirectory: props.activeDirectory }
|
||||
return {
|
||||
sdk,
|
||||
data,
|
||||
server: ServerConnection.key(sdk.server),
|
||||
activeDirectory: props.activeDirectory,
|
||||
}
|
||||
}
|
||||
const loadSessions = async (context = captureDeleteContext()) => {
|
||||
const fetched = await listAllSessions(context.sdk.api.session, { order: "desc" })
|
||||
return mergeWorkspaceSessionInventory(
|
||||
fetched,
|
||||
Object.values(context.sync.session.data.info).filter((session): session is SessionInfo => !!session),
|
||||
context.data.session.list(),
|
||||
)
|
||||
}
|
||||
const sessionQuery = useQuery(() => ({
|
||||
@@ -182,19 +201,7 @@ export const SettingsWorkspacesV2: Component<{ activeDirectory?: string }> = (pr
|
||||
platform,
|
||||
context.sdk.scope,
|
||||
)
|
||||
context.sync.set(
|
||||
"project",
|
||||
produce((draft) => {
|
||||
const project = draft.find((item) => item.id === workspace.project.id)
|
||||
if (!project) return
|
||||
project.sandboxes = (project.sandboxes ?? []).filter(
|
||||
(directory) => pathKey(directory) !== pathKey(workspace.directory),
|
||||
)
|
||||
project.worktrees = project.worktrees.filter(
|
||||
(worktree) => pathKey(worktree.directory) !== pathKey(workspace.directory),
|
||||
)
|
||||
}),
|
||||
)
|
||||
await projectQuery.refetch()
|
||||
}
|
||||
|
||||
let inspectionID = 0
|
||||
|
||||
@@ -1,17 +1,10 @@
|
||||
import { Switch } from "@opencode-ai/ui/switch"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { createEffect, createMemo, createResource, For, type JSXElement, onCleanup, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createMemo, createResource, For, type JSXElement, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
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"
|
||||
import { useData } from "@/context/server"
|
||||
|
||||
const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
const parts = value.split(file)
|
||||
@@ -25,105 +18,16 @@ const pluginEmptyMessage = (value: string, file: string): JSXElement => {
|
||||
)
|
||||
}
|
||||
|
||||
const listServersByHealth = (
|
||||
list: ServerConnection.Any[],
|
||||
active: ServerConnection.Key | undefined,
|
||||
status: Record<ServerConnection.Key, ServerHealth | undefined>,
|
||||
) => {
|
||||
if (!list.length) return list
|
||||
const order = new Map(list.map((url, index) => [url, index] as const))
|
||||
const rank = (value?: ServerHealth) => {
|
||||
if (value?.healthy === true) return 0
|
||||
if (value?.healthy === false) return 2
|
||||
return 1
|
||||
}
|
||||
|
||||
return list.slice().sort((a, b) => {
|
||||
if (ServerConnection.key(a) === active) return -1
|
||||
if (ServerConnection.key(b) === active) return 1
|
||||
const diff = rank(status[ServerConnection.key(a)]) - rank(status[ServerConnection.key(b)])
|
||||
if (diff !== 0) return diff
|
||||
return (order.get(a) ?? 0) - (order.get(b) ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
const useDefaultServerKey = (
|
||||
get: (() => string | Promise<string | null | undefined> | null | undefined) | undefined,
|
||||
) => {
|
||||
const [state, setState] = createStore({
|
||||
key: undefined as ServerConnection.Key | undefined,
|
||||
tick: 0,
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
state.tick
|
||||
let dead = false
|
||||
const result = get?.()
|
||||
if (!result) {
|
||||
setState("key", undefined)
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (result instanceof Promise) {
|
||||
void result.then((next) => {
|
||||
if (dead) return
|
||||
setState("key", next ?? undefined)
|
||||
})
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
setState("key", ServerConnection.Key.make(result))
|
||||
onCleanup(() => {
|
||||
dead = true
|
||||
})
|
||||
})
|
||||
|
||||
return {
|
||||
key: () => {
|
||||
return state.key
|
||||
},
|
||||
refresh: () => setState("tick", (value) => value + 1),
|
||||
}
|
||||
}
|
||||
|
||||
type ServerStatusItem = {
|
||||
key: ServerConnection.Key
|
||||
conn: ServerConnection.Any
|
||||
health?: ServerHealth
|
||||
blocked: boolean
|
||||
active: boolean
|
||||
onSelect: () => void
|
||||
}
|
||||
|
||||
export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : String(err),
|
||||
})
|
||||
}
|
||||
|
||||
let dialogRun = 0
|
||||
onCleanup(() => {
|
||||
dialogRun += 1
|
||||
})
|
||||
const toggleMcp = useMcpToggle()
|
||||
const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => sync().data.mcp?.[name]?.status
|
||||
const toggleMcp = useMcpToggle(() => sdk().directory)
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory }) ?? []
|
||||
const mcpNames = createMemo(() => mcp().map((server) => server.name).sort((a, b) => a.localeCompare(b)))
|
||||
const mcpStatus = (name: string) => mcp().find((server) => server.name === name)?.status.status
|
||||
const mcpConnected = createMemo(() => mcpNames().filter((name) => mcpStatus(name) === "connected").length)
|
||||
const lspItems = createMemo(() => sync().data.lsp ?? [])
|
||||
const lspCount = createMemo(() => lspItems().length)
|
||||
const [pluginList] = createResource(
|
||||
() => (props.shown ? sdk().directory : undefined),
|
||||
(directory) =>
|
||||
@@ -150,10 +54,7 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
{mcpConnected() > 0 ? `${mcpConnected()} ` : ""}
|
||||
{language.t("status.popover.tab.mcp")}
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="lsp" data-slot="tab" class="text-12-regular">
|
||||
{lspCount() > 0 ? `${lspCount()} ` : ""}
|
||||
{language.t("status.popover.tab.lsp")}
|
||||
</Tabs.Trigger>
|
||||
{/* TODO: Restore LSP status when V2 exposes it. */}
|
||||
<Show when={true}>
|
||||
<Tabs.Trigger value="plugins" data-slot="tab" class="text-12-regular">
|
||||
{pluginCount() > 0 ? `${pluginCount()} ` : ""}
|
||||
@@ -223,34 +124,6 @@ export function StatusPopoverBody(props: { shown: boolean }) {
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Tabs.Content value="lsp">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
<div class="flex flex-col p-3 bg-background-base rounded-sm min-h-14">
|
||||
<Show
|
||||
when={lspItems().length > 0}
|
||||
fallback={
|
||||
<div class="text-14-regular text-text-base text-center my-auto">{language.t("dialog.lsp.empty")}</div>
|
||||
}
|
||||
>
|
||||
<For each={lspItems()}>
|
||||
{(item) => (
|
||||
<div class="flex items-center gap-2 w-full px-2 py-1">
|
||||
<div
|
||||
classList={{
|
||||
"size-1.5 rounded-full shrink-0": true,
|
||||
"bg-icon-success-base": item.status === "connected",
|
||||
"bg-icon-critical-base": item.status === "error",
|
||||
}}
|
||||
/>
|
||||
<span class="text-14-regular text-text-base truncate">{item.name || item.id}</span>
|
||||
</div>
|
||||
)}
|
||||
</For>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</Tabs.Content>
|
||||
|
||||
<Show when={true}>
|
||||
<Tabs.Content value="plugins">
|
||||
<div class="flex flex-col px-2 pb-2">
|
||||
|
||||
@@ -5,16 +5,14 @@ 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, useServers } from "@/context/servers"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useGlobal } from "@/context/global"
|
||||
import {
|
||||
hasNonBlockingServiceIssue,
|
||||
hasServiceNeedingAttention,
|
||||
serverStatusDotClass,
|
||||
} from "./status-popover-indicator"
|
||||
import { useServer } from "@/context/server"
|
||||
import { useData, useServer } from "@/context/server"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
|
||||
const Body = lazy(() => import("./status-popover-body").then((x) => ({ default: x.StatusPopoverBody })))
|
||||
|
||||
@@ -22,19 +20,21 @@ export function StatusPopover() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
|
||||
const attention = createMemo(() =>
|
||||
hasServiceNeedingAttention({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
}),
|
||||
)
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
lsp: [],
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -86,19 +86,21 @@ export function StatusPopoverV2() {
|
||||
const language = useLanguage()
|
||||
const server = useServer()
|
||||
const global = useGlobal()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const [shown, setShown] = createSignal(false)
|
||||
const serverHealth = () => global.servers.health[server.key]?.healthy
|
||||
const ready = createMemo(() => serverHealth() === false || (sync().data.mcp_ready && sync().data.lsp_ready))
|
||||
const mcp = () => data.location.mcp.server.list({ directory: sdk().directory })
|
||||
const ready = createMemo(() => serverHealth() === false || mcp() !== undefined)
|
||||
const attention = createMemo(() =>
|
||||
hasServiceNeedingAttention({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
}),
|
||||
)
|
||||
const issue = createMemo(() =>
|
||||
hasNonBlockingServiceIssue({
|
||||
mcp: Object.values(sync().data.mcp ?? {}).map((item) => item.status),
|
||||
lsp: (sync().data.lsp ?? []).map((item) => item.status),
|
||||
mcp: (mcp() ?? []).map((item) => item.status.status),
|
||||
lsp: [],
|
||||
}),
|
||||
)
|
||||
const state = createMemo<StatusPopoverState>(() => ({
|
||||
|
||||
@@ -84,14 +84,18 @@ function SessionTabEntry(props: {
|
||||
const tabs = useTabs()
|
||||
const language = useLanguage()
|
||||
const sdk = createMemo(() => props.serverCtx?.sdk ?? null)
|
||||
const cachedSession = createMemo(() => props.serverCtx?.sync.session.peek(props.tab.sessionId))
|
||||
const cachedSession = createMemo(() => props.serverCtx?.data.session.get(props.tab.sessionId))
|
||||
const persisted = createMemo(() => tabs.info[props.id])
|
||||
const [loadedSession] = createResource(
|
||||
() => {
|
||||
const ctx = props.serverCtx
|
||||
return ctx ? { id: props.tab.sessionId, ctx } : null
|
||||
},
|
||||
({ id, ctx }) => ctx.sync.session.resolve(id).catch(() => undefined),
|
||||
({ id, ctx }) =>
|
||||
ctx.data.session
|
||||
.sync(id)
|
||||
.then(() => ctx.data.session.get(id))
|
||||
.catch(() => undefined),
|
||||
)
|
||||
const session = createMemo(() => cachedSession() ?? loadedSession())
|
||||
const missingSession = createMemo(() => !!props.serverCtx && !loadedSession.loading && !session())
|
||||
@@ -103,13 +107,13 @@ function SessionTabEntry(props: {
|
||||
const ctx = props.serverCtx
|
||||
if (!value || !ctx) return
|
||||
|
||||
ctx.sync.session.remember({ ...value, title })
|
||||
ctx.data.session.remember({ ...value, title })
|
||||
try {
|
||||
await ctx.sdk.api.session.rename({ sessionID: value.id, title })
|
||||
} catch (err) {
|
||||
const current = session()
|
||||
const currentCtx = props.serverCtx
|
||||
if (current && currentCtx) currentCtx.sync.session.remember({ ...current, title: value.title })
|
||||
if (current && currentCtx) currentCtx.data.session.remember({ ...current, title: value.title })
|
||||
showToast({
|
||||
title: language.t("common.requestFailed"),
|
||||
description: err instanceof Error ? err.message : undefined,
|
||||
@@ -126,9 +130,13 @@ function SessionTabEntry(props: {
|
||||
prefetched = true
|
||||
createRoot((dispose) => {
|
||||
try {
|
||||
void ctx.sync
|
||||
.ensureDirSyncContext(value.location.directory)
|
||||
.session.sync(value.id)
|
||||
void Promise.allSettled([
|
||||
ctx.data.session.sync(value.id, { children: true }),
|
||||
ctx.data.session.pending.sync(value.id),
|
||||
ctx.data.session.message.sync(value.id),
|
||||
ctx.data.session.permission.sync(value.id),
|
||||
ctx.data.session.form.sync(value.id),
|
||||
])
|
||||
.catch(() => {})
|
||||
.finally(dispose)
|
||||
} catch {
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useParams } from "@solidjs/router"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { createPathHelpers } from "./file/path"
|
||||
@@ -57,7 +56,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({
|
||||
gate: false,
|
||||
init: () => {
|
||||
const sdk = useSDK()
|
||||
useSync()
|
||||
const params = useParams()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
|
||||
@@ -1,15 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import type { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import {
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./bootstrap"
|
||||
import { loadPathQuery, loadProjectsQuery } from "./bootstrap"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
@@ -18,44 +10,11 @@ type WorktreeApi = ServerApi["worktree"]
|
||||
|
||||
describe("query keys", () => {
|
||||
test("partitions identical directories by server scope", () => {
|
||||
const api = {} as CatalogApi
|
||||
const location = {} as ServerApi["location"]
|
||||
const remote = "https://debian.example" as typeof ServerScope.local
|
||||
|
||||
expect([...loadPathQuery(ServerScope.local, "/repo", location).queryKey]).toEqual(["local", "/repo", "path"])
|
||||
expect([...loadPathQuery(remote, "/repo", location).queryKey]).toEqual(["https://debian.example", "/repo", "path"])
|
||||
expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"])
|
||||
})
|
||||
|
||||
test("loads the current provider and model catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
provider: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["provider", input])
|
||||
return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] }
|
||||
},
|
||||
},
|
||||
model: {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(["model", input])
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
default: async (input: unknown) => {
|
||||
calls.push(["default", input])
|
||||
return { location: {}, data: null }
|
||||
},
|
||||
},
|
||||
} as unknown as CatalogApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([
|
||||
["provider", { location: { directory: "/repo" } }],
|
||||
["model", { location: { directory: "/repo" } }],
|
||||
["default", { location: { directory: "/repo" } }],
|
||||
])
|
||||
expect(result.connected).toEqual(["openai"])
|
||||
})
|
||||
|
||||
test("loads current location metadata", async () => {
|
||||
@@ -73,39 +32,6 @@ describe("query keys", () => {
|
||||
expect(result).toMatchObject({ directory: "/repo/subpath", worktree: "/repo" })
|
||||
})
|
||||
|
||||
test("loads agents from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [] }
|
||||
},
|
||||
} as unknown as AgentApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([])
|
||||
})
|
||||
|
||||
test("loads commands from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: {},
|
||||
data: [{ name: "review", template: "Review files" /* source: "command" as const */ }],
|
||||
}
|
||||
},
|
||||
} as unknown as CommandApi
|
||||
|
||||
const result = await loadCommands("/repo", api)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }])
|
||||
})
|
||||
|
||||
test("loads projects from the current endpoint", async () => {
|
||||
const calls: string[] = []
|
||||
const projects = {
|
||||
@@ -161,18 +87,4 @@ describe("query keys", () => {
|
||||
])
|
||||
})
|
||||
|
||||
test("loads references from the current location-scoped endpoint", async () => {
|
||||
const calls: unknown[] = []
|
||||
const api = {
|
||||
list: async (input: unknown) => {
|
||||
calls.push(input)
|
||||
return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] }
|
||||
},
|
||||
} as unknown as ReferenceApi
|
||||
|
||||
const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api))
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/repo" } }])
|
||||
expect(result).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
import type { Config, Path, Project, ProviderAuthResponse } from "@/types"
|
||||
import type {
|
||||
AgentListInput,
|
||||
AgentListOutput,
|
||||
CatalogApi,
|
||||
CommandInfo,
|
||||
CommandListInput,
|
||||
CommandListOutput,
|
||||
IntegrationListInput,
|
||||
IntegrationListOutput,
|
||||
LocationGetInput,
|
||||
LocationGetOutput,
|
||||
PermissionRequest,
|
||||
ProjectCurrentInput,
|
||||
ProjectCurrentOutput,
|
||||
ProjectListOutput,
|
||||
ReferenceListInput,
|
||||
ReferenceListOutput,
|
||||
ReferenceInfo,
|
||||
QuestionRequest,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
@@ -28,20 +17,16 @@ import { batch } from "solid-js"
|
||||
import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import type { State } from "./types"
|
||||
import type { ServerSession } from "../server-session"
|
||||
import { cmp, directoryKey, normalizeAgentList, normalizeProjectInfo, normalizeProviderList } from "./utils"
|
||||
import { cmp, normalizeProjectInfo } from "./utils"
|
||||
import { formatServerError } from "@/utils/server-errors"
|
||||
import { QueryClient, queryOptions } from "@tanstack/solid-query"
|
||||
import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import { sameDirectory } from "@/utils/workspace"
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
@@ -70,12 +55,6 @@ function errors(list: PromiseSettledResult<unknown>[]) {
|
||||
return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason)
|
||||
}
|
||||
|
||||
const providerRev = new Map<string, number>()
|
||||
|
||||
export function clearProviderRev(scope: ServerScope, directory: string) {
|
||||
providerRev.delete(ScopedKey.from(scope, directory))
|
||||
}
|
||||
|
||||
function runAll(list: Array<() => Promise<unknown>>) {
|
||||
return Promise.allSettled(list.map((item) => item()))
|
||||
}
|
||||
@@ -110,10 +89,8 @@ type ProjectApi = {
|
||||
type WorktreeApi = Pick<ServerApi["worktree"], "list">
|
||||
type LocationApi = { readonly get: (input?: LocationGetInput) => Promise<LocationGetOutput> }
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
type PermissionApi = ServerApi["permission"]
|
||||
type QuestionApi = ServerApi["question"]
|
||||
type VcsApi = ServerApi["vcs"]
|
||||
|
||||
export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, worktrees: WorktreeApi) =>
|
||||
queryOptions({
|
||||
@@ -152,7 +129,7 @@ export const loadProjectsQuery = (scope: ServerScope, projects: ProjectApi, work
|
||||
})
|
||||
|
||||
export async function bootstrapGlobal(input: {
|
||||
serverAPI: CatalogApi & {
|
||||
serverAPI: {
|
||||
readonly location: LocationApi
|
||||
readonly project: ProjectApi
|
||||
readonly worktree: WorktreeApi
|
||||
@@ -166,7 +143,6 @@ export async function bootstrapGlobal(input: {
|
||||
}) {
|
||||
const slow = [
|
||||
() => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope)),
|
||||
() => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverAPI)),
|
||||
() => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.location)),
|
||||
() =>
|
||||
input.queryClient
|
||||
@@ -226,53 +202,6 @@ function warmSessions(input: {
|
||||
).then(() => undefined)
|
||||
}
|
||||
|
||||
export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: CatalogApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "providers"],
|
||||
queryFn: () =>
|
||||
retry(async () => {
|
||||
const location = directory ? { location: { directory } } : undefined
|
||||
const [providers, models, defaultModel] = await Promise.all([
|
||||
sdk.provider.list(location),
|
||||
sdk.model.list(location),
|
||||
sdk.model.default(location),
|
||||
])
|
||||
return normalizeProviderList(providers.data, models.data, defaultModel.data)
|
||||
}),
|
||||
})
|
||||
|
||||
type AgentListApi = {
|
||||
readonly list: (input?: AgentListInput) => Promise<AgentListOutput>
|
||||
}
|
||||
|
||||
type CommandListApi = {
|
||||
readonly list: (input?: CommandListInput) => Promise<CommandListOutput>
|
||||
}
|
||||
|
||||
type IntegrationListApi = {
|
||||
readonly list: (input?: IntegrationListInput) => Promise<IntegrationListOutput>
|
||||
}
|
||||
|
||||
type ReferenceListApi = {
|
||||
readonly list: (input?: ReferenceListInput) => Promise<ReferenceListOutput>
|
||||
}
|
||||
|
||||
export const loadAgentsQuery = (scope: ServerScope, directory: string, sdk: AgentListApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "agents"],
|
||||
queryFn: () => retry(() => sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data))),
|
||||
})
|
||||
|
||||
export const loadIntegrationsQuery = (scope: ServerScope, directory: string | null, sdk: IntegrationListApi) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "integrations"] as const,
|
||||
queryFn: () =>
|
||||
retry(() => sdk.list(directory ? { location: { directory } } : undefined).then((result) => result.data)),
|
||||
})
|
||||
|
||||
export const loadCommands = (directory: string, api: CommandListApi): Promise<CommandInfo[]> =>
|
||||
retry(() => api.list({ location: { directory } }).then((result) => result.data))
|
||||
|
||||
export const loadPathQuery = (scope: ServerScope, directory: string | null, api: LocationApi) =>
|
||||
queryOptions<Path>({
|
||||
queryKey: [scope, directory, "path"],
|
||||
@@ -286,28 +215,15 @@ export const loadPathQuery = (scope: ServerScope, directory: string | null, api:
|
||||
})),
|
||||
})
|
||||
|
||||
export const loadReferencesQuery = (scope: ServerScope, directory: string, api: ReferenceListApi) =>
|
||||
queryOptions<ReferenceInfo[]>({
|
||||
queryKey: [scope, directory, "references"] as const,
|
||||
queryFn: () => retry(() => api.list({ location: { directory } }).then((result) => result.data)).catch(() => []),
|
||||
placeholderData: [],
|
||||
})
|
||||
|
||||
export async function bootstrapDirectory(input: {
|
||||
directory: string
|
||||
scope: ServerScope
|
||||
mcp: boolean
|
||||
api: CatalogApi & {
|
||||
readonly agent: AgentListApi
|
||||
readonly command: CommandListApi
|
||||
readonly mcp: McpApi
|
||||
api: {
|
||||
readonly permission: PermissionApi
|
||||
readonly project: ProjectApi
|
||||
readonly question: QuestionApi
|
||||
readonly reference: ReferenceListApi
|
||||
readonly session: SessionApi
|
||||
readonly vcs: VcsApi
|
||||
readonly location: LocationApi
|
||||
}
|
||||
store: Store<State>
|
||||
setStore: SetStoreFunction<State>
|
||||
@@ -317,49 +233,25 @@ export async function bootstrapDirectory(input: {
|
||||
config: Config
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: NormalizedProviderListResponse
|
||||
}
|
||||
queryClient: QueryClient
|
||||
session?: ServerSession
|
||||
}) {
|
||||
const loading = input.store.status !== "complete"
|
||||
const seededProject = projectID(input.directory, input.global.project)
|
||||
const seededPath = input.global.path.directory === input.directory ? input.global.path : undefined
|
||||
if (seededProject) input.setStore("project", seededProject)
|
||||
if (seededPath) input.setStore("path", seededPath)
|
||||
if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) {
|
||||
input.setStore("config", reconcile(input.global.config, { merge: false }))
|
||||
}
|
||||
if (loading) input.setStore("status", "partial")
|
||||
|
||||
const revKey = ScopedKey.from(input.scope, input.directory)
|
||||
const rev = (providerRev.get(revKey) ?? 0) + 1
|
||||
providerRev.set(revKey, rev)
|
||||
const slow = [
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadAgentsQuery(input.scope, directoryKey(input.directory), input.api.agent))
|
||||
.then((data) => input.setStore("agent", data)),
|
||||
!seededProject &&
|
||||
(() =>
|
||||
retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) =>
|
||||
input.setStore("project", project.id),
|
||||
)),
|
||||
!seededPath &&
|
||||
(() =>
|
||||
input.queryClient
|
||||
.ensureQueryData(loadPathQuery(input.scope, directoryKey(input.directory), input.api.location))
|
||||
.then((data) => {
|
||||
const next = projectID(data.directory ?? input.directory, input.global.project)
|
||||
if (next) input.setStore("project", next)
|
||||
})),
|
||||
input.mcp &&
|
||||
(() => loadCommands(input.directory, input.api.command).then((commands) => input.setStore("command", commands))),
|
||||
() =>
|
||||
input.queryClient.fetchQuery(
|
||||
loadReferencesQuery(input.scope, directoryKey(input.directory), input.api.reference),
|
||||
),
|
||||
() =>
|
||||
retry(() =>
|
||||
input.api.permission.request
|
||||
@@ -429,22 +321,6 @@ export async function bootstrapDirectory(input: {
|
||||
}),
|
||||
),
|
||||
() => Promise.resolve(input.loadSessions(input.directory)),
|
||||
input.mcp &&
|
||||
(() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||
input.mcp &&
|
||||
(() =>
|
||||
input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, directoryKey(input.directory), input.api.mcp))),
|
||||
() =>
|
||||
input.queryClient
|
||||
.fetchQuery(loadProvidersQuery(input.scope, directoryKey(input.directory), input.api))
|
||||
.catch((err) => {
|
||||
const project = getFilename(input.directory)
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: input.translate("toast.project.reloadFailed.title", { project }),
|
||||
description: formatServerError(err, input.translate),
|
||||
})
|
||||
}),
|
||||
].filter(Boolean) as (() => Promise<any>)[]
|
||||
|
||||
await waitForPaint()
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { beforeAll, describe, expect, mock, test } from "bun:test"
|
||||
import { createRoot, getOwner, type Owner } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { State } from "./types"
|
||||
import type { QueryOptionsApi } from "../server-sync"
|
||||
import { ServerScope } from "@/utils/server-scope"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
|
||||
let createChildStoreManager: typeof import("./child-store").createChildStoreManager
|
||||
const querySingles: Array<() => { queryKey?: unknown[]; enabled?: boolean }> = []
|
||||
let providerQuerySuccess = true
|
||||
const persist: typeof import("@/utils/persist").persisted = (_target, store) => [
|
||||
store[0],
|
||||
store[1],
|
||||
@@ -17,12 +16,26 @@ const persist: typeof import("@/utils/persist").persisted = (_target, store) =>
|
||||
]
|
||||
|
||||
const child = () => createStore({} as State)
|
||||
const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse
|
||||
const path = { state: "", config: "", worktree: "", directory: "", home: "" }
|
||||
const data = {
|
||||
location: {
|
||||
info: () => undefined,
|
||||
agent: { list: () => undefined },
|
||||
command: { list: () => undefined },
|
||||
reference: { list: () => undefined },
|
||||
provider: { list: () => undefined },
|
||||
model: { list: () => undefined, default: () => undefined },
|
||||
mcp: {
|
||||
server: { list: () => undefined },
|
||||
resource: { list: () => undefined },
|
||||
},
|
||||
vcs: { info: () => undefined },
|
||||
},
|
||||
} as unknown as Data
|
||||
|
||||
const queryOptionsApi = {
|
||||
globalConfig: () => ({ queryKey: ["globalConfig"], queryFn: async () => ({}) }),
|
||||
projects: () => ({ queryKey: ["projects"], queryFn: async () => [] }),
|
||||
providers: (directory: string | null) => ({ queryKey: [directory, "providers"], queryFn: async () => provider }),
|
||||
path: (directory: string | null) => ({
|
||||
queryKey: [directory, "path"],
|
||||
queryFn: async () => ({
|
||||
@@ -60,7 +73,7 @@ beforeAll(async () => {
|
||||
return options().queryKey?.[1] === "path"
|
||||
},
|
||||
get isSuccess() {
|
||||
return options().queryKey?.[1] === "providers" && options().enabled === true && providerQuerySuccess
|
||||
return false
|
||||
},
|
||||
get isRefetchError() {
|
||||
return false
|
||||
@@ -69,7 +82,6 @@ beforeAll(async () => {
|
||||
if (options().queryKey?.[1] === "path") throw new Error("pending path data read")
|
||||
if (options().queryKey?.[1] === "mcp") return options().enabled ? { demo: { status: "disabled" } } : undefined
|
||||
if (options().queryKey?.[1] === "lsp") return []
|
||||
if (options().queryKey?.[1] === "providers") return providerQuerySuccess ? provider : undefined
|
||||
return undefined
|
||||
},
|
||||
}
|
||||
@@ -100,7 +112,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
|
||||
Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => {
|
||||
@@ -134,7 +147,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -167,7 +181,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -198,7 +213,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -214,7 +230,7 @@ describe("createChildStoreManager", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("enables MCP only when requested for the directory", () => {
|
||||
test("syncs MCP only when requested for the directory", () => {
|
||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||
const offset = querySingles.length
|
||||
const mcpLoads: string[] = []
|
||||
@@ -234,30 +250,21 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
try {
|
||||
if (!manager) throw new Error("manager required")
|
||||
const [store, setStore] = manager.child("/project", { bootstrap: false })
|
||||
expect(querySingles.length - offset).toBe(6)
|
||||
const query = querySingles[offset + 1]
|
||||
const resourceQuery = querySingles[offset + 2]
|
||||
if (!query) throw new Error("query required")
|
||||
if (!resourceQuery) throw new Error("resource query required")
|
||||
expect(query().enabled).toBe(false)
|
||||
expect(resourceQuery().enabled).toBe(false)
|
||||
const [, setStore] = manager.child("/project", { bootstrap: false })
|
||||
expect(querySingles.length - offset).toBe(1)
|
||||
|
||||
setStore("status", "complete")
|
||||
manager.child("/project", { bootstrap: false, mcp: true })
|
||||
expect(query().enabled).toBe(true)
|
||||
expect(resourceQuery().enabled).toBe(true)
|
||||
expect(store.mcp).toEqual({ demo: { status: "disabled" } })
|
||||
expect(mcpLoads).toEqual(["/project"])
|
||||
|
||||
manager.disableMcp("/project")
|
||||
expect(query().enabled).toBe(false)
|
||||
expect(manager.mcp("/project")).toBe(false)
|
||||
} finally {
|
||||
dispose()
|
||||
@@ -284,7 +291,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -293,11 +301,8 @@ describe("createChildStoreManager", () => {
|
||||
const [store] = manager.child("/project", { bootstrap: false })
|
||||
const queries = querySingles.slice(offset)
|
||||
|
||||
expect(queries).toHaveLength(6)
|
||||
expect(queries).toHaveLength(1)
|
||||
expect(queries[0]?.().enabled).toBe(false)
|
||||
expect(queries[3]?.().enabled).toBe(false)
|
||||
expect(queries[4]?.().enabled).toBe(false)
|
||||
expect(queries[5]?.().enabled).toBe(false)
|
||||
expect(store.path.directory).toBe("/project")
|
||||
expect(store.provider_ready).toBe(false)
|
||||
expect(store.lsp_ready).toBe(false)
|
||||
@@ -305,9 +310,6 @@ describe("createChildStoreManager", () => {
|
||||
|
||||
manager.child("/project")
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
expect(queries[3]?.().enabled).toBe(true)
|
||||
expect(queries[4]?.().enabled).toBe(true)
|
||||
expect(queries[5]?.().enabled).toBe(true)
|
||||
expect(bootstraps).toEqual(["/project"])
|
||||
|
||||
manager.child("/project", { bootstrap: false })
|
||||
@@ -317,9 +319,8 @@ describe("createChildStoreManager", () => {
|
||||
}
|
||||
})
|
||||
|
||||
test("does not mark a cancelled provider query as ready", () => {
|
||||
test("does not mark unsynced provider data as ready", () => {
|
||||
let manager: ReturnType<typeof createChildStoreManager> | undefined
|
||||
providerQuerySuccess = false
|
||||
|
||||
const dispose = createOwner((owner) => {
|
||||
manager = createChildStoreManager({
|
||||
@@ -334,7 +335,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -343,7 +345,6 @@ describe("createChildStoreManager", () => {
|
||||
const [store] = manager.child("/cancelled")
|
||||
expect(store.provider_ready).toBe(false)
|
||||
} finally {
|
||||
providerQuerySuccess = true
|
||||
dispose()
|
||||
}
|
||||
})
|
||||
@@ -365,7 +366,8 @@ describe("createChildStoreManager", () => {
|
||||
onDispose() {},
|
||||
translate: (key) => key,
|
||||
queryOptions: queryOptionsApi,
|
||||
global: { provider },
|
||||
data,
|
||||
global: { path },
|
||||
})
|
||||
})
|
||||
|
||||
@@ -374,11 +376,9 @@ describe("createChildStoreManager", () => {
|
||||
manager.child("/handshake")
|
||||
const queries = querySingles.slice(offset)
|
||||
expect(queries[0]?.().enabled).toBe(false)
|
||||
expect(queries[4]?.().enabled).toBe(false)
|
||||
|
||||
connected = true
|
||||
expect(queries[0]?.().enabled).toBe(true)
|
||||
expect(queries[4]?.().enabled).toBe(true)
|
||||
} finally {
|
||||
dispose()
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { createRoot, createSignal, getOwner, onCleanup, runWithOwner, type Accessor, type Owner } from "solid-js"
|
||||
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { VcsInfo } from "@/types"
|
||||
import type { Path, VcsInfo } from "@/types"
|
||||
import {
|
||||
DIR_IDLE_TTL_MS,
|
||||
MAX_DIR_STORES,
|
||||
@@ -17,8 +17,9 @@ import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { QueryOptionsApi } from "../server-sync"
|
||||
import { directoryKey, type DirectoryKey } from "./utils"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { normalizeAgentList, normalizeProviderList } from "./utils"
|
||||
|
||||
export function createChildStoreManager(input: {
|
||||
owner: Owner
|
||||
@@ -32,8 +33,9 @@ export function createChildStoreManager(input: {
|
||||
onDispose: (directory: string) => void
|
||||
translate: (key: string, vars?: Record<string, string | number>) => string
|
||||
queryOptions: QueryOptionsApi
|
||||
data: Data
|
||||
global: {
|
||||
provider: NormalizedProviderListResponse
|
||||
path: Path
|
||||
}
|
||||
}) {
|
||||
const children: Record<string, [Store<State>, SetStoreFunction<State>]> = {}
|
||||
@@ -45,7 +47,6 @@ export function createChildStoreManager(input: {
|
||||
const ownerPins = new WeakMap<object, Set<string>>()
|
||||
const disposers = new Map<string, () => void>()
|
||||
const mcpDirectories = new Set<string>()
|
||||
const mcpToggles = new Map<string, (enabled: boolean) => void>()
|
||||
const activeDirectories = new Set<string>()
|
||||
const activationToggles = new Map<string, (enabled: boolean) => void>()
|
||||
|
||||
@@ -120,7 +121,6 @@ export function createChildStoreManager(input: {
|
||||
iconCache.delete(key)
|
||||
lifecycle.delete(key)
|
||||
mcpDirectories.delete(key)
|
||||
mcpToggles.delete(key)
|
||||
activeDirectories.delete(key)
|
||||
activationToggles.delete(key)
|
||||
const dispose = disposers.get(key)
|
||||
@@ -186,59 +186,49 @@ export function createChildStoreManager(input: {
|
||||
createRoot((dispose) => {
|
||||
const initialMeta = meta[0].value
|
||||
const initialIcon = icon[0].value
|
||||
const [mcpEnabled, setMcpEnabled] = createSignal(false)
|
||||
const [instanceQueriesEnabled, setInstanceQueriesEnabled] = createSignal(false)
|
||||
|
||||
const pathQuery = useQuery(() => ({
|
||||
...input.queryOptions.path(key),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
const mcpQuery = useQuery(() => ({
|
||||
...input.queryOptions.mcp(key),
|
||||
enabled: input.connected() && mcpEnabled(),
|
||||
}))
|
||||
const mcpResourceQuery = useQuery(() => ({
|
||||
...input.queryOptions.mcpResources(key),
|
||||
enabled: input.connected() && mcpEnabled(),
|
||||
}))
|
||||
const lspQuery = useQuery(() => ({
|
||||
...input.queryOptions.lsp(key),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
const providerQuery = useQuery(() => ({
|
||||
...input.queryOptions.providers(key),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
const referenceQuery = useQuery(() => ({
|
||||
...input.queryOptions.references(key),
|
||||
enabled: input.connected() && instanceQueriesEnabled(),
|
||||
}))
|
||||
|
||||
const child = createStore<State>({
|
||||
project: "",
|
||||
projectMeta: initialMeta,
|
||||
icon: initialIcon,
|
||||
get provider_ready() {
|
||||
return instanceQueriesEnabled() && (providerQuery.isSuccess || providerQuery.isRefetchError)
|
||||
return (
|
||||
input.data.location.provider.list({ directory }) !== undefined &&
|
||||
input.data.location.model.list({ directory }) !== undefined &&
|
||||
input.data.location.model.default({ directory }) !== undefined
|
||||
)
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
if (!providerQuery.isSuccess && !providerQuery.isRefetchError) return EMPTY
|
||||
const provider = providerQuery.data
|
||||
if (provider.all.size === 0 && input.global.provider.all.size > 0) return input.global.provider
|
||||
return provider
|
||||
const provider = input.data.location.provider.list({ directory })
|
||||
const model = input.data.location.model.list({ directory })
|
||||
const defaultModel = input.data.location.model.default({ directory })
|
||||
if (!provider || !model || defaultModel === undefined) return { all: new Map(), connected: [], default: {} }
|
||||
return normalizeProviderList(provider, model, defaultModel)
|
||||
},
|
||||
config: {},
|
||||
get path() {
|
||||
const EMPTY = { state: "", config: "", worktree: "", directory, home: "" }
|
||||
if (pathQuery.isLoading) return EMPTY
|
||||
return pathQuery.data ?? EMPTY
|
||||
const location = input.data.location.info({ directory })
|
||||
return {
|
||||
state: "",
|
||||
config: "",
|
||||
worktree: location?.project.directory ?? "",
|
||||
directory: location?.directory ?? directory,
|
||||
home: input.global.path.home,
|
||||
}
|
||||
},
|
||||
status: "loading" as const,
|
||||
agent: [],
|
||||
command: [],
|
||||
get agent() {
|
||||
return normalizeAgentList(input.data.location.agent.list({ directory }) ?? [])
|
||||
},
|
||||
get command() {
|
||||
return input.data.location.command.list({ directory }) ?? []
|
||||
},
|
||||
get reference() {
|
||||
return referenceQuery.isLoading ? [] : (referenceQuery.data ?? [])
|
||||
return input.data.location.reference.list({ directory }) ?? []
|
||||
},
|
||||
session: [],
|
||||
sessionTotal: 0,
|
||||
@@ -252,13 +242,20 @@ export function createChildStoreManager(input: {
|
||||
permission: {},
|
||||
question: {},
|
||||
get mcp_ready() {
|
||||
return !mcpQuery.isLoading
|
||||
return input.data.location.mcp.server.list({ directory }) !== undefined
|
||||
},
|
||||
get mcp() {
|
||||
return mcpQuery.isLoading ? {} : (mcpQuery.data ?? {})
|
||||
return Object.fromEntries(
|
||||
(input.data.location.mcp.server.list({ directory }) ?? []).map((server) => [server.name, server.status]),
|
||||
)
|
||||
},
|
||||
get mcp_resource() {
|
||||
return mcpResourceQuery.isLoading ? {} : (mcpResourceQuery.data ?? {})
|
||||
return Object.fromEntries(
|
||||
(input.data.location.mcp.resource.list({ directory }) ?? []).map((resource) => [
|
||||
`${resource.server}:${resource.uri}`,
|
||||
resource,
|
||||
]),
|
||||
)
|
||||
},
|
||||
get lsp_ready() {
|
||||
return instanceQueriesEnabled() && !lspQuery.isLoading
|
||||
@@ -266,7 +263,11 @@ export function createChildStoreManager(input: {
|
||||
get lsp() {
|
||||
return lspQuery.isLoading ? [] : (lspQuery.data ?? [])
|
||||
},
|
||||
vcs: vcsStore.value,
|
||||
get vcs() {
|
||||
const vcs = input.data.location.vcs.info({ directory })
|
||||
if (!vcs) return vcsStore.value
|
||||
return { branch: vcs.branch.current, default_branch: vcs.branch.default }
|
||||
},
|
||||
limit: 5,
|
||||
message: {},
|
||||
session_message: {},
|
||||
@@ -275,7 +276,6 @@ export function createChildStoreManager(input: {
|
||||
})
|
||||
children[key] = child
|
||||
disposers.set(key, dispose)
|
||||
mcpToggles.set(key, setMcpEnabled)
|
||||
activationToggles.set(key, setInstanceQueriesEnabled)
|
||||
|
||||
const onPersistedInit = (init: Promise<string> | string | null, run: () => void) => {
|
||||
@@ -339,7 +339,6 @@ export function createChildStoreManager(input: {
|
||||
function enableMcp(directory: string, key: DirectoryKey, childStore: [Store<State>, SetStoreFunction<State>]) {
|
||||
if (mcpDirectories.has(key)) return
|
||||
mcpDirectories.add(key)
|
||||
mcpToggles.get(key)?.(true)
|
||||
if (childStore[0].status !== "loading") input.onMcp(directory, childStore[1])
|
||||
}
|
||||
|
||||
@@ -356,7 +355,6 @@ export function createChildStoreManager(input: {
|
||||
function disableMcp(directory: string) {
|
||||
const key = directoryKey(directory)
|
||||
if (!mcpDirectories.delete(key)) return
|
||||
mcpToggles.get(key)?.(false)
|
||||
}
|
||||
|
||||
function projectMeta(directory: string, patch: ProjectMeta) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useServerHealth } from "@/utils/server-health"
|
||||
import { createServerSdkContext } from "./server-sdk"
|
||||
import { createServerSyncContext } from "./server-sync"
|
||||
import { getOwner } from "solid-js/web"
|
||||
import { createServerData } from "@opencode-ai/client/solid"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerPermissionState } from "./permission"
|
||||
import { createServerNotificationState } from "./notification"
|
||||
@@ -99,9 +100,15 @@ function createServerController(
|
||||
) {
|
||||
const connKey = ServerConnection.key(conn)
|
||||
const sdk = createServerSdkContext(conn, scope)
|
||||
const sync = createServerSyncContext(sdk)
|
||||
const permission = createServerPermissionState({ sdk, sync })
|
||||
const notification = createServerNotificationState({ sdk, sync, key: connKey })
|
||||
const data = createServerData({
|
||||
api: () => sdk.api,
|
||||
event: sdk.event,
|
||||
connection: sdk.connection,
|
||||
directory: "",
|
||||
})
|
||||
const sync = createServerSyncContext(sdk, data)
|
||||
const permission = createServerPermissionState({ sdk, sync, data })
|
||||
const notification = createServerNotificationState({ sdk, data, key: connKey })
|
||||
|
||||
function enrich(project: { worktree: string; expanded: boolean }) {
|
||||
const [childStore] = sync.child(project.worktree, { bootstrap: false })
|
||||
@@ -134,6 +141,7 @@ function createServerController(
|
||||
(conn?.type === "sidecar" && conn.variant === "base") || (conn?.type === "http" && isLocalHost(conn.http.url))
|
||||
|
||||
return {
|
||||
data,
|
||||
sdk,
|
||||
sync,
|
||||
isLocal,
|
||||
|
||||
@@ -3,7 +3,6 @@ import { batch, createEffect, createMemo, onCleanup, onMount, type Accessor } fr
|
||||
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 { ServerConnection, useServers } from "./servers"
|
||||
import { usePlatform } from "./platform"
|
||||
import type { Project } from "@/types"
|
||||
@@ -150,7 +149,6 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
|
||||
name: "Layout",
|
||||
gate: false,
|
||||
init: () => {
|
||||
// const serverSync = useServerSync()
|
||||
const servers = useServers()
|
||||
const platform = usePlatform()
|
||||
|
||||
|
||||
@@ -10,7 +10,8 @@ import { Persist, persisted } from "@/utils/persist"
|
||||
import { hasCustomAgent, resolveAgent } from "./local-agent"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "./model-variant"
|
||||
import { useSDK } from "./sdk"
|
||||
import { useSync } from "./sync"
|
||||
import { useData } from "./server"
|
||||
import { normalizeAgentList } from "./global-sync/utils"
|
||||
import { useServerSDK } from "./server-sdk"
|
||||
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
|
||||
|
||||
@@ -60,14 +61,18 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
init: () => {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const models = useModels()
|
||||
const settings = useSettings()
|
||||
|
||||
const id = createMemo(() => params.id || undefined)
|
||||
const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden))
|
||||
const list = createMemo(() =>
|
||||
normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []).filter(
|
||||
(item) => item.mode !== "subagent" && !item.hidden,
|
||||
),
|
||||
)
|
||||
const agentsVisible = createMemo(() => settings.visibility.customAgents() || hasCustomAgent(list()))
|
||||
const connected = createMemo(() => new Set(providers.connected().map((item) => item.id)))
|
||||
|
||||
@@ -149,10 +154,9 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
|
||||
})
|
||||
|
||||
const configuredModel = () => {
|
||||
const configured = sync().data.config.model
|
||||
const configured = data.location.model.default({ directory: sdk().directory })
|
||||
if (!configured) return
|
||||
const [providerID, modelID] = configured.split("/")
|
||||
const model = { providerID, modelID }
|
||||
const model = { providerID: configured.providerID, modelID: configured.modelID }
|
||||
if (validModel(model)) return model
|
||||
}
|
||||
|
||||
|
||||
@@ -1,14 +1,45 @@
|
||||
import { useMutation } from "@tanstack/solid-query"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { showToast } from "@/utils/toast"
|
||||
|
||||
export function useMcpToggle() {
|
||||
const sync = useSync()
|
||||
export function useMcpToggle(directory?: Accessor<string | undefined>, onSuccess?: () => unknown) {
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const platform = usePlatform()
|
||||
const language = useLanguage()
|
||||
const location = () => {
|
||||
const value = directory ? directory() : data.location.default().directory
|
||||
return value ? { directory: value } : undefined
|
||||
}
|
||||
|
||||
return useMutation(() => ({
|
||||
mutationFn: sync().mcp.toggle,
|
||||
mutationFn: async (name: string) => {
|
||||
const ref = location()
|
||||
const server = (await serverSDK.api.mcp.list({ location: ref })).data.find((item) => item.name === name)
|
||||
if (!server || server.status.status === "pending") return
|
||||
if (server.status.status === "connected") {
|
||||
await serverSDK.api.mcp.disconnect({ server: name, location: ref })
|
||||
} else if (server.status.status === "needs_auth" && server.integrationID) {
|
||||
const integration = await serverSDK.api.integration.get({ integrationID: server.integrationID, location: ref })
|
||||
const method = integration.data?.methods.find((item) => item.type === "oauth" && !item.form?.length)
|
||||
if (!method || method.type !== "oauth") throw new Error(`MCP server ${name} requires an interactive authentication form`)
|
||||
const attempt = await serverSDK.api.integration.oauth.connect({
|
||||
integrationID: server.integrationID,
|
||||
methodID: method.id,
|
||||
location: ref,
|
||||
})
|
||||
platform.openExternal(attempt.data.url)
|
||||
} else {
|
||||
await serverSDK.api.mcp.connect({ server: name, location: ref })
|
||||
}
|
||||
data.location.mcp.server.invalidate(ref)
|
||||
data.location.mcp.resource.invalidate(ref)
|
||||
await Promise.all([data.location.mcp.server.sync(ref), data.location.mcp.resource.sync(ref), onSuccess?.()])
|
||||
},
|
||||
onError: (error) =>
|
||||
showToast({
|
||||
variant: "error",
|
||||
|
||||
@@ -2,7 +2,7 @@ import { createStore, reconcile } from "solid-js/store"
|
||||
import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "@opencode-ai/ui/context"
|
||||
import type { ServerSDK } from "./server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSettings } from "@/context/settings"
|
||||
@@ -109,7 +109,7 @@ function buildNotificationIndex(list: Notification[]) {
|
||||
return index
|
||||
}
|
||||
|
||||
export function createServerNotificationState(input: { sdk: ServerSDK; sync: ServerSync; key: ServerConnection.Key }) {
|
||||
export function createServerNotificationState(input: { sdk: ServerSDK; data: Data; key: ServerConnection.Key }) {
|
||||
const platform = usePlatform()
|
||||
const settings = useSettings()
|
||||
const language = useLanguage()
|
||||
@@ -197,14 +197,13 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
})
|
||||
}
|
||||
|
||||
const lookup = async (directory: string, sessionID?: string) => {
|
||||
const lookup = async (sessionID?: string) => {
|
||||
if (!sessionID) return undefined
|
||||
const sync = input.sync.ensureDirSyncContext(directory)
|
||||
const session = sync.session.get(sessionID)
|
||||
const session = input.data.session.get(sessionID)
|
||||
if (session) return session
|
||||
return sync.session
|
||||
return input.data.session
|
||||
.sync(sessionID)
|
||||
.then(() => sync.session.get(sessionID))
|
||||
.then(() => input.data.session.get(sessionID))
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
@@ -219,7 +218,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
|
||||
const handleSessionIdle = (directory: string, event: { properties: { sessionID: string } }, time: number) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
void lookup(sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (!session) return
|
||||
if (session.parentID) return
|
||||
@@ -251,7 +250,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
time: number,
|
||||
) => {
|
||||
const sessionID = event.properties.sessionID
|
||||
void lookup(directory, sessionID).then((session) => {
|
||||
void lookup(sessionID).then((session) => {
|
||||
if (meta.disposed) return
|
||||
if (session?.parentID) return
|
||||
|
||||
@@ -278,7 +277,7 @@ export function createServerNotificationState(input: { sdk: ServerSDK; sync: Ser
|
||||
})
|
||||
}
|
||||
|
||||
const unsub = input.sdk.event.listen((e) => {
|
||||
const unsub = input.sdk.eventByDir.listen((e) => {
|
||||
const event = e.details
|
||||
if (
|
||||
event.type !== "session.execution.succeeded" &&
|
||||
|
||||
@@ -5,6 +5,7 @@ import type { PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import type { ServerSDK } from "@/context/server-sdk"
|
||||
import type { ServerSync } from "./server-sync"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
import { useParams, useSearchParams } from "@solidjs/router"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { useGlobal } from "./global"
|
||||
@@ -52,9 +53,9 @@ function hasPermissionPromptRules(permission: unknown) {
|
||||
return Object.values(config).some(isNonAllowRule)
|
||||
}
|
||||
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["event"]["listen"]>[0]>[0]
|
||||
type PermissionEvent = Parameters<Parameters<ServerSDK["eventByDir"]["listen"]>[0]>[0]
|
||||
|
||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync }) {
|
||||
export function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync; data: Data }) {
|
||||
const [store, setStore, _, ready] = persisted(
|
||||
{
|
||||
...Persist.serverGlobal(input.sdk.scope, "permission", ["permission.v3"]),
|
||||
@@ -141,10 +142,8 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
})
|
||||
}
|
||||
|
||||
function sessions(directory?: string) {
|
||||
const info = Object.values(input.sync.session.data.info).filter((session) => !!session)
|
||||
if (!directory) return info
|
||||
return [...info, ...input.sync.child(directory, { bootstrap: false })[0].session]
|
||||
function sessions(_directory?: string) {
|
||||
return input.data.session.list()
|
||||
}
|
||||
|
||||
function isAutoAccepting(sessionID: string, directory?: string) {
|
||||
@@ -160,15 +159,15 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
}
|
||||
|
||||
function isPending(permission: PermissionRequest) {
|
||||
const pending = input.sync.session.data.permission[permission.sessionID]
|
||||
const pending = input.data.session.permission.list(permission.sessionID)
|
||||
return pending === undefined || pending.some((item) => item.id === permission.id)
|
||||
}
|
||||
|
||||
async function shouldAutoRespondResolved(permission: PermissionRequest, directory?: string) {
|
||||
const override = sessionAutoAccept(store.autoAccept, sessions(directory), permission, directory)
|
||||
if (override !== undefined) return override
|
||||
if (input.sync.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||
const lineage = await input.sync.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||
if (input.data.session.lineage.peek(permission.sessionID)) return shouldAutoRespond(permission, directory)
|
||||
const lineage = await input.data.session.lineage.resolve(permission.sessionID).catch(() => undefined)
|
||||
if (meta.disposed || !lineage) return false
|
||||
return shouldAutoRespond(permission, directory)
|
||||
}
|
||||
@@ -197,7 +196,7 @@ export function createServerPermissionState(input: { sdk: ServerSDK; sync: Serve
|
||||
void respondPending(event.properties, e.name)
|
||||
}
|
||||
|
||||
const unsubscribe = input.sdk.event.listen((event) => {
|
||||
const unsubscribe = input.sdk.eventByDir.listen((event) => {
|
||||
if (ready()) {
|
||||
handlePermission(event)
|
||||
return
|
||||
|
||||
@@ -1,18 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk"
|
||||
|
||||
describe("resumeStreamAfterPageShow", () => {
|
||||
test("restarts a stream only after a back-forward cache restore", () => {
|
||||
let starts = 0
|
||||
const start = () => starts++
|
||||
|
||||
resumeStreamAfterPageShow({ persisted: false } as PageTransitionEvent, start)
|
||||
resumeStreamAfterPageShow({ persisted: true } as PageTransitionEvent, start)
|
||||
|
||||
expect(starts).toBe(1)
|
||||
})
|
||||
})
|
||||
import { adaptServerEvent } from "./server-sdk"
|
||||
|
||||
describe("adaptServerEvent", () => {
|
||||
test("preserves current permission requests", () => {
|
||||
@@ -43,57 +31,3 @@ describe("adaptServerEvent", () => {
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe("current event buffering", () => {
|
||||
const delta = (id: string, value: string, ordinal = 0) => ({
|
||||
directory: "/repo",
|
||||
payload: adaptServerEvent({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||
} as OpenCodeEvent),
|
||||
})
|
||||
|
||||
test("merges adjacent text deltas for the same message and ordinal", () => {
|
||||
const result = coalesceServerEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
expect(result[0]?.payload.properties).toMatchObject({ delta: "hello world" })
|
||||
})
|
||||
|
||||
test("coalesces current tool input deltas by tool ID", () => {
|
||||
const current = (eventID: string, id: string, delta: string) =>
|
||||
adaptServerEvent({
|
||||
id: eventID,
|
||||
created: 1,
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta },
|
||||
} as OpenCodeEvent)
|
||||
const result = coalesceServerEvents([
|
||||
{ directory: "/repo", payload: current("evt_1", "call_1", "{") },
|
||||
{ directory: "/repo", payload: current("evt_2", "call_1", "}") },
|
||||
{ directory: "/repo", payload: current("evt_3", "call_2", "[]") },
|
||||
])
|
||||
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]?.payload.current).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
})
|
||||
|
||||
test("preserves boundaries between distinct delta streams", () => {
|
||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||
|
||||
expect(coalesceServerEvents(events).map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||
})
|
||||
|
||||
test("preserves current event order when enqueuing", () => {
|
||||
const events: Parameters<typeof enqueueServerEvent>[0] = []
|
||||
;[delta("evt_1", "a"), delta("evt_2", "b", 1)].forEach((event) => enqueueServerEvent(events, event))
|
||||
|
||||
expect(events.map((event) => event.payload.current?.id)).toEqual(["evt_1", "evt_2"])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
import type { OpenCodeEvent } from "@opencode-ai/client/promise"
|
||||
import { createClientConnection, type ClientConnectionStatus } from "@opencode-ai/client/solid"
|
||||
import type { Event } from "@/types"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { makeEventListener } from "@solid-primitives/event-listener"
|
||||
import { type Accessor, batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { type Accessor, onCleanup } from "solid-js"
|
||||
import { createApiForServer, type ServerApi } from "@/utils/server"
|
||||
import { usePlatform } from "./platform"
|
||||
import { ServerConnection } from "./servers"
|
||||
@@ -12,85 +11,15 @@ import { ServerScope } from "@/utils/server-scope"
|
||||
import { useServer } from "./server"
|
||||
|
||||
export type ServerEvent = Event & { id?: string; current?: OpenCodeEvent }
|
||||
type QueuedServerEvent = { directory: string; payload: ServerEvent }
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||
>
|
||||
|
||||
export function adaptServerEvent(event: OpenCodeEvent): ServerEvent {
|
||||
return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent
|
||||
}
|
||||
|
||||
export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServerEvent) {
|
||||
queue.push(event)
|
||||
return true
|
||||
}
|
||||
|
||||
export function coalesceServerEvents(events: QueuedServerEvent[]) {
|
||||
const output: QueuedServerEvent[] = []
|
||||
events.forEach((event) => {
|
||||
const current = currentDelta(event.payload.current)
|
||||
if (current) {
|
||||
const previous = output[output.length - 1]
|
||||
const prior = currentDelta(previous?.payload.current)
|
||||
if (
|
||||
previous &&
|
||||
prior &&
|
||||
previous.directory === event.directory &&
|
||||
currentDeltaKey(prior) === currentDeltaKey(current)
|
||||
) {
|
||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||
const data =
|
||||
current.type === "session.compaction.delta"
|
||||
? { ...current.data, text: fragment }
|
||||
: { ...current.data, delta: fragment }
|
||||
output[output.length - 1] = {
|
||||
directory: event.directory,
|
||||
payload: {
|
||||
...event.payload,
|
||||
properties: data,
|
||||
current: { ...current, data } as CurrentDelta,
|
||||
} as ServerEvent,
|
||||
}
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
return
|
||||
}
|
||||
output.push(event)
|
||||
})
|
||||
return output
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
event?.type === "session.reasoning.delta" ||
|
||||
event?.type === "session.tool.input.delta" ||
|
||||
event?.type === "session.compaction.delta"
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
function currentDeltaKey(event: CurrentDelta) {
|
||||
if (event.type === "session.tool.input.delta")
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||
}
|
||||
|
||||
function currentDeltaFragment(event: CurrentDelta) {
|
||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||
}
|
||||
|
||||
export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) {
|
||||
if (!event.persisted) return
|
||||
start()
|
||||
}
|
||||
|
||||
type ServerEventEmitter = ReturnType<typeof createGlobalEmitter<{ [key: string]: ServerEvent }>>
|
||||
export type ServerConnectionStatus = "connecting" | "connected" | "reconnecting"
|
||||
type CurrentEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
type CurrentEventEmitter = ReturnType<typeof createGlobalEmitter<CurrentEventMap>>
|
||||
export type ServerConnectionStatus = ClientConnectionStatus
|
||||
type ServerSDKBase = {
|
||||
server: ServerConnection.Any
|
||||
scope: ServerScope
|
||||
@@ -101,231 +30,51 @@ type ServerSDKBase = {
|
||||
attempt: Accessor<number>
|
||||
error: Accessor<string | undefined>
|
||||
}
|
||||
event: {
|
||||
eventByDir: {
|
||||
on: ServerEventEmitter["on"]
|
||||
listen: ServerEventEmitter["listen"]
|
||||
}
|
||||
event: {
|
||||
on: CurrentEventEmitter["on"]
|
||||
listen: CurrentEventEmitter["listen"]
|
||||
}
|
||||
}
|
||||
|
||||
function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase {
|
||||
const platform = usePlatform()
|
||||
const abort = new AbortController()
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
const dirEmitter = createGlobalEmitter<{ [key: string]: ServerEvent }>()
|
||||
const emitter = createGlobalEmitter<CurrentEventMap>()
|
||||
|
||||
const eventFetch = (() => {
|
||||
if (!platform.fetch || !server) return
|
||||
try {
|
||||
const url = new URL(server.http.url)
|
||||
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "::1"
|
||||
if (url.protocol === "http:" && !loopback) return platform.fetch
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
})()
|
||||
|
||||
const eventApi = createApiForServer({ server: server.http, fetch: eventFetch })
|
||||
const emitter = createGlobalEmitter<{
|
||||
[key: string]: ServerEvent
|
||||
}>()
|
||||
|
||||
type Queued = QueuedServerEvent
|
||||
const FLUSH_FRAME_MS = 16
|
||||
const STREAM_YIELD_MS = 8
|
||||
const CONNECT_TIMEOUT_MS = 2_000
|
||||
const RECONNECT_DELAY_MS = 1_000
|
||||
|
||||
let queue: Queued[] = []
|
||||
let buffer: Queued[] = []
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let last = 0
|
||||
|
||||
function flush() {
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
|
||||
if (queue.length === 0) return
|
||||
|
||||
const events = queue
|
||||
queue = buffer
|
||||
buffer = events
|
||||
queue.length = 0
|
||||
|
||||
last = Date.now()
|
||||
const output = coalesceServerEvents(events)
|
||||
batch(() => {
|
||||
output.forEach((event) => emitter.emit(event.directory, event.payload))
|
||||
})
|
||||
|
||||
buffer.length = 0
|
||||
}
|
||||
|
||||
function schedule() {
|
||||
if (timer) return
|
||||
const elapsed = Date.now() - last
|
||||
timer = setTimeout(flush, Math.max(0, FLUSH_FRAME_MS - elapsed))
|
||||
}
|
||||
|
||||
function publish(event: OpenCodeEvent) {
|
||||
const directory = event.location?.directory ?? "global"
|
||||
if (enqueueServerEvent(queue, { directory, payload: adaptServerEvent(event) })) schedule()
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
let attempt: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ServerConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({ status: "connecting", attempt: 0 })
|
||||
|
||||
async function connect(signal: AbortSignal): Promise<{ error: unknown; connectedAt: number | undefined }> {
|
||||
let connectedAt: number | undefined
|
||||
|
||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
||||
const request = new AbortController()
|
||||
const cancel = () => request.abort(signal.reason)
|
||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), CONNECT_TIMEOUT_MS)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
try {
|
||||
// Open the event stream and validate its initial handshake.
|
||||
const iterator = eventApi.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (first.done) {
|
||||
const error =
|
||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
||||
return { error, connectedAt }
|
||||
}
|
||||
if (first.value.type !== "server.connected")
|
||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||
|
||||
// Publish the connected state before forwarding live events.
|
||||
clearTimeout(timeout)
|
||||
publish(first.value)
|
||||
connectedAt = Date.now()
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
// Forward events until the stream closes or this connection is cancelled.
|
||||
let yielded = Date.now()
|
||||
while (!signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
publish(event.value)
|
||||
if (Date.now() - yielded < STREAM_YIELD_MS) continue
|
||||
yielded = Date.now()
|
||||
await wait(0, signal)
|
||||
}
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
async function runStream(active: number) {
|
||||
let retries = 0
|
||||
// oxlint-disable-next-line no-unmodified-loop-condition -- stop() changes the lifecycle flags and aborts the active request
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
setConnection({ status: retries === 0 ? "connecting" : "reconnecting", attempt: retries, error: undefined })
|
||||
const controller = new AbortController()
|
||||
attempt = controller
|
||||
const onAbort = () => controller.abort()
|
||||
abort.signal.addEventListener("abort", onAbort)
|
||||
const result = await connect(controller.signal)
|
||||
abort.signal.removeEventListener("abort", onAbort)
|
||||
|
||||
if (abort.signal.aborted || !started || generation !== active) {
|
||||
if (attempt === controller) attempt = undefined
|
||||
return
|
||||
}
|
||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) retries = 0
|
||||
retries += 1
|
||||
const message =
|
||||
result.error === undefined
|
||||
? undefined
|
||||
: result.error instanceof Error
|
||||
? result.error.message
|
||||
: String(result.error)
|
||||
console.info("[global-sdk] event stream disconnected", {
|
||||
url: server.http.url,
|
||||
fetch: eventFetch ? "platform" : "webview",
|
||||
attempt: retries,
|
||||
error: message,
|
||||
})
|
||||
setConnection({ status: "reconnecting", attempt: retries, error: message })
|
||||
await wait(RECONNECT_DELAY_MS, controller.signal)
|
||||
if (attempt === controller) attempt = undefined
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return run
|
||||
started = true
|
||||
const active = ++generation
|
||||
const previous = run
|
||||
const current = (async () => {
|
||||
if (previous) await previous
|
||||
await runStream(active)
|
||||
})().finally(() => {
|
||||
if (run !== current) return
|
||||
run = undefined
|
||||
flush()
|
||||
})
|
||||
run = current
|
||||
return run
|
||||
}
|
||||
|
||||
function stop() {
|
||||
started = false
|
||||
generation++
|
||||
attempt?.abort()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
makeEventListener(window, "pagehide", stop)
|
||||
makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start))
|
||||
void start()
|
||||
const connection = createClientConnection(api, {
|
||||
flushInterval: 16,
|
||||
pageLifecycle: true,
|
||||
onEvent(event) {
|
||||
emitter.emit(event.type, event)
|
||||
dirEmitter.emit(event.location?.directory ?? "global", adaptServerEvent(event))
|
||||
},
|
||||
log: {
|
||||
info(message, data) {
|
||||
if (message !== "event stream disconnected") return
|
||||
console.info("[global-sdk] event stream disconnected", { url: server.http.url, ...data })
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
abort.abort()
|
||||
if (timer) clearTimeout(timer)
|
||||
timer = undefined
|
||||
queue = []
|
||||
buffer = []
|
||||
dirEmitter.clear()
|
||||
emitter.clear()
|
||||
})
|
||||
|
||||
const api = createApiForServer({ server: server.http, fetch: platform.fetch })
|
||||
|
||||
return {
|
||||
server,
|
||||
scope,
|
||||
url: server.http.url,
|
||||
api,
|
||||
connection: {
|
||||
status: () => connection.status,
|
||||
attempt: () => connection.attempt,
|
||||
error: () => connection.error,
|
||||
connection,
|
||||
eventByDir: {
|
||||
on: dirEmitter.on.bind(dirEmitter),
|
||||
listen: dirEmitter.listen.bind(dirEmitter),
|
||||
},
|
||||
event: {
|
||||
on: emitter.on.bind(emitter),
|
||||
@@ -365,7 +114,7 @@ export type DirectorySDK = {
|
||||
function createDirSdkContext(directory: string, serverSDK: ServerSDKBase): DirectorySDK {
|
||||
const emitter = createGlobalEmitter<SDKEventMap>()
|
||||
|
||||
const unsub = serverSDK.event.on(directory, (event) => {
|
||||
const unsub = serverSDK.eventByDir.on(directory, (event) => {
|
||||
emitter.emit(event.type, event)
|
||||
})
|
||||
onCleanup(unsub)
|
||||
|
||||
@@ -1,18 +1,10 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpResourceCatalogInput,
|
||||
SessionApi,
|
||||
SessionInfo,
|
||||
SessionListInput,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise"
|
||||
import { QueryClient } from "@tanstack/solid-query"
|
||||
import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction"
|
||||
import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load"
|
||||
import {
|
||||
loadActiveSessionsQuery,
|
||||
loadMcpQuery,
|
||||
loadMcpResourcesQuery,
|
||||
reconcileActiveSessionStatuses,
|
||||
seedActiveSessionStatuses,
|
||||
shouldRefreshWorkspaceSessions,
|
||||
@@ -21,56 +13,6 @@ import { ServerScope } from "@/utils/server-scope"
|
||||
import { createServerSession } from "./server-session"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
|
||||
type McpApi = ServerApi["mcp"]
|
||||
|
||||
describe("MCP queries", () => {
|
||||
test("loads current servers for the requested location", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpQuery(ServerScope.local, "/project", {
|
||||
list: async (input: McpListInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: [
|
||||
{ name: "docs", status: { status: "connected" } },
|
||||
{ name: "search", status: { status: "pending" } },
|
||||
],
|
||||
}
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } })
|
||||
})
|
||||
|
||||
test("loads and keys the current resource catalog", async () => {
|
||||
const calls: unknown[] = []
|
||||
const queryClient = new QueryClient()
|
||||
const result = await queryClient.fetchQuery(
|
||||
loadMcpResourcesQuery(ServerScope.local, "/project", {
|
||||
resource: {
|
||||
catalog: async (input: McpResourceCatalogInput = {}) => {
|
||||
calls.push(input)
|
||||
return {
|
||||
location: { directory: "/project", project: { id: "project", directory: "/project" } },
|
||||
data: {
|
||||
resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }],
|
||||
templates: [],
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
} as unknown as McpApi),
|
||||
)
|
||||
|
||||
expect(calls).toEqual([{ location: { directory: "/project" } }])
|
||||
expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } })
|
||||
})
|
||||
})
|
||||
|
||||
describe("active session query", () => {
|
||||
test("loads active sessions immediately and once per server cache", async () => {
|
||||
let calls = 0
|
||||
|
||||
@@ -4,20 +4,12 @@ import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { batch, getOwner, onCleanup, untrack } from "solid-js"
|
||||
import { createStore, reconcile } from "solid-js/store"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import type { InitError } from "../pages/error"
|
||||
import { type ServerEvent, type ServerSDK } from "./server-sdk"
|
||||
import {
|
||||
bootstrapDirectory,
|
||||
bootstrapGlobal,
|
||||
clearProviderRev,
|
||||
loadAgentsQuery,
|
||||
loadCommands,
|
||||
loadGlobalConfigQuery,
|
||||
loadIntegrationsQuery,
|
||||
loadPathQuery,
|
||||
loadProjectsQuery,
|
||||
loadProvidersQuery,
|
||||
loadReferencesQuery,
|
||||
} from "./global-sync/bootstrap"
|
||||
import { createChildStoreManager } from "./global-sync/child-store"
|
||||
import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer"
|
||||
@@ -32,7 +24,6 @@ import { createRefreshQueue } from "./global-sync/queue"
|
||||
import { directoryKey } from "./global-sync/utils"
|
||||
import { pathKey, PathKey } from "@/utils/path-key"
|
||||
import { createDirSyncContext } from "./directory-sync"
|
||||
import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context"
|
||||
import { createRefCountMap } from "@/utils/refcount"
|
||||
import { ServerConnection } from "./servers"
|
||||
import type { ServerScope } from "@/utils/server-scope"
|
||||
@@ -40,21 +31,14 @@ import { createHomeSessionIndexCache } from "./global-sync/home-session-index"
|
||||
import { persisted } from "@/utils/persist"
|
||||
import type { ServerApi } from "@/utils/server"
|
||||
import type {
|
||||
McpListInput,
|
||||
McpListOutput,
|
||||
McpResource,
|
||||
McpResourceCatalogInput,
|
||||
McpResourceCatalogOutput,
|
||||
McpServer,
|
||||
SessionActiveOutput,
|
||||
SessionStatus,
|
||||
} from "@opencode-ai/client/promise"
|
||||
import { toggleMcp } from "./global-sync/mcp"
|
||||
import { createServerSession, type ServerSession } from "./server-session"
|
||||
import { createCatalogSync } from "./server-sync/catalog"
|
||||
import { createConnectionSync } from "./server-sync/connection"
|
||||
import { usePlatform } from "./platform"
|
||||
import { useServer } from "./server"
|
||||
import type { Data } from "@opencode-ai/client/solid"
|
||||
|
||||
export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||
const type = event.current?.type ?? event.type
|
||||
@@ -68,11 +52,8 @@ export function shouldRefreshWorkspaceSessions(event: ServerEvent) {
|
||||
}
|
||||
|
||||
type GlobalStore = {
|
||||
ready: boolean
|
||||
error?: InitError
|
||||
path: Path
|
||||
project: Project[]
|
||||
provider: NormalizedProviderListResponse
|
||||
provider_auth: ProviderAuthResponse
|
||||
config: Config
|
||||
reload: undefined | "pending" | "complete"
|
||||
@@ -88,16 +69,6 @@ const SESSION_LIST_EVENTS = new Set([
|
||||
"session.usage.updated",
|
||||
])
|
||||
|
||||
type McpListApi = {
|
||||
readonly list: (input?: McpListInput) => Promise<McpListOutput>
|
||||
}
|
||||
|
||||
type McpResourceApi = {
|
||||
readonly resource: {
|
||||
readonly catalog: (input?: McpResourceCatalogInput) => Promise<McpResourceCatalogOutput>
|
||||
}
|
||||
}
|
||||
|
||||
type ApiQueryOptions<T, K extends readonly unknown[]> = SolidQueryOptions<T, Error, T, K> & {
|
||||
initialData?: undefined
|
||||
queryKey: K
|
||||
@@ -107,47 +78,6 @@ type SessionActiveApi = {
|
||||
readonly active: () => Promise<SessionActiveOutput>
|
||||
}
|
||||
|
||||
export const loadMcpQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpListApi,
|
||||
): ApiQueryOptions<Record<string, McpServer["status"]>, readonly [ServerScope, string, "mcp"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpServer["status"]>,
|
||||
Error,
|
||||
Record<string, McpServer["status"]>,
|
||||
readonly [ServerScope, string, "mcp"]
|
||||
>({
|
||||
queryKey: [scope, directory, "mcp"] as const,
|
||||
queryFn: async () => {
|
||||
return api
|
||||
.list({ location: { directory } })
|
||||
.then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status])))
|
||||
},
|
||||
})
|
||||
|
||||
export const loadMcpResourcesQuery = (
|
||||
scope: ServerScope,
|
||||
directory: string,
|
||||
api: McpResourceApi,
|
||||
): ApiQueryOptions<Record<string, McpResource>, readonly [ServerScope, string, "mcpResources"]> =>
|
||||
queryOptions<
|
||||
Record<string, McpResource>,
|
||||
Error,
|
||||
Record<string, McpResource>,
|
||||
readonly [ServerScope, string, "mcpResources"]
|
||||
>({
|
||||
queryKey: [scope, directory, "mcpResources"] as const,
|
||||
queryFn: async () => {
|
||||
return api.resource
|
||||
.catalog({ location: { directory } })
|
||||
.then((result) =>
|
||||
Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])),
|
||||
)
|
||||
},
|
||||
placeholderData: {},
|
||||
})
|
||||
|
||||
export const loadLspQuery = (scope: ServerScope, directory: string) =>
|
||||
queryOptions({
|
||||
queryKey: [scope, directory, "lsp"] as const,
|
||||
@@ -194,21 +124,14 @@ export function reconcileActiveSessionStatuses(
|
||||
function makeQueryOptionsApi(scope: ServerScope, serverAPI: ServerApi) {
|
||||
return {
|
||||
globalConfig: () => loadGlobalConfigQuery(scope),
|
||||
projects: () => loadProjectsQuery(scope, serverAPI.project, serverAPI.worktree),
|
||||
providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI),
|
||||
integrations: (directory: PathKey | null) => loadIntegrationsQuery(scope, directory, serverAPI.integration),
|
||||
path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.location),
|
||||
agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent),
|
||||
references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference),
|
||||
mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp),
|
||||
mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, serverAPI.mcp),
|
||||
path: () => loadPathQuery(scope, null, serverAPI.location),
|
||||
lsp: (directory: PathKey) => loadLspQuery(scope, directory),
|
||||
sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }),
|
||||
}
|
||||
}
|
||||
export type QueryOptionsApi = ReturnType<typeof makeQueryOptionsApi>
|
||||
|
||||
export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
export function createServerSyncContextInner(serverSDK: ServerSDK, data: Data) {
|
||||
const language = useLanguage()
|
||||
const platform = usePlatform()
|
||||
const owner = getOwner()
|
||||
@@ -222,22 +145,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const session = createServerSession(serverSDK.api.session, serverSDK.api.message)
|
||||
const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, serverSDK.api)
|
||||
const connected = () => serverSDK.connection.status() === "connected"
|
||||
const hydrateSessionState = async (sessionID: string) => {
|
||||
await session.hydrateTransient(sessionID, async () => {
|
||||
const [pending, forms] = await Promise.all([
|
||||
serverSDK.api.session.inbox.list({ sessionID }),
|
||||
serverSDK.api.form.list({ sessionID }),
|
||||
])
|
||||
return { pending, forms }
|
||||
})
|
||||
}
|
||||
const hydrateSession = (sessionID: string) => Promise.all([session.sync(sessionID), hydrateSessionState(sessionID)])
|
||||
const hydrateSession = (sessionID: string) => session.sync(sessionID)
|
||||
|
||||
const [configQuery, providerQuery, pathQuery] = useQueries(() => ({
|
||||
const [configQuery, pathQuery] = useQueries(() => ({
|
||||
queries: [
|
||||
{ ...queryOptionsApi.globalConfig(), enabled: connected() },
|
||||
{ ...queryOptionsApi.providers(null), enabled: connected() },
|
||||
{ ...queryOptionsApi.path(null), enabled: connected() },
|
||||
{ ...queryOptionsApi.path(), enabled: connected() },
|
||||
],
|
||||
}))
|
||||
const activeSessionsQuery = useQuery(() => ({
|
||||
@@ -246,7 +159,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
const active = await serverSDK.api.session.active()
|
||||
reconcileActiveSessionStatuses(session, active)
|
||||
Object.keys(active).forEach((sessionID) => {
|
||||
void Promise.all([session.resolve(sessionID), hydrateSessionState(sessionID)]).catch(() => undefined)
|
||||
void session.resolve(sessionID).catch(() => undefined)
|
||||
})
|
||||
return active
|
||||
},
|
||||
@@ -255,9 +168,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}))
|
||||
|
||||
const [globalStore, setGlobalStore] = createStore<GlobalStore>({
|
||||
get ready() {
|
||||
return bootstrap.isSuccess
|
||||
},
|
||||
project: [],
|
||||
provider_auth: {},
|
||||
get path() {
|
||||
@@ -265,11 +175,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
if (pathQuery.isLoading) return EMPTY
|
||||
return pathQuery.data ?? EMPTY
|
||||
},
|
||||
get provider() {
|
||||
const EMPTY = { all: new Map(), connected: [], default: {} }
|
||||
if (providerQuery.isLoading) return EMPTY
|
||||
return providerQuery.data ?? EMPTY
|
||||
},
|
||||
get config() {
|
||||
if (configQuery.isLoading) return {}
|
||||
return configQuery.data ?? {}
|
||||
@@ -338,51 +243,33 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
if (!connected()) return
|
||||
void bootstrapInstance(directory)
|
||||
},
|
||||
onMcp: (directory, setStore) => {
|
||||
void loadCommands(directory, serverSDK.api.command)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
onMcp: (directory) => {
|
||||
void Promise.all([
|
||||
data.location.command.sync({ directory }),
|
||||
data.location.mcp.server.sync({ directory }),
|
||||
data.location.mcp.resource.sync({ directory }),
|
||||
]).catch((err) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
})
|
||||
},
|
||||
onDispose: (directory) => {
|
||||
const key = directoryKey(directory)
|
||||
queue.clear(key)
|
||||
sessionMeta.delete(key)
|
||||
clearProviderRev(serverSDK.scope, key)
|
||||
},
|
||||
translate: language.t,
|
||||
queryOptions: queryOptionsApi,
|
||||
data,
|
||||
global: {
|
||||
get provider() {
|
||||
return globalStore.provider
|
||||
get path() {
|
||||
return globalStore.path
|
||||
},
|
||||
},
|
||||
})
|
||||
const catalog = createCatalogSync({
|
||||
scope: serverSDK.scope,
|
||||
queryClient,
|
||||
active: () => Object.keys(children.children).filter(children.active).map(pathKey),
|
||||
load: (directory) =>
|
||||
Promise.all([
|
||||
queryClient.fetchQuery(queryOptionsApi.providers(directory)),
|
||||
queryClient.fetchQuery(queryOptionsApi.integrations(directory)),
|
||||
]).then(() => undefined),
|
||||
})
|
||||
const refreshVcs = (directory: string) =>
|
||||
serverSDK.api.vcs
|
||||
.get({ location: { directory } })
|
||||
.then((result) =>
|
||||
children.vcs(directory, {
|
||||
branch: result.data.branch.current,
|
||||
default_branch: result.data.branch.default,
|
||||
}),
|
||||
)
|
||||
.catch(() => undefined)
|
||||
const connection = createConnectionSync({
|
||||
status: serverSDK.connection.status,
|
||||
invalidate: () => {
|
||||
@@ -393,14 +280,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
},
|
||||
connected: (info) => {
|
||||
if (info.reconnect) void session.refreshPinned(hydrateSessionState).catch(() => undefined)
|
||||
if (info.reconnect) void session.refreshPinned(() => Promise.resolve()).catch(() => undefined)
|
||||
if (activeSessionsQuery.data !== undefined && !activeSessionsQuery.isFetching) void activeSessionsQuery.refetch()
|
||||
if (bootstrap.data !== undefined && !bootstrap.isFetching) void bootstrap.refetch()
|
||||
Object.keys(children.children)
|
||||
.filter(children.active)
|
||||
.forEach((directory) => {
|
||||
queue.push(directory)
|
||||
if (children.children[directory]?.[0].status !== "loading") void refreshVcs(directory)
|
||||
void data.location.sync({ directory }).catch(() => undefined)
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -498,8 +385,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
children.pin(key)
|
||||
const promise = Promise.resolve().then(async () => {
|
||||
const child = children.ensureChild(directory)
|
||||
const initial = child[0].status === "loading"
|
||||
await Promise.all([
|
||||
data.location.sync({ directory }),
|
||||
bootstrapDirectory({
|
||||
directory,
|
||||
scope: serverSDK.scope,
|
||||
@@ -508,7 +395,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
config: globalStore.config,
|
||||
path: globalStore.path,
|
||||
project: globalStore.project,
|
||||
provider: globalStore.provider,
|
||||
},
|
||||
api: serverSDK.api,
|
||||
store: child[0],
|
||||
@@ -518,7 +404,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
queryClient,
|
||||
session,
|
||||
}),
|
||||
initial ? refreshVcs(directory) : Promise.resolve(),
|
||||
])
|
||||
})
|
||||
|
||||
@@ -568,7 +453,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
return event
|
||||
}
|
||||
|
||||
const unsub = serverSDK.event.listen((e) => {
|
||||
const unsub = serverSDK.eventByDir.listen((e) => {
|
||||
const directory = e.name
|
||||
const key = directoryKey(directory)
|
||||
const event = e.details
|
||||
@@ -628,7 +513,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
.catch(() => undefined)
|
||||
}
|
||||
homeSessions.refresh(event.type)
|
||||
catalog.handleEvent({ type: eventType, directory })
|
||||
connection.handleEvent({ type: eventType, directory })
|
||||
|
||||
if (directory === "global") {
|
||||
@@ -661,21 +545,10 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
eventType === "agent.updated"
|
||||
)
|
||||
queue.push(key)
|
||||
if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key))
|
||||
if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key))
|
||||
const [store, setStore] = existing
|
||||
if (eventType === "agent.updated")
|
||||
void queryClient
|
||||
.fetchQuery(queryOptionsApi.agents(key))
|
||||
.then((data) => setStore("agent", data))
|
||||
.catch(() => {})
|
||||
if (eventType === "command.updated")
|
||||
void loadCommands(directory, serverSDK.api.command)
|
||||
.then((commands) => setStore("command", commands))
|
||||
.catch(() => {})
|
||||
if (eventType === "worktree.updated") void bootstrap.refetch()
|
||||
const projected = toDirectoryEvent(event)
|
||||
if (projected)
|
||||
if (projected && eventType !== "vcs.branch.updated")
|
||||
applyDirectoryEvent({
|
||||
event: projected,
|
||||
directory,
|
||||
@@ -694,7 +567,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
loadReferences: () => {
|
||||
if (!children.active(key)) return
|
||||
void queryClient.fetchQuery(queryOptionsApi.references(key))
|
||||
void data.location.reference.sync({ directory: key }).catch(() => undefined)
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -710,7 +583,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
})
|
||||
|
||||
const projectApi = {
|
||||
loadSessions,
|
||||
meta(directory: string, patch: ProjectMeta) {
|
||||
children.projectMeta(directory, patch)
|
||||
},
|
||||
@@ -727,29 +599,22 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
},
|
||||
onSuccess: () => {
|
||||
bootstrap.refetch()
|
||||
// Invalidate all provider queries so newly configured custom providers
|
||||
// appear immediately in the available provider list across all directories.
|
||||
queryClient.invalidateQueries({ queryKey: [serverSDK.scope, null, "providers"] })
|
||||
queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0] === serverSDK.scope && query.queryKey[2] === "providers",
|
||||
})
|
||||
data.location.provider.invalidate()
|
||||
data.location.model.invalidate()
|
||||
data.location.model.invalidateDefault()
|
||||
void Promise.all([
|
||||
data.location.provider.sync(),
|
||||
data.location.model.sync(),
|
||||
data.location.model.syncDefault(),
|
||||
])
|
||||
},
|
||||
}))
|
||||
|
||||
return {
|
||||
data: globalStore,
|
||||
set,
|
||||
get ready() {
|
||||
return globalStore.ready
|
||||
},
|
||||
get error() {
|
||||
return globalStore.error
|
||||
},
|
||||
child: children.child,
|
||||
peek: children.peek,
|
||||
disableMcp: children.disableMcp,
|
||||
queryOptions: queryOptionsApi,
|
||||
refreshProviders: catalog.refreshActive,
|
||||
// bootstrap,
|
||||
updateConfig: updateConfigMutation.mutateAsync,
|
||||
project: projectApi,
|
||||
@@ -788,8 +653,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
platform.openExternal(attempt.data.url)
|
||||
},
|
||||
refresh: async () => {
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcp(key))
|
||||
await queryClient.refetchQueries(queryOptionsApi.mcpResources(key))
|
||||
data.location.mcp.server.invalidate({ directory: key })
|
||||
data.location.mcp.resource.invalidate({ directory: key })
|
||||
await Promise.all([
|
||||
data.location.mcp.server.sync({ directory: key }),
|
||||
data.location.mcp.resource.sync({ directory: key }),
|
||||
])
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -797,8 +666,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) {
|
||||
}
|
||||
}
|
||||
|
||||
export function createServerSyncContext(serverSDK: ServerSDK) {
|
||||
const inner = createServerSyncContextInner(serverSDK)
|
||||
export function createServerSyncContext(serverSDK: ServerSDK, data: Data) {
|
||||
const inner = createServerSyncContextInner(serverSDK, data)
|
||||
return Object.assign(inner, {
|
||||
ensureDirSyncContext: createRefCountMap(
|
||||
(dir) => createDirSyncContext(dir, inner, serverSDK),
|
||||
@@ -809,13 +678,3 @@ export function createServerSyncContext(serverSDK: ServerSDK) {
|
||||
}
|
||||
|
||||
export type ServerSync = ReturnType<typeof createServerSyncContext>
|
||||
|
||||
export const useServerSync = () => {
|
||||
const server = useServer()
|
||||
return server.ctx.sync
|
||||
}
|
||||
|
||||
export function useQueryOptions() {
|
||||
const sync = useServerSync()
|
||||
return sync.queryOptions
|
||||
}
|
||||
|
||||
@@ -22,3 +22,8 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext(
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
export const useData = () => {
|
||||
const server = useServer()
|
||||
return server.ctx.data
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
import { Binary } from "@opencode-ai/core/util/binary"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useServerSync } from "./server-sync"
|
||||
import { useSDK } from "./sdk"
|
||||
import type { Message, Part } from "@/types"
|
||||
import { messageKey } from "@/utils/session-message"
|
||||
|
||||
@@ -109,12 +106,3 @@ export function applyOptimisticRemove(draft: OptimisticStore, input: OptimisticR
|
||||
}
|
||||
delete draft.part[input.messageID]
|
||||
}
|
||||
|
||||
export const useSync = () => {
|
||||
const serverSync = useServerSync()
|
||||
const sdk = useSDK()
|
||||
|
||||
return createMemo(() => serverSync.ensureDirSyncContext(sdk().directory))
|
||||
}
|
||||
|
||||
export type DirectorySync = ReturnType<ReturnType<typeof useSync>>
|
||||
|
||||
@@ -1,21 +1,18 @@
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import type { Accessor } from "solid-js"
|
||||
import { useData } from "@/context/server"
|
||||
import { createEffect, type Accessor } from "solid-js"
|
||||
|
||||
export function useIntegrations(directory: Accessor<string | undefined>) {
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const query = createQuery(() => {
|
||||
const data = useData()
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
const value = directory()
|
||||
return {
|
||||
...serverSync.queryOptions.integrations(value ? pathKey(value) : null),
|
||||
enabled: serverSDK.connection.status() === "connected",
|
||||
}
|
||||
void data.location.integration.sync(value ? { directory: value } : undefined).catch(() => undefined)
|
||||
})
|
||||
|
||||
return {
|
||||
list: () => (query.isSuccess || query.isRefetchError ? query.data : []),
|
||||
list: () => data.location.integration.list(directory() ? { directory: directory()! } : undefined) ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useQueryOptions } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { normalizeProviderList } from "@/context/global-sync/utils"
|
||||
import { Iterable, pipe } from "effect"
|
||||
import { type Accessor } from "solid-js"
|
||||
import { createEffect, createMemo, 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,16 +18,41 @@ export const popularProviders = [
|
||||
const popularProviderSet = new Set(popularProviders)
|
||||
|
||||
export function useProviders(directory: Accessor<string | undefined>) {
|
||||
const providersQuery = useQuery(() => {
|
||||
const queryOpts = useQueryOptions()
|
||||
const data = useData()
|
||||
const sdk = useServerSDK()
|
||||
const location = () => {
|
||||
const dir = directory()
|
||||
return queryOpts.providers(dir ? pathKey(dir) : null)
|
||||
return dir ? { directory: dir } : undefined
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
if (sdk.connection.status() !== "connected") return
|
||||
const ref = location()
|
||||
void Promise.all([
|
||||
data.location.provider.sync(ref),
|
||||
data.location.model.sync(ref),
|
||||
data.location.model.syncDefault(ref),
|
||||
]).catch(() => undefined)
|
||||
})
|
||||
|
||||
const providers = () => (!providersQuery.isSuccess ? emptyProviderCatalog : providersQuery.data)
|
||||
const providers = createMemo(() => {
|
||||
const ref = location()
|
||||
const provider = data.location.provider.list(ref)
|
||||
const model = data.location.model.list(ref)
|
||||
const defaultModel = data.location.model.default(ref)
|
||||
if (!provider || !model || defaultModel === undefined) return emptyProviderCatalog
|
||||
return normalizeProviderList(provider, model, defaultModel)
|
||||
})
|
||||
|
||||
return {
|
||||
ready: () => providersQuery.isSuccess,
|
||||
ready: () => {
|
||||
const ref = location()
|
||||
return (
|
||||
data.location.provider.list(ref) !== undefined &&
|
||||
data.location.model.list(ref) !== undefined &&
|
||||
data.location.model.default(ref) !== undefined
|
||||
)
|
||||
},
|
||||
all: () => providers().all,
|
||||
default: () => providers().default,
|
||||
popular: () =>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
export { AppBaseProviders, AppInterface } from "./app"
|
||||
export { useLayout } from "./context/layout"
|
||||
export { useServerSDK } from "./context/server-sdk"
|
||||
export { useServerSync } from "./context/server-sync"
|
||||
export { useServers as useServers } from "./context/servers"
|
||||
export { useSettings } from "./context/settings"
|
||||
export { useTabs } from "./context/tabs"
|
||||
|
||||
@@ -2,16 +2,16 @@ import { DataProvider } from "@opencode-ai/session-ui/context"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { useLocation, useNavigate, useParams } from "@solidjs/router"
|
||||
import { createEffect, createMemo, createResource, onCleanup, type ParentProps, Show } from "solid-js"
|
||||
import { createEffect, createMemo, type ParentProps, Show } from "solid-js"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { LocalProvider } from "@/context/local"
|
||||
import { SDKProvider } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { decode64 } from "@/utils/base64"
|
||||
import { Schema } from "effect"
|
||||
import type { ServerConnection } from "@/context/servers"
|
||||
import { sessionHref } from "@/utils/session-route"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
|
||||
export function DirectoryDataProvider(
|
||||
props: ParentProps<{
|
||||
@@ -23,8 +23,8 @@ export function DirectoryDataProvider(
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const serverSDK = useServerSDK()
|
||||
const language = useLanguage()
|
||||
const directory = () => props.directory
|
||||
const slug = createMemo(() => base64Encode(directory()))
|
||||
@@ -33,37 +33,41 @@ export function DirectoryDataProvider(
|
||||
return `/${slug()}/session/${sessionID}`
|
||||
}
|
||||
const navigateToSession = async (sessionID: string) => {
|
||||
const session = serverSync.session
|
||||
await Promise.allSettled([session.lineage.resolve(sessionID), session.sync(sessionID)])
|
||||
await data.session.lineage.resolve(sessionID).catch(() => undefined)
|
||||
navigate(href(sessionID))
|
||||
}
|
||||
|
||||
createEffect(() => {
|
||||
// A draft lives at /new-session?draftId=… and has no directory segment to normalize.
|
||||
if (props.draftID || props.server) return
|
||||
const next = sync().data.path.directory
|
||||
const next = data.location.info({ directory: directory() })?.directory
|
||||
if (!next || next === directory()) return
|
||||
const path = location.pathname.slice(slug().length + 1)
|
||||
navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true })
|
||||
})
|
||||
|
||||
createResource(
|
||||
() => params.id,
|
||||
(id) => serverSync.session.hydrate(id).catch(() => {}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
const ref = { directory: directory() }
|
||||
void data.location.sync(ref).catch(() => undefined)
|
||||
void data.project.sync().catch(() => undefined)
|
||||
const sessionID = params.id
|
||||
if (!sessionID) return
|
||||
serverSync.session.pin(sessionID)
|
||||
onCleanup(() => serverSync.session.unpin(sessionID))
|
||||
void Promise.allSettled([
|
||||
data.session.sync(sessionID, { children: true }),
|
||||
data.session.pending.sync(sessionID),
|
||||
data.session.message.sync(sessionID),
|
||||
data.session.permission.sync(sessionID),
|
||||
data.session.form.sync(sessionID),
|
||||
])
|
||||
})
|
||||
|
||||
return (
|
||||
<Show when={directory()} keyed>
|
||||
{(directory) => (
|
||||
<DataProvider
|
||||
data={sync().data}
|
||||
// TODO: Remove this legacy session-ui bridge once message parts use the current Data projections.
|
||||
data={{ session: [], session_status: {}, session_diff: {}, message: {}, part: {} }}
|
||||
directory={directory}
|
||||
sessionID={params.id}
|
||||
onNavigateToSession={navigateToSession}
|
||||
|
||||
@@ -112,7 +112,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const key = `${ServerConnection.key(conn)}\0${record.session.id}`
|
||||
if (prefetched.has(key)) return
|
||||
prefetched.add(key)
|
||||
void untrack(() => ctx.sync.session.sync(record.session.id)).catch(() => {})
|
||||
void untrack(() => ctx.data.session.sync(record.session.id)).catch(() => {})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -179,7 +179,7 @@ export function createHomeSessionsController(home: HomeController) {
|
||||
const directory = project?.worktree ?? session.location.directory
|
||||
const ctx = home.server.focusedContext()
|
||||
if (!ctx) return
|
||||
ctx.sync.session.remember(session)
|
||||
ctx.data.session.remember(session)
|
||||
ctx.projects.open(directory)
|
||||
if (options?.background) {
|
||||
tabs.addSessionTab({ server: connKey, sessionId: session.id })
|
||||
|
||||
@@ -11,32 +11,28 @@ export function useSessionTabAvatarState(
|
||||
const global = useGlobal()
|
||||
const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server()))
|
||||
const serverCtx = useServerCtx(connection)
|
||||
const sync = () => serverCtx()?.sync
|
||||
const hasPermissions = createMemo(() => {
|
||||
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 !!sessionPermissionRequest(ctx.data.session.list(), ctx.data.session.permission.list, sessionId(), (item) => {
|
||||
return !permission.autoResponds(item, directory())
|
||||
})
|
||||
})
|
||||
const hasQuestions = createMemo(() => {
|
||||
const serverSync = sync()
|
||||
if (!serverSync) return false
|
||||
const [store] = serverSync.child(directory(), { bootstrap: false })
|
||||
return !!sessionQuestionForm(store.session, serverSync.session.data.form, sessionId())
|
||||
const data = serverCtx()?.data
|
||||
if (!data) return false
|
||||
return !!sessionQuestionForm(data.session.list(), data.session.form.list, sessionId())
|
||||
})
|
||||
const needsAttention = createMemo(() => hasPermissions() || hasQuestions())
|
||||
const unread = createMemo(
|
||||
() => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
|
||||
)
|
||||
const loading = createMemo(() => {
|
||||
const serverSync = sync()
|
||||
if (!serverSync) return false
|
||||
const data = serverCtx()?.data
|
||||
if (!data) return false
|
||||
if (needsAttention()) return false
|
||||
return serverSync.session.data.session_working(sessionId())
|
||||
return data.session.status(sessionId()) === "running"
|
||||
})
|
||||
return { unread, loading }
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { usePromptInputV2Controller } from "@/components/prompt-input-v2"
|
||||
import { useComments } from "@/context/comments"
|
||||
import { useLocal } from "@/context/local"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer"
|
||||
import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection"
|
||||
import { useSessionKey } from "@/pages/session/session-layout"
|
||||
@@ -17,7 +16,6 @@ export function createNewSessionDraftController(props: {
|
||||
onSubmit: () => void
|
||||
}) {
|
||||
const prompt = usePrompt()
|
||||
const serverSync = useServerSync()
|
||||
const comments = useComments()
|
||||
const local = useLocal()
|
||||
const route = useSessionKey()
|
||||
@@ -29,7 +27,6 @@ export function createNewSessionDraftController(props: {
|
||||
const controls = createPromptInputController({
|
||||
sessionKey: route.sessionKey,
|
||||
sessionID: () => route.params.id,
|
||||
queryOptions: serverSync.queryOptions,
|
||||
model,
|
||||
})
|
||||
const projectControls = createPromptProjectControls({
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
import { StatusPopoverV2 } from "@/components/status-popover"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
@@ -107,7 +106,6 @@ function ProviderTip() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const serverSync = useServerSync()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
@@ -115,7 +113,7 @@ function ProviderTip() {
|
||||
)
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
serverSync.child(sdk().directory)[0].provider_ready &&
|
||||
providers.ready() &&
|
||||
persistedReady() &&
|
||||
providers.paid().length === 0 &&
|
||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createMemo } from "solid-js"
|
||||
import { createEffect, createMemo } from "solid-js"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
import {
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
@@ -45,19 +45,23 @@ export function createNewSessionWorkspaceController(input: {
|
||||
onViewAll: () => void
|
||||
}) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const settings = useSettings()
|
||||
const visible = createMemo(() => sync().project?.vcs === "git")
|
||||
const currentProject = createMemo(() => {
|
||||
const projectID = data.location.info({ directory: sdk().directory })?.project.id
|
||||
const current = projectID ? data.project.get(projectID) : undefined
|
||||
return current ? normalizeProjectInfo(current) : undefined
|
||||
})
|
||||
const visible = createMemo(() => currentProject()?.vcs === "git")
|
||||
const selected = createMemo(() => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
const worktree = input.selected()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
if (!project) return "main"
|
||||
return workspaceDefaultSelection(
|
||||
settings.workspaces.defaultDestination(),
|
||||
@@ -69,21 +73,27 @@ export function createNewSessionWorkspaceController(input: {
|
||||
enabled: visible(),
|
||||
selected: selected(),
|
||||
directory: sdk().directory,
|
||||
projectWorktree: sync().project?.worktree,
|
||||
projectWorktree: currentProject()?.worktree,
|
||||
fallback: fallback(),
|
||||
}),
|
||||
)
|
||||
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
|
||||
const localBranch = createMemo(() => serverSync.child(projectRoot())[0].vcs?.branch)
|
||||
const projectRoot = createMemo(() => currentProject()?.worktree ?? sdk().directory)
|
||||
createEffect(() => {
|
||||
void Promise.all([data.location.syncInfo({ directory: sdk().directory }), data.project.sync()]).catch(() => undefined)
|
||||
const project = currentProject()
|
||||
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
|
||||
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
||||
})
|
||||
const localBranch = createMemo(() => data.location.vcs.info({ directory: projectRoot() })?.branch.current)
|
||||
const branch = createMemo(() =>
|
||||
resolveNewSessionBranch({
|
||||
worktree: value(),
|
||||
local: localBranch(),
|
||||
worktreeBranch: (worktree) => serverSync.child(worktree)[0].vcs?.branch,
|
||||
worktreeBranch: (worktree) => data.location.vcs.info({ directory: worktree })?.branch.current,
|
||||
}),
|
||||
)
|
||||
const remember = (worktree = value()) => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
if (!project) return
|
||||
const local = worktree === "main" || sameDirectory(worktree, project.worktree)
|
||||
settings.workspaces.setLastUsed(serverSDK.scope, project.id, local ? "local" : "workspace")
|
||||
@@ -93,24 +103,24 @@ export function createNewSessionWorkspaceController(input: {
|
||||
selection: {
|
||||
value,
|
||||
workspace: createMemo(() => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
const current = value()
|
||||
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
|
||||
}),
|
||||
reset: () => input.setSelected(undefined),
|
||||
remember,
|
||||
set: (worktree: string) => {
|
||||
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree))
|
||||
input.setSelected(normalizeNewSessionWorktree(worktree, sdk().directory, currentProject()?.worktree))
|
||||
remember(worktree)
|
||||
},
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: () => {
|
||||
const project = sync().project
|
||||
const project = currentProject()
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
git: () => sync().project?.vcs === "git",
|
||||
git: () => currentProject()?.vcs === "git",
|
||||
openAll: input.onViewAll,
|
||||
},
|
||||
bar: {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { FilePart, Project, UserMessage } from "@/types"
|
||||
import type { FilePart, UserMessage } from "@/types"
|
||||
import type { FileDiffInfo } from "@opencode-ai/client/promise"
|
||||
import { getFilename } from "@opencode-ai/core/util/path"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
@@ -39,14 +39,14 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { checksum } from "@opencode-ai/core/util/encode"
|
||||
import { isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { containsDirectory, isWorkspaceDirectory } from "@/utils/workspace"
|
||||
import { useLocation, useNavigate, useParams, useSearchParams } from "@solidjs/router"
|
||||
import { NewSessionView, SessionHeader } from "@/components/session"
|
||||
import { ErrorPage } from "@/pages/error"
|
||||
import { CommentsProvider, useComments } from "@/context/comments"
|
||||
import { useCommand } from "@/context/command"
|
||||
import { DirectoryDataProvider } from "@/pages/directory-layout"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useLayout } from "@/context/layout"
|
||||
import { ModelsProvider } from "@/context/models"
|
||||
@@ -57,7 +57,6 @@ import { SDKProvider, useSDK } from "@/context/sdk"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { ServerConnection, serverName, useServers } from "@/context/servers"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { TerminalProvider } from "@/context/terminal"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
@@ -65,7 +64,7 @@ import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/
|
||||
import { useSettingsCommand } from "@/components/settings-dialog"
|
||||
import { setCursorPosition } from "@/components/prompt-input/editor-dom"
|
||||
import { promptLength } from "@/components/prompt-input/history"
|
||||
import { type FollowupDraft, sendFollowupDraft } from "@/components/prompt-input/submit"
|
||||
import type { FollowupDraft } from "@/components/prompt-input/submit"
|
||||
import {
|
||||
createPromptInputController,
|
||||
createSessionComposerController,
|
||||
@@ -99,9 +98,8 @@ import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
|
||||
import { Identifier } from "@/utils/id"
|
||||
import { diffs as list } from "@/utils/diffs"
|
||||
import { Persist, persisted } from "@/utils/persist"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs"
|
||||
import { createSessionLineage } from "./session/session-lineage"
|
||||
|
||||
@@ -122,37 +120,14 @@ function isCurrentSessionNotFoundError(error: unknown, sessionID: string | undef
|
||||
return isSessionNotFoundError(error, sessionID) || isLocalSessionNotFoundError(error, sessionID)
|
||||
}
|
||||
|
||||
async function runPromptRollbackMutation<T, R>(input: {
|
||||
capturePrompt: () => { current: () => T[]; set: (value: T[]) => void; reset: () => void }
|
||||
optimistic: (prompt: { set: (value: T[]) => void; reset: () => void }) => void
|
||||
request: () => Promise<R>
|
||||
complete: (result: R) => void
|
||||
rollback: () => void
|
||||
fail: (error: unknown) => void
|
||||
}) {
|
||||
const prompt = input.capturePrompt()
|
||||
const previous = prompt.current().slice()
|
||||
batch(() => input.optimistic(prompt))
|
||||
await input
|
||||
.request()
|
||||
.then(input.complete)
|
||||
.catch((error) => {
|
||||
batch(() => {
|
||||
input.rollback()
|
||||
prompt.set(previous)
|
||||
})
|
||||
input.fail(error)
|
||||
})
|
||||
}
|
||||
|
||||
// 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
|
||||
// workspace-scoped state (terminal, directory providers) lives below.
|
||||
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 data = useData()
|
||||
const directory = createMemo(() => data.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.
|
||||
@@ -238,11 +213,11 @@ function SessionErrorFallback(props: { error: unknown; sessionID?: string; serve
|
||||
function ResolvedTargetSessionRoute() {
|
||||
const params = useParams<{ serverKey: string; id: string }>()
|
||||
const tabs = useTabs()
|
||||
const sync = useServerSync()
|
||||
const data = useData()
|
||||
const serverKey = createMemo(() => requireServerKey(params.serverKey))
|
||||
const current = createSessionLineage(
|
||||
() => params.id,
|
||||
() => sync.session.lineage,
|
||||
() => data.session.lineage,
|
||||
)
|
||||
const directory = createMemo(() => current()?.session.location.directory)
|
||||
|
||||
@@ -329,11 +304,10 @@ function SessionPanelFrame(props: ParentProps<{ newLayout: boolean; raised?: boo
|
||||
}
|
||||
|
||||
export default function Page() {
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const file = useFile()
|
||||
const sync = useSync()
|
||||
const queryClient = useQueryClient()
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
@@ -347,9 +321,18 @@ export default function Page() {
|
||||
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const params = useParams()
|
||||
const isDesktop = createMediaQuery("(min-width: 768px)")
|
||||
const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns())
|
||||
const canReview = createMemo(() => !!sync().project)
|
||||
const project = createMemo(() => {
|
||||
const info = params.id ? data.session.get(params.id) : undefined
|
||||
const value = info?.projectID
|
||||
? data.project.get(info.projectID)
|
||||
: data.project.list().find((item) => containsDirectory(item.canonical, sdk().directory))
|
||||
if (!value) return
|
||||
return { ...value, worktree: value.canonical, worktrees: [] }
|
||||
})
|
||||
const canReview = createMemo(() => !!project())
|
||||
const controller = createSessionController({
|
||||
review: isDesktop,
|
||||
hasReview: canReview,
|
||||
@@ -384,7 +367,6 @@ export default function Page() {
|
||||
const inputController = createPromptInputController({
|
||||
sessionKey: controller.identity.sessionKey,
|
||||
sessionID: () => controller.identity.params.id,
|
||||
queryOptions: serverSync.queryOptions,
|
||||
})
|
||||
|
||||
const sessionPanelKey = createMemo(() =>
|
||||
@@ -462,7 +444,7 @@ export default function Page() {
|
||||
}
|
||||
|
||||
const sessionDirectory = createMemo(() => controller.data.info()?.location.directory ?? sdk().directory)
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
const timeline = createTimelineModel({ session: controller })
|
||||
const historyLoading = timeline.history.loading
|
||||
const historyMore = timeline.history.more
|
||||
@@ -549,10 +531,6 @@ export default function Page() {
|
||||
})
|
||||
|
||||
let reviewFrame: number | undefined
|
||||
let todoFrame: number | undefined
|
||||
let todoTimer: number | undefined
|
||||
let diffFrame: number | undefined
|
||||
let diffTimer: number | undefined
|
||||
|
||||
createComputed((prev) => {
|
||||
const open = desktopReviewOpen()
|
||||
@@ -567,17 +545,23 @@ export default function Page() {
|
||||
return open
|
||||
}, desktopReviewOpen())
|
||||
|
||||
const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs))
|
||||
// TODO: Restore turn diffs when current transcript projections expose message summaries.
|
||||
const turnDiffs = createMemo(() => list(undefined))
|
||||
const nogit = createMemo(() => {
|
||||
const project = sync().project
|
||||
return !!project && project.vcs !== "git"
|
||||
const current = project()
|
||||
return !!current && current.vcs !== "git"
|
||||
})
|
||||
const vcs = createMemo(() => data.location.vcs.info({ directory: sdk().directory }))
|
||||
const changesOptions = createMemo<ChangeMode[]>(() => {
|
||||
const list: ChangeMode[] = []
|
||||
const project = sync().project
|
||||
const vcs = sync().data.vcs
|
||||
if (project?.vcs === "git") list.push("git")
|
||||
if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) {
|
||||
const current = project()
|
||||
if (current?.vcs === "git") list.push("git")
|
||||
if (
|
||||
current?.vcs === "git" &&
|
||||
vcs()?.branch.current &&
|
||||
vcs()?.branch.default &&
|
||||
vcs()?.branch.current !== vcs()?.branch.default
|
||||
) {
|
||||
list.push("branch")
|
||||
}
|
||||
list.push("turn")
|
||||
@@ -601,13 +585,13 @@ export default function Page() {
|
||||
serverSDK.scope,
|
||||
"session-vcs",
|
||||
sdk().directory,
|
||||
sync().data.vcs?.branch ?? "",
|
||||
sync().data.vcs?.default_branch ?? "",
|
||||
vcs()?.branch.current ?? "",
|
||||
vcs()?.branch.default ?? "",
|
||||
] as const,
|
||||
)
|
||||
const vcsQuery = createQuery(() => {
|
||||
const mode = vcsMode()
|
||||
const enabled = serverSDK.connection.status() === "connected" && wantsReview() && sync().project?.vcs === "git"
|
||||
const enabled = serverSDK.connection.status() === "connected" && wantsReview() && project()?.vcs === "git"
|
||||
|
||||
return {
|
||||
queryKey: [...vcsKey(), mode] as const,
|
||||
@@ -624,7 +608,7 @@ export default function Page() {
|
||||
})
|
||||
const sessionDetailsQuery = createQuery(() => ({
|
||||
queryKey: [serverSDK.scope, "session-details", sessionDirectory()] as const,
|
||||
enabled: store.sessionDetailsOpen && serverSDK.connection.status() === "connected" && sync().project?.vcs === "git",
|
||||
enabled: store.sessionDetailsOpen && serverSDK.connection.status() === "connected" && project()?.vcs === "git",
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.vcs.diff({ location: { directory: sessionDirectory() }, mode: "working" })
|
||||
@@ -675,7 +659,7 @@ export default function Page() {
|
||||
const loadReviewDiff = async (file: string, version?: number): Promise<FileDiffInfo | undefined> => {
|
||||
const mode = vcsMode()
|
||||
if (!mode) return
|
||||
const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory)
|
||||
const root = reviewRootDirectory(project()?.worktree ?? sdk().directory)
|
||||
const directory = reviewDiffDirectory(root, file)
|
||||
const source = reviewDiffs().find((diff) => diff.file === file)
|
||||
const valid = (diff: FileDiffInfo | undefined) => {
|
||||
@@ -719,8 +703,8 @@ export default function Page() {
|
||||
|
||||
const newSessionWorktree = createMemo(() => {
|
||||
if (store.newSessionWorktree === "create") return "create"
|
||||
const project = sync().project
|
||||
if (project && sdk().directory !== project.worktree) return sdk().directory
|
||||
const current = project()
|
||||
if (current && sdk().directory !== current.worktree) return sdk().directory
|
||||
return "main"
|
||||
})
|
||||
|
||||
@@ -781,25 +765,6 @@ export default function Page() {
|
||||
scrollToMessage(msgs[targetIndex], "auto")
|
||||
}
|
||||
|
||||
function upsert(next: 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(
|
||||
"project",
|
||||
list.map((item, i) => (i === idx ? { ...item, ...next } : item)),
|
||||
)
|
||||
return
|
||||
}
|
||||
const at = list.findIndex((item) => item.id > next.id)
|
||||
if (at >= 0) {
|
||||
serverSync.set("project", [...list.slice(0, at), next, ...list.slice(at)])
|
||||
return
|
||||
}
|
||||
serverSync.set("project", [...list, next])
|
||||
}
|
||||
|
||||
const gitMutation = useMutation(() => ({
|
||||
// TODO: Restore Git initialization when the V2 client exposes this operation.
|
||||
mutationFn: async () => Promise.reject(new Error("Git initialization is unavailable")),
|
||||
@@ -842,40 +807,7 @@ export default function Page() {
|
||||
|
||||
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => {
|
||||
const id = controller.identity.params.id
|
||||
return [
|
||||
sdk().directory,
|
||||
id,
|
||||
id ? (sync().data.session_status[id]?.type ?? "idle") : "idle",
|
||||
id ? composer.blocked() : false,
|
||||
] as const
|
||||
},
|
||||
([dir, id, status, blocked]) => {
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
todoFrame = undefined
|
||||
todoTimer = undefined
|
||||
if (!id) return
|
||||
if (status === "idle" && !blocked) return
|
||||
const cached = untrack(() => sync().data.todo[id] !== undefined)
|
||||
|
||||
todoFrame = requestAnimationFrame(() => {
|
||||
todoFrame = undefined
|
||||
todoTimer = window.setTimeout(() => {
|
||||
todoTimer = undefined
|
||||
if (sdk().directory !== dir || controller.identity.params.id !== id) return
|
||||
untrack(() => {
|
||||
void sync().session.todo(id, cached ? { force: true } : undefined)
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
)
|
||||
// TODO: Restore todo refresh when todos are available from the current session API.
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
@@ -1022,8 +954,8 @@ export default function Page() {
|
||||
|
||||
createEffect(() => {
|
||||
if (!layout.ready()) return
|
||||
if (sync().status !== "complete") return
|
||||
if (!sync().project) return
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
if (!project()) return
|
||||
const list = changesOptions()
|
||||
const mode = reviewMode()
|
||||
if (list.includes(mode)) return
|
||||
@@ -1034,7 +966,7 @@ export default function Page() {
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => sync().data.session_status[controller.identity.params.id ?? ""]?.type,
|
||||
() => data.session.status(controller.identity.params.id ?? ""),
|
||||
(next, prev) => {
|
||||
if (next !== "idle" || prev === undefined || prev === "idle") return
|
||||
refreshVcs()
|
||||
@@ -1416,7 +1348,7 @@ export default function Page() {
|
||||
const dir = sdk().directory
|
||||
if (!isDesktop()) return
|
||||
if (!layout.fileTree.opened()) return
|
||||
if (sync().status === "loading") return
|
||||
if (serverSDK.connection.status() !== "connected") return
|
||||
|
||||
fileTreeTab()
|
||||
const refresh = treeDir !== dir
|
||||
@@ -1612,41 +1544,6 @@ export default function Page() {
|
||||
),
|
||||
)
|
||||
|
||||
const draft = (id: string) =>
|
||||
extractPromptFromParts(sync().data.part[id] ?? [], {
|
||||
directory: sdk().directory,
|
||||
attachmentName: language.t("common.attachment"),
|
||||
})
|
||||
|
||||
const line = (id: string) => {
|
||||
const text = draft(id)
|
||||
.map((part) => (part.type === "image" ? `[image:${part.filename}]` : part.content))
|
||||
.join("")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
if (text) return text
|
||||
return `[${language.t("common.attachment")}]`
|
||||
}
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("common.requestFailed"),
|
||||
description: formatServerError(err, language.t),
|
||||
})
|
||||
}
|
||||
|
||||
const roll = (
|
||||
sessionID: string,
|
||||
next: NonNullable<ReturnType<typeof controller.data.info>>["revert"],
|
||||
target = sync(),
|
||||
) => {
|
||||
const session = target.session.get(sessionID)
|
||||
if (!session) return
|
||||
target.session.remember({ ...session, revert: next })
|
||||
}
|
||||
|
||||
const busy = (sessionID: string) => sync().data.session_working(sessionID)
|
||||
const queuedFollowups = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
if (!id) return emptyFollowups
|
||||
@@ -1667,29 +1564,13 @@ export default function Page() {
|
||||
|
||||
const followupMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; id: string; manual?: boolean }) => {
|
||||
const owner = controller.ownership.capture()
|
||||
const item = (followup.items[input.sessionID] ?? []).find((entry) => entry.id === input.id)
|
||||
if (!item) return
|
||||
if (!(followup.items[input.sessionID] ?? []).some((entry) => entry.id === input.id)) return
|
||||
|
||||
if (input.manual) setFollowup("paused", input.sessionID, undefined)
|
||||
setFollowup("failed", input.sessionID, undefined)
|
||||
|
||||
const ok = await sendFollowupDraft({
|
||||
api: sdk().api.session,
|
||||
sync: sync(),
|
||||
serverSync: serverSync,
|
||||
session: () => sync().session.get(input.sessionID),
|
||||
draft: item,
|
||||
optimisticBusy: item.sessionDirectory === sdk().directory,
|
||||
}).catch((err) => {
|
||||
setFollowup("failed", input.sessionID, input.id)
|
||||
fail(err)
|
||||
return false
|
||||
})
|
||||
if (!ok) return
|
||||
|
||||
setFollowup("items", input.sessionID, (items) => (items ?? []).filter((entry) => entry.id !== input.id))
|
||||
if (input.manual) owner.run(resumeScroll)
|
||||
// TODO: Restore queued followups once submission no longer requires the legacy sync transcript.
|
||||
setFollowup("failed", input.sessionID, input.id)
|
||||
},
|
||||
}))
|
||||
|
||||
@@ -1706,12 +1587,8 @@ export default function Page() {
|
||||
const queueEnabled = createMemo(() => {
|
||||
const id = controller.identity.params.id
|
||||
if (!id) return false
|
||||
return (
|
||||
settings.general.followup() === "queue" &&
|
||||
controller.data.working() &&
|
||||
!composer.blocked() &&
|
||||
!controller.data.isChild()
|
||||
)
|
||||
// TODO: Restore queued followups with current session admission APIs.
|
||||
return false
|
||||
})
|
||||
|
||||
const followupText = (item: FollowupDraft) => {
|
||||
@@ -1743,7 +1620,7 @@ export default function Page() {
|
||||
const followupDock = createMemo(() => queuedFollowups().map((item) => ({ id: item.id, text: followupText(item) })))
|
||||
|
||||
const sendFollowup = (sessionID: string, id: string, opts?: { manual?: boolean }) => {
|
||||
if (sync().session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
if (data.session.get(sessionID)?.parentID) return Promise.resolve()
|
||||
const item = (followup.items[sessionID] ?? []).find((entry) => entry.id === id)
|
||||
if (!item) return Promise.resolve()
|
||||
if (followupBusy(sessionID)) return Promise.resolve()
|
||||
@@ -1774,88 +1651,12 @@ export default function Page() {
|
||||
setFollowup("edit", id, undefined)
|
||||
}
|
||||
|
||||
const halt = (sessionID: string) =>
|
||||
busy(sessionID)
|
||||
? sdk()
|
||||
.api.session.interrupt({ sessionID })
|
||||
.catch(() => {})
|
||||
: Promise.resolve()
|
||||
|
||||
const revertMutation = useMutation(() => ({
|
||||
mutationFn: async (input: { sessionID: string; messageID: string }) => {
|
||||
const api = sdk().api.session
|
||||
const target = sync()
|
||||
const last = target.session.get(input.sessionID)?.revert
|
||||
const value = draft(input.messageID)
|
||||
await runPromptRollbackMutation({
|
||||
capturePrompt: prompt.capture,
|
||||
optimistic: (prompt) => {
|
||||
roll(input.sessionID, { messageID: input.messageID }, target)
|
||||
prompt.set(value)
|
||||
},
|
||||
request: () => halt(input.sessionID).then(() => api.revert.stage(input)),
|
||||
complete: () => undefined,
|
||||
rollback: () => roll(input.sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const restoreMutation = useMutation(() => ({
|
||||
mutationFn: async (id: string) => {
|
||||
const sessionID = controller.identity.params.id
|
||||
if (!sessionID) return
|
||||
|
||||
const api = sdk().api.session
|
||||
const target = sync()
|
||||
const index = userMessages().findIndex((item) => item.id === id)
|
||||
if (index < 0) return
|
||||
const next = userMessages()[index + 1]
|
||||
const last = target.session.get(sessionID)?.revert
|
||||
|
||||
await runPromptRollbackMutation({
|
||||
capturePrompt: prompt.capture,
|
||||
optimistic: (promptSession) => {
|
||||
roll(sessionID, next ? { messageID: next.id } : undefined, target)
|
||||
if (next) {
|
||||
promptSession.set(draft(next.id))
|
||||
return
|
||||
}
|
||||
promptSession.reset()
|
||||
},
|
||||
request: () =>
|
||||
!next
|
||||
? halt(sessionID).then(() => api.revert.clear({ sessionID }))
|
||||
: halt(sessionID).then(() => api.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)),
|
||||
complete: () => undefined,
|
||||
rollback: () => roll(sessionID, last, target),
|
||||
fail,
|
||||
})
|
||||
},
|
||||
}))
|
||||
|
||||
const reverting = createMemo(() => revertMutation.isPending || restoreMutation.isPending)
|
||||
const restoring = createMemo(() => (restoreMutation.isPending ? restoreMutation.variables : undefined))
|
||||
|
||||
const revert = (input: { sessionID: string; messageID: string }) => {
|
||||
if (reverting()) return
|
||||
return revertMutation.mutateAsync(input)
|
||||
}
|
||||
|
||||
const restore = (id: string) => {
|
||||
if (!controller.identity.params.id || reverting()) return
|
||||
return restoreMutation.mutateAsync(id)
|
||||
}
|
||||
|
||||
const rolled = createMemo(() => {
|
||||
const id = controller.data.revertMessageID()
|
||||
if (!id) return []
|
||||
const index = userMessages().findIndex((item) => item.id === id)
|
||||
if (index < 0) return []
|
||||
return userMessages()
|
||||
.slice(index)
|
||||
.map((item) => ({ id: item.id, text: line(item.id) }))
|
||||
})
|
||||
// TODO: Restore revert drafts when current transcript messages expose prompt parts.
|
||||
const reverting = () => false
|
||||
const restoring = () => undefined
|
||||
const revert = (_input: { sessionID: string; messageID: string }) => undefined
|
||||
const restore = (_id: string) => undefined
|
||||
const rolled = () => []
|
||||
|
||||
// attachment bytes are embedded as a data URL, so downloading always works;
|
||||
// revealing requires the on-disk path captured by the client that attached the file
|
||||
@@ -1927,7 +1728,8 @@ export default function Page() {
|
||||
visibleUserMessages,
|
||||
historyMore,
|
||||
historyLoading,
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
// TODO: Restore hash paging when the current message API exposes history cursors.
|
||||
loadMore: async () => undefined,
|
||||
currentMessageId: () => store.messageId,
|
||||
pendingMessage: () => ui.pendingMessage,
|
||||
setPendingMessage: (value) => setUi("pendingMessage", value),
|
||||
@@ -1961,10 +1763,6 @@ export default function Page() {
|
||||
|
||||
onCleanup(() => {
|
||||
if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame)
|
||||
if (todoFrame !== undefined) cancelAnimationFrame(todoFrame)
|
||||
if (todoTimer !== undefined) window.clearTimeout(todoTimer)
|
||||
if (diffFrame !== undefined) cancelAnimationFrame(diffFrame)
|
||||
if (diffTimer !== undefined) window.clearTimeout(diffTimer)
|
||||
if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame)
|
||||
if (fillFrame !== undefined) cancelAnimationFrame(fillFrame)
|
||||
})
|
||||
@@ -2128,9 +1926,12 @@ export default function Page() {
|
||||
const id = controller.data.parentID()
|
||||
if (!id) return
|
||||
navigate(
|
||||
controller.identity.params.serverKey
|
||||
? sessionHref(requireServerKey(controller.identity.params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id),
|
||||
sessionHref(
|
||||
controller.identity.params.serverKey
|
||||
? requireServerKey(controller.identity.params.serverKey)
|
||||
: ServerConnection.key(serverSDK.server),
|
||||
id,
|
||||
),
|
||||
)
|
||||
},
|
||||
setPromptRef: (el) => {
|
||||
|
||||
@@ -4,12 +4,10 @@ import type { ModelKey, ModelSelection } from "@/context/local"
|
||||
import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
|
||||
export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) {
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const models = useModels()
|
||||
const prompt = usePrompt()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
@@ -21,11 +19,8 @@ export function createPromptModelSelection(input: { agent: () => { model?: Model
|
||||
}
|
||||
|
||||
const configured = () => {
|
||||
const value = sync().data.config.model
|
||||
if (!value) return
|
||||
const [providerID, modelID] = value.split("/")
|
||||
const model = { providerID, modelID }
|
||||
if (valid(model)) return model
|
||||
// TODO: Restore the configured model fallback when current location data exposes config.
|
||||
return undefined
|
||||
}
|
||||
|
||||
const recent = () => models.recent.list().find(valid)
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { base64Encode } from "@opencode-ai/core/util/encode"
|
||||
import { createQuery } from "@tanstack/solid-query"
|
||||
import { useNavigate, useSearchParams } from "@solidjs/router"
|
||||
import { type Accessor, createMemo } from "solid-js"
|
||||
import type { PromptInputControls } from "@/components/prompt-input/contracts"
|
||||
@@ -8,43 +7,40 @@ import { useDirectoryPicker } from "@/components/directory-picker"
|
||||
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, useServers } from "@/context/servers"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { pathKey } from "@/utils/path-key"
|
||||
import { useData } from "@/context/server"
|
||||
import { normalizeAgentList } from "@/context/global-sync/utils"
|
||||
|
||||
export function createPromptInputController(input: {
|
||||
sessionKey: Accessor<string>
|
||||
sessionID: Accessor<string | undefined>
|
||||
queryOptions: Pick<QueryOptionsApi, "agents">
|
||||
model?: ModelSelection
|
||||
}) {
|
||||
const layout = useLayout()
|
||||
const local = useLocal()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const view = layout.view(input.sessionKey)
|
||||
const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
|
||||
|
||||
return createMemo<PromptInputControls>(() => {
|
||||
return {
|
||||
agents: {
|
||||
available: sync().data.agent,
|
||||
available: normalizeAgentList(data.location.agent.list({ directory: sdk().directory }) ?? []),
|
||||
options: local.agent.list().map((agent) => agent.name),
|
||||
current: local.agent.current()?.name ?? "",
|
||||
loading: agentsQuery.isLoading,
|
||||
loading: data.location.agent.list({ directory: sdk().directory }) === undefined,
|
||||
visible: local.agent.visible(),
|
||||
select: local.agent.set,
|
||||
},
|
||||
model: {
|
||||
selection: input.model ?? local.model,
|
||||
paid: providers.paid().length > 0,
|
||||
loading: (local.agent.visible() && agentsQuery.isLoading) || !providers.ready(),
|
||||
loading: (local.agent.visible() && data.location.agent.list({ directory: sdk().directory }) === undefined) || !providers.ready(),
|
||||
},
|
||||
session: {
|
||||
id: input.sessionID(),
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useSpring } from "@opencode-ai/ui/motion-spring"
|
||||
import { type Accessor, createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { PromptInputState } from "@/components/prompt-input"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { getSessionHandoff, setSessionHandoff } from "@/pages/session/handoff"
|
||||
import type { SessionComposerController } from "./session-composer-state"
|
||||
|
||||
@@ -39,7 +39,7 @@ export function createSessionComposerRegionController(input: {
|
||||
setPromptRef: (el: HTMLDivElement) => void
|
||||
setDockRef: (el: HTMLDivElement) => void
|
||||
}) {
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const [store, setStore] = createStore({
|
||||
ready: input.ready() || input.state.dock(),
|
||||
height: 320,
|
||||
@@ -104,7 +104,7 @@ export function createSessionComposerRegionController(input: {
|
||||
|
||||
const parentID = createMemo(() => {
|
||||
const id = input.sessionID()
|
||||
return id ? sync().session.get(id)?.parentID : undefined
|
||||
return id ? data.session.get(id)?.parentID : undefined
|
||||
})
|
||||
const open = createMemo(() => store.ready && input.state.dock() && !input.state.closing())
|
||||
const progress = useSpring(
|
||||
|
||||
@@ -4,14 +4,12 @@ import type { Todo } from "@/types"
|
||||
import type { FormInfo, PermissionRequest } from "@opencode-ai/client/promise"
|
||||
import { useParams } from "@solidjs/router"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePermission } from "@/context/permission"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { sessionPermissionRequest, sessionQuestionForm } from "./session-request-tree"
|
||||
import { createQuery, useQueryClient } from "@tanstack/solid-query"
|
||||
import { useData } from "@/context/server"
|
||||
|
||||
export const todoState = (input: {
|
||||
count: number
|
||||
@@ -31,39 +29,21 @@ const idle = { type: "idle" as const }
|
||||
export function createSessionComposerController(options?: { closeMs?: number | (() => number) }) {
|
||||
const params = useParams()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSync = useServerSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const queryClient = useQueryClient()
|
||||
const data = useData()
|
||||
const language = useLanguage()
|
||||
const permission = usePermission()
|
||||
const shellKey = () => [serverSDK.scope, sdk().directory, "shell"] as const
|
||||
const shells = createQuery(() => ({
|
||||
queryKey: shellKey(),
|
||||
enabled: !!params.id && serverSDK.connection.status() === "connected",
|
||||
queryFn: () =>
|
||||
sdk()
|
||||
.api.shell.list({ location: { directory: sdk().directory } })
|
||||
.then((result) => result.data ?? []),
|
||||
}))
|
||||
onCleanup(
|
||||
sdk().event.listen((event) => {
|
||||
if (
|
||||
event.details.type !== "shell.created" &&
|
||||
event.details.type !== "shell.exited" &&
|
||||
event.details.type !== "shell.deleted"
|
||||
)
|
||||
return
|
||||
void queryClient.invalidateQueries({ queryKey: shellKey(), exact: true })
|
||||
}),
|
||||
)
|
||||
createEffect(() => {
|
||||
if (!params.id || serverSDK.connection.status() !== "connected") return
|
||||
void data.shell.sync({ directory: sdk().directory }).catch(() => undefined)
|
||||
})
|
||||
|
||||
const questionRequest = createMemo((): FormInfo | undefined => {
|
||||
return sessionQuestionForm(sync().data.session, serverSync.session.data.form, params.id)
|
||||
return sessionQuestionForm(data.session.list(), data.session.form.list, params.id)
|
||||
})
|
||||
|
||||
const permissionRequest = createMemo((): PermissionRequest | undefined => {
|
||||
return sessionPermissionRequest(sync().data.session, sync().data.permission, params.id, (item) => {
|
||||
return sessionPermissionRequest(data.session.list(), data.session.permission.list, params.id, (item) => {
|
||||
return !permission.autoResponds(item, sdk().directory)
|
||||
})
|
||||
})
|
||||
@@ -74,26 +54,23 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
return !!permissionRequest() || !!questionRequest()
|
||||
})
|
||||
|
||||
const todos = createMemo((): Todo[] => {
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
return serverSync.session.data.todo[id] ?? []
|
||||
})
|
||||
// TODO: Restore todos when they are available from the current session API.
|
||||
const todos = createMemo((): Todo[] => [])
|
||||
|
||||
const done = createMemo(
|
||||
() => todos().length > 0 && todos().every((todo) => todo.status === "completed" || todo.status === "cancelled"),
|
||||
)
|
||||
|
||||
const live = createMemo(() => sync().data.session_working(params.id ?? "") || blocked())
|
||||
const live = createMemo(() => data.session.status(params.id ?? "") === "running" || blocked())
|
||||
const primary = () => {
|
||||
const id = params.id
|
||||
return !!id && !serverSync.session.get(id)?.parentID
|
||||
return !!id && !data.session.get(id)?.parentID
|
||||
}
|
||||
const backgroundBlocking = createMemo(() => {
|
||||
if (!primary()) return []
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const assistant = (serverSync.session.data.session_message[id] ?? []).findLast(
|
||||
const assistant = data.session.message.list(id).findLast(
|
||||
(message) => message.type === "assistant" && message.time.completed === undefined,
|
||||
)
|
||||
if (assistant?.type !== "assistant") return []
|
||||
@@ -116,7 +93,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
const id = params.id
|
||||
if (!id) return []
|
||||
const blocking = backgroundBlocking()
|
||||
const messages = serverSync.session.data.session_message[id] ?? []
|
||||
const messages = data.session.message.list(id)
|
||||
const completed = new Set(
|
||||
messages.flatMap((message) => {
|
||||
if (message.type !== "synthetic") return []
|
||||
@@ -144,9 +121,9 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
]
|
||||
})
|
||||
})
|
||||
const active = Object.values(serverSync.session.data.info).flatMap((info) => {
|
||||
const active = data.session.list().flatMap((info) => {
|
||||
if (info?.parentID !== id) return []
|
||||
if ((serverSync.session.data.session_status[info.id]?.type ?? "idle") === "idle") return []
|
||||
if (data.session.status(info.id) === "idle") return []
|
||||
if (
|
||||
blocking.some(
|
||||
(item) => item.type === "subagent" && (item.id === info.id || (!!item.label && info.title === item.label)),
|
||||
@@ -171,7 +148,7 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
]
|
||||
})
|
||||
})
|
||||
const running = (shells.isSuccess || shells.isRefetchError ? shells.data : []).flatMap((shell) => {
|
||||
const running = data.shell.list({ directory: sdk().directory }).flatMap((shell) => {
|
||||
if (shell.status !== "running" || shell.metadata.sessionID !== id) return []
|
||||
if (
|
||||
blocking.some(
|
||||
@@ -248,13 +225,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
}, closeMs())
|
||||
}
|
||||
|
||||
// Keep stale turn todos from reopening if the model never clears them.
|
||||
const clear = () => {
|
||||
const id = params.id
|
||||
if (!id) return
|
||||
sync().set("todo", id, [])
|
||||
}
|
||||
|
||||
createEffect(
|
||||
on(
|
||||
() => [params.id, todos().length, done(), live()] as const,
|
||||
@@ -272,7 +242,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
setStore({ sessionID: id, dock: todoDockAtBoundary(next), closing: false, opening: false })
|
||||
if (next === "clear") clear()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -286,7 +255,6 @@ export function createSessionComposerController(options?: { closeMs?: number | (
|
||||
if (next === "clear") {
|
||||
if (timer) window.clearTimeout(timer)
|
||||
timer = undefined
|
||||
clear()
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FormInfo, PermissionRequest, SessionInfo } from "@opencode-ai/clie
|
||||
|
||||
function sessionTreeRequest<T>(
|
||||
session: SessionInfo[],
|
||||
request: Record<string, T[] | undefined>,
|
||||
request: Record<string, T[] | undefined> | ((sessionID: string) => T[] | undefined),
|
||||
sessionID?: string,
|
||||
include: (item: T) => boolean = () => true,
|
||||
) {
|
||||
@@ -28,14 +28,15 @@ function sessionTreeRequest<T>(
|
||||
}
|
||||
}
|
||||
|
||||
const id = ids.find((id) => request[id]?.some(include))
|
||||
const list = (id: string) => (typeof request === "function" ? request(id) : request[id])
|
||||
const id = ids.find((id) => list(id)?.some(include))
|
||||
if (!id) return
|
||||
return request[id]?.find(include)
|
||||
return list(id)?.find(include)
|
||||
}
|
||||
|
||||
export function sessionPermissionRequest(
|
||||
session: SessionInfo[],
|
||||
request: Record<string, PermissionRequest[] | undefined>,
|
||||
request: Record<string, PermissionRequest[] | undefined> | ((sessionID: string) => PermissionRequest[] | undefined),
|
||||
sessionID?: string,
|
||||
include?: (item: PermissionRequest) => boolean,
|
||||
) {
|
||||
@@ -44,7 +45,7 @@ export function sessionPermissionRequest(
|
||||
|
||||
export function sessionQuestionForm(
|
||||
session: SessionInfo[],
|
||||
request: Record<string, FormInfo[] | undefined>,
|
||||
request: Record<string, FormInfo[] | undefined> | ((sessionID: string) => FormInfo[] | undefined),
|
||||
sessionID?: string,
|
||||
) {
|
||||
return sessionTreeRequest(session, request, sessionID, (item) => item.metadata?.kind === "question")
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { createEffect, createMemo, For, onCleanup } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { Todo } from "@/types"
|
||||
import { useServerSync } from "@/context/global-sync"
|
||||
import { PromptInput } from "@/components/prompt-input"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import {
|
||||
@@ -152,7 +151,6 @@ const css = `
|
||||
|
||||
export const Playground = {
|
||||
render: () => {
|
||||
const global = useServerSync()
|
||||
const prompt = usePrompt()
|
||||
const [cfg, setCfg] = createStore({
|
||||
open: true,
|
||||
@@ -207,7 +205,7 @@ export const Playground = {
|
||||
})
|
||||
|
||||
createEffect(() => {
|
||||
global.todo.set("story-session", todos())
|
||||
// TODO: Restore story todo updates when the V2 client exposes a todo API.
|
||||
})
|
||||
|
||||
const clear = () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Message, UserMessage } from "@/types"
|
||||
import { createMemo, type Accessor } from "solid-js"
|
||||
import { useFile } from "@/context/file"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { same } from "@/utils/same"
|
||||
import { createSessionTabs } from "./helpers"
|
||||
import {
|
||||
@@ -23,26 +23,24 @@ export function createSessionController(input: {
|
||||
fileBrowser?: (sessionID: string | undefined) => boolean
|
||||
}) {
|
||||
const file = useFile()
|
||||
const sync = useSync()
|
||||
const data = useData()
|
||||
const layout = useSessionLayout()
|
||||
const sessionID = createMemo(() => layout.params.id)
|
||||
const info = createMemo(() => {
|
||||
const id = sessionID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
return id ? data.session.get(id) : undefined
|
||||
})
|
||||
const parentID = createMemo(() => info()?.parentID)
|
||||
const parent = createMemo(() => {
|
||||
const id = parentID()
|
||||
return id ? sync().session.get(id) : undefined
|
||||
return id ? data.session.get(id) : undefined
|
||||
})
|
||||
const status = createMemo(() => {
|
||||
const id = sessionID()
|
||||
return id ? (sync().data.session_status[id] ?? idle) : idle
|
||||
})
|
||||
const messages = createMemo(() => {
|
||||
const id = sessionID()
|
||||
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
|
||||
return id && data.session.status(id) === "running" ? { type: "busy" as const } : idle
|
||||
})
|
||||
// TODO: Restore transcript projections when current session messages can be rendered without V1 Message/Part data.
|
||||
const messages = createMemo(() => emptyMessages)
|
||||
const userMessages = createMemo(() => selectSessionUserMessages(messages()), emptyUserMessages, { equals: same })
|
||||
const revertMessageID = createMemo(() => info()?.revert?.messageID)
|
||||
const visibleUserMessages = createMemo(
|
||||
@@ -75,7 +73,7 @@ export function createSessionController(input: {
|
||||
status,
|
||||
working: createMemo(() => {
|
||||
const id = sessionID()
|
||||
return id ? sync().data.session_working(id) : false
|
||||
return id ? data.session.status(id) === "running" : false
|
||||
}),
|
||||
revertMessageID,
|
||||
},
|
||||
|
||||
@@ -5,23 +5,24 @@ import { DialogFooter, DialogHeader, DialogTitleGroup, DialogV2 } from "@opencod
|
||||
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
|
||||
import { useNavigate } from "@solidjs/router"
|
||||
import { createEffect, createMemo, on, type Accessor } from "solid-js"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { notifySessionTabsRemoved } from "@/components/titlebar-session-events"
|
||||
import { useDialog } from "@opencode-ai/ui/context/dialog"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { usePlatform } from "@/context/platform"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTabs } from "@/context/tabs"
|
||||
import type { SessionController } from "@/pages/session/session-controller"
|
||||
import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { requireServerKey, sessionHref } from "@/utils/session-route"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { sessionTitle } from "@/utils/session-title"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { timelineChildTitle, timelineRemovedSessionIDs } from "./controller-projection"
|
||||
import { createTimelineProjection } from "./projection"
|
||||
import { useServer } from "@/context/server"
|
||||
import { ServerConnection } from "@/context/servers"
|
||||
|
||||
const emptyMessages: Message[] = []
|
||||
const emptyParts: Part[] = []
|
||||
@@ -46,8 +47,9 @@ export function createTimelineController(input: {
|
||||
}) {
|
||||
const navigate = useNavigate()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const serverSDK = useServerSDK()
|
||||
const server = useServer()
|
||||
const data = server.ctx.data
|
||||
const settings = useSettings()
|
||||
const tabs = useTabs()
|
||||
const dialog = useDialog()
|
||||
@@ -60,7 +62,7 @@ export function createTimelineController(input: {
|
||||
const boundary = input.session.history
|
||||
.messages()
|
||||
.find((message) => message.role === "user" && !visible.has(message.id))?.id
|
||||
const projected = sync().data.session_message[id] ?? []
|
||||
const projected = data.session.message.list(id)
|
||||
if (!boundary) return projected
|
||||
const index = projected.findIndex((message) => message.id === boundary)
|
||||
return index < 0 ? projected : projected.slice(0, index)
|
||||
@@ -70,13 +72,14 @@ export function createTimelineController(input: {
|
||||
const shareUrl = (): string | undefined => undefined
|
||||
const shareEnabled = () => false
|
||||
const parentMessages = createMemo(() => {
|
||||
const id = input.session.data.parentID()
|
||||
return id ? (sync().data.message[id] ?? emptyMessages) : emptyMessages
|
||||
// TODO: Restore child task labels when current transcript messages expose the legacy task projection.
|
||||
return emptyMessages
|
||||
})
|
||||
const parentTitle = createMemo(
|
||||
() => sessionTitle(input.session.data.parent()?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const parts = (messageID: string) => sync().data.part[messageID] ?? emptyParts
|
||||
// TODO: Remove legacy Part access once the timeline renders current session message content directly.
|
||||
const parts = (_messageID: string) => emptyParts
|
||||
const part = (messageID: string, partID: string) => parts(messageID).find((item) => item.id === partID)
|
||||
const childTaskDescription = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
@@ -129,12 +132,8 @@ export function createTimelineController(input: {
|
||||
})
|
||||
setPending("rename", false)
|
||||
if (!success) return false
|
||||
sync().set(
|
||||
produce((draft) => {
|
||||
const index = draft.session.findIndex((session) => session.id === id)
|
||||
if (index !== -1) draft.session[index].title = next
|
||||
}),
|
||||
)
|
||||
const current = data.session.get(id)
|
||||
if (current) data.session.remember({ ...current, title: next })
|
||||
return true
|
||||
}
|
||||
const share = async () => {
|
||||
@@ -146,19 +145,22 @@ export function createTimelineController(input: {
|
||||
if (!id || pending.unshare || !shareEnabled()) return
|
||||
}
|
||||
const href = (id: string) =>
|
||||
input.session.identity.params.serverKey
|
||||
? sessionHref(requireServerKey(input.session.identity.params.serverKey), id)
|
||||
: legacySessionHref(sdk().directory, id)
|
||||
sessionHref(
|
||||
input.session.identity.params.serverKey
|
||||
? requireServerKey(input.session.identity.params.serverKey)
|
||||
: ServerConnection.key(serverSDK.server),
|
||||
id,
|
||||
)
|
||||
const navigateAfterRemoval = (id: string, parent?: string, next?: string) => {
|
||||
if (input.session.identity.params.id !== id) return
|
||||
if (parent) return navigate(href(parent))
|
||||
if (next) return navigate(href(next))
|
||||
if (input.session.identity.params.serverKey)
|
||||
return tabs.newDraft({
|
||||
server: requireServerKey(input.session.identity.params.serverKey),
|
||||
directory: sdk().directory,
|
||||
})
|
||||
navigate(`/${input.session.identity.params.dir}/session`)
|
||||
return tabs.newDraft({
|
||||
server: input.session.identity.params.serverKey
|
||||
? requireServerKey(input.session.identity.params.serverKey)
|
||||
: ServerConnection.key(serverSDK.server),
|
||||
directory: sdk().directory,
|
||||
})
|
||||
}
|
||||
const exportSession = async (id: string) => {
|
||||
try {
|
||||
@@ -180,9 +182,9 @@ export function createTimelineController(input: {
|
||||
}
|
||||
}
|
||||
const remove = async (id: string) => {
|
||||
const session = sync().session.get(id)
|
||||
const session = data.session.get(id)
|
||||
if (!session) return false
|
||||
const sessions = sync().data.session.filter((item) => !item.parentID && !item.time?.archived)
|
||||
const sessions = data.session.list().filter((item) => !item.parentID && !item.time?.archived)
|
||||
const index = sessions.findIndex((item) => item.id === id)
|
||||
const next = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
|
||||
const success = await sdk()
|
||||
@@ -193,17 +195,15 @@ export function createTimelineController(input: {
|
||||
return false
|
||||
})
|
||||
if (!success) return false
|
||||
const removed = timelineRemovedSessionIDs(sync().data.session, id)
|
||||
const removed = timelineRemovedSessionIDs(data.session.list(), id)
|
||||
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({ server: server.key, directory: sdk().directory, sessionIDs: [...removed] })
|
||||
return true
|
||||
}
|
||||
|
||||
function DeleteDialog(props: { sessionID: string }) {
|
||||
const name = createMemo(
|
||||
() => sessionTitle(sync().session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
() => sessionTitle(data.session.get(props.sessionID)?.title) ?? language.t("command.session.new"),
|
||||
)
|
||||
const confirm = async () => {
|
||||
await remove(props.sessionID)
|
||||
@@ -253,8 +253,8 @@ export function createTimelineController(input: {
|
||||
on(
|
||||
() => [input.session.data.parentID(), childTaskDescription()] as const,
|
||||
([id, description]) => {
|
||||
if (!id || description || sync().data.message[id] !== undefined) return
|
||||
void sync().session.sync(id)
|
||||
if (!id || description || data.session.message.list(id).length > 0) return
|
||||
void Promise.all([data.session.sync(id), data.session.message.sync(id)])
|
||||
},
|
||||
{ defer: true },
|
||||
),
|
||||
|
||||
@@ -51,9 +51,8 @@ import { useFileComponent } from "@opencode-ai/ui/context/file"
|
||||
import { shouldMarkBoundaryGesture, normalizeWheelDelta } from "@/pages/session/message-gesture"
|
||||
import { SessionContextUsage } from "@/components/session-context-usage"
|
||||
import { useLanguage } from "@/context/language"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useData } from "@/context/server"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { scheduleConnectedMeasure } from "./measure"
|
||||
import { observeElementOffsetReconnectAware } from "./observe-element-offset"
|
||||
import { MessageComment, SummaryDiff, TimelineRow, TimelineRowMap } from "./rows"
|
||||
@@ -414,9 +413,8 @@ function MessageTimelineView(
|
||||
) {
|
||||
let touchGesture: number | undefined
|
||||
const language = useLanguage()
|
||||
const serverSync = useServerSync()
|
||||
const data = useData()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const shouldAnchorBottom = createMemo(() => props.shouldAnchorBottom)
|
||||
const hasScrollGesture = createMemo(() => props.hasScrollGesture)
|
||||
const ownerSessionKey = props.data.sessionKey()
|
||||
@@ -437,7 +435,20 @@ function MessageTimelineView(
|
||||
const getMsgPart = props.data.part
|
||||
const projection = props.data.projection
|
||||
const sessionDirectory = createMemo(() => props.session.data.info()?.location.directory ?? sdk().directory)
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(sync().project, sessionDirectory()))
|
||||
const project = createMemo(() => {
|
||||
const projectID = props.session.data.info()?.projectID
|
||||
const value = projectID
|
||||
? data.project.get(projectID)
|
||||
: data.project.list().find((item) => containsDirectory(item.canonical, sessionDirectory()))
|
||||
if (!value) return
|
||||
return { ...value, worktree: value.canonical, worktrees: [] }
|
||||
})
|
||||
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
|
||||
createEffect(() => {
|
||||
const directory = project()?.worktree
|
||||
if (!directory) return
|
||||
void data.location.vcs.sync({ directory }).catch(() => undefined)
|
||||
})
|
||||
const [workspaceSuggestionDismissed, setWorkspaceSuggestionDismissed] = createSignal(false)
|
||||
const [summaryOpen, setSummaryOpen] = createSignal(false)
|
||||
const setSummary = (open: boolean) => {
|
||||
@@ -1127,7 +1138,7 @@ function MessageTimelineView(
|
||||
diffSummaryRow().userMessageID === props.userMessages.at(-1)?.id &&
|
||||
!workspaceSession() &&
|
||||
props.workspaceMoveEligible &&
|
||||
sync().project?.vcs === "git" &&
|
||||
project()?.vcs === "git" &&
|
||||
sessionStatus().type === "idle"
|
||||
return (
|
||||
<TimelineRowFrame row={diffSummaryRow()}>
|
||||
@@ -1135,7 +1146,7 @@ function MessageTimelineView(
|
||||
<TimelineDiffSummaryRow
|
||||
diffs={diffSummaryRow().diffs}
|
||||
action={
|
||||
<Show when={canMove() && sync().project}>
|
||||
<Show when={canMove() && project()}>
|
||||
{(project) => (
|
||||
<WorkspaceMoveAction
|
||||
variant="inline"
|
||||
@@ -1445,7 +1456,7 @@ function MessageTimelineView(
|
||||
placement="bottom"
|
||||
buttonAppearance={props.data.newLayoutDesigns() ? "v2" : "default"}
|
||||
/>
|
||||
<Show when={props.data.newLayoutDesigns() && !parentID() && sync().project}>
|
||||
<Show when={props.data.newLayoutDesigns() && !parentID() && project()}>
|
||||
{(project) => (
|
||||
<KobaltePopover
|
||||
open={summaryOpen()}
|
||||
@@ -1468,8 +1479,8 @@ function MessageTimelineView(
|
||||
project={project()}
|
||||
directory={sessionDirectory()}
|
||||
local={!workspaceSession()}
|
||||
branch={sync().data.vcs?.branch}
|
||||
baseBranch={serverSync.child(project().worktree)[0].vcs?.branch}
|
||||
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
|
||||
baseBranch={data.location.vcs.info({ directory: project().worktree })?.branch.current}
|
||||
diffs={sessionDiffs()}
|
||||
sessionID={id}
|
||||
moveEligible={props.workspaceMoveEligible}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import type { Message } from "@/types"
|
||||
import { createMemo, createResource, onCleanup, untrack, type Accessor } from "solid-js"
|
||||
import { useServerSync } from "@/context/server-sync"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { createMemo, createResource, type Accessor } from "solid-js"
|
||||
import { useData } from "@/context/server"
|
||||
import type { SessionController } from "../session-controller"
|
||||
|
||||
export {
|
||||
@@ -9,62 +8,28 @@ export {
|
||||
selectVisibleSessionUserMessages as selectVisibleUserMessages,
|
||||
} from "../session-domain"
|
||||
|
||||
const sessionFreshness = 15_000
|
||||
|
||||
export function createTimelineModel(input: { session: Pick<SessionController, "identity" | "history"> }) {
|
||||
const serverSync = useServerSync()
|
||||
const sync = useSync()
|
||||
let refreshFrame: number | undefined
|
||||
let refreshTimer: number | undefined
|
||||
const data = useData()
|
||||
|
||||
const [resource] = createResource(
|
||||
() => input.session.identity.sessionID(),
|
||||
(id) => {
|
||||
clearRefresh()
|
||||
if (!id) return
|
||||
|
||||
const cached = untrack(() => sync().data.message[id] !== undefined)
|
||||
const stale = cached && !serverSync.session.fresh(id, sessionFreshness)
|
||||
|
||||
refreshFrame = requestAnimationFrame(() => {
|
||||
refreshFrame = undefined
|
||||
refreshTimer = window.setTimeout(() => {
|
||||
refreshTimer = undefined
|
||||
if (input.session.identity.sessionID() !== id) return
|
||||
untrack(() => {
|
||||
if (stale) void sync().session.sync(id, { force: true })
|
||||
})
|
||||
}, 0)
|
||||
})
|
||||
|
||||
return sync().session.sync(id)
|
||||
},
|
||||
(id) => (id ? data.session.message.sync(id) : undefined),
|
||||
)
|
||||
const ready = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
return !id || isTimelineReady(sync().data.message[id], serverSync.session.history.loading(id))
|
||||
})
|
||||
const more = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
return id ? sync().session.history.more(id) : false
|
||||
})
|
||||
const loading = createMemo(() => {
|
||||
const id = input.session.identity.sessionID()
|
||||
return id ? sync().session.history.loading(id) : false
|
||||
})
|
||||
const ready = createMemo(() => !input.session.identity.sessionID() || !resource.loading)
|
||||
// TODO: Restore paging when the current message API exposes history cursors.
|
||||
const more = () => false
|
||||
const loading = () => false
|
||||
const loadOlder = async (options?: { before?: () => void; after?: (done: boolean) => void }) => {
|
||||
return loadOlderTimeline({
|
||||
sessionID: input.session.identity.sessionID,
|
||||
more,
|
||||
loading,
|
||||
loadMore: (sessionID) => sync().session.history.loadMore(sessionID),
|
||||
loadMore: async () => undefined,
|
||||
before: options?.before,
|
||||
after: options?.after,
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(clearRefresh)
|
||||
|
||||
return {
|
||||
history: { loadOlder, loading, more },
|
||||
lastUserMessage: input.session.history.lastUserMessage,
|
||||
@@ -75,12 +40,6 @@ export function createTimelineModel(input: { session: Pick<SessionController, "i
|
||||
visibleUserMessages: input.session.history.visibleUserMessages,
|
||||
}
|
||||
|
||||
function clearRefresh() {
|
||||
if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame)
|
||||
if (refreshTimer !== undefined) window.clearTimeout(refreshTimer)
|
||||
refreshFrame = undefined
|
||||
refreshTimer = undefined
|
||||
}
|
||||
}
|
||||
|
||||
export function isTimelineReady(messages: Message[] | undefined, loading: boolean) {
|
||||
|
||||
@@ -9,11 +9,9 @@ import { usePermission } from "@/context/permission"
|
||||
import { usePrompt } from "@/context/prompt"
|
||||
import { useSDK } from "@/context/sdk"
|
||||
import { useSettings } from "@/context/settings"
|
||||
import { useSync } from "@/context/sync"
|
||||
import { useTerminal } from "@/context/terminal"
|
||||
import { showToast } from "@/utils/toast"
|
||||
import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export"
|
||||
import { extractPromptFromParts } from "@/utils/prompt"
|
||||
import type { UserMessage } from "@/types"
|
||||
import type { SessionController } from "./session-controller"
|
||||
|
||||
@@ -53,7 +51,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const prompt = usePrompt()
|
||||
const sdk = useSDK()
|
||||
const settings = useSettings()
|
||||
const sync = useSync()
|
||||
const terminal = useTerminal()
|
||||
const layout = useLayout()
|
||||
const navigate = useNavigate()
|
||||
@@ -62,18 +59,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
const value = await load()
|
||||
owner.run(() => show(value))
|
||||
}
|
||||
const runCommand = async <T,>(input: {
|
||||
owner: ReturnType<SessionController["ownership"]["capture"]>
|
||||
prompt: T
|
||||
request: () => Promise<unknown>
|
||||
updatePrompt: (prompt: T) => void
|
||||
updateViewport: () => void
|
||||
}) => {
|
||||
await input.request()
|
||||
input.updatePrompt(input.prompt)
|
||||
input.owner.run(input.updateViewport)
|
||||
}
|
||||
|
||||
const shown = settings.visibility.fileTree
|
||||
|
||||
const showAllFiles = () => {
|
||||
@@ -101,7 +86,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
}
|
||||
|
||||
const navigateMessageByOffset = actions.navigateMessageByOffset
|
||||
const setActiveMessage = actions.setActiveMessage
|
||||
const focusInput = actions.focusInput
|
||||
|
||||
const sessionCommand = withCategory(language.t("command.category.session"))
|
||||
@@ -289,70 +273,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
})
|
||||
}
|
||||
|
||||
const undo = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
const owner = actions.session.ownership.capture()
|
||||
const session = sdk().api.session
|
||||
const directory = sdk().directory
|
||||
const promptSession = prompt.capture()
|
||||
const revert = actions.session.data.revertMessageID()
|
||||
const messages = actions.session.history.userMessages()
|
||||
const boundary = revert ? messages.findIndex((message) => message.id === revert) : messages.length
|
||||
if (boundary < 0) return
|
||||
const message = messages[boundary - 1]
|
||||
if (!message) return
|
||||
const parts = sync().data.part[message.id]
|
||||
|
||||
if (sync().data.session_working(sessionID)) {
|
||||
await session.interrupt({ sessionID }).catch(() => {})
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => session.revert.stage({ sessionID, messageID: message.id }),
|
||||
updatePrompt: (promptSession) => {
|
||||
if (parts) promptSession.set(extractPromptFromParts(parts, { directory }))
|
||||
},
|
||||
updateViewport: () => setActiveMessage(messages[boundary - 2]),
|
||||
})
|
||||
}
|
||||
|
||||
const redo = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
const owner = actions.session.ownership.capture()
|
||||
const session = sdk().api.session
|
||||
const messages = actions.session.history.userMessages()
|
||||
const promptSession = prompt.capture()
|
||||
|
||||
const revertMessageID = actions.session.data.revertMessageID()
|
||||
if (!revertMessageID) return
|
||||
|
||||
const boundary = messages.findIndex((message) => message.id === revertMessageID)
|
||||
if (boundary < 0) return
|
||||
const next = messages[boundary + 1]
|
||||
if (!next) {
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => session.revert.clear({ sessionID }),
|
||||
updatePrompt: (promptSession) => promptSession.reset(),
|
||||
updateViewport: () => setActiveMessage(messages.at(-1)),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
await runCommand({
|
||||
owner,
|
||||
prompt: promptSession,
|
||||
request: () => session.revert.stage({ sessionID, messageID: next.id }),
|
||||
updatePrompt: () => undefined,
|
||||
updateViewport: () => setActiveMessage(messages[boundary]),
|
||||
})
|
||||
}
|
||||
|
||||
const compact = async () => {
|
||||
const sessionID = actions.session.identity.params.id
|
||||
if (!sessionID) return
|
||||
@@ -371,7 +291,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
|
||||
const shareCmds = () => {
|
||||
// TODO: Restore these commands when the V2 client exposes session sharing.
|
||||
// if (sync().data.config.share === "disabled") return []
|
||||
// Sharing remains disabled until the current API exposes it.
|
||||
return []
|
||||
/*
|
||||
return [
|
||||
@@ -418,16 +338,18 @@ export const useSessionCommands = (actions: SessionCommandContext) => {
|
||||
title: language.t("command.session.undo"),
|
||||
description: language.t("command.session.undo.description"),
|
||||
slash: "undo",
|
||||
disabled: !actions.session.identity.params.id || actions.session.history.visibleUserMessages().length === 0,
|
||||
onSelect: undo,
|
||||
// TODO: Restore undo when current transcript parts can reconstruct the prompt draft.
|
||||
disabled: true,
|
||||
onSelect: () => undefined,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.redo",
|
||||
title: language.t("command.session.redo"),
|
||||
description: language.t("command.session.redo.description"),
|
||||
slash: "redo",
|
||||
disabled: !actions.session.identity.params.id || !actions.session.data.info()?.revert?.messageID,
|
||||
onSelect: redo,
|
||||
// TODO: Restore redo with the current transcript projection.
|
||||
disabled: true,
|
||||
onSelect: () => undefined,
|
||||
}),
|
||||
sessionCommand({
|
||||
id: "session.compact",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"./promise": "./src/promise/index.ts",
|
||||
"./promise/api": "./src/promise/api.ts",
|
||||
"./service": "./src/promise/service.ts",
|
||||
"./solid": "./src/solid/index.ts",
|
||||
"./effect": "./src/effect/index.ts",
|
||||
"./effect/api": "./src/effect/api.ts",
|
||||
"./effect/service": "./src/effect/service.ts"
|
||||
@@ -36,11 +37,15 @@
|
||||
"@opencode-ai/protocol": "workspace:*"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"effect": "4.0.0-beta.101"
|
||||
"effect": "4.0.0-beta.101",
|
||||
"solid-js": ">=1.9.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"effect": {
|
||||
"optional": true
|
||||
},
|
||||
"solid-js": {
|
||||
"optional": true
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -49,6 +54,7 @@
|
||||
"@tsconfig/bun": "catalog:",
|
||||
"@types/bun": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"effect": "catalog:"
|
||||
"effect": "catalog:",
|
||||
"solid-js": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "../promise"
|
||||
|
||||
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type ClientConnectionEvent = {
|
||||
readonly type: "client.connection"
|
||||
readonly created: number
|
||||
readonly data: {
|
||||
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
|
||||
readonly attempt: number
|
||||
readonly error?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type ClientConnectionOptions = {
|
||||
readonly reconnect?: (signal: AbortSignal) => Promise<OpenCodeClient>
|
||||
readonly onEvent: (event: OpenCodeEvent) => void
|
||||
readonly flushInterval?: number
|
||||
readonly pageLifecycle?: boolean
|
||||
readonly log?: {
|
||||
readonly debug?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||
readonly info?: (message: string, data?: Readonly<Record<string, unknown>>) => void
|
||||
}
|
||||
}
|
||||
|
||||
const connectTimeout = 2_000
|
||||
const reconnectDelay = 1_000
|
||||
const connectionHistoryLimit = 50
|
||||
|
||||
type CurrentDelta = Extract<
|
||||
OpenCodeEvent,
|
||||
{ type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" }
|
||||
>
|
||||
|
||||
export function coalesceClientEvents(events: OpenCodeEvent[]) {
|
||||
return events.reduce<OpenCodeEvent[]>((output, event) => {
|
||||
const current = currentDelta(event)
|
||||
const previous = output[output.length - 1]
|
||||
const prior = currentDelta(previous)
|
||||
if (
|
||||
!current ||
|
||||
!prior ||
|
||||
previous?.location?.directory !== event.location?.directory ||
|
||||
currentDeltaKey(prior) !== currentDeltaKey(current)
|
||||
) {
|
||||
output.push(event)
|
||||
return output
|
||||
}
|
||||
const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current)
|
||||
output[output.length - 1] = {
|
||||
...current,
|
||||
data:
|
||||
current.type === "session.compaction.delta"
|
||||
? { ...current.data, text: fragment }
|
||||
: { ...current.data, delta: fragment },
|
||||
} as CurrentDelta
|
||||
return output
|
||||
}, [])
|
||||
}
|
||||
|
||||
function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined {
|
||||
if (
|
||||
event?.type === "session.text.delta" ||
|
||||
event?.type === "session.reasoning.delta" ||
|
||||
event?.type === "session.tool.input.delta" ||
|
||||
event?.type === "session.compaction.delta"
|
||||
)
|
||||
return event
|
||||
}
|
||||
|
||||
function currentDeltaKey(event: CurrentDelta) {
|
||||
if (event.type === "session.tool.input.delta")
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.id}`
|
||||
if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}`
|
||||
return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}`
|
||||
}
|
||||
|
||||
function currentDeltaFragment(event: CurrentDelta) {
|
||||
return event.type === "session.compaction.delta" ? event.data.text : event.data.delta
|
||||
}
|
||||
|
||||
export function createClientConnection(initialApi: OpenCodeClient, options: ClientConnectionOptions) {
|
||||
const abort = new AbortController()
|
||||
const history: ClientConnectionEvent[] = []
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ClientConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({ status: "connecting", attempt: 0 })
|
||||
let api = initialApi
|
||||
let pending: OpenCodeEvent[] = []
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
let stream: AbortController | undefined
|
||||
let run: Promise<void> | undefined
|
||||
let started = false
|
||||
let generation = 0
|
||||
|
||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||
if (history.length > connectionHistoryLimit) history.shift()
|
||||
}
|
||||
|
||||
function publish(event: OpenCodeEvent) {
|
||||
pending.push(event)
|
||||
if (flushTimer) return
|
||||
flushTimer = setTimeout(() => {
|
||||
flushTimer = undefined
|
||||
const events = pending
|
||||
pending = []
|
||||
batch(() => coalesceClientEvents(events).forEach(options.onEvent))
|
||||
}, options.flushInterval ?? 10)
|
||||
}
|
||||
|
||||
async function connect(signal: AbortSignal, attempt: number) {
|
||||
let connectedAt: number | undefined
|
||||
const request = new AbortController()
|
||||
const cancel = () => request.abort(signal.reason)
|
||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
try {
|
||||
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
|
||||
options.log?.info?.("event stream connecting", { attempt })
|
||||
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (first.done)
|
||||
return {
|
||||
error: request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected"),
|
||||
connectedAt,
|
||||
}
|
||||
if (first.value.type !== "server.connected")
|
||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||
|
||||
clearTimeout(timeout)
|
||||
record("connected", attempt)
|
||||
connectedAt = Date.now()
|
||||
options.log?.info?.("event stream connected")
|
||||
publish(first.value)
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
while (!signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
if ("durable" in event.value)
|
||||
options.log?.debug?.("event", {
|
||||
type: event.value.type,
|
||||
aggregateID: event.value.durable.aggregateID,
|
||||
seq: event.value.durable.seq,
|
||||
})
|
||||
publish(event.value)
|
||||
}
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
async function runStream(active: number) {
|
||||
let attempt = 0
|
||||
while (!abort.signal.aborted && started && generation === active) {
|
||||
setConnection({ status: attempt === 0 ? "connecting" : "reconnecting", attempt, error: undefined })
|
||||
const controller = new AbortController()
|
||||
stream = controller
|
||||
const cancel = () => controller.abort(abort.signal.reason)
|
||||
abort.signal.addEventListener("abort", cancel)
|
||||
const result = await connect(controller.signal, attempt)
|
||||
abort.signal.removeEventListener("abort", cancel)
|
||||
if (abort.signal.aborted || !started || generation !== active) return
|
||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= reconnectDelay) attempt = 0
|
||||
attempt += 1
|
||||
const message = errorMessage(result.error)
|
||||
record("disconnected", attempt, message)
|
||||
options.log?.info?.("event stream disconnected", { attempt, error: message })
|
||||
setConnection({ status: "reconnecting", attempt, error: message })
|
||||
|
||||
if (options.reconnect) {
|
||||
const next = await options.reconnect(controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
options.log?.info?.("server resolution failed", { attempt, error: errorMessage(error) })
|
||||
})
|
||||
if (abort.signal.aborted || controller.signal.aborted || !started || generation !== active) return
|
||||
if (next) {
|
||||
api = next
|
||||
if (attempt === 1) continue
|
||||
}
|
||||
}
|
||||
await wait(reconnectDelay, controller.signal)
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (started) return run
|
||||
started = true
|
||||
const active = ++generation
|
||||
const previous = run
|
||||
const current = (async () => {
|
||||
if (previous) await previous
|
||||
await runStream(active)
|
||||
})().finally(() => {
|
||||
if (run !== current) return
|
||||
run = undefined
|
||||
})
|
||||
run = current
|
||||
return run
|
||||
}
|
||||
|
||||
function stop() {
|
||||
started = false
|
||||
generation += 1
|
||||
stream?.abort()
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (options.pageLifecycle) {
|
||||
const pagehide = () => stop()
|
||||
const pageshow = (event: PageTransitionEvent) => {
|
||||
if (event.persisted) void start()
|
||||
}
|
||||
window.addEventListener("pagehide", pagehide)
|
||||
window.addEventListener("pageshow", pageshow)
|
||||
onCleanup(() => {
|
||||
window.removeEventListener("pagehide", pagehide)
|
||||
window.removeEventListener("pageshow", pageshow)
|
||||
})
|
||||
}
|
||||
void start()
|
||||
})
|
||||
|
||||
onCleanup(() => {
|
||||
stop()
|
||||
abort.abort()
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
pending = []
|
||||
})
|
||||
|
||||
return {
|
||||
status: () => connection.status,
|
||||
attempt: () => connection.attempt,
|
||||
error: () => connection.error,
|
||||
internal: {
|
||||
history: () => history.slice(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
if (error === undefined) return undefined
|
||||
if (error instanceof Error) return error.message
|
||||
return String(error)
|
||||
}
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
export * from "./data"
|
||||
export * from "./connection"
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import type { OpenCodeEvent } from "../src/promise"
|
||||
import { coalesceClientEvents } from "../src/solid/connection"
|
||||
|
||||
describe("coalesceClientEvents", () => {
|
||||
const delta = (id: string, value: string, ordinal = 0) =>
|
||||
({
|
||||
id,
|
||||
created: 1,
|
||||
type: "session.text.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", ordinal, delta: value },
|
||||
}) as OpenCodeEvent
|
||||
|
||||
test("merges adjacent deltas for the same stream", () => {
|
||||
const result = coalesceClientEvents([delta("evt_1", "hello "), delta("evt_2", "world")])
|
||||
expect(result).toHaveLength(1)
|
||||
expect(result[0]).toMatchObject({ id: "evt_2", data: { delta: "hello world" } })
|
||||
})
|
||||
|
||||
test("coalesces tool input deltas by tool ID", () => {
|
||||
const current = (eventID: string, id: string, value: string) =>
|
||||
({
|
||||
id: eventID,
|
||||
created: 1,
|
||||
type: "session.tool.input.delta",
|
||||
location: { directory: "/repo" },
|
||||
data: { sessionID: "ses", assistantMessageID: "msg", id, delta: value },
|
||||
}) as OpenCodeEvent
|
||||
const result = coalesceClientEvents([
|
||||
current("evt_1", "call_1", "{"),
|
||||
current("evt_2", "call_1", "}"),
|
||||
current("evt_3", "call_2", "[]"),
|
||||
])
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result[0]).toMatchObject({ id: "evt_2", data: { id: "call_1", delta: "{}" } })
|
||||
expect(result[1]).toMatchObject({ id: "evt_3", data: { id: "call_2", delta: "[]" } })
|
||||
})
|
||||
|
||||
test("preserves boundaries between distinct delta streams", () => {
|
||||
const events = [delta("evt_1", "a"), delta("evt_2", "b", 1), delta("evt_3", "c")]
|
||||
expect(coalesceClientEvents(events).map((event) => event.id)).toEqual(["evt_1", "evt_2", "evt_3"])
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,7 @@ export async function startBackgroundCli(logger: Logger) {
|
||||
? join(app.getPath("userData"), "opencode", "service-local.json")
|
||||
: undefined,
|
||||
version: cli.version,
|
||||
command: [...cli.command, "serve", "--service"],
|
||||
command: [...cli.command, "serve", "--service", ...(isolated ? ["--port", "0"] : [])],
|
||||
onStart: (reason, previousVersion) => logger.log("v2 CLI background service starting", { reason, previousVersion }),
|
||||
})
|
||||
if (service.auth?.type !== "basic") throw new Error("V2 CLI background service did not provide authentication")
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const provider = {
|
||||
all: [
|
||||
{
|
||||
@@ -17,23 +15,6 @@ const provider = {
|
||||
default: { anthropic: "claude-3-7-sonnet" },
|
||||
}
|
||||
|
||||
const [store, setStore] = createStore({
|
||||
provider,
|
||||
session: [] as any[],
|
||||
config: { permission: {} },
|
||||
})
|
||||
|
||||
export function useServerSync() {
|
||||
return {
|
||||
data: {
|
||||
provider,
|
||||
},
|
||||
child() {
|
||||
return [store, setStore] as const
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function useQueryOptions() {
|
||||
return {
|
||||
agents: (directory: string) => ({
|
||||
|
||||
@@ -21,13 +21,3 @@ export function mockProviderAuth(provider: string, methods: ProviderAuthMethod[]
|
||||
delete data.provider_auth[provider]
|
||||
}
|
||||
}
|
||||
|
||||
export function useServerSync() {
|
||||
return () => ({
|
||||
data,
|
||||
set(key: "provider_auth", value: typeof data.provider_auth) {
|
||||
data[key] = value
|
||||
},
|
||||
updateConfig: async () => {},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,37 +1 @@
|
||||
import { createStore } from "solid-js/store"
|
||||
|
||||
const [data, setData] = createStore({
|
||||
session: [] as Array<{ id: string; parentID?: string }>,
|
||||
permission: {} as Record<string, Array<{ id: string; sessionID: string; permission: string; patterns: string[] }>>,
|
||||
question: {} as Record<string, Array<{ id: string; questions: unknown[] }>>,
|
||||
session_diff: {} as Record<string, Array<{ file: string }>>,
|
||||
message: {
|
||||
"story-session": [] as Array<{ id: string; role: string }>,
|
||||
} as Record<string, Array<{ id: string; role: string }>>,
|
||||
session_status: {} as Record<string, { type: "idle" | "busy" }>,
|
||||
session_working: () => false,
|
||||
agent: [{ name: "build", mode: "task", hidden: false }],
|
||||
command: [{ name: "fix", description: "Run fix command", source: "project" }],
|
||||
reference: [],
|
||||
mcp_resource: {},
|
||||
})
|
||||
|
||||
const sync = {
|
||||
data,
|
||||
set(...input: unknown[]) {
|
||||
;(setData as (...args: unknown[]) => void)(...input)
|
||||
},
|
||||
session: {
|
||||
get(id: string) {
|
||||
return { id }
|
||||
},
|
||||
optimistic: {
|
||||
add() {},
|
||||
remove() {},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
export function useSync() {
|
||||
return () => sync
|
||||
}
|
||||
export {}
|
||||
|
||||
@@ -1,177 +1,39 @@
|
||||
import type { OpenCodeClient, OpenCodeEvent } from "@opencode-ai/client"
|
||||
import { createClientConnection } from "@opencode-ai/client/solid"
|
||||
import { createGlobalEmitter } from "@solid-primitives/event-bus"
|
||||
import { batch, onCleanup, onMount } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { errorMessage } from "../util/error"
|
||||
import { onCleanup } from "solid-js"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useLog } from "./log"
|
||||
|
||||
export type ClientConnectionStatus = "connected" | "connecting" | "reconnecting"
|
||||
export type ClientConnectionEvent = {
|
||||
readonly type: "client.connection"
|
||||
readonly created: number
|
||||
readonly data: {
|
||||
readonly status: "connecting" | "connected" | "disconnected" | "reconnecting"
|
||||
readonly attempt: number
|
||||
readonly error?: string
|
||||
}
|
||||
}
|
||||
|
||||
type ManagedService = {
|
||||
reconnect: (signal: AbortSignal) => Promise<{ api: OpenCodeClient }>
|
||||
restart: () => Promise<void>
|
||||
}
|
||||
|
||||
type ClientEventMap = { [Type in OpenCodeEvent["type"]]: Extract<OpenCodeEvent, { type: Type }> }
|
||||
const connectTimeout = 2_000
|
||||
const connectionHistoryLimit = 50
|
||||
const eventFlushInterval = 10
|
||||
|
||||
export const { use: useClient, provider: ClientProvider } = createSimpleContext({
|
||||
name: "Client",
|
||||
init: (props: { api: OpenCodeClient; service?: ManagedService }) => {
|
||||
const log = useLog({ component: "client" })
|
||||
const abort = new AbortController()
|
||||
const history: ClientConnectionEvent[] = []
|
||||
let api = props.api
|
||||
const service = props.service
|
||||
const events = createGlobalEmitter<ClientEventMap>()
|
||||
let pending: OpenCodeEvent[] = []
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
const [connection, setConnection] = createStore<{
|
||||
status: ClientConnectionStatus
|
||||
attempt: number
|
||||
error?: string
|
||||
}>({
|
||||
status: "connecting",
|
||||
attempt: 0,
|
||||
})
|
||||
let stream: AbortController | undefined
|
||||
let api = props.api
|
||||
|
||||
function record(status: ClientConnectionEvent["data"]["status"], attempt: number, error?: string) {
|
||||
history.push({ type: "client.connection", created: Date.now(), data: { status, attempt, error } })
|
||||
if (history.length > connectionHistoryLimit) history.shift()
|
||||
}
|
||||
|
||||
function flushEvents() {
|
||||
flushTimer = undefined
|
||||
const queued = pending
|
||||
pending = []
|
||||
batch(() => queued.forEach((event) => events.emit(event.type, event)))
|
||||
}
|
||||
|
||||
function emit(event: OpenCodeEvent) {
|
||||
pending.push(event)
|
||||
if (flushTimer) return
|
||||
flushTimer = setTimeout(flushEvents, eventFlushInterval)
|
||||
}
|
||||
|
||||
async function connect(signal: AbortSignal, attempt: number) {
|
||||
let connectedAt: number | undefined
|
||||
|
||||
// Bound the initial handshake and tie this request to the stream lifetime.
|
||||
const request = new AbortController()
|
||||
const cancel = () => request.abort(signal.reason)
|
||||
const timeout = setTimeout(() => request.abort(new Error("Timed out connecting to server")), connectTimeout)
|
||||
signal.addEventListener("abort", cancel, { once: true })
|
||||
|
||||
try {
|
||||
// Open the event stream and validate its initial handshake.
|
||||
record(attempt === 0 ? "connecting" : "reconnecting", attempt)
|
||||
log.info("event stream connecting", { attempt })
|
||||
|
||||
const iterator = api.event.subscribe({ signal: request.signal })[Symbol.asyncIterator]()
|
||||
const first = await iterator.next()
|
||||
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (first.done) {
|
||||
const error =
|
||||
request.signal.reason instanceof Error ? request.signal.reason : new Error("Event stream disconnected")
|
||||
return { error, connectedAt }
|
||||
}
|
||||
if (first.value.type !== "server.connected")
|
||||
return { error: new Error("Event stream did not start with server.connected"), connectedAt }
|
||||
|
||||
// Publish the connected state before forwarding live events.
|
||||
clearTimeout(timeout)
|
||||
record("connected", attempt)
|
||||
connectedAt = Date.now()
|
||||
log.info("event stream connected")
|
||||
emit(first.value)
|
||||
setConnection({ status: "connected", attempt: 0, error: undefined })
|
||||
|
||||
// Forward events until the stream closes or this connection is cancelled.
|
||||
while (!signal.aborted) {
|
||||
const event = await iterator.next()
|
||||
|
||||
if (signal.aborted) return { error: undefined, connectedAt }
|
||||
if (event.done) return { error: new Error("Event stream disconnected"), connectedAt }
|
||||
|
||||
if ("durable" in event.value)
|
||||
log.debug("event", {
|
||||
type: event.value.type,
|
||||
aggregateID: event.value.durable.aggregateID,
|
||||
seq: event.value.durable.seq,
|
||||
})
|
||||
|
||||
emit(event.value)
|
||||
}
|
||||
|
||||
return { error: undefined, connectedAt }
|
||||
} catch (error) {
|
||||
return { error, connectedAt }
|
||||
} finally {
|
||||
request.abort()
|
||||
clearTimeout(timeout)
|
||||
signal.removeEventListener("abort", cancel)
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
stream?.abort()
|
||||
const controller = new AbortController()
|
||||
stream = controller
|
||||
void (async () => {
|
||||
let attempt = 0
|
||||
while (!abort.signal.aborted && !controller.signal.aborted) {
|
||||
const result = await connect(controller.signal, attempt)
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (result.connectedAt !== undefined && Date.now() - result.connectedAt >= 1_000) attempt = 0
|
||||
attempt += 1
|
||||
const message = errorMessage(result.error)
|
||||
record("disconnected", attempt, message)
|
||||
log.info("event stream disconnected", {
|
||||
attempt,
|
||||
error: message,
|
||||
})
|
||||
setConnection({ status: "reconnecting", attempt, error: message })
|
||||
// Re-resolve the transport before retrying: the server may have
|
||||
// moved (service restarted on a new port) or need starting. Static
|
||||
// transports (--server, standalone) resolve to the same address.
|
||||
if (props.service) {
|
||||
const next = await props.service.reconnect(controller.signal).catch((error) => {
|
||||
if (!controller.signal.aborted)
|
||||
log.info("server resolution failed", {
|
||||
attempt,
|
||||
error: errorMessage(error),
|
||||
})
|
||||
})
|
||||
if (abort.signal.aborted || controller.signal.aborted) return
|
||||
if (next) {
|
||||
api = next.api
|
||||
if (attempt === 1) continue
|
||||
}
|
||||
const connection = createClientConnection(api, {
|
||||
reconnect: service
|
||||
? async (signal) => {
|
||||
api = (await service.reconnect(signal)).api
|
||||
return api
|
||||
}
|
||||
await wait(1_000, controller.signal)
|
||||
}
|
||||
})()
|
||||
}
|
||||
: undefined,
|
||||
onEvent(event) {
|
||||
events.emit(event.type, event)
|
||||
},
|
||||
log,
|
||||
})
|
||||
|
||||
onMount(start)
|
||||
onCleanup(() => {
|
||||
abort.abort()
|
||||
stream?.abort()
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
pending = []
|
||||
events.clear()
|
||||
})
|
||||
|
||||
@@ -183,35 +45,8 @@ export const { use: useClient, provider: ClientProvider } = createSimpleContext(
|
||||
on: events.on,
|
||||
listen: events.listen,
|
||||
},
|
||||
connection: {
|
||||
status() {
|
||||
return connection.status
|
||||
},
|
||||
attempt() {
|
||||
return connection.attempt
|
||||
},
|
||||
error() {
|
||||
return connection.error
|
||||
},
|
||||
internal: {
|
||||
history() {
|
||||
return history.slice()
|
||||
},
|
||||
},
|
||||
},
|
||||
restart: props.service?.restart,
|
||||
connection,
|
||||
restart: service?.restart,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
function wait(delay: number, signal: AbortSignal) {
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = setTimeout(done, delay)
|
||||
signal.addEventListener("abort", done, { once: true })
|
||||
function done() {
|
||||
clearTimeout(timer)
|
||||
signal.removeEventListener("abort", done)
|
||||
resolve()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1873,15 +1873,14 @@ test("refreshes effective catalog data after catalog updates", async () => {
|
||||
|
||||
test("refreshes agents after agent updates", async () => {
|
||||
const events = createEventStream()
|
||||
let requests = 0
|
||||
let agentID = "build"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== "/api/agent") return
|
||||
requests++
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory } },
|
||||
data: [
|
||||
{
|
||||
id: requests === 1 ? "build" : "reviewer",
|
||||
id: agentID,
|
||||
request: { headers: {}, body: {} },
|
||||
mode: "primary",
|
||||
hidden: false,
|
||||
@@ -1911,6 +1910,11 @@ test("refreshes agents after agent updates", async () => {
|
||||
|
||||
try {
|
||||
await wait(() => data.location.agent.list()?.[0]?.id === "build")
|
||||
await Bun.sleep(20)
|
||||
events.emit({ id: "evt_agent_unlocated", created: 0, type: "agent.updated", data: {} })
|
||||
await Bun.sleep(20)
|
||||
expect(data.location.agent.list()?.[0]?.id).toBe("build")
|
||||
agentID = "reviewer"
|
||||
emitEvent(events, { id: "evt_agent", created: 0, type: "agent.updated", data: {} })
|
||||
await wait(() => data.location.agent.list()?.[0]?.id === "reviewer")
|
||||
} finally {
|
||||
@@ -2801,7 +2805,7 @@ test("renders admitted prompts immediately and tracks them until promoted", asyn
|
||||
await mounted
|
||||
const received: string[] = []
|
||||
const unsubscribe = sync.listen((event) => received.push(event.name))
|
||||
emitEvent(events, {
|
||||
events.emit({
|
||||
id: "evt_admitted_1",
|
||||
created: 0,
|
||||
type: "session.inbox.enqueued",
|
||||
|
||||
@@ -154,6 +154,11 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
||||
return json({ location: { directory, project: { id: "proj_test", directory, canonical: directory } }, data: [] })
|
||||
}
|
||||
if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
|
||||
if (url.pathname === "/api/model/default")
|
||||
return json({
|
||||
location: { directory, project: { id: "proj_test", directory: worktree, canonical: worktree } },
|
||||
data: null,
|
||||
})
|
||||
if (url.pathname === "/session") return json([])
|
||||
if (url.pathname === "/vcs") return json({ branch: "main" })
|
||||
if (url.pathname === "/api/experimental/migration/v1") return json({ status: "completed" })
|
||||
|
||||
Reference in New Issue
Block a user