mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-06 17:06:25 +00:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
44c846e1a4 | ||
|
|
e6930bad7f | ||
|
|
84fe6101ef | ||
|
|
b7f88bbc78 | ||
|
|
008d571539 | ||
|
|
a26a978051 | ||
|
|
218a0dde97 |
@@ -113,8 +113,8 @@ const driver = (options: Options, body: string): WebSocketChannelDriver => {
|
||||
responseID = created
|
||||
return { type: "frame", frame }
|
||||
}
|
||||
// Keepalives carry no response state and may arrive before response.created.
|
||||
if (event.type === "keepalive") return { type: "frame", frame }
|
||||
// Keepalives and provider notifications carry no response state and may precede response.created.
|
||||
if (!event.type.startsWith("response.")) return { type: "frame", frame }
|
||||
if (!responseID)
|
||||
return yield* ProviderShared.eventError(
|
||||
options.id,
|
||||
|
||||
@@ -526,7 +526,7 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("tolerates keepalive frames before response.created", () =>
|
||||
it.effect("tolerates keepalive and provider notifications before response.created", () =>
|
||||
Effect.gen(function* () {
|
||||
const webSocket = WebSocketTransport.makeDirect({
|
||||
open: () =>
|
||||
@@ -534,6 +534,7 @@ describe("OpenAI Responses route", () => {
|
||||
sendText: () => Effect.void,
|
||||
messages: Stream.fromArray([
|
||||
ProviderShared.encodeJson({ type: "keepalive", sequence_number: 0 }),
|
||||
ProviderShared.encodeJson({ type: "codex.rate_limits" }),
|
||||
ProviderShared.encodeJson({ type: "response.created", response: { id: "resp_alive" } }),
|
||||
ProviderShared.encodeJson({
|
||||
type: "response.completed",
|
||||
|
||||
@@ -34,8 +34,6 @@ export const ProviderTipSchema = Persistence.struct({
|
||||
dismissedAt: Schema.Finite,
|
||||
})
|
||||
|
||||
export const WorkspaceTipSchema = ProviderTipSchema
|
||||
|
||||
export function NewSessionView(props: {
|
||||
composer: ComposerModel
|
||||
project: PromptProjectController
|
||||
@@ -101,15 +99,7 @@ export function NewSessionView(props: {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<NewSessionTips
|
||||
workspaceEligible={
|
||||
!!props.project.selected() &&
|
||||
props.workspace.bar.visible() &&
|
||||
props.workspace.selection.value() !== "create" &&
|
||||
props.workspace.project.managed() === 0
|
||||
}
|
||||
onWorkspace={() => select("create")}
|
||||
/>
|
||||
<ProviderTip />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -128,90 +118,56 @@ export function NewSessionStatus(props: { visible: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () => void }) {
|
||||
function ProviderTip() {
|
||||
const language = useLanguage()
|
||||
const dialog = useDialog()
|
||||
const sdk = useWorkspaceLocation()
|
||||
const providers = useProviders(() => sdk().directory)
|
||||
const [providerState, setProviderState, , providerReady] = persisted(
|
||||
const [persistedState, setPersistedState, , persistedReady] = persisted(
|
||||
Persist.global("new-session.provider-tip"),
|
||||
ProviderTipSchema,
|
||||
{ dismissedAt: 0 },
|
||||
)
|
||||
const [workspaceState, setWorkspaceState, , workspaceReady] = persisted(
|
||||
Persist.global("new-session.workspace-tip"),
|
||||
WorkspaceTipSchema,
|
||||
{ dismissedAt: 0 },
|
||||
)
|
||||
const workspaceVisible = createMemo(
|
||||
() =>
|
||||
props.workspaceEligible &&
|
||||
workspaceReady() &&
|
||||
Date.now() - workspaceState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
const providerVisible = createMemo(
|
||||
const visible = createMemo(
|
||||
() =>
|
||||
providers.ready() &&
|
||||
providerReady() &&
|
||||
persistedReady() &&
|
||||
providers.paid().length === 0 &&
|
||||
Date.now() - providerState.dismissedAt >= providerTipDismissalDuration,
|
||||
Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration,
|
||||
)
|
||||
const tip = createMemo<"workspace" | "provider" | undefined>(() => {
|
||||
if (providerVisible()) return "provider"
|
||||
if (workspaceVisible()) return "workspace"
|
||||
})
|
||||
const displayed = createMemo<"workspace" | "provider" | undefined>((previous) => tip() ?? previous)
|
||||
const [ref, setRef] = createSignal<HTMLDivElement>()
|
||||
const presence = createPresence({
|
||||
show: () => tip() !== undefined,
|
||||
show: visible,
|
||||
element: () => ref() ?? null,
|
||||
})
|
||||
const open = () => {
|
||||
const current = tip()
|
||||
if (!current) return
|
||||
if (current === "workspace") {
|
||||
setWorkspaceState("dismissedAt", Date.now())
|
||||
props.onWorkspace()
|
||||
return
|
||||
}
|
||||
const openProviders = () => {
|
||||
void import("@/providers/connect/dialog").then(({ DialogConnectProvider }) => {
|
||||
void dialog.show(() => <DialogConnectProvider directory={sdk().directory} />)
|
||||
})
|
||||
}
|
||||
const dismiss = () => {
|
||||
const current = tip()
|
||||
if (!current) return
|
||||
if (current === "workspace") {
|
||||
setWorkspaceState("dismissedAt", Date.now())
|
||||
return
|
||||
}
|
||||
setProviderState("dismissedAt", Date.now())
|
||||
}
|
||||
|
||||
return (
|
||||
<Show when={presence.present()}>
|
||||
<div class="pointer-events-none absolute inset-x-0 bottom-4 flex justify-center px-10">
|
||||
<div
|
||||
ref={setRef}
|
||||
data-component="new-session-tip"
|
||||
data-visible={tip() !== undefined}
|
||||
class="group/new-session-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
data-component="provider-tip"
|
||||
data-visible={visible()}
|
||||
class="group/provider-tip pointer-events-auto relative flex h-6 max-w-full items-center transition-[opacity,transform] duration-[250ms] ease-[cubic-bezier(0.215,0.61,0.355,1)] motion-reduce:transition-none"
|
||||
classList={{ "data-[visible=false]:animate-out fade-out slide-out-to-bottom-4": true }}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
|
||||
onClick={open}
|
||||
onClick={openProviders}
|
||||
>
|
||||
<span class="truncate">
|
||||
{language.t(displayed() === "workspace" ? "home.workspaceTip" : "home.providerTip")}
|
||||
</span>
|
||||
<span class="truncate">{language.t("home.providerTip")}</span>
|
||||
<span class="flex size-6 shrink-0 items-center justify-center" aria-hidden="true">
|
||||
<Icon name="chevron-down" size="small" class="-rotate-90" />
|
||||
</span>
|
||||
</button>
|
||||
<Tooltip
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/new-session-tip:delay-[250ms] group-hover/new-session-tip:duration-150 group-hover/new-session-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
class="hover-reveal absolute left-full top-0 flex h-6 w-7 items-center justify-end delay-0 duration-0 group-hover/provider-tip:delay-[250ms] group-hover/provider-tip:duration-150 group-hover/provider-tip:opacity-100 focus-within:delay-0 focus-within:duration-0 focus-within:opacity-100"
|
||||
placement="top"
|
||||
openDelay={1000}
|
||||
value={language.t("common.dismiss")}
|
||||
@@ -220,7 +176,7 @@ function NewSessionTips(props: { workspaceEligible: boolean; onWorkspace: () =>
|
||||
type="button"
|
||||
class="flex size-6 items-center justify-center rounded-[4px] text-v2-icon-icon-muted transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-icon-icon-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-icon-icon-base focus-visible:outline-none"
|
||||
aria-label={language.t("common.dismiss")}
|
||||
onClick={dismiss}
|
||||
onClick={() => setPersistedState("dismissedAt", Date.now())}
|
||||
>
|
||||
<Icon name="xmark-small" />
|
||||
</button>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { debounce } from "@solid-primitives/scheduled"
|
||||
import { createEffect, createMemo, createResource, onCleanup } from "solid-js"
|
||||
import { createEffect, createMemo, createResource } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { useWorkspaceLocation } from "@/workspaces/location"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
@@ -11,8 +11,8 @@ import { normalizeProjectInfo } from "@/runtime/server/global-sync/utils"
|
||||
import {
|
||||
isWorkspaceDirectory,
|
||||
isWorkspaceSelection,
|
||||
sameDirectory,
|
||||
workspaceDefaultSelection,
|
||||
workspaceDirectories,
|
||||
workspaceSelectionDestination,
|
||||
} from "@/workspaces/paths"
|
||||
|
||||
@@ -56,45 +56,6 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const current = projectID ? data.project.get(projectID) : undefined
|
||||
return current ? normalizeProjectInfo(current) : undefined
|
||||
})
|
||||
const [worktrees, worktreeActions] = createResource(
|
||||
() => currentProject()?.id,
|
||||
async (projectID) => ({
|
||||
projectID,
|
||||
items: await serverSDK.api.worktree
|
||||
.list({ projectID })
|
||||
.catch(() => (currentProject()?.id === projectID ? currentProject()?.worktrees : undefined) ?? []),
|
||||
}),
|
||||
)
|
||||
onCleanup(
|
||||
serverSDK.event.listen((event) => {
|
||||
if (event.type === "worktree.updated") void worktreeActions.refetch()
|
||||
}),
|
||||
)
|
||||
const worktreeItems = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return []
|
||||
const loaded = worktrees.latest
|
||||
return loaded?.projectID === project.id ? loaded.items : project.worktrees
|
||||
})
|
||||
const worktreeDirectories = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return []
|
||||
const directories = [
|
||||
...worktreeItems().map((item) => item.directory),
|
||||
...project.worktrees.map((item) => item.directory),
|
||||
...(project.sandboxes ?? []),
|
||||
]
|
||||
return directories
|
||||
.filter((directory) => !sameDirectory(project.worktree, directory))
|
||||
.filter((directory, index, items) => items.findIndex((item) => sameDirectory(item, directory)) === index)
|
||||
})
|
||||
const managedWorktrees = createMemo(() => {
|
||||
const project = currentProject()
|
||||
if (!project) return 0
|
||||
return worktreeItems().filter(
|
||||
(item) => item.strategy !== undefined && !sameDirectory(project.worktree, item.directory),
|
||||
).length
|
||||
})
|
||||
const visible = createMemo(() =>
|
||||
resolveNewSessionGit({
|
||||
projectVcs: currentProject()?.vcs,
|
||||
@@ -105,9 +66,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
const project = currentProject()
|
||||
const worktree = input.selectedWorktree()
|
||||
if (!project || !worktree) return
|
||||
return isWorkspaceSelection(project, worktree) || worktreeDirectories().some((item) => sameDirectory(item, worktree))
|
||||
? worktree
|
||||
: undefined
|
||||
return isWorkspaceSelection(project, worktree) ? worktree : undefined
|
||||
})
|
||||
const fallback = createMemo(() => {
|
||||
const project = currentProject()
|
||||
@@ -138,7 +97,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
() => undefined,
|
||||
)
|
||||
const project = currentProject()
|
||||
const directories = project ? [project.worktree, ...worktreeDirectories()] : [sdk().directory]
|
||||
const directories = project ? [project.worktree, ...workspaceDirectories(project)] : [sdk().directory]
|
||||
directories.forEach((directory) => void data.location.vcs.sync({ directory }).catch(() => undefined))
|
||||
})
|
||||
const branch = createMemo(() =>
|
||||
@@ -163,11 +122,7 @@ export function createNewSessionWorkspaceController(input: {
|
||||
workspace: createMemo(() => {
|
||||
const project = currentProject()
|
||||
const current = value()
|
||||
return (
|
||||
current === "create" ||
|
||||
(!!project &&
|
||||
(isWorkspaceDirectory(project, current) || worktreeDirectories().some((item) => sameDirectory(item, current))))
|
||||
)
|
||||
return current === "create" || (!!project && isWorkspaceDirectory(project, current))
|
||||
}),
|
||||
reset: () => {
|
||||
input.setSelectedWorktree(undefined)
|
||||
@@ -187,8 +142,10 @@ export function createNewSessionWorkspaceController(input: {
|
||||
},
|
||||
project: {
|
||||
root: projectRoot,
|
||||
workspaces: worktreeDirectories,
|
||||
managed: managedWorktrees,
|
||||
workspaces: () => {
|
||||
const project = currentProject()
|
||||
return project ? workspaceDirectories(project) : []
|
||||
},
|
||||
git: visible,
|
||||
branches: () => {
|
||||
const current = data.location.vcs.info({ directory: sdk().directory })?.branch.current
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { createMemo, createSignal, For, Show } from "solid-js"
|
||||
import { createMemo, For, Show } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
import { Tooltip } from "@opencode-ai/ui/tooltip"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
@@ -26,9 +25,6 @@ export function PromptWorkspaceSelector(props: {
|
||||
let searchInput: HTMLInputElement | undefined
|
||||
let branchSearchInput: HTMLInputElement | undefined
|
||||
let focusSearch = false
|
||||
const branchTruncation = createTruncatedText()
|
||||
const focusWorktreeSearch = () =>
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => searchInput?.focus({ preventScroll: true })))
|
||||
let pending: { type: "select"; value: string } | { type: "create"; branch: string } | { type: "viewAll" } | undefined
|
||||
const selected = () => (sameDirectory(props.value, props.projectRoot) ? "main" : props.value)
|
||||
const workspaces = createMemo(() => {
|
||||
@@ -38,8 +34,8 @@ export function PromptWorkspaceSelector(props: {
|
||||
})
|
||||
const icon = () => {
|
||||
if (selected() === "main") return "monitor"
|
||||
if (selected() === "create") return "plus"
|
||||
return "outline-worktree"
|
||||
if (selected() === "create") return "workspace-new"
|
||||
return "workspace-isolated"
|
||||
}
|
||||
const select = (value: string) => {
|
||||
pending = { type: "select", value }
|
||||
@@ -70,14 +66,13 @@ export function PromptWorkspaceSelector(props: {
|
||||
<>
|
||||
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
|
||||
<Tooltip
|
||||
appearance={props.onboarding ? "large" : undefined}
|
||||
placement="top"
|
||||
openDelay={800}
|
||||
value={
|
||||
props.onboarding ? (
|
||||
<div class="flex flex-col gap-1 text-start">
|
||||
<div class="flex items-center gap-1.5 font-[530] text-v2-text-text-base">
|
||||
<Icon name="outline-worktree" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<Icon name="workspace-isolated" size="small" class="shrink-0 text-v2-text-text-accent" />
|
||||
<span>{language.t("workspace.onboarding.title")}</span>
|
||||
</div>
|
||||
<span class="font-[440] text-v2-text-text-muted">{language.t("workspace.onboarding.description")}</span>
|
||||
@@ -94,10 +89,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
aria-description={language.t("session.new.workspace.trigger.tooltip")}
|
||||
class="flex h-6 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted"
|
||||
>
|
||||
<Icon
|
||||
name={icon()}
|
||||
class={`shrink-0 ${selected() === "main" || selected() === "create" ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<Icon name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{label()}</span>
|
||||
<Show when={props.onboarding}>
|
||||
<span
|
||||
@@ -117,8 +109,14 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Tooltip
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={language.t("session.new.workspace.local.tooltip")}
|
||||
contentClass="max-w-[140px]"
|
||||
value={
|
||||
<span class="flex flex-col gap-0.5">
|
||||
<span>{language.t("session.new.workspace.local")}</span>
|
||||
<span class="font-[440] text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.local.tooltip")}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("session.new.workspace.local")}</span>
|
||||
@@ -128,22 +126,24 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
<Menu.Item onSelect={() => select("create")}>
|
||||
<Icon name="plus" />
|
||||
<Tooltip
|
||||
placement="right"
|
||||
openDelay={800}
|
||||
value={language.t("session.new.workspace.new.tooltip")}
|
||||
contentClass="max-w-[140px]"
|
||||
class="min-w-0 flex-1"
|
||||
>
|
||||
<span class="min-w-0 truncate">{language.t("workspace.new")}</span>
|
||||
</Tooltip>
|
||||
<Icon name="workspace-new" />
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
|
||||
<Show when={selected() === "create"}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
</Menu.Group>
|
||||
<Show when={props.workspaces.length > 0}>
|
||||
<Show
|
||||
when={props.workspaces.length > 0}
|
||||
fallback={
|
||||
<>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</Menu.Item>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Sub
|
||||
gutter={0}
|
||||
@@ -156,11 +156,10 @@ export function PromptWorkspaceSelector(props: {
|
||||
}
|
||||
if (!focusSearch || props.workspaces.length < 10) return
|
||||
focusSearch = false
|
||||
focusWorktreeSearch()
|
||||
requestAnimationFrame(() => searchInput?.focus())
|
||||
}}
|
||||
>
|
||||
<Menu.SubTrigger
|
||||
onClick={focusWorktreeSearch}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.key === "ArrowRight" ||
|
||||
@@ -171,7 +170,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
focusSearch = true
|
||||
}}
|
||||
>
|
||||
<Icon name="outline-worktree" />
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</span>
|
||||
@@ -206,7 +205,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item onSelect={() => select(workspace)}>
|
||||
<Icon name="outline-worktree" />
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
<Show when={selected() === workspace}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
@@ -214,11 +213,6 @@ export function PromptWorkspaceSelector(props: {
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
<Show when={search.workspaces.trim() && workspaces().length === 0}>
|
||||
<div class="px-3 py-4 text-center text-[13px] font-[440] leading-5 text-v2-text-text-muted">
|
||||
{language.t("session.new.workspace.search.empty")}
|
||||
</div>
|
||||
</Show>
|
||||
<Menu.Separator class="h-[0.5px]" />
|
||||
<Menu.Item onSelect={() => (pending = { type: "viewAll" })}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
@@ -238,7 +232,6 @@ export function PromptWorkspaceSelector(props: {
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
disabled={!branchTruncation.truncated()}
|
||||
class="ms-1 min-w-0 max-w-[220px]"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
@@ -252,7 +245,7 @@ export function PromptWorkspaceSelector(props: {
|
||||
>
|
||||
<Menu.Trigger class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-background-bg-layer-03 hover:text-v2-text-text-muted focus-visible:bg-v2-background-bg-layer-03 focus-visible:text-v2-text-text-muted focus-visible:outline-none data-[expanded]:bg-v2-background-bg-layer-03 data-[expanded]:text-v2-text-text-muted">
|
||||
<Icon name="branch-out" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={branchTruncation.observe} class="min-w-0 truncate">
|
||||
<span class="min-w-0 truncate">
|
||||
{language.t("session.new.workspace.fromBranch", { branch: props.branch! })}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
@@ -326,7 +319,6 @@ export function PromptWorkspaceSelector(props: {
|
||||
|
||||
export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?: boolean; class?: string }) {
|
||||
const language = useLanguage()
|
||||
const truncation = createTruncatedText()
|
||||
const label = () => {
|
||||
if (props.noGit) return language.t("session.new.git.none")
|
||||
if (!props.branch) return undefined
|
||||
@@ -346,27 +338,15 @@ export function PromptGitStatus(props: { branch?: string; noGit?: boolean; from?
|
||||
<Tooltip
|
||||
placement="top"
|
||||
value={value()}
|
||||
disabled={!truncation.truncated()}
|
||||
class={`min-w-0 max-w-[220px] ${props.class ?? ""}`}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<div class="flex h-6 min-w-0 max-w-[220px] items-center gap-1.5 rounded-full bg-v2-background-bg-layer-02 px-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
|
||||
<Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span ref={truncation.observe} class="min-w-0 truncate">
|
||||
{value()}
|
||||
</span>
|
||||
<span class="min-w-0 truncate">{value()}</span>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
)
|
||||
}
|
||||
|
||||
function createTruncatedText() {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
return {
|
||||
truncated,
|
||||
observe: (element: HTMLSpanElement) =>
|
||||
createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -659,7 +659,6 @@ export const dict = {
|
||||
"home.sessions.group.yesterday": "Yesterday",
|
||||
"home.sessions.group.older": "Older",
|
||||
"home.providerTip": "Connect to 75+ providers to use other models, including Claude, GPT, Gemini, etc",
|
||||
"home.workspaceTip": "Start next session in a new workspace to keep changes isolated",
|
||||
|
||||
"session.tab.session": "Session",
|
||||
"session.tab.files": "Files",
|
||||
@@ -1239,43 +1238,30 @@ export const dict = {
|
||||
|
||||
"workspace.new": "New worktree",
|
||||
"common.viewAll": "View all",
|
||||
"session.new.workspace.local.tooltip": "Uses project’s current checkout",
|
||||
"session.new.workspace.new.tooltip": "Creates isolated copy from current checkout",
|
||||
"session.new.workspace.local.tooltip": "Use current checkout",
|
||||
"session.new.workspace.new.tooltip": "Create isolated checkout",
|
||||
"session.new.workspace.fromBranch": "from {{branch}}",
|
||||
"session.new.workspace.createFrom": "Create from branch",
|
||||
"session.new.workspace.branch.search.placeholder": "Search branches",
|
||||
"session.new.workspace.trigger.tooltip": "Select where to run session",
|
||||
"session.new.workspace.search.placeholder": "Search worktrees",
|
||||
"session.new.workspace.search.empty": "No matching worktrees",
|
||||
"settings.tab.workspaces": "Worktrees",
|
||||
"settings.workspaces.description": "Review worktrees and manage disk usage",
|
||||
"settings.workspaces.filter.all": "All projects",
|
||||
"settings.workspaces.empty": "No worktrees yet",
|
||||
"settings.workspaces.empty.description": "Worktrees created in OpenCode will appear here",
|
||||
"settings.workspaces.empty": "No worktrees",
|
||||
"settings.workspaces.count.one": "{{count}} worktree",
|
||||
"settings.workspaces.count.other": "{{count}} worktrees",
|
||||
"settings.workspaces.sessions.one": "{{count}} session in {{project}}",
|
||||
"settings.workspaces.sessions.other": "{{count}} sessions in {{project}}",
|
||||
"settings.workspaces.sessions.filtered.one": "{{count}} session",
|
||||
"settings.workspaces.sessions.filtered.other": "{{count}} sessions",
|
||||
"settings.workspaces.lastActiveSession": "Last active session",
|
||||
"settings.workspaces.deleteAll": "Delete all worktrees",
|
||||
"settings.workspaces.deleteAll.confirm.one": "This will permanently delete {{count}} selected worktree.",
|
||||
"settings.workspaces.deleteAll.confirm.other": "This will permanently delete {{count}} selected worktrees.",
|
||||
"settings.workspaces.deleteWithoutSessions": "Delete worktrees without sessions",
|
||||
"settings.workspaces.deleteWithoutSessions.confirm.one":
|
||||
"This will permanently delete {{count}} worktree without linked sessions.",
|
||||
"settings.workspaces.deleteWithoutSessions.confirm.other":
|
||||
"This will permanently delete {{count}} worktrees without linked sessions.",
|
||||
"settings.workspaces.deleteWithoutSessions.warning":
|
||||
"Worktrees with unmerged changes or active locations will be kept.",
|
||||
"settings.workspaces.delete.button": "Delete worktrees",
|
||||
"settings.workspaces.delete.warning": "This permanently deletes the worktree and its branch.",
|
||||
"settings.workspaces.deleteAll.confirm": "Delete all {{count}} worktrees?",
|
||||
"settings.workspaces.delete.warning":
|
||||
"The worktree directory and branch will be permanently removed, including any unmerged changes shown below.",
|
||||
"settings.workspaces.deleteAll.warning":
|
||||
"Worktrees with unmerged changes, linked sessions, or active locations will be kept.",
|
||||
"The {{count}} selected worktrees in {{project}} will be permanently removed only if each is clean, inactive, and has no linked sessions.",
|
||||
"settings.workspaces.delete.blocked.active": "The active worktree cannot be deleted.",
|
||||
"settings.workspaces.delete.blocked.linked":
|
||||
"Linked sessions will remain, but their working directory will no longer exist.",
|
||||
"Linked sessions will remain, but their working directory will be permanently removed.",
|
||||
"settings.workspaces.default.title": "Default environment",
|
||||
"settings.workspaces.default.description": "Choose where new sessions start",
|
||||
"settings.workspaces.default.lastUsed": "Last used per project",
|
||||
@@ -1286,10 +1272,9 @@ export const dict = {
|
||||
"workspace.move.failed": "Failed to move session",
|
||||
"workspace.lifecycle.creating": "Creating worktree",
|
||||
"workspace.lifecycle.created": "Worktree created",
|
||||
"workspace.lifecycle.deleting": "Deleting…",
|
||||
"workspace.lifecycle.starting": "Starting session",
|
||||
"workspace.onboarding.title": "Isolate sessions with worktrees",
|
||||
"workspace.onboarding.description": "Each gets its own checkout",
|
||||
"workspace.onboarding.description": "Each gets its own checkout, so nothing interferes with your local repository",
|
||||
"workspace.lifecycle.moving": "Moving to worktree",
|
||||
"workspace.lifecycle.set": "Worktree set",
|
||||
"session.summary.title": "Session details",
|
||||
@@ -1308,10 +1293,9 @@ export const dict = {
|
||||
"workspace.status.checking": "Checking for unmerged changes…",
|
||||
"workspace.status.error": "Unable to verify git status.",
|
||||
"workspace.status.clean": "No unmerged changes detected.",
|
||||
"workspace.status.dirty": "Unmerged changes will be lost.",
|
||||
"workspace.status.dirty": "Unmerged changes detected in this worktree.",
|
||||
"workspace.delete.title": "Delete worktree",
|
||||
"workspace.delete.confirm": "Delete “{{name}}”?",
|
||||
"workspace.delete.location": "Location",
|
||||
"workspace.delete.confirm": 'Delete worktree "{{name}}"?',
|
||||
"workspace.delete.button": "Delete worktree",
|
||||
"workspace.reset.title": "Reset worktree",
|
||||
"workspace.reset.confirm": 'Reset worktree "{{name}}"?',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { Schema } from "effect"
|
||||
import { WorkspaceOnboardingSchema, ProviderTipSchema, WorkspaceTipSchema } from "@/new-session/view"
|
||||
import { WorkspaceOnboardingSchema, ProviderTipSchema } from "@/new-session/view"
|
||||
import { ModelSelectionSchema } from "@/providers/models/selection"
|
||||
import { Persistence } from "@/runtime/persistence/schema"
|
||||
import { FileViewsSchema } from "@/workspaces/files/view-cache"
|
||||
@@ -12,7 +12,6 @@ describe("persisted consumer schemas", () => {
|
||||
test("onboarding and provider tip retain defaults and validate stored values", () => {
|
||||
const onboarding = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceOnboardingSchema, { used: false }))
|
||||
const tip = Schema.decodeUnknownSync(Persistence.withInitial(ProviderTipSchema, { dismissedAt: 0 }))
|
||||
const workspaceTip = Schema.decodeUnknownSync(Persistence.withInitial(WorkspaceTipSchema, { dismissedAt: 0 }))
|
||||
expect(onboarding({})).toEqual({ used: false })
|
||||
expect(onboarding({ used: "true" })).toEqual({ used: false })
|
||||
expect(onboarding({ used: true })).toEqual({ used: true })
|
||||
@@ -20,7 +19,6 @@ describe("persisted consumer schemas", () => {
|
||||
expect(tip({ dismissedAt: "yesterday" })).toEqual({ dismissedAt: 0 })
|
||||
expect(tip({ dismissedAt: Infinity })).toEqual({ dismissedAt: 0 })
|
||||
expect(tip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
|
||||
expect(workspaceTip({ dismissedAt: 123 })).toEqual({ dismissedAt: 123 })
|
||||
})
|
||||
|
||||
test("collapse records recover malformed entries without losing valid siblings", () => {
|
||||
|
||||
@@ -91,7 +91,7 @@ export function SessionProjectMenu(props: {
|
||||
when={props.showProjectIcon}
|
||||
fallback={
|
||||
<span class={props.workspace ? "text-v2-icon-icon-accent" : "text-v2-icon-icon-muted"}>
|
||||
<Icon name={props.workspace ? "outline-worktree" : "monitor"} />
|
||||
<Icon name={props.workspace ? "workspace-isolated" : "monitor"} />
|
||||
</span>
|
||||
}
|
||||
>
|
||||
|
||||
@@ -185,7 +185,7 @@ function WorkspaceMoveAction(props: {
|
||||
: "flex h-[46px] w-full items-center gap-2 rounded-b-[6px] px-3 pe-9 pt-2.5 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted focus-visible:outline-none"
|
||||
}
|
||||
>
|
||||
<Icon name="outline-worktree" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<Icon name="workspace-new" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span class="min-w-0 truncate">{language.t("workspace.move.title")}</span>
|
||||
</SessionWorkspaceMenu>
|
||||
<button
|
||||
@@ -257,10 +257,7 @@ export function SessionSummaryPanel(props: {
|
||||
gutter={props.mobile ? 4 : -22}
|
||||
class={`${row} hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed`}
|
||||
>
|
||||
<Icon
|
||||
name={props.local ? "monitor" : "outline-worktree"}
|
||||
class={`shrink-0 ${props.local ? "text-v2-icon-icon-muted" : "text-v2-icon-icon-accent"}`}
|
||||
/>
|
||||
<Icon name={props.local ? "monitor" : "workspace-isolated"} class="shrink-0 text-v2-icon-icon-muted" />
|
||||
<span dir="auto" class="min-w-0 flex-1 truncate text-start">
|
||||
{location()}
|
||||
</span>
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { Project } from "@/runtime/server/types"
|
||||
import { useLanguage } from "@/runtime/i18n/language"
|
||||
import { useServerSDK } from "@/runtime/server/client"
|
||||
import { useData } from "@/runtime/server/current"
|
||||
import { useSettingsDialog } from "@/settings/command"
|
||||
import { pathKey } from "@/workspaces/path-key"
|
||||
import { showToast } from "@/shell/notifications/toast"
|
||||
import { containsDirectory, sameDirectory, workspaceDirectories } from "@/workspaces/paths"
|
||||
@@ -27,6 +28,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
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 || data.session.status(props.sessionID) === "running"
|
||||
@@ -98,13 +100,13 @@ export function SessionWorkspaceMenu(props: {
|
||||
</Menu.Item>
|
||||
</Show>
|
||||
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move("create")}>
|
||||
<Icon name="plus" />
|
||||
<Icon name="workspace-new" />
|
||||
{language.t("workspace.new")}
|
||||
</Menu.Item>
|
||||
<Show when={workspaces().length > 0}>
|
||||
<Menu.Sub gutter={0} overlap overflowPadding={8}>
|
||||
<Menu.SubTrigger>
|
||||
<Icon name="outline-worktree" />
|
||||
<Icon name="workspace-isolated" />
|
||||
{language.t("session.new.workspace.existing").replace(/(…|\.{3})$/, "")}
|
||||
</Menu.SubTrigger>
|
||||
<Menu.Portal>
|
||||
@@ -112,7 +114,7 @@ export function SessionWorkspaceMenu(props: {
|
||||
<For each={workspaces()}>
|
||||
{(workspace) => (
|
||||
<Menu.Item disabled={!!store.selected || blocked()} onSelect={() => void move(workspace)}>
|
||||
<Icon name="outline-worktree" />
|
||||
<Icon name="workspace-isolated" />
|
||||
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
|
||||
</Menu.Item>
|
||||
)}
|
||||
@@ -122,6 +124,10 @@ export function SessionWorkspaceMenu(props: {
|
||||
</Menu.Sub>
|
||||
</Show>
|
||||
</Menu.Group>
|
||||
<Menu.Separator class="h-[0.5px] bg-v2-border-border-base" />
|
||||
<Menu.Item onSelect={() => openWorkspaces()}>
|
||||
<span class="min-w-0 flex-1 truncate">{language.t("common.viewAll")}</span>
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
|
||||
@@ -1029,92 +1029,25 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
background-color: var(--v2-background-bg-layer-01);
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
box-shadow: inset 0 0 0 0.5px var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-workspaces-inventory[data-empty="true"] [data-component="settings-list"] {
|
||||
padding: 0;
|
||||
background-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion {
|
||||
display: grid;
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
opacity: 1;
|
||||
transition:
|
||||
grid-template-rows 150ms ease-out,
|
||||
opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion[data-removing="true"] {
|
||||
grid-template-rows: minmax(0, 0fr);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.settings-workspaces-row {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
padding-bottom 150ms ease-out,
|
||||
margin-bottom 150ms ease-out,
|
||||
border-color 120ms ease-out;
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion:not(:last-child) > .settings-workspaces-row {
|
||||
.settings-workspaces-row:not(:last-child) {
|
||||
padding-bottom: 20px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 0.5px solid var(--v2-border-border-base);
|
||||
}
|
||||
|
||||
.settings-workspaces-row-motion:has(+ .settings-workspaces-row-motion[data-removing="true"]:last-child)
|
||||
> .settings-workspaces-row {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: 0;
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-motion {
|
||||
display: grid;
|
||||
min-height: 0;
|
||||
grid-template-rows: minmax(0, 0fr);
|
||||
opacity: 0;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
transition:
|
||||
grid-template-rows 150ms ease-out,
|
||||
opacity 120ms ease-out;
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-motion[data-visible="true"] {
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-motion > .settings-workspaces-empty {
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.settings-workspaces-row-motion {
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.settings-workspaces-row,
|
||||
.settings-workspaces-empty-motion {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
.settings-workspaces-row-header {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
@@ -1155,7 +1088,7 @@
|
||||
color: var(--v2-text-text-base);
|
||||
font-family: inherit;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
font-weight: 530;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
text-overflow: ellipsis;
|
||||
@@ -1165,11 +1098,6 @@
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.settings-workspaces-path-name {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
.settings-workspaces-meta {
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
@@ -1177,10 +1105,6 @@
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-workspaces-meta-project {
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-workspaces-active,
|
||||
.settings-workspaces-more {
|
||||
flex-shrink: 0;
|
||||
@@ -1190,16 +1114,11 @@
|
||||
color: var(--v2-text-text-faint);
|
||||
}
|
||||
|
||||
.settings-workspaces-active {
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
}
|
||||
|
||||
.settings-workspaces-sessions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 0.5px solid var(--v2-border-border-base);
|
||||
border-radius: 6px;
|
||||
border-radius: 4px;
|
||||
background-color: var(--v2-background-bg-base);
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -1210,8 +1129,7 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-block: 10px;
|
||||
padding-inline: 12px 16px;
|
||||
padding: 10px 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: 16px;
|
||||
@@ -1231,28 +1149,22 @@
|
||||
|
||||
.settings-workspaces-session-time {
|
||||
flex-shrink: 0;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-faint);
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-workspaces-empty {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-block: 48px;
|
||||
font-size: 13px;
|
||||
font-weight: 440;
|
||||
line-height: var(--line-height-compact);
|
||||
color: var(--v2-text-text-muted);
|
||||
}
|
||||
|
||||
.settings-workspaces-empty-title {
|
||||
color: var(--v2-text-text-base);
|
||||
font-weight: 530;
|
||||
}
|
||||
|
||||
@media (max-width: 639px) {
|
||||
.settings-workspaces-header {
|
||||
padding: 24px 20px 20px;
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { Component, createEffect, createMemo, For, Show, onMount, startTransition } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { Tabs } from "@opencode-ai/ui/tabs"
|
||||
import { Icon } from "@opencode-ai/ui/icon"
|
||||
import { Menu } from "@opencode-ai/ui/menu"
|
||||
@@ -36,7 +35,7 @@ const sections = [
|
||||
[
|
||||
{ value: "servers", icon: "server", label: "status.popover.tab.servers" },
|
||||
{ value: "projects", icon: "folder", label: "settings.tab.projects" },
|
||||
{ value: "workspaces", icon: "outline-worktree", label: "settings.tab.workspaces" },
|
||||
{ value: "workspaces", icon: "workspace-isolated", label: "settings.tab.workspaces" },
|
||||
],
|
||||
[
|
||||
{ value: "providers", icon: "providers", label: "settings.providers.title" },
|
||||
@@ -55,7 +54,6 @@ export const SettingsScreen: Component = () => {
|
||||
const servers = useServers()
|
||||
const tabs = useTabs()
|
||||
const global = useGlobal()
|
||||
const [state, setState] = createStore({ worktreeFilterReset: 0 })
|
||||
let root: HTMLDivElement | undefined
|
||||
|
||||
onMount(() => {
|
||||
@@ -148,14 +146,7 @@ export const SettingsScreen: Component = () => {
|
||||
</Show>
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Menu.RadioItem
|
||||
value={section.value}
|
||||
closeOnSelect
|
||||
onSelect={() => {
|
||||
if (section.value === "workspaces")
|
||||
setState("worktreeFilterReset", (value) => value + 1)
|
||||
}}
|
||||
>
|
||||
<Menu.RadioItem value={section.value} closeOnSelect>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Menu.RadioItem>
|
||||
@@ -181,12 +172,7 @@ export const SettingsScreen: Component = () => {
|
||||
<div class="flex flex-col gap-1 w-full">
|
||||
<For each={group}>
|
||||
{(section) => (
|
||||
<Tabs.Trigger
|
||||
value={section.value}
|
||||
onClick={() => {
|
||||
if (section.value === "workspaces") setState("worktreeFilterReset", (value) => value + 1)
|
||||
}}
|
||||
>
|
||||
<Tabs.Trigger value={section.value}>
|
||||
<Icon name={section.icon} />
|
||||
{language.t(section.label)}
|
||||
</Tabs.Trigger>
|
||||
@@ -222,10 +208,7 @@ export const SettingsScreen: Component = () => {
|
||||
</Tabs.Content>
|
||||
<SettingsServerScope directory={directory()}>
|
||||
<Tabs.Content value="workspaces" class="settings-panel">
|
||||
<SettingsWorkspaces
|
||||
activeDirectory={directory()}
|
||||
resetProjectFilter={() => state.worktreeFilterReset}
|
||||
/>
|
||||
<SettingsWorkspaces activeDirectory={directory()} />
|
||||
</Tabs.Content>
|
||||
<Tabs.Content value="providers" class="settings-panel">
|
||||
<SettingsProviders directory={directory()} onBack={showProviders} />
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import type { Component } from "solid-js"
|
||||
import { For, Show, createEffect, createMemo, createSignal } from "solid-js"
|
||||
import { For, Show, createMemo } from "solid-js"
|
||||
import { createStore } from "solid-js/store"
|
||||
import { createResizeObserver } from "@solid-primitives/resize-observer"
|
||||
import { Key } from "@solid-primitives/keyed"
|
||||
import type { SessionInfo } from "@opencode-ai/client/promise"
|
||||
import { useQuery } from "@tanstack/solid-query"
|
||||
import { Button } from "@opencode-ai/ui/button"
|
||||
@@ -48,7 +46,7 @@ type Workspace = {
|
||||
project: Project
|
||||
}
|
||||
|
||||
export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProjectFilter: () => number }> = (props) => {
|
||||
export const SettingsWorkspaces: Component<{ activeDirectory?: string }> = (props) => {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const serverSDK = useServerSDK()
|
||||
@@ -58,12 +56,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
const [store, setStore] = createStore({
|
||||
project: "all",
|
||||
transaction: undefined as "confirm" | "running" | undefined,
|
||||
deleting: [] as string[],
|
||||
removing: [] as string[],
|
||||
})
|
||||
createEffect(() => {
|
||||
props.resetProjectFilter()
|
||||
setStore("project", "all")
|
||||
})
|
||||
|
||||
const projectQuery = useQuery(() => ({
|
||||
@@ -119,7 +111,6 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
] as const,
|
||||
queryFn: () => loadSessions(workspaceDirectories()),
|
||||
enabled: serverSDK.connection.status() === "connected" && workspaceDirectories().length > 0,
|
||||
placeholderData: (previous) => previous,
|
||||
refetchOnMount: "always",
|
||||
}))
|
||||
const sessionsByWorkspace = createMemo(() => {
|
||||
@@ -132,29 +123,14 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
)
|
||||
})
|
||||
const workspaceSessions = (workspace: Workspace) => sessionsByWorkspace().get(pathKey(workspace.directory)) ?? []
|
||||
const workspacesWithoutSessions = createMemo(() => {
|
||||
if (sessionQuery.isPending || sessionQuery.isError) return []
|
||||
return filtered().filter((workspace) => workspaceSessions(workspace).length === 0)
|
||||
})
|
||||
const sessionCount = (workspace: Workspace) => {
|
||||
if (sessionQuery.isPending) return language.t("session.messages.loading")
|
||||
if (sessionQuery.isError) return language.t("common.requestFailed")
|
||||
const count = workspaceSessions(workspace).length
|
||||
if (selectedProject() !== "all") return language.plural("settings.workspaces.sessions.filtered", count, { count })
|
||||
const project = projectName(workspace.project)
|
||||
const label = language.plural("settings.workspaces.sessions", count, {
|
||||
return language.plural("settings.workspaces.sessions", count, {
|
||||
count,
|
||||
project,
|
||||
project: projectName(workspace.project),
|
||||
})
|
||||
const start = label.lastIndexOf(project)
|
||||
if (start < 0) return label
|
||||
return (
|
||||
<>
|
||||
{label.slice(0, start)}
|
||||
<span class="settings-workspaces-meta-project">{project}</span>
|
||||
{label.slice(start + project.length)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
const lastActive = (workspace: Workspace) => {
|
||||
const updated = workspaceSessions(workspace)[0]?.time.updated
|
||||
@@ -182,64 +158,54 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
}
|
||||
const inspectionMessages = (result: WorkspaceDeleteInspection) => {
|
||||
const messages = [
|
||||
result.active ? language.t("settings.workspaces.delete.blocked.active") : undefined,
|
||||
result.linked ? language.t("settings.workspaces.delete.blocked.linked") : undefined,
|
||||
result.dirty ? language.t("workspace.status.dirty") : undefined,
|
||||
].filter((message): message is string => message !== undefined)
|
||||
return messages
|
||||
return messages.length > 0 ? messages : [language.t("workspace.status.clean")]
|
||||
}
|
||||
const blocked = (result: WorkspaceDeleteInspection) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: result.active
|
||||
? language.t("settings.workspaces.delete.blocked.active")
|
||||
: inspectionMessages(result)[0],
|
||||
description: inspectionMessages(result)[0],
|
||||
})
|
||||
}
|
||||
|
||||
const remove = async (workspace: Workspace, force = false, context = captureDeleteContext()) => {
|
||||
const key = String(pathKey(workspace.directory))
|
||||
setStore("deleting", (items) => [...items, key])
|
||||
try {
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (!force && (preflight.result.active || preflight.result.linked || preflight.result.dirty)) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
projectID: workspace.project.id,
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
if (!removed) return
|
||||
setStore("removing", (items) => [...items, key])
|
||||
await new Promise((resolve) => setTimeout(resolve, 150))
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||
if (!directoryMatches && !worktreeMatches) return
|
||||
tabs.updateDraft(tab.draftID, {
|
||||
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||
worktree: undefined,
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
|
||||
await projectQuery.refetch()
|
||||
} finally {
|
||||
setStore("deleting", (items) => items.filter((item) => item !== key))
|
||||
setStore("removing", (items) => items.filter((item) => item !== key))
|
||||
const preflight = await inspect(workspace, context)
|
||||
if (preflight.result.active || (!force && (preflight.result.linked || preflight.result.dirty))) {
|
||||
blocked(preflight.result)
|
||||
return
|
||||
}
|
||||
const removed = await context.sdk.api.worktree
|
||||
.remove({
|
||||
projectID: workspace.project.id,
|
||||
directory: workspace.directory,
|
||||
force,
|
||||
})
|
||||
.then(() => true)
|
||||
.catch((error) => {
|
||||
showToast({
|
||||
variant: "error",
|
||||
title: language.t("workspace.delete.failed.title"),
|
||||
description: error instanceof Error ? error.message : language.t("common.requestFailed"),
|
||||
})
|
||||
return false
|
||||
})
|
||||
if (!removed) return
|
||||
tabs.store.forEach((tab) => {
|
||||
if (tab.type !== "draft" || tab.server !== context.server) return
|
||||
const directoryMatches = containsDirectory(workspace.directory, tab.directory)
|
||||
const worktreeMatches = tab.worktree && containsDirectory(workspace.directory, tab.worktree)
|
||||
if (!directoryMatches && !worktreeMatches) return
|
||||
tabs.updateDraft(tab.draftID, {
|
||||
directory: directoryMatches ? workspace.project.worktree : tab.directory,
|
||||
worktree: undefined,
|
||||
})
|
||||
})
|
||||
clearWorkspaceTerminals(workspace.directory, platform, context.sdk.scope)
|
||||
await projectQuery.refetch()
|
||||
}
|
||||
|
||||
let inspectionID = 0
|
||||
@@ -287,30 +253,13 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
if (store.transaction) return
|
||||
const context = captureDeleteContext()
|
||||
const inventory = [...filtered()]
|
||||
const project = projectOptions().find((option) => option.id === selectedProject())?.label ?? selectedProject()
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteWorkspaces
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
confirmation={language.plural("settings.workspaces.deleteAll.confirm", inventory.length)}
|
||||
warning={language.t("settings.workspaces.deleteAll.warning")}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
releaseConfirmation,
|
||||
)
|
||||
}
|
||||
const confirmDeleteWithoutSessions = () => {
|
||||
if (store.transaction || workspacesWithoutSessions().length === 0) return
|
||||
const context = captureDeleteContext()
|
||||
const inventory = [...workspacesWithoutSessions()]
|
||||
setStore("transaction", "confirm")
|
||||
void dialog.push(
|
||||
() => (
|
||||
<DialogDeleteWorkspaces
|
||||
title={language.t("settings.workspaces.deleteWithoutSessions")}
|
||||
confirmation={language.plural("settings.workspaces.deleteWithoutSessions.confirm", inventory.length)}
|
||||
warning={language.t("settings.workspaces.deleteWithoutSessions.warning")}
|
||||
<DialogDeleteAllWorkspaces
|
||||
count={inventory.length}
|
||||
project={project}
|
||||
onDelete={() => transact(() => removeAll(inventory, context))}
|
||||
/>
|
||||
),
|
||||
@@ -322,47 +271,44 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
<>
|
||||
<div class="settings-tab-header settings-workspaces-header">
|
||||
<div class="settings-tab-header-row">
|
||||
<div class="flex flex-col gap-1">
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
<span class="text-11-regular text-v2-text-text-muted">
|
||||
{language.t("settings.workspaces.description")}
|
||||
</span>
|
||||
</div>
|
||||
<h2 class="settings-tab-title">{language.t("settings.tab.workspaces")}</h2>
|
||||
<InlineServerSelect />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-tab-body settings-workspaces">
|
||||
<Show when={filtered().length > 0}>
|
||||
<div class="settings-workspaces-toolbar">
|
||||
<span class="settings-workspaces-count">
|
||||
<div class="settings-workspaces-toolbar">
|
||||
<span class="settings-workspaces-count">
|
||||
<Show when={!projectQuery.isPending && !projectQuery.isError}>
|
||||
{language.plural("settings.workspaces.count", filtered().length)}
|
||||
</span>
|
||||
<div class="settings-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger as={Button} size="small" variant="ghost-muted" class="max-w-48">
|
||||
<span class="min-w-0 truncate">
|
||||
{projectOptions().find((option) => option.id === selectedProject())?.label}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<For each={projectOptions()}>
|
||||
{(option) => (
|
||||
<Menu.Item onSelect={() => setStore("project", option.id)}>
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<Show when={selectedProject() === option.id}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
</Show>
|
||||
</span>
|
||||
<div class="settings-workspaces-toolbar-actions">
|
||||
<Show when={projects().length > 1}>
|
||||
<Menu placement="bottom-end" gutter={6}>
|
||||
<Menu.Trigger class="flex h-6 max-w-48 items-center gap-1 rounded-sm px-2 text-13-medium hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed">
|
||||
<span class="min-w-0 truncate">
|
||||
{projectOptions().find((option) => option.id === selectedProject())?.label}
|
||||
</span>
|
||||
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
|
||||
</Menu.Trigger>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<For each={projectOptions()}>
|
||||
{(option) => (
|
||||
<Menu.Item onSelect={() => setStore("project", option.id)}>
|
||||
<span class="min-w-0 flex-1 truncate">{option.label}</span>
|
||||
<Show when={selectedProject() === option.id}>
|
||||
<Icon name="check" size="small" class="shrink-0" />
|
||||
</Show>
|
||||
</Menu.Item>
|
||||
)}
|
||||
</For>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</Show>
|
||||
<Show when={filtered().length > 0}>
|
||||
<Menu placement="bottom-end" gutter={4}>
|
||||
<Menu.Trigger
|
||||
as={IconButton}
|
||||
@@ -375,85 +321,78 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
/>
|
||||
<Menu.Portal>
|
||||
<Menu.Content>
|
||||
<Show when={workspacesWithoutSessions().length > 0}>
|
||||
<Menu.Item onSelect={confirmDeleteWithoutSessions}>
|
||||
{language.t("settings.workspaces.deleteWithoutSessions")}
|
||||
</Menu.Item>
|
||||
<Menu.Separator />
|
||||
</Show>
|
||||
<Menu.Item onSelect={confirmDeleteAll}>
|
||||
<span class="settings-workspaces-delete-all">{language.t("settings.workspaces.deleteAll")}</span>
|
||||
</Menu.Item>
|
||||
</Menu.Content>
|
||||
</Menu.Portal>
|
||||
</Menu>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
|
||||
<div class="settings-workspaces-inventory" data-empty={filtered().length === 0}>
|
||||
<SettingsList>
|
||||
<div class="settings-workspaces-empty-motion" data-visible={filtered().length === 0}>
|
||||
<div class="settings-workspaces-inventory">
|
||||
<Show
|
||||
when={filtered().length > 0}
|
||||
fallback={
|
||||
<div class="settings-workspaces-empty">
|
||||
<Show
|
||||
when={!projectQuery.isPending && !projectQuery.isError}
|
||||
fallback={language.t(projectQuery.isPending ? "common.loading" : "common.requestFailed")}
|
||||
>
|
||||
<span class="settings-workspaces-empty-title">{language.t("settings.workspaces.empty")}</span>
|
||||
<span>{language.t("settings.workspaces.empty.description")}</span>
|
||||
</Show>
|
||||
{language.t(
|
||||
projectQuery.isPending
|
||||
? "common.loading"
|
||||
: projectQuery.isError
|
||||
? "common.requestFailed"
|
||||
: "settings.workspaces.empty",
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<Key each={filtered()} by={(workspace) => `${workspace.project.id}:${pathKey(workspace.directory)}`}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace())
|
||||
const key = () => String(pathKey(workspace().directory))
|
||||
const deleting = () => store.deleting.includes(key())
|
||||
return (
|
||||
<div class="settings-workspaces-row-motion" data-removing={store.removing.includes(key())}>
|
||||
}
|
||||
>
|
||||
<SettingsList>
|
||||
<For each={filtered()}>
|
||||
{(workspace) => {
|
||||
const linked = () => workspaceSessions(workspace)
|
||||
return (
|
||||
<div class="settings-workspaces-row">
|
||||
<div class="settings-workspaces-row-header">
|
||||
<div class="settings-workspaces-copy">
|
||||
<div class="settings-workspaces-main">
|
||||
<WorkspacePath directory={workspace().directory} />
|
||||
<Tooltip
|
||||
value={workspace.directory}
|
||||
placement="top-start"
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
tabIndex={0}
|
||||
dir="ltr"
|
||||
aria-label={workspace.directory}
|
||||
class="settings-workspaces-path"
|
||||
>
|
||||
{workspace.directory}
|
||||
</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<span class="settings-workspaces-meta">{sessionCount(workspace())}</span>
|
||||
<span class="settings-workspaces-meta">{sessionCount(workspace)}</span>
|
||||
</div>
|
||||
<div class="settings-workspaces-row-actions">
|
||||
<Show
|
||||
when={deleting()}
|
||||
fallback={
|
||||
<>
|
||||
<Show when={lastActive(workspace())}>
|
||||
{(value) => (
|
||||
<Tooltip
|
||||
value={language.t("settings.workspaces.lastActiveSession")}
|
||||
placement="top-end"
|
||||
>
|
||||
<span tabIndex={0} class="settings-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", {
|
||||
name: getFilename(workspace().directory),
|
||||
})}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="outline-trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace())}
|
||||
/>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<span class="settings-workspaces-active">
|
||||
{language.t("workspace.lifecycle.deleting")}
|
||||
</span>
|
||||
<Show when={lastActive(workspace)}>
|
||||
{(value) => (
|
||||
<Tooltip value={language.t("settings.workspaces.lastActiveSession")} placement="top-end">
|
||||
<span tabIndex={0} class="settings-workspaces-active">
|
||||
{value()}
|
||||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Show>
|
||||
<IconButton
|
||||
type="button"
|
||||
variant="ghost-muted"
|
||||
size="small"
|
||||
aria-label={language.t("workspace.delete.confirm", {
|
||||
name: getFilename(workspace.directory),
|
||||
})}
|
||||
disabled={!!store.transaction}
|
||||
icon={<Icon name="trash" size="small" />}
|
||||
onClick={() => confirmDelete(workspace)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<Show when={linked().length > 0}>
|
||||
@@ -462,7 +401,7 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
{(session) => (
|
||||
<div class="settings-workspaces-session">
|
||||
<span>{sessionLabel(session)}</span>
|
||||
<Show when={linked().length > 1 ? sessionTime(session) : undefined}>
|
||||
<Show when={sessionTime(session)}>
|
||||
{(time) => <span class="settings-workspaces-session-time">{time()}</span>}
|
||||
</Show>
|
||||
</div>
|
||||
@@ -471,48 +410,18 @@ export const SettingsWorkspaces: Component<{ activeDirectory?: string; resetProj
|
||||
</div>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Key>
|
||||
</SettingsList>
|
||||
)
|
||||
}}
|
||||
</For>
|
||||
</SettingsList>
|
||||
</Show>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspacePath(props: { directory: string }) {
|
||||
const [truncated, setTruncated] = createSignal(false)
|
||||
const name = () => getFilename(props.directory)
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
value={props.directory}
|
||||
placement="top-start"
|
||||
disabled={!truncated()}
|
||||
contentClass="max-w-[calc(100vw-32px)] break-all"
|
||||
>
|
||||
<span
|
||||
ref={(element) => createResizeObserver(element, () => setTruncated(element.scrollWidth > element.clientWidth))}
|
||||
tabIndex={truncated() ? 0 : undefined}
|
||||
dir="ltr"
|
||||
aria-label={props.directory}
|
||||
class="settings-workspaces-path"
|
||||
>
|
||||
<span>{props.directory.slice(0, -name().length)}</span>
|
||||
<span class="settings-workspaces-path-name">{name()}</span>
|
||||
</span>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
function DialogDeleteWorkspaces(props: {
|
||||
title: string
|
||||
confirmation: string
|
||||
warning: string
|
||||
onDelete: () => Promise<void>
|
||||
}) {
|
||||
function DialogDeleteAllWorkspaces(props: { count: number; project: string; onDelete: () => Promise<void> }) {
|
||||
const dialog = useDialog()
|
||||
const language = useLanguage()
|
||||
const remove = () => {
|
||||
@@ -525,12 +434,13 @@ function DialogDeleteWorkspaces(props: {
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={props.title}
|
||||
title={language.t("settings.workspaces.deleteAll")}
|
||||
description={
|
||||
<div class="flex flex-col gap-2">
|
||||
<div>{props.confirmation}</div>
|
||||
<div>{props.warning}</div>
|
||||
</div>
|
||||
<>
|
||||
{language.t("settings.workspaces.deleteAll.confirm", { count: props.count })}
|
||||
<br />
|
||||
{language.t("settings.workspaces.deleteAll.warning", { count: props.count, project: props.project })}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
@@ -539,7 +449,7 @@ function DialogDeleteWorkspaces(props: {
|
||||
{language.t("common.cancel")}
|
||||
</Button>
|
||||
<Button type="button" variant="danger" onClick={remove}>
|
||||
{language.t("settings.workspaces.delete.button")}
|
||||
{language.t("settings.workspaces.deleteAll")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</Dialog>
|
||||
@@ -562,7 +472,7 @@ function DialogDeleteWorkspace(props: {
|
||||
staleTime: 0,
|
||||
}))
|
||||
const descriptions = () => {
|
||||
if (status.isPending) return []
|
||||
if (status.isPending) return [language.t("workspace.status.checking")]
|
||||
if (status.isError) return [language.t("workspace.status.error")]
|
||||
if (!status.data) return []
|
||||
return props.inspectionMessages(status.data.result)
|
||||
@@ -577,20 +487,18 @@ function DialogDeleteWorkspace(props: {
|
||||
<Dialog fit>
|
||||
<DialogHeader>
|
||||
<DialogTitleGroup
|
||||
title={language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||
title={language.t("workspace.delete.title")}
|
||||
description={
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="flex flex-col gap-1">
|
||||
<span class="text-11-regular text-v2-text-text-faint">
|
||||
{language.t(status.isPending ? "workspace.status.checking" : "workspace.delete.location")}
|
||||
</span>
|
||||
<code class="block w-fit max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||
{props.workspace.directory}
|
||||
</code>
|
||||
</div>
|
||||
<div>{language.t("settings.workspaces.delete.warning")}</div>
|
||||
<>
|
||||
{language.t("workspace.delete.confirm", { name: getFilename(props.workspace.directory) })}
|
||||
<br />
|
||||
<code class="max-w-full rounded-[4px] bg-[color-mix(in_oklch,var(--v2-text-text-base)_8%,transparent)] px-1 py-0.5 font-mono text-xs font-medium leading-4 text-v2-text-text-base break-all">
|
||||
{props.workspace.directory}
|
||||
</code>
|
||||
<br />
|
||||
{language.t("settings.workspaces.delete.warning")}
|
||||
<For each={descriptions()}>{(description) => <div>{description}</div>}</For>
|
||||
</div>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
</DialogHeader>
|
||||
@@ -601,7 +509,7 @@ function DialogDeleteWorkspace(props: {
|
||||
<Button
|
||||
type="button"
|
||||
variant="danger"
|
||||
disabled={status.isPending || status.isError}
|
||||
disabled={status.isPending || status.isError || status.data?.result.active}
|
||||
onClick={remove}
|
||||
>
|
||||
{language.t("workspace.delete.button")}
|
||||
|
||||
@@ -967,6 +967,20 @@ export type SessionLogOutput =
|
||||
readonly reason: "auto" | "manual"
|
||||
readonly model?: Model.Ref | undefined
|
||||
readonly providerState?: SessionMessage.ProviderState | undefined
|
||||
readonly providerContext?:
|
||||
| {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: Provider.ID
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: Schema.Json
|
||||
}
|
||||
| undefined
|
||||
readonly text: string
|
||||
readonly recent: string
|
||||
}
|
||||
|
||||
@@ -138,6 +138,15 @@ export type SessionMessageCompactionRunning = {
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type SessionProviderContextProvenance = {
|
||||
providerID: string
|
||||
provider: string
|
||||
modelID: string
|
||||
route: string
|
||||
protocol: string
|
||||
endpoint: string
|
||||
}
|
||||
|
||||
export type SessionActive = { type: "running" }
|
||||
|
||||
export type SessionInboxDelivery = "steer" | "queue"
|
||||
@@ -510,19 +519,6 @@ export type SessionMessageAssistantReasoning = {
|
||||
time?: { created: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
recent: string
|
||||
}
|
||||
|
||||
export type ToolContent = ToolTextContent | ToolFileContent
|
||||
|
||||
export type SessionMessageAssistantRetry = { attempt: number; at: number; error: SessionStructuredError }
|
||||
@@ -537,6 +533,8 @@ export type SessionMessageCompactionFailed = {
|
||||
error: SessionStructuredError
|
||||
}
|
||||
|
||||
export type SessionProviderContext = { version: 1; provenance: SessionProviderContextProvenance; messages: JsonValue }
|
||||
|
||||
export type SessionInboxSynthetic = {
|
||||
id: string
|
||||
sessionID: string
|
||||
@@ -1343,23 +1341,6 @@ export type SessionToolCalled = {
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
text: string
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionMessageAssistantText1 = { type: "text"; text: string; state?: SessionMessageProviderState1 }
|
||||
|
||||
export type SessionMessageAssistantReasoning1 = {
|
||||
@@ -1740,10 +1721,37 @@ export type SessionMessageToolStateError = {
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
export type SessionMessageCompactionCompleted = {
|
||||
type: "compaction"
|
||||
id: string
|
||||
metadata?: { [x: string]: JsonValue }
|
||||
time: { created: number }
|
||||
status: "completed"
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState
|
||||
summary: string
|
||||
recent: string
|
||||
providerContext?: SessionProviderContext
|
||||
}
|
||||
|
||||
export type SessionCompactionEnded = {
|
||||
id: string
|
||||
created: number
|
||||
metadata?: { [x: string]: any }
|
||||
type: "session.compaction.ended"
|
||||
durable: { aggregateID: string; seq: number; version: 1 }
|
||||
location?: LocationRef
|
||||
data: {
|
||||
sessionID: string
|
||||
reason: "auto" | "manual"
|
||||
model?: ModelRef
|
||||
providerState?: SessionMessageProviderState1
|
||||
providerContext?: SessionProviderContext
|
||||
text: string
|
||||
recent: string
|
||||
}
|
||||
}
|
||||
|
||||
export type SessionForked = {
|
||||
id: string
|
||||
@@ -2082,6 +2090,11 @@ export type SessionMessageAssistantTool = {
|
||||
time: { created: number; ran?: number; completed?: number }
|
||||
}
|
||||
|
||||
export type SessionMessageCompaction =
|
||||
| SessionMessageCompactionRunning
|
||||
| SessionMessageCompactionCompleted
|
||||
| SessionMessageCompactionFailed
|
||||
|
||||
export type SessionMessageAssistantTool1 = {
|
||||
type: "tool"
|
||||
id: string
|
||||
@@ -3077,6 +3090,18 @@ export type SessionImportInput = {
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly providerContext?: {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: string
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3356,6 +3381,18 @@ export type SessionImportInput = {
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly providerContext?: {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: string
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
@@ -3635,6 +3672,18 @@ export type SessionImportInput = {
|
||||
readonly providerState?: { readonly [x: string]: JsonValue }
|
||||
readonly summary: string
|
||||
readonly recent: string
|
||||
readonly providerContext?: {
|
||||
readonly version: 1
|
||||
readonly provenance: {
|
||||
readonly providerID: string
|
||||
readonly provider: string
|
||||
readonly modelID: string
|
||||
readonly route: string
|
||||
readonly protocol: string
|
||||
readonly endpoint: string
|
||||
}
|
||||
readonly messages: JsonValue
|
||||
}
|
||||
}
|
||||
| {
|
||||
readonly type: "compaction"
|
||||
|
||||
@@ -21,6 +21,7 @@ import { SessionEvent } from "./event.js"
|
||||
import type { SessionContext } from "./context.js"
|
||||
import type { SessionMessage } from "./message.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionRunnerRetry } from "./runner/retry.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
@@ -147,7 +148,12 @@ export const estimateTokens = (input: RequiredInput) => {
|
||||
const last = input.messages[index]
|
||||
// Keep the anchor's local tool results: they are not covered by its provider usage.
|
||||
const added = SessionModelRequest.unsupportedParts(
|
||||
toLLMMessages(input.messages.slice(Math.max(0, index)), input.resolved.ref),
|
||||
toLLMMessages(
|
||||
input.messages.slice(Math.max(0, index)),
|
||||
input.resolved.ref,
|
||||
input.resolved.model.route.providerMetadataKey ?? input.resolved.model.provider,
|
||||
SessionProviderContext.provenance(input.resolved),
|
||||
),
|
||||
input.resolved.capabilities,
|
||||
)
|
||||
.filter((message) => message.role !== "assistant" || message.id !== last?.id)
|
||||
@@ -406,6 +412,7 @@ export const layer = Layer.effect(
|
||||
},
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
providerContext: transcript.providerContext,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(input.instructionUpdate ? [Message.system(input.instructionUpdate)] : []),
|
||||
|
||||
@@ -18,6 +18,7 @@ import { SkillInstructions } from "../skill/instructions.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { AgentNotFoundError } from "./error.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
import { SessionMessage } from "./message.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
@@ -156,7 +157,12 @@ const layer = Layer.effect(
|
||||
|
||||
const load = Effect.fn("SessionContext.load")(function* (selection: Selection) {
|
||||
const model = yield* resolveModel(selection.session)
|
||||
const history = yield* SessionHistory.entriesForRunner(db, selection.session.id, selection.instructions)
|
||||
const history = yield* SessionHistory.entriesForRunner(
|
||||
db,
|
||||
selection.session.id,
|
||||
selection.instructions,
|
||||
SessionProviderContext.provenance(model),
|
||||
)
|
||||
return {
|
||||
session: selection.session,
|
||||
agent: selection.agent,
|
||||
|
||||
@@ -9,6 +9,7 @@ import type { Instructions } from "../instructions/index.js"
|
||||
import { SessionContext } from "./context.js"
|
||||
import type { AgentNotFoundError } from "./error.js"
|
||||
import { SessionHistory } from "./history.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionModelRequest } from "./model-request.js"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
import type { SessionSchema } from "./schema.js"
|
||||
@@ -29,7 +30,12 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
const context = yield* SessionContext.Service
|
||||
const selection = yield* context.select(input.session.id)
|
||||
const model = yield* context.resolveModel(selection.session)
|
||||
const history = yield* SessionHistory.preview(database.db, selection.session.id, selection.instructions)
|
||||
const history = yield* SessionHistory.preview(
|
||||
database.db,
|
||||
selection.session.id,
|
||||
selection.instructions,
|
||||
SessionProviderContext.provenance(model),
|
||||
)
|
||||
const transcript = SessionModelRequest.baseTranscript({
|
||||
agent: selection.agent.info,
|
||||
model,
|
||||
@@ -42,6 +48,7 @@ export const generate = Effect.fn("SessionGenerate.generate")(function* (input:
|
||||
scope: { session: selection.session, agentID: selection.agent.id, model, tools: selection.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
providerContext: transcript.providerContext,
|
||||
messages: [
|
||||
...transcript.messages,
|
||||
...(history.instructionUpdate ? [Message.system(history.instructionUpdate)] : []),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, gte, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gte, or, sql } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { Database } from "../database/database.js"
|
||||
import { MessageDecodeError } from "./error.js"
|
||||
@@ -6,13 +6,18 @@ import { SessionMessage } from "./message.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { Instructions } from "../instructions/index.js"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionMessageTable } from "./sql.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { InstructionStateTable, SessionMessageTable } from "./sql.js"
|
||||
|
||||
type DatabaseService = Database.Interface["db"]
|
||||
|
||||
const decode = Schema.decodeUnknownEffect(SessionMessage.Info)
|
||||
|
||||
export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
export const latestCompaction = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
return yield* db
|
||||
.select({ seq: SessionMessageTable.seq })
|
||||
.from(SessionMessageTable)
|
||||
@@ -21,6 +26,17 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "compaction"),
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.status') = 'completed'`,
|
||||
or(
|
||||
sql`json_extract(${SessionMessageTable.data}, '$.providerContext') is null`,
|
||||
target === undefined
|
||||
? undefined
|
||||
: and(
|
||||
...Object.entries(target).map(
|
||||
([key, value]) =>
|
||||
sql`json_extract(${SessionMessageTable.data}, ${`$.providerContext.provenance.${key}`}) = ${value}`,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(SessionMessageTable.seq))
|
||||
@@ -31,6 +47,11 @@ export const latestCompaction = Effect.fnUntraced(function* (db: DatabaseService
|
||||
|
||||
export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =>
|
||||
decode({ ...row.data, id: row.id, type: row.type }).pipe(
|
||||
Effect.tap((message) =>
|
||||
message.type === "compaction" && message.status === "completed" && message.providerContext
|
||||
? SessionProviderContext.validate(message.providerContext)
|
||||
: Effect.void,
|
||||
),
|
||||
Effect.mapError(
|
||||
() =>
|
||||
new MessageDecodeError({
|
||||
@@ -40,8 +61,12 @@ export const decodeMessageRow = (row: typeof SessionMessageTable.$inferSelect) =
|
||||
),
|
||||
)
|
||||
|
||||
const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
const compaction = yield* latestCompaction(db, sessionID)
|
||||
const messageEntries = Effect.fnUntraced(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
const compaction = yield* latestCompaction(db, sessionID, target)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
@@ -54,24 +79,57 @@ const messageEntries = Effect.fnUntraced(function* (db: DatabaseService, session
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
return yield* Effect.forEach(rows, (row) =>
|
||||
const entries = yield* Effect.forEach(rows, (row) =>
|
||||
decodeMessageRow(row).pipe(Effect.map((message) => ({ seq: row.seq, message }))),
|
||||
)
|
||||
const native = entries.findLast(
|
||||
(entry) =>
|
||||
entry.message.type === "compaction" && entry.message.status === "completed" && entry.message.providerContext,
|
||||
)
|
||||
const epoch = native
|
||||
? yield* db
|
||||
.select({ start: InstructionStateTable.epoch_start })
|
||||
.from(InstructionStateTable)
|
||||
.where(eq(InstructionStateTable.session_id, sessionID))
|
||||
.get()
|
||||
.pipe(Effect.orDie)
|
||||
: undefined
|
||||
// Skipped native checkpoints are not textual summaries. Their original transcript remains available.
|
||||
return entries.filter((entry) => {
|
||||
const message = entry.message
|
||||
// Re-expansion may cross native checkpoints, but their advanced baseline still applies.
|
||||
// Do not replay superseded instruction updates ahead of post-epoch updates.
|
||||
// Forks seed their baseline at sequence 0 but retain parent message sequences.
|
||||
// The copied native boundary still retires the instructions preceding it.
|
||||
if (message.type === "system" && native && entry.seq < Math.max(epoch?.start ?? 0, native.seq)) return false
|
||||
return (
|
||||
message.type !== "compaction" ||
|
||||
message.status !== "completed" ||
|
||||
!message.providerContext ||
|
||||
SessionProviderContext.compatible(message.providerContext.provenance, target)
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
export const load = Effect.fn("SessionHistory.load")(function* (db: DatabaseService, sessionID: SessionSchema.ID) {
|
||||
return (yield* messageEntries(db, sessionID)).map((entry) => entry.message)
|
||||
/** Without a resolved target, native checkpoints are conservatively skipped. */
|
||||
export const load = Effect.fn("SessionHistory.load")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
return (yield* messageEntries(db, sessionID, target)).map((entry) => entry.message)
|
||||
})
|
||||
|
||||
export const entriesForRunner = Effect.fn("SessionHistory.entriesForRunner")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.List,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* messageEntries(db, sessionID)
|
||||
const messages = yield* messageEntries(db, sessionID, target)
|
||||
return {
|
||||
initial: yield* InstructionState.initial(db, sessionID, instructions),
|
||||
entries: messages,
|
||||
@@ -85,12 +143,13 @@ export const preview = Effect.fn("SessionHistory.preview")(function* (
|
||||
db: DatabaseService,
|
||||
sessionID: SessionSchema.ID,
|
||||
instructions: Instructions.List,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) {
|
||||
const observed = yield* Instructions.read(instructions)
|
||||
return yield* db
|
||||
.transaction(() =>
|
||||
Effect.gen(function* () {
|
||||
const messages = yield* messageEntries(db, sessionID)
|
||||
const messages = yield* messageEntries(db, sessionID, target)
|
||||
// An active assistant may contain an unresolved tool call, so only preview the settled prefix.
|
||||
const unsettled = messages.findIndex(
|
||||
(entry) => entry.message.type === "assistant" && entry.message.time.completed === undefined,
|
||||
|
||||
@@ -413,6 +413,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
providerContext: event.data.providerContext,
|
||||
recent: event.data.recent,
|
||||
})
|
||||
return
|
||||
@@ -427,6 +428,7 @@ export function update(adapter: Adapter, event: SessionEvent.DurableEvent) {
|
||||
model: event.data.model,
|
||||
providerState: event.data.providerState,
|
||||
summary: event.data.text,
|
||||
providerContext: event.data.providerContext,
|
||||
recent: event.data.recent,
|
||||
time: { created },
|
||||
}),
|
||||
|
||||
@@ -15,6 +15,7 @@ import { PluginHooks } from "../plugin/hooks.js"
|
||||
import { QuestionTool } from "../tool/plugin/question.js"
|
||||
import { Tool } from "../tool.js"
|
||||
import { SessionModelTransport } from "./model-transport.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionRunnerModel } from "./runner/model.js"
|
||||
import { SessionSchema } from "./schema.js"
|
||||
import { SessionSystemPrompt } from "./system-prompt.js"
|
||||
@@ -74,6 +75,8 @@ interface PrepareInput {
|
||||
readonly transcript: {
|
||||
readonly system: Array<SystemPart>
|
||||
readonly messages: Array<Message>
|
||||
/** Selected durable window, checked again after model request hooks resolve the route. */
|
||||
readonly providerContext?: SessionProviderContext.Provenance
|
||||
}
|
||||
readonly toolChoice?: LLM.RequestInput["toolChoice"]
|
||||
/**
|
||||
@@ -93,8 +96,13 @@ export const baseTranscript = (input: {
|
||||
readonly messages: ReadonlyArray<SessionMessage.Info>
|
||||
}) => {
|
||||
const providerMetadataKey = input.model.model.route.providerMetadataKey ?? input.model.model.provider
|
||||
const checkpoint = input.messages.findLast(
|
||||
(message): message is SessionMessage.CompactionCompleted =>
|
||||
message.type === "compaction" && message.status === "completed" && message.providerContext !== undefined,
|
||||
)
|
||||
return {
|
||||
providerMetadataKey,
|
||||
providerContext: checkpoint?.providerContext?.provenance,
|
||||
system: [
|
||||
input.agent.system
|
||||
? input.agent.system
|
||||
@@ -103,7 +111,12 @@ export const baseTranscript = (input: {
|
||||
]
|
||||
.filter((part) => part.length > 0)
|
||||
.map(SystemPart.make),
|
||||
messages: toLLMMessages(input.messages, input.model.ref, providerMetadataKey),
|
||||
messages: toLLMMessages(
|
||||
input.messages,
|
||||
input.model.ref,
|
||||
providerMetadataKey,
|
||||
SessionProviderContext.provenance(input.model),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -345,6 +358,18 @@ export const layer = Layer.effect(
|
||||
providerOptions: Object.keys(context.providerOptions).length === 0 ? undefined : context.providerOptions,
|
||||
}),
|
||||
)
|
||||
// A newly installed routing hook must not send an existing opaque window to another deployment.
|
||||
// Checkpoint producers stamp the final prepared route, not the pre-hook catalog selection.
|
||||
if (
|
||||
input.transcript.providerContext &&
|
||||
!SessionProviderContext.compatible(
|
||||
input.transcript.providerContext,
|
||||
SessionProviderContext.provenance({ model: request.model, ref: resolved.ref }),
|
||||
)
|
||||
)
|
||||
return yield* Effect.die(
|
||||
new Error("Provider context is incompatible with the route selected by model request hooks"),
|
||||
)
|
||||
const hasHttpHooks =
|
||||
(yield* hooks.has("session", "http.request", resolved.ref.providerID)) ||
|
||||
(yield* hooks.has("session", "http.response", resolved.ref.providerID))
|
||||
|
||||
@@ -14,6 +14,7 @@ import { SessionMessageUpdater } from "./message-updater.js"
|
||||
import { SessionInbox } from "./inbox.js"
|
||||
import { Workspace } from "@opencode-ai/schema/workspace"
|
||||
import { InstructionState } from "./instruction-state.js"
|
||||
import { SessionProviderContext } from "./provider-context.js"
|
||||
import { SessionInboxTable, SessionMessageTable, SessionTable } from "./sql.js"
|
||||
import { InstructionEntry } from "./instruction-entry.js"
|
||||
import { Slug } from "../util/slug.js"
|
||||
@@ -691,6 +692,8 @@ const layer = Layer.effectDiscard(
|
||||
yield* bus.project(SessionEvent.Compaction.Started, (event) => run(db, event))
|
||||
yield* bus.project(SessionEvent.Compaction.Ended, (event) =>
|
||||
Effect.gen(function* () {
|
||||
if (event.data.providerContext)
|
||||
yield* SessionProviderContext.validate(event.data.providerContext).pipe(Effect.orDie)
|
||||
yield* run(db, event)
|
||||
yield* InstructionState.advanceEpoch(db, event.data.sessionID, event.durable.seq)
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
export * as SessionProviderContext from "./provider-context.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { SessionProviderContext } from "@opencode-ai/schema/session-provider-context"
|
||||
import { Schema } from "effect"
|
||||
import { Hash } from "@opencode-ai/util/hash"
|
||||
import type { SessionRunnerModel } from "./runner/model.js"
|
||||
|
||||
export type Provenance = SessionProviderContext.Provenance
|
||||
export const Info = SessionProviderContext.Info
|
||||
export type Info = SessionProviderContext.Info
|
||||
|
||||
const messages = Schema.toCodecJson(Schema.Array(Message))
|
||||
|
||||
/** No guessed endpoints. Dynamic URL builders cannot establish a durable deployment identity here. */
|
||||
export function provenance(resolved: Pick<SessionRunnerModel.Resolved, "model" | "ref">): Provenance | undefined {
|
||||
const model = resolved.model
|
||||
const endpoint = model.route.endpoint
|
||||
if (!endpoint.baseURL || typeof endpoint.path !== "string") return undefined
|
||||
return {
|
||||
providerID: resolved.ref.providerID,
|
||||
provider: model.provider,
|
||||
modelID: model.id,
|
||||
route: model.route.id,
|
||||
protocol: model.route.protocol,
|
||||
endpoint: Hash.sha256(
|
||||
JSON.stringify([
|
||||
endpoint.baseURL,
|
||||
endpoint.path,
|
||||
Object.entries(endpoint.query ?? {}).sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
|
||||
]),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
export const compatible = (source: Provenance, target: Provenance | undefined) =>
|
||||
target !== undefined &&
|
||||
source.providerID === target.providerID &&
|
||||
source.provider === target.provider &&
|
||||
source.modelID === target.modelID &&
|
||||
source.route === target.route &&
|
||||
source.protocol === target.protocol &&
|
||||
source.endpoint === target.endpoint
|
||||
|
||||
/** Stores the canonical replacement, not a local summary or transport continuation.
|
||||
* Provider and attachment metadata can contain optional undefined entries. Use JSON's
|
||||
* omission semantics, while preserving canonical binary media as equivalent base64.
|
||||
*/
|
||||
export const encode = (provenance: Provenance, replacement: ReadonlyArray<Message>): Info => ({
|
||||
version: 1,
|
||||
provenance,
|
||||
messages: Schema.decodeSync(Schema.fromJsonString(Schema.Json))(
|
||||
JSON.stringify(
|
||||
replacement.map((message) => ({
|
||||
...message,
|
||||
content: message.content.map((part) =>
|
||||
part.type === "media" && part.data instanceof Uint8Array
|
||||
? { ...part, data: Buffer.from(part.data).toString("base64") }
|
||||
: part,
|
||||
),
|
||||
})),
|
||||
),
|
||||
),
|
||||
})
|
||||
|
||||
export const decode = (context: Info) => Schema.decodeUnknownSync(messages)(context.messages)
|
||||
export const validate = (context: Info) => Schema.decodeUnknownEffect(messages)(context.messages)
|
||||
@@ -1,7 +1,7 @@
|
||||
export * as SessionRunnerLLM from "./llm.js"
|
||||
|
||||
import { Message } from "@opencode-ai/ai"
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import { and, asc, desc, eq, gt, sql } from "drizzle-orm"
|
||||
import { Cause, Effect, Exit, FiberMap, Layer } from "effect"
|
||||
import { Database } from "../../database/database.js"
|
||||
import { Bus } from "../../bus.js"
|
||||
@@ -11,6 +11,7 @@ import { SessionContext } from "../context.js"
|
||||
import { SessionEvent } from "../event.js"
|
||||
import { SessionInbox } from "../inbox.js"
|
||||
import { SessionHistory } from "../history.js"
|
||||
import { SessionProviderContext } from "../provider-context.js"
|
||||
import { SessionModelRequest } from "../model-request.js"
|
||||
import { SessionModelTransport } from "../model-transport.js"
|
||||
import { SessionMessage } from "../message.js"
|
||||
@@ -113,7 +114,12 @@ const layer = Layer.effect(
|
||||
const selected = yield* context.select(session.id)
|
||||
const model = yield* context.resolveModel(selected.session)
|
||||
// Preview updates without admitting them after the already-delivered compaction marker.
|
||||
const history = yield* SessionHistory.preview(db, session.id, selected.instructions)
|
||||
const history = yield* SessionHistory.preview(
|
||||
db,
|
||||
session.id,
|
||||
selected.instructions,
|
||||
SessionProviderContext.provenance(model),
|
||||
)
|
||||
return {
|
||||
session: selected.session,
|
||||
agent: selected.agent,
|
||||
@@ -221,6 +227,7 @@ const layer = Layer.effect(
|
||||
scope: { session: loaded.session, agentID: loaded.agent.id, model: loaded.model, tools: loaded.tools },
|
||||
transcript: {
|
||||
system: transcript.system,
|
||||
providerContext: transcript.providerContext,
|
||||
messages: stepLimitReached
|
||||
? [...transcript.messages, Message.assistant(MAX_STEPS_PROMPT)]
|
||||
: transcript.messages,
|
||||
@@ -313,7 +320,28 @@ const layer = Layer.effect(
|
||||
const settleStaleToolCalls = Effect.fn("SessionRunner.settleStaleToolCalls")(function* (
|
||||
sessionID: SessionSchema.ID,
|
||||
) {
|
||||
for (const message of yield* store.context(sessionID)) {
|
||||
// Recovery only needs unfinished tools, not every original message hidden by native checkpoints.
|
||||
const boundary = yield* SessionHistory.latestCompaction(db, sessionID)
|
||||
const rows = yield* db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SessionMessageTable.session_id, sessionID),
|
||||
eq(SessionMessageTable.type, "assistant"),
|
||||
boundary ? gt(SessionMessageTable.seq, boundary.seq) : undefined,
|
||||
sql`exists (
|
||||
select 1 from json_each(${SessionMessageTable.data}, '$.content') as part
|
||||
where json_extract(part.value, '$.type') = 'tool'
|
||||
and json_extract(part.value, '$.state.status') in ('streaming', 'running')
|
||||
)`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(SessionMessageTable.seq))
|
||||
.all()
|
||||
.pipe(Effect.orDie)
|
||||
for (const row of rows) {
|
||||
const message = yield* SessionHistory.decodeMessageRow(row)
|
||||
if (message.type !== "assistant") continue
|
||||
for (const tool of message.content) {
|
||||
if (tool.type !== "tool" || (tool.state.status !== "streaming" && tool.state.status !== "running")) continue
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { Model } from "@opencode-ai/schema/model"
|
||||
import { Option, Schema } from "effect"
|
||||
import { fileURLToPath } from "url"
|
||||
import { SessionMessage } from "../message.js"
|
||||
import { SessionProviderContext } from "../provider-context.js"
|
||||
import type { FileAttachment } from "@opencode-ai/schema/prompt"
|
||||
|
||||
const imageMimes = new Set(["image/png", "image/jpeg", "image/gif", "image/webp"])
|
||||
@@ -221,7 +222,12 @@ const assistant = (message: SessionMessage.Assistant, model: Model.Ref, provider
|
||||
]
|
||||
}
|
||||
|
||||
function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMetadataKey: string): Message[] {
|
||||
function toLLMMessage(
|
||||
message: SessionMessage.Info,
|
||||
model: Model.Ref,
|
||||
providerMetadataKey: string,
|
||||
target?: SessionProviderContext.Provenance,
|
||||
): Message[] {
|
||||
switch (message.type) {
|
||||
case "agent-switched":
|
||||
case "model-switched":
|
||||
@@ -274,6 +280,12 @@ function toLLMMessage(message: SessionMessage.Info, model: Model.Ref, providerMe
|
||||
return assistant(message, model, providerMetadataKey)
|
||||
case "compaction":
|
||||
if (message.status !== "completed") return []
|
||||
// Explicit system updates inside a native replacement predate its completed
|
||||
// compaction epoch; the current epoch baseline supersedes those instructions.
|
||||
if (message.providerContext)
|
||||
return SessionProviderContext.compatible(message.providerContext.provenance, target)
|
||||
? SessionProviderContext.decode(message.providerContext).filter((message) => message.role !== "system")
|
||||
: []
|
||||
return [
|
||||
Message.make({
|
||||
id: message.id,
|
||||
@@ -300,4 +312,5 @@ export const toLLMMessages = (
|
||||
messages: readonly SessionMessage.Info[],
|
||||
model: Model.Ref,
|
||||
providerMetadataKey: string = model.providerID,
|
||||
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey))
|
||||
target?: SessionProviderContext.Provenance,
|
||||
) => messages.flatMap((message) => toLLMMessage(message, model, providerMetadataKey, target))
|
||||
|
||||
@@ -53,6 +53,7 @@ export interface Interface {
|
||||
readonly get: (sessionID: Session.ID) => Effect.Effect<Session.Info | undefined>
|
||||
readonly list: (input?: ListInput) => Effect.Effect<Session.Info[]>
|
||||
readonly messages: (input: MessagesInput) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
/** Model-neutral history: native windows are skipped; request assembly uses model-aware SessionHistory. */
|
||||
readonly context: (sessionID: Session.ID) => Effect.Effect<SessionMessage.Info[], MessageDecodeError>
|
||||
readonly message: (
|
||||
messageID: SessionMessage.ID,
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
import { expect, test } from "bun:test"
|
||||
import { CompactionPart, LanguageModel, Message, ToolCallPart } from "@opencode-ai/ai"
|
||||
import { OpenAIResponses } from "@opencode-ai/ai/protocols"
|
||||
import { Bus } from "@opencode-ai/core/bus"
|
||||
import { Database } from "@opencode-ai/core/database/database"
|
||||
import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
|
||||
import { EventTable } from "@opencode-ai/core/event/sql"
|
||||
import { Instructions } from "@opencode-ai/core/instructions/index"
|
||||
import { Project } from "@opencode-ai/core/project"
|
||||
import { ProjectTable } from "@opencode-ai/core/project/sql"
|
||||
import { AbsolutePath } from "@opencode-ai/core/schema"
|
||||
import { SessionEvent } from "@opencode-ai/core/session/event"
|
||||
import { SessionHistory } from "@opencode-ai/core/session/history"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { InstructionState } from "@opencode-ai/core/session/instruction-state"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
|
||||
import { SessionRunnerModel } from "@opencode-ai/core/session/runner/model"
|
||||
import { toLLMMessages } from "@opencode-ai/core/session/runner/to-llm-message"
|
||||
import { SessionSchema } from "@opencode-ai/core/session/schema"
|
||||
import { InstructionStateTable, SessionMessageTable, SessionTable } from "@opencode-ai/core/session/sql"
|
||||
import { SessionStore } from "@opencode-ai/core/session/store"
|
||||
import { LayerNode } from "@opencode-ai/util/effect/layer-node"
|
||||
import { Model } from "@opencode-ai/schema/model"
|
||||
import { asc, eq } from "drizzle-orm"
|
||||
import { Effect, Schema } from "effect"
|
||||
import { testEffect } from "./lib/effect"
|
||||
|
||||
const model = SessionRunnerModel.resolved(
|
||||
LanguageModel.make({ id: "deployment", provider: "openai", route: OpenAIResponses.route }),
|
||||
{
|
||||
capabilities: { tools: true, input: ["text"], output: ["text"] },
|
||||
cost: [],
|
||||
limit: { context: 128_000, output: 4096 },
|
||||
},
|
||||
)
|
||||
const target = SessionProviderContext.provenance(model)
|
||||
if (!target) throw new Error("Fixture must have a concrete endpoint")
|
||||
const replacement = [
|
||||
Message.user("retained request"),
|
||||
Message.system("changed instructions"),
|
||||
Message.assistant(
|
||||
CompactionPart.make({ provider: model.model.provider, encrypted: "opaque-checkpoint", id: "cp_1" }),
|
||||
),
|
||||
]
|
||||
const providerContext = SessionProviderContext.encode(target, replacement)
|
||||
const sessionID = SessionSchema.ID.make("ses_provider_context")
|
||||
|
||||
const it = testEffect(
|
||||
AppNodeBuilder.build(
|
||||
LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionInbox.node, SessionStore.node]),
|
||||
[Bus.node.replace(Bus.configured({ persist: true }))],
|
||||
),
|
||||
)
|
||||
|
||||
const setup = Effect.gen(function* () {
|
||||
const database = yield* Database.Service
|
||||
const bus = yield* Bus.Service
|
||||
const inbox = yield* SessionInbox.Service
|
||||
yield* database.db
|
||||
.insert(ProjectTable)
|
||||
.values({ id: Project.ID.global, worktree: AbsolutePath.make("/project"), sandboxes: [] })
|
||||
.run()
|
||||
yield* bus.publish(SessionEvent.Created, {
|
||||
sessionID,
|
||||
projectID: Project.ID.global,
|
||||
location: { directory: AbsolutePath.make("/project") },
|
||||
slug: "provider-context",
|
||||
version: "test",
|
||||
})
|
||||
const state = { value: "initial instructions" }
|
||||
const instructions = Instructions.make({
|
||||
key: Instructions.Key.make("test/context"),
|
||||
codec: Schema.toCodecJson(Schema.String),
|
||||
read: Effect.sync(() => state.value),
|
||||
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
|
||||
})
|
||||
const prepare = InstructionState.prepare(database.db, bus, instructions, sessionID)
|
||||
const prompt = Effect.fnUntraced(function* (text: string) {
|
||||
const id = SessionMessage.ID.create()
|
||||
yield* inbox.admit({ id, sessionID, item: { type: "user", payload: { text }, delivery: "steer" } })
|
||||
yield* bus.publish(SessionEvent.InboxDelivered, { sessionID, inboxID: id })
|
||||
return id
|
||||
})
|
||||
const compact = (context?: SessionProviderContext.Info) =>
|
||||
bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: context ? "" : "local summary",
|
||||
recent: "",
|
||||
providerContext: context,
|
||||
})
|
||||
const load = (identity?: SessionProviderContext.Provenance) =>
|
||||
SessionHistory.entriesForRunner(database.db, sessionID, instructions, identity)
|
||||
return { db: database.db, bus, state, instructions, prepare, prompt, compact, load }
|
||||
})
|
||||
|
||||
test("canonical provider context round-trips tools, opaque checkpoints and binary media through JSON", () => {
|
||||
const messages = [
|
||||
...replacement,
|
||||
Message.assistant(ToolCallPart.make({ id: "call_1", name: "read", input: { path: "file" } })),
|
||||
Message.tool({ id: "call_1", name: "read", result: { text: "result" } }),
|
||||
Message.user({ type: "media", mediaType: "image/png", data: new Uint8Array([1, 2, 3]) }),
|
||||
]
|
||||
const context = SessionProviderContext.encode(providerContext.provenance, messages)
|
||||
const stored = Schema.decodeUnknownSync(Schema.fromJsonString(SessionProviderContext.Info))(JSON.stringify(context))
|
||||
const decoded = SessionProviderContext.decode(stored)
|
||||
expect(decoded.slice(0, -1)).toEqual(messages.slice(0, -1))
|
||||
expect(decoded.at(-1)?.content).toEqual([{ type: "media", mediaType: "image/png", data: "AQID" }])
|
||||
const optionalMetadata = SessionProviderContext.encode(providerContext.provenance, [
|
||||
Message.make({
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "attachment", metadata: { attachment: { name: undefined, source: { type: "inline" } } } },
|
||||
],
|
||||
providerMetadata: { openai: { itemId: undefined, type: "message", status: undefined, phase: undefined } },
|
||||
}),
|
||||
])
|
||||
expect(SessionProviderContext.decode(optionalMetadata)[0]).toMatchObject({
|
||||
providerMetadata: { openai: { type: "message" } },
|
||||
content: [{ metadata: { attachment: { source: { type: "inline" } } } }],
|
||||
})
|
||||
expect(() =>
|
||||
SessionProviderContext.decode({
|
||||
...context,
|
||||
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai" }] }],
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("compatibility uses the actual deployment and endpoint rather than a catalog alias or variant", () => {
|
||||
expect(
|
||||
SessionProviderContext.compatible(
|
||||
providerContext.provenance,
|
||||
SessionProviderContext.provenance({
|
||||
...model,
|
||||
ref: { ...model.ref, id: Model.ID.make("alias"), variant: Model.VariantID.make("high") },
|
||||
}),
|
||||
),
|
||||
).toBe(true)
|
||||
for (const changed of [
|
||||
{ ...model, model: LanguageModel.update(model.model, { id: "other-deployment" }) },
|
||||
{
|
||||
...model,
|
||||
model: LanguageModel.update(model.model, {
|
||||
route: model.model.route.with({ endpoint: { baseURL: "https://another.example/v1?api-key=secret" } }),
|
||||
}),
|
||||
},
|
||||
{ ...model, model: LanguageModel.update(model.model, { route: model.model.route.with({ id: "other-route" }) }) },
|
||||
])
|
||||
expect(
|
||||
SessionProviderContext.compatible(providerContext.provenance, SessionProviderContext.provenance(changed)),
|
||||
).toBe(false)
|
||||
const privateEndpoint = SessionProviderContext.provenance({
|
||||
...model,
|
||||
model: LanguageModel.update(model.model, {
|
||||
route: model.model.route.with({ endpoint: { baseURL: "https://user:secret@example.com/v1?api-key=secret" } }),
|
||||
}),
|
||||
})
|
||||
expect(JSON.stringify(privateEndpoint)).not.toContain("secret")
|
||||
expect(
|
||||
SessionProviderContext.provenance({
|
||||
...model,
|
||||
model: LanguageModel.update(model.model, {
|
||||
route: model.model.route.with({ endpoint: { path: () => "/dynamic" } }),
|
||||
}),
|
||||
}),
|
||||
).toBeUndefined()
|
||||
expect(SessionProviderContext.compatible(providerContext.provenance, undefined)).toBe(false)
|
||||
})
|
||||
|
||||
it.effect(
|
||||
"advances the native instruction epoch and omits superseded chronological updates after durable replay and provider switches",
|
||||
() =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
yield* s.prepare
|
||||
yield* s.prompt("original request")
|
||||
s.state.value = "changed instructions"
|
||||
yield* s.prepare
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Started, { sessionID, reason: "manual", recent: "" })
|
||||
const completed = yield* s.compact(providerContext)
|
||||
s.state.value = "newest instructions"
|
||||
yield* s.prepare
|
||||
yield* s.prompt("continue")
|
||||
|
||||
const verify = Effect.gen(function* () {
|
||||
expect(
|
||||
yield* s.db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, sessionID)).get(),
|
||||
).toMatchObject({
|
||||
epoch_start: completed.durable.seq,
|
||||
initial_values: { "test/context": Instructions.hash("changed instructions") },
|
||||
current_values: { "test/context": Instructions.hash("newest instructions") },
|
||||
})
|
||||
const native = yield* s.load(target)
|
||||
expect(native.initial).toBe("changed instructions")
|
||||
expect(
|
||||
toLLMMessages(
|
||||
native.entries.map((entry) => entry.message),
|
||||
model.ref,
|
||||
"openai",
|
||||
target,
|
||||
),
|
||||
).toEqual([
|
||||
replacement[0],
|
||||
replacement[2],
|
||||
Message.system("newest instructions"),
|
||||
expect.objectContaining({ role: "user", content: [Message.text("continue")] }),
|
||||
])
|
||||
for (const incompatible of [
|
||||
undefined,
|
||||
{ ...providerContext.provenance, modelID: "other" },
|
||||
{ ...providerContext.provenance, provider: "other" },
|
||||
]) {
|
||||
const expanded = yield* s.load(incompatible)
|
||||
expect(expanded.initial).toBe("changed instructions")
|
||||
expect(
|
||||
toLLMMessages(
|
||||
expanded.entries.map((entry) => entry.message),
|
||||
model.ref,
|
||||
).map((message) => message.content),
|
||||
).toEqual([
|
||||
[Message.text("original request")],
|
||||
[Message.text("newest instructions")],
|
||||
[Message.text("continue")],
|
||||
])
|
||||
}
|
||||
const preview = yield* SessionHistory.preview(s.db, sessionID, s.instructions, target)
|
||||
expect(preview.initial).toBe("changed instructions")
|
||||
expect(preview.messages).toEqual(native.entries.map((entry) => entry.message))
|
||||
const store = yield* SessionStore.Service
|
||||
expect((yield* store.messages({ sessionID })).map((message) => message.type)).toEqual([
|
||||
"user",
|
||||
"system",
|
||||
"compaction",
|
||||
"system",
|
||||
"user",
|
||||
])
|
||||
})
|
||||
yield* verify
|
||||
const recorded = yield* s.db
|
||||
.select()
|
||||
.from(EventTable)
|
||||
.where(eq(EventTable.aggregate_id, sessionID))
|
||||
.orderBy(asc(EventTable.seq))
|
||||
.all()
|
||||
expect(recorded.filter((event) => event.data.providerContext !== undefined)).toHaveLength(1)
|
||||
yield* s.bus.remove(sessionID)
|
||||
yield* s.db.delete(SessionTable).where(eq(SessionTable.id, sessionID)).run()
|
||||
for (const event of recorded)
|
||||
yield* s.bus.replay({
|
||||
id: event.id,
|
||||
created: event.created,
|
||||
aggregateID: event.aggregate_id,
|
||||
seq: event.seq,
|
||||
type: event.type,
|
||||
data: event.data,
|
||||
})
|
||||
yield* verify
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to an earlier compatible native or local checkpoint", () =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
yield* s.prepare
|
||||
yield* s.prompt("before local")
|
||||
s.state.value = "local baseline"
|
||||
yield* s.prepare
|
||||
yield* s.compact()
|
||||
yield* s.prompt("after local")
|
||||
yield* s.compact(providerContext)
|
||||
yield* s.prompt("after native")
|
||||
s.state.value = "new native baseline"
|
||||
yield* s.prepare
|
||||
yield* s.compact({ ...providerContext, provenance: { ...providerContext.provenance, modelID: "other" } })
|
||||
s.state.value = "post-epoch update"
|
||||
yield* s.prepare
|
||||
const native = yield* s.load(target)
|
||||
expect(native.initial).toBe("new native baseline")
|
||||
expect(native.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "system"])
|
||||
expect(native.entries[0]?.message).toMatchObject({ providerContext })
|
||||
expect(
|
||||
toLLMMessages(
|
||||
native.entries.map((entry) => entry.message),
|
||||
model.ref,
|
||||
"openai",
|
||||
target,
|
||||
).filter((message) => message.role === "system"),
|
||||
).toEqual([Message.system("post-epoch update")])
|
||||
const local = yield* s.load()
|
||||
expect(local.initial).toBe("new native baseline")
|
||||
expect(local.entries.map((entry) => entry.message.type)).toEqual(["compaction", "user", "user", "system"])
|
||||
expect(local.entries[0]?.message).toMatchObject({ summary: "local summary" })
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("rejects malformed installed or persisted native windows instead of silently dropping them", () =>
|
||||
Effect.gen(function* () {
|
||||
const s = yield* setup
|
||||
const malformed = { ...providerContext, messages: [{ role: "invalid", content: [] }] }
|
||||
expect(yield* s.compact(malformed).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
|
||||
yield* s.compact(providerContext)
|
||||
const row = yield* s.db
|
||||
.select()
|
||||
.from(SessionMessageTable)
|
||||
.where(eq(SessionMessageTable.session_id, sessionID))
|
||||
.get()
|
||||
if (!row) throw new Error("Expected projected checkpoint")
|
||||
const data = Schema.encodeSync(SessionMessage.CompactionCompleted)(
|
||||
Schema.decodeUnknownSync(SessionMessage.CompactionCompleted)({ ...row.data, id: row.id, type: row.type }),
|
||||
)
|
||||
yield* s.db
|
||||
.update(SessionMessageTable)
|
||||
.set({ data: { ...data, providerContext: malformed } })
|
||||
.where(eq(SessionMessageTable.id, row.id))
|
||||
.run()
|
||||
expect(yield* SessionHistory.load(s.db, sessionID, target).pipe(Effect.flip)).toMatchObject({
|
||||
_tag: "Session.MessageDecodeError",
|
||||
})
|
||||
}),
|
||||
)
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import {
|
||||
AIError,
|
||||
CompactionPart,
|
||||
HttpContext,
|
||||
LLMEvent,
|
||||
LLMRequest,
|
||||
@@ -40,6 +41,7 @@ import { SessionCompaction } from "@opencode-ai/core/session/compaction"
|
||||
import { SessionInbox } from "@opencode-ai/core/session/inbox"
|
||||
import { SessionMessage } from "@opencode-ai/core/session/message"
|
||||
import { SessionModelTransport } from "@opencode-ai/core/session/model-transport"
|
||||
import { SessionProviderContext } from "@opencode-ai/core/session/provider-context"
|
||||
import { Money } from "@opencode-ai/schema/money"
|
||||
import { SessionProjector } from "@opencode-ai/core/session/projector"
|
||||
import { SessionExecution } from "@opencode-ai/core/session/execution"
|
||||
@@ -1435,6 +1437,93 @@ describe("SessionRunnerLLM", () => {
|
||||
expect(yield* s.inbox).toEqual([])
|
||||
})
|
||||
|
||||
scenario(
|
||||
"restores installed native context with auto disabled and preserves it across fork and revert",
|
||||
function* (s) {
|
||||
const compaction = yield* SessionCompaction.Service
|
||||
yield* compaction.transform((editor) => editor.configure({ auto: false }))
|
||||
yield* s.runPrompt("Original request")
|
||||
s.systemBaseline = "Checkpoint instructions"
|
||||
yield* s.runPrompt("Before checkpoint")
|
||||
const target = SessionProviderContext.provenance({
|
||||
model: s.currentModel,
|
||||
ref: Model.Ref.make({
|
||||
id: Model.ID.make(s.currentModel.id),
|
||||
providerID: Provider.ID.make(s.currentModel.provider),
|
||||
}),
|
||||
})
|
||||
if (!target) throw new Error("Expected concrete fixture endpoint")
|
||||
const replacement = [
|
||||
Message.system("Checkpoint instructions"),
|
||||
Message.assistant(CompactionPart.make({ provider: s.currentModel.provider, encrypted: "checkpoint" })),
|
||||
]
|
||||
const providerContext = SessionProviderContext.encode(target, replacement)
|
||||
yield* s.bus.publish(SessionEvent.Compaction.Ended, {
|
||||
sessionID,
|
||||
reason: "manual",
|
||||
text: "",
|
||||
recent: "",
|
||||
providerContext,
|
||||
})
|
||||
const checkpoint = (yield* s.messages).find((message) => message.type === "compaction")
|
||||
if (!checkpoint) throw new Error("Expected checkpoint")
|
||||
|
||||
s.systemBaseline = "Newest instructions"
|
||||
const after = yield* s.runPrompt("After checkpoint")
|
||||
const continued = s.requests.at(-1)
|
||||
if (!continued) throw new Error("Expected continuation request")
|
||||
expect(continued.messages[0]).toEqual(replacement[1])
|
||||
expect(continued.system.map((part) => part.text)).toContain("Checkpoint instructions")
|
||||
expect(systemTexts(continued)).toEqual(["Newest instructions"])
|
||||
|
||||
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: after.id } })
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Fork prompt", resume: false })
|
||||
yield* s.session.resume(forked.id)
|
||||
expect(s.requests.at(-1)?.messages[0]).toEqual(replacement[1])
|
||||
expect(s.requests.at(-1)?.system.map((part) => part.text)).toContain("Newest instructions")
|
||||
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
|
||||
Message.system("Newest instructions"),
|
||||
])
|
||||
expect(
|
||||
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
|
||||
).toMatchObject({ providerContext })
|
||||
|
||||
const original = s.currentModel
|
||||
s.currentModel = LanguageModel.update(original, { id: "different-deployment" })
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Switched fork", resume: false })
|
||||
yield* s.session.resume(forked.id)
|
||||
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
|
||||
expect(s.requests.at(-1)?.messages.filter((message) => message.role === "system")).toEqual([
|
||||
Message.system("Newest instructions"),
|
||||
])
|
||||
s.currentModel = original
|
||||
|
||||
yield* s.bus.publish(SessionEvent.RevertEvent.Committed, { sessionID, to: checkpoint.id })
|
||||
yield* s.runPrompt("After revert")
|
||||
expect(
|
||||
s.requests
|
||||
.at(-1)
|
||||
?.messages.flatMap((message) => message.content)
|
||||
.some((part) => part.type === "compaction"),
|
||||
).toBe(false)
|
||||
expect(s.requests.at(-1)?.messages[0]?.content).toEqual([Message.text("Original request")])
|
||||
expect(
|
||||
(yield* s.session.messages({ sessionID: forked.id })).find((message) => message.type === "compaction"),
|
||||
).toMatchObject({ providerContext })
|
||||
|
||||
const hooks = yield* PluginHooks.Service
|
||||
yield* hooks.register("session", "model.request", (event) =>
|
||||
Effect.sync(() => {
|
||||
event.baseURL = "https://another-deployment.example/v1"
|
||||
}),
|
||||
)
|
||||
const before = s.requests.length
|
||||
yield* s.session.prompt({ sessionID: forked.id, text: "Changed route", resume: false })
|
||||
expect(yield* s.session.resume(forked.id).pipe(Effect.exit)).toMatchObject({ _tag: "Failure" })
|
||||
expect(s.requests).toHaveLength(before)
|
||||
},
|
||||
)
|
||||
|
||||
scenario("seeds a fork with the parent's newest instruction values", function* (s) {
|
||||
yield* s.runPrompt("First")
|
||||
s.systemBaseline = "Changed context"
|
||||
|
||||
@@ -18221,11 +18221,20 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"recent": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18448,6 +18457,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18771,6 +18783,46 @@
|
||||
"Session.Metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.ProviderContext": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"enum": [1]
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
|
||||
},
|
||||
"messages": {}
|
||||
},
|
||||
"required": ["version", "provenance", "messages"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.ProviderContext.Provenance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
"route": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Revert": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -587,6 +587,7 @@ export namespace Compaction {
|
||||
reason: Started.data.fields.reason,
|
||||
model: SessionMessage.CompactionCompleted.fields.model,
|
||||
providerState: SessionMessage.CompactionCompleted.fields.providerState,
|
||||
providerContext: SessionMessage.CompactionCompleted.fields.providerContext,
|
||||
text: Schema.String,
|
||||
recent: Schema.String,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export * as SessionMessage from "./session-message.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { SessionProviderContext } from "./session-provider-context.js"
|
||||
import { optional } from "./schema.js"
|
||||
import { Content } from "./tool.js"
|
||||
import { Location } from "./location.js"
|
||||
@@ -254,6 +255,7 @@ export const CompactionCompleted = Schema.Struct({
|
||||
providerState: ProviderState.pipe(optional),
|
||||
summary: Schema.String,
|
||||
recent: Schema.String,
|
||||
providerContext: SessionProviderContext.Info.pipe(optional),
|
||||
}).annotate({ identifier: "Session.Message.Compaction.Completed" })
|
||||
|
||||
export interface CompactionFailed extends Schema.Schema.Type<typeof CompactionFailed> {}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
export * as SessionProviderContext from "./session-provider-context.js"
|
||||
|
||||
import { Schema } from "effect"
|
||||
import { Provider } from "./provider.js"
|
||||
|
||||
/** Exact producing model/deployment and route identity, never credentials or a connection ID. */
|
||||
export interface Provenance extends Schema.Schema.Type<typeof Provenance> {}
|
||||
export const Provenance = Schema.Struct({
|
||||
providerID: Provider.ID,
|
||||
provider: Schema.String,
|
||||
modelID: Schema.String,
|
||||
route: Schema.String,
|
||||
protocol: Schema.String,
|
||||
/** Digest of the configured endpoint; raw URLs and query values are not persisted. */
|
||||
endpoint: Schema.String,
|
||||
}).annotate({ identifier: "Session.ProviderContext.Provenance" })
|
||||
|
||||
/** Core validates the versioned canonical AI Message[] payload on installation and replay. */
|
||||
export interface Info extends Schema.Schema.Type<typeof Info> {}
|
||||
export const Info = Schema.Struct({
|
||||
version: Schema.Literal(1),
|
||||
provenance: Provenance,
|
||||
messages: Schema.Json,
|
||||
}).annotate({ identifier: "Session.ProviderContext" })
|
||||
@@ -48,3 +48,25 @@ test("failed steps only override the assistant finish for content filters", () =
|
||||
})
|
||||
expect(() => decode({ ...input, finish: "stop" })).toThrow()
|
||||
})
|
||||
|
||||
test("provider compaction context is optional, versioned and JSON-only", () => {
|
||||
const decode = Schema.decodeUnknownSync(SessionEvent.Compaction.Ended.data)
|
||||
const encode = Schema.encodeSync(SessionEvent.Compaction.Ended.data)
|
||||
const local = { sessionID: "ses_context", reason: "manual" as const, text: "summary", recent: "" }
|
||||
expect(encode({ ...decode(local), providerContext: undefined })).toEqual(local)
|
||||
const providerContext = {
|
||||
version: 1 as const,
|
||||
provenance: {
|
||||
providerID: "openai",
|
||||
provider: "openai",
|
||||
modelID: "deployment",
|
||||
route: "responses",
|
||||
protocol: "responses",
|
||||
endpoint: "digest",
|
||||
},
|
||||
messages: [{ role: "assistant", content: [{ type: "compaction", provider: "openai", encrypted: "opaque" }] }],
|
||||
}
|
||||
expect(encode(decode({ ...local, providerContext }))).toEqual({ ...local, providerContext })
|
||||
expect(() => decode({ ...local, providerContext: { ...providerContext, version: 2 } })).toThrow()
|
||||
expect(() => decode({ ...local, providerContext: { ...providerContext, messages: [() => "invalid"] } })).toThrow()
|
||||
})
|
||||
|
||||
@@ -2926,6 +2926,7 @@ function InlineTool(props: {
|
||||
pending: string
|
||||
failure?: string
|
||||
spinner?: boolean
|
||||
running?: boolean
|
||||
status?: JSX.Element
|
||||
children: JSX.Element
|
||||
part: SessionMessageAssistantTool
|
||||
@@ -2937,7 +2938,9 @@ function InlineTool(props: {
|
||||
const [errorExpanded, setErrorExpanded] = createSignal(false)
|
||||
const permission = useToolPermission(() => props.part)
|
||||
|
||||
const error = createMemo(() => (props.part.state.status === "error" ? props.part.state.error.message : undefined))
|
||||
const error = createMemo(() =>
|
||||
!props.running && props.part.state.status === "error" ? props.part.state.error.message : undefined,
|
||||
)
|
||||
|
||||
const denied = createMemo(
|
||||
() =>
|
||||
@@ -3482,6 +3485,7 @@ function Subagent(props: ToolProps) {
|
||||
<InlineTool
|
||||
icon={continuation() ? "↳" : isRunning() ? "│" : props.part.state.status === "completed" ? "✓" : "│"}
|
||||
spinner={!continuation() && isRunning()}
|
||||
running={isRunning()}
|
||||
complete={description()}
|
||||
pending="Delegating…"
|
||||
part={props.part}
|
||||
|
||||
@@ -231,7 +231,6 @@
|
||||
[data-component="button-v2"][data-variant="ghost-muted"] {
|
||||
background-color: transparent;
|
||||
color: var(--v2-text-text-muted);
|
||||
font-weight: 440;
|
||||
}
|
||||
|
||||
[data-component="button-v2"][data-variant="ghost-muted"] [data-slot="icon-svg"] {
|
||||
|
||||
@@ -100,14 +100,6 @@ const icons = {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2 10.668V14.0013H10.6667M13.9974 10.6667V2H2.66406M13.9974 10.668V14.0013H10.6641M2 10V2H5.33333" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M10.6693 10.6654V5.33203H5.33594V10.6654H10.6693Z" fill="currentColor"/>`,
|
||||
},
|
||||
"outline-worktree": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M12 3.11133L14.3885 5.49977L12 7.88822M14.5 5.49972H10.058L5.13456 11.8139M14.5 11.6606L8.5 11.6606M5.50012 11.6597H1.5" stroke="currentColor"/>`,
|
||||
},
|
||||
"outline-trash": {
|
||||
viewBox: "0 0 16 16",
|
||||
body: `<path d="M2.44434 4.22224H13.5554M5.99994 4.22224V2.44446C5.99994 1.95557 5.99992 1.55557 5.99992 1.55557H9.99989C9.99989 1.55557 9.99994 1.95557 9.99994 2.44446V4.22224M6.55545 7.77779L6.7485 11.7778M9.44437 7.77779L9.2513 11.7778M12.1756 6.88891L11.8666 12.7556C11.8168 13.7068 11.7748 14.4445 11.7748 14.4445H4.22511C4.22511 14.4445 4.18392 13.7067 4.13414 12.7556L3.82509 6.88891" stroke="currentColor"/>`,
|
||||
},
|
||||
close: {
|
||||
viewBox: "0 0 20 20",
|
||||
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,
|
||||
|
||||
@@ -65,13 +65,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
[data-component="tooltip-v2"][data-appearance="large"] {
|
||||
padding: 12px;
|
||||
font-size: 13px;
|
||||
line-height: var(--line-height-compact);
|
||||
letter-spacing: -0.04px;
|
||||
}
|
||||
|
||||
@keyframes tooltipV2In {
|
||||
from {
|
||||
opacity: 0;
|
||||
|
||||
@@ -8,7 +8,7 @@ Floating tooltip built on Kobalte's tooltip primitive.
|
||||
- \`value\`: Content rendered inside the floating tooltip.
|
||||
- \`children\`: The trigger element that activates the tooltip on hover/focus.
|
||||
- \`placement\`: Kobalte placement string (e.g. "top", "bottom", "left", "right").
|
||||
- \`appearance\`: \`compact\` (default), \`standard\`, or \`large\`.
|
||||
- \`appearance\`: \`compact\` (default) or \`standard\`.
|
||||
- \`inactive\`: When true, renders only the trigger without tooltip behavior.
|
||||
- \`forceOpen\`: Forces the tooltip to stay open.
|
||||
- Inherits Kobalte Tooltip root props.
|
||||
@@ -49,9 +49,6 @@ export const Appearances = {
|
||||
<Tooltip appearance="compact" value="Compact tooltip">
|
||||
<span>Compact</span>
|
||||
</Tooltip>
|
||||
<Tooltip appearance="large" value="Large tooltip">
|
||||
<span>Large</span>
|
||||
</Tooltip>
|
||||
</div>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import "./tooltip.css"
|
||||
|
||||
export interface TooltipProps extends ComponentProps<typeof Root> {
|
||||
value: JSX.Element
|
||||
appearance?: "standard" | "compact" | "large"
|
||||
appearance?: "standard" | "compact"
|
||||
class?: string
|
||||
contentClass?: string
|
||||
contentStyle?: JSX.CSSProperties
|
||||
|
||||
@@ -18221,11 +18221,20 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"recent": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18448,6 +18457,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18771,6 +18783,46 @@
|
||||
"Session.Metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.ProviderContext": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"enum": [1]
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
|
||||
},
|
||||
"messages": {}
|
||||
},
|
||||
"required": ["version", "provenance", "messages"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.ProviderContext.Provenance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
"route": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Revert": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -18221,11 +18221,20 @@
|
||||
"type": "string",
|
||||
"enum": ["auto", "manual"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model.Ref"
|
||||
},
|
||||
"providerState": {
|
||||
"$ref": "#/components/schemas/Session.Message.ProviderState_5"
|
||||
},
|
||||
"summary": {
|
||||
"type": "string"
|
||||
},
|
||||
"recent": {
|
||||
"type": "string"
|
||||
},
|
||||
"providerContext": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext"
|
||||
}
|
||||
},
|
||||
"required": ["type", "id", "time", "status", "reason", "summary", "recent"],
|
||||
@@ -18448,6 +18457,9 @@
|
||||
"Session.Message.ProviderState_4": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.ProviderState_5": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.Message.Shell": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -18771,6 +18783,46 @@
|
||||
"Session.Metadata": {
|
||||
"type": "object"
|
||||
},
|
||||
"Session.ProviderContext": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": {
|
||||
"type": "number",
|
||||
"enum": [1]
|
||||
},
|
||||
"provenance": {
|
||||
"$ref": "#/components/schemas/Session.ProviderContext.Provenance"
|
||||
},
|
||||
"messages": {}
|
||||
},
|
||||
"required": ["version", "provenance", "messages"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.ProviderContext.Provenance": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"providerID": {
|
||||
"type": "string"
|
||||
},
|
||||
"provider": {
|
||||
"type": "string"
|
||||
},
|
||||
"modelID": {
|
||||
"type": "string"
|
||||
},
|
||||
"route": {
|
||||
"type": "string"
|
||||
},
|
||||
"protocol": {
|
||||
"type": "string"
|
||||
},
|
||||
"endpoint": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": ["providerID", "provider", "modelID", "route", "protocol", "endpoint"],
|
||||
"additionalProperties": false
|
||||
},
|
||||
"Session.Revert": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
Reference in New Issue
Block a user