Merge remote-tracking branch 'origin/v2' into search-integration
# Conflicts: # packages/client/src/promise/generated/client.ts # packages/client/src/promise/generated/types.ts # packages/client/test/promise.test.ts # packages/core/src/plugin/host.ts # packages/core/src/plugin/internal.ts # packages/core/src/plugin/promise.ts # packages/core/test/plugin/host.ts # packages/plugin/src/v2/effect/index.ts # packages/plugin/src/v2/effect/integration.ts # packages/plugin/src/v2/promise/index.ts # packages/plugin/src/v2/promise/integration.ts # packages/protocol/src/client.ts # packages/schema/src/index.ts # packages/sdk/js/src/v2/gen/sdk.gen.ts
This commit is contained in:
commit
a6acc2397d
374 changed files with 15254 additions and 10009 deletions
|
|
@ -123,8 +123,8 @@ function manageIntegration(
|
|||
const credentials = credentialConnections(integration)
|
||||
const selected = createMemo(() => data.location.search.provider() === integration.id)
|
||||
const selectSearch = () => {
|
||||
void sdk.api.search
|
||||
.selectProvider({
|
||||
void sdk.api.search.provider
|
||||
.select({
|
||||
providerID: integration.id,
|
||||
location: location(data),
|
||||
})
|
||||
|
|
@ -256,8 +256,8 @@ function KeyMethod(props: {
|
|||
placeholder="API key"
|
||||
onConfirm={(key) => {
|
||||
if (!key) return
|
||||
void sdk.api.integration
|
||||
.connectKey({
|
||||
void sdk.api.integration.connect
|
||||
.key({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
key,
|
||||
|
|
@ -295,8 +295,8 @@ function OAuthStarting(props: {
|
|||
const toast = useToast()
|
||||
|
||||
onMount(() => {
|
||||
void sdk.api.integration
|
||||
.connectOauth({
|
||||
void sdk.api.integration.connect
|
||||
.oauth({
|
||||
integrationID: props.integration.id,
|
||||
location: location(data),
|
||||
methodID: props.method.id,
|
||||
|
|
@ -364,8 +364,8 @@ function OAuthAuto(props: {
|
|||
}))
|
||||
|
||||
const poll = () => {
|
||||
void sdk.api.integration
|
||||
.attemptStatus({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
void sdk.api.integration.attempt
|
||||
.status({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
.then((result) => {
|
||||
const status = result.data
|
||||
if (status.status === "pending") {
|
||||
|
|
@ -391,7 +391,7 @@ function OAuthAuto(props: {
|
|||
onCleanup(() => {
|
||||
if (timer) clearTimeout(timer)
|
||||
if (settled) return
|
||||
void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -421,7 +421,7 @@ function OAuthCode(props: {
|
|||
|
||||
onCleanup(() => {
|
||||
if (settled) return
|
||||
void sdk.api.integration.attemptCancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
void sdk.api.integration.attempt.cancel({ attemptID: props.attempt.attemptID, location: location(data) })
|
||||
})
|
||||
|
||||
return (
|
||||
|
|
@ -430,8 +430,8 @@ function OAuthCode(props: {
|
|||
placeholder="Authorization code"
|
||||
onConfirm={(code) => {
|
||||
if (!code) return
|
||||
void sdk.api.integration
|
||||
.attemptComplete({ attemptID: props.attempt.attemptID, location: location(data), code })
|
||||
void sdk.api.integration.attempt
|
||||
.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
|
||||
.then(() => {
|
||||
settled = true
|
||||
return connected(props.integration, data, dialog, toast, props.onConnected)
|
||||
|
|
|
|||
|
|
@ -19,8 +19,9 @@ import { Spinner } from "./spinner"
|
|||
import { DialogWorkspaceFileChanges } from "./dialog-workspace-file-changes"
|
||||
import type { ProjectDirectoriesOutput } from "@opencode-ai/client/promise"
|
||||
import { useRoute } from "../context/route"
|
||||
import { DialogProjectCopyName } from "./dialog-project-copy-name"
|
||||
|
||||
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
|
||||
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
|
||||
type ProjectDirectory = ProjectDirectoriesOutput[number]
|
||||
|
||||
type DialogMoveSessionProps = {
|
||||
|
|
@ -291,6 +292,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
if (await removedCurrent(deletingCurrent)) return
|
||||
}
|
||||
|
||||
async function create() {
|
||||
const name = await DialogProjectCopyName.show(dialog)
|
||||
if (name === null) return
|
||||
props.onSelect({ type: "new", name })
|
||||
}
|
||||
|
||||
const fullHeight = createMemo(() =>
|
||||
Math.max(8, Math.min(16, dimensions().height - Math.floor(dimensions().height / 4) - 2)),
|
||||
)
|
||||
|
|
@ -334,7 +341,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
|
|||
{
|
||||
command: "dialog.move_session.new",
|
||||
title: "new",
|
||||
onTrigger: () => props.onSelect({ type: "new" }),
|
||||
onTrigger: () => void create(),
|
||||
},
|
||||
{
|
||||
command: "dialog.move_session.delete",
|
||||
|
|
|
|||
95
packages/tui/src/component/dialog-project-copy-name.tsx
Normal file
95
packages/tui/src/component/dialog-project-copy-name.tsx
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import { InputRenderable, TextAttributes } from "@opentui/core"
|
||||
import { Slug } from "@opencode-ai/core/util/slug"
|
||||
import { createSignal, onMount } from "solid-js"
|
||||
import { useTuiConfig } from "../config"
|
||||
import { useTheme } from "../context/theme"
|
||||
import { useBindings, useCommandShortcut } from "../keymap"
|
||||
import { useDialog, type DialogContext } from "../ui/dialog"
|
||||
|
||||
export function DialogProjectCopyName(props: { onConfirm: (name: string) => void }) {
|
||||
const dialog = useDialog()
|
||||
const { theme } = useTheme()
|
||||
const tuiConfig = useTuiConfig()
|
||||
const generateShortcut = useCommandShortcut("dialog.project_copy.generate")
|
||||
const [inputTarget, setInputTarget] = createSignal<InputRenderable>()
|
||||
let input: InputRenderable
|
||||
|
||||
function generate() {
|
||||
input.value = Slug.create()
|
||||
input.gotoLineEnd()
|
||||
}
|
||||
|
||||
function confirm() {
|
||||
props.onConfirm(slugify(input.value) || Slug.create())
|
||||
}
|
||||
|
||||
useBindings(() => ({
|
||||
target: inputTarget,
|
||||
enabled: inputTarget() !== undefined,
|
||||
priority: 1,
|
||||
commands: [
|
||||
{
|
||||
name: "dialog.project_copy.generate",
|
||||
title: "Generate project copy name",
|
||||
category: "Dialog",
|
||||
run: generate,
|
||||
},
|
||||
],
|
||||
bindings: tuiConfig.keybinds.get("dialog.project_copy.generate"),
|
||||
}))
|
||||
|
||||
onMount(() => {
|
||||
dialog.setSize("medium")
|
||||
setTimeout(() => {
|
||||
if (!input || input.isDestroyed) return
|
||||
input.focus()
|
||||
}, 1)
|
||||
})
|
||||
|
||||
return (
|
||||
<box paddingLeft={2} paddingRight={2} gap={1}>
|
||||
<box flexDirection="row" justifyContent="space-between">
|
||||
<text attributes={TextAttributes.BOLD} fg={theme.text}>
|
||||
Name project copy
|
||||
</text>
|
||||
<text fg={theme.textMuted} onMouseUp={() => dialog.clear()}>
|
||||
esc
|
||||
</text>
|
||||
</box>
|
||||
<input
|
||||
ref={(value: InputRenderable) => {
|
||||
input = value
|
||||
setInputTarget(value)
|
||||
}}
|
||||
onSubmit={confirm}
|
||||
placeholder="Project copy name"
|
||||
placeholderColor={theme.textMuted}
|
||||
textColor={theme.text}
|
||||
focusedTextColor={theme.text}
|
||||
cursorColor={theme.text}
|
||||
/>
|
||||
<box paddingBottom={1} flexDirection="row" gap={2}>
|
||||
<text fg={theme.text}>
|
||||
enter <span style={{ fg: theme.textMuted }}>submit</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
{generateShortcut()} <span style={{ fg: theme.textMuted }}>generate one</span>
|
||||
</text>
|
||||
</box>
|
||||
</box>
|
||||
)
|
||||
}
|
||||
|
||||
DialogProjectCopyName.show = (dialog: DialogContext) =>
|
||||
new Promise<string | null>((resolve) => {
|
||||
dialog.replace(() => <DialogProjectCopyName onConfirm={resolve} />, () => resolve(null))
|
||||
})
|
||||
|
||||
function slugify(input: string) {
|
||||
return input
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { createMemo, createResource, createSignal, onMount } from "solid-js"
|
||||
import path from "path"
|
||||
import type { SessionV2Info } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionInfo } from "@opencode-ai/sdk/v2"
|
||||
import { useDialog } from "../ui/dialog"
|
||||
import { DialogSelect } from "../ui/dialog-select"
|
||||
import { useRoute } from "../context/route"
|
||||
|
|
@ -44,7 +44,7 @@ export function DialogSessionList() {
|
|||
workspace: location.workspaceID,
|
||||
})
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; session list UI reuses legacy mutable session types.
|
||||
return { query, sessions: structuredClone(response.data) as SessionV2Info[] }
|
||||
return { query, sessions: structuredClone(response.data) as SessionInfo[] }
|
||||
})
|
||||
|
||||
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
|
||||
|
|
@ -77,7 +77,7 @@ export function DialogSessionList() {
|
|||
const pinnedSet = new Set(pinned)
|
||||
const slotByID = new Map(local.session.slots().map((sessionID, index) => [sessionID, index + 1]))
|
||||
|
||||
const option = (session: SessionV2Info, category: string) => {
|
||||
const option = (session: SessionInfo, category: string) => {
|
||||
const directory = session.location.directory
|
||||
const footer = directory !== project.data.project.mainDir ? Locale.truncate(path.basename(directory), 20) : ""
|
||||
const slot = slotByID.get(session.id)
|
||||
|
|
@ -88,12 +88,11 @@ export function DialogSessionList() {
|
|||
category,
|
||||
footer,
|
||||
bg: deleting ? theme.error : undefined,
|
||||
gutter:
|
||||
data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.accent}>{slot}</text>,
|
||||
gutter: data.session.family(session.id).some((id) => data.session.status(id) === "running")
|
||||
? () => <Spinner />
|
||||
: slot === undefined
|
||||
? undefined
|
||||
: () => <text fg={theme.accent}>{slot}</text>,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,16 +134,14 @@ export function DialogSessionList() {
|
|||
setToDelete(option.value)
|
||||
return
|
||||
}
|
||||
void sdk.client.v2.session
|
||||
.remove({ sessionID: option.value }, { throwOnError: true })
|
||||
.catch((error) => {
|
||||
setToDelete(undefined)
|
||||
toast.show({
|
||||
message: `Failed to delete session: ${errorMessage(error)}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
void sdk.client.v2.session.remove({ sessionID: option.value }, { throwOnError: true }).catch((error) => {
|
||||
setToDelete(undefined)
|
||||
toast.show({
|
||||
message: `Failed to delete session: ${errorMessage(error)}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -45,10 +45,10 @@ export function DialogSkill(props: DialogSkillProps) {
|
|||
return list.map((skill) => ({
|
||||
title: skill.name.padEnd(maxWidth),
|
||||
description: skill.description?.replace(/\s+/g, " ").trim(),
|
||||
value: skill.name,
|
||||
value: skill.id,
|
||||
category: "Skills",
|
||||
onSelect: () => {
|
||||
props.onSelect(skill.name)
|
||||
props.onSelect(skill.id)
|
||||
dialog.clear()
|
||||
},
|
||||
}))
|
||||
|
|
|
|||
|
|
@ -448,12 +448,12 @@ export function Autocomplete(props: {
|
|||
|
||||
for (const skill of data.location.skill
|
||||
.list(location())
|
||||
?.filter((skill) => skill.slash === true && !commandNames.has(skill.name)) ?? []) {
|
||||
?.filter((skill) => skill.slash === true && !commandNames.has(skill.id)) ?? []) {
|
||||
results.push({
|
||||
display: "/" + skill.name,
|
||||
display: "/" + skill.id,
|
||||
description: skill.description,
|
||||
onSelect: () => {
|
||||
const newText = "/" + skill.name + " "
|
||||
const newText = "/" + skill.id + " "
|
||||
const cursor = props.input().logicalCursor
|
||||
props.input().deleteRange(0, 0, cursor.row, cursor.col)
|
||||
props.input().insertText(newText)
|
||||
|
|
|
|||
|
|
@ -37,7 +37,7 @@ import { usePromptStash } from "../../prompt/stash"
|
|||
import { DialogStash } from "../dialog-stash"
|
||||
import { type AutocompleteRef, Autocomplete } from "./autocomplete"
|
||||
import { useRenderer, useTerminalDimensions, type JSX } from "@opentui/solid"
|
||||
import type { AssistantMessage, SessionV2Info, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionInfo, UserMessage } from "@opencode-ai/sdk/v2"
|
||||
import { Locale } from "../../util/locale"
|
||||
import { errorMessage } from "../../util/error"
|
||||
import { createColors, createFrames } from "../../ui/spinner"
|
||||
|
|
@ -57,6 +57,7 @@ import { usePromptMove } from "./move"
|
|||
import { readLocalAttachment } from "./local-attachment"
|
||||
import { useData } from "../../context/data"
|
||||
import { useLocation } from "../../context/location"
|
||||
import { lastAssistantWithUsage } from "../../util/session"
|
||||
|
||||
registerOpencodeSpinner()
|
||||
|
||||
|
|
@ -164,9 +165,9 @@ export function Prompt(props: PromptProps) {
|
|||
const status = createMemo(() => data.session.status(props.sessionID ?? ""))
|
||||
const activeSubagents = createMemo(() => {
|
||||
if (!props.sessionID) return 0
|
||||
return data.session.family(props.sessionID).filter(
|
||||
(id) => id !== props.sessionID && data.session.status(id) === "running",
|
||||
).length
|
||||
return data.session
|
||||
.family(props.sessionID)
|
||||
.filter((id) => id !== props.sessionID && data.session.status(id) === "running").length
|
||||
})
|
||||
const runningShells = createMemo(
|
||||
() => data.shell.list(currentLocation()).filter((shell) => shell.metadata.sessionID === props.sessionID).length,
|
||||
|
|
@ -218,7 +219,10 @@ export function Prompt(props: PromptProps) {
|
|||
const editorContextLabelState = createMemo(() => editor.labelState())
|
||||
const [auto, setAuto] = createSignal<AutocompleteRef>()
|
||||
const workspace = usePromptWorkspace(props.sessionID)
|
||||
const move = usePromptMove({ projectID: project.project, sessionID: () => props.sessionID })
|
||||
const move = usePromptMove({
|
||||
projectID: () => (props.sessionID ? data.session.get(props.sessionID)?.projectID : undefined) ?? project.project(),
|
||||
sessionID: () => props.sessionID,
|
||||
})
|
||||
const [cursorVersion, setCursorVersion] = createSignal(0)
|
||||
const currentProviderLabel = createMemo(() => local.model.parsed().provider)
|
||||
const connected = useConnected()
|
||||
|
|
@ -273,18 +277,20 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
const usage = createMemo(() => {
|
||||
if (!props.sessionID) return
|
||||
const session = sync.session.get(props.sessionID)
|
||||
const msg = sync.data.message[props.sessionID] ?? []
|
||||
const last = msg.findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
||||
const session = data.session.get(props.sessionID)
|
||||
if (!session) return
|
||||
const last = lastAssistantWithUsage(data.session.message.list(props.sessionID), session.revert?.messageID)
|
||||
if (!last) return
|
||||
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (tokens <= 0) return
|
||||
|
||||
const model = sync.data.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
||||
const model = data.location
|
||||
.model.list(session.location)
|
||||
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
|
||||
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
||||
const cost = session?.cost ?? 0
|
||||
const cost = session.cost
|
||||
return {
|
||||
context: pct ? `${Locale.number(tokens)} (${pct})` : Locale.number(tokens),
|
||||
cost: cost > 0 ? money.format(cost) : undefined,
|
||||
|
|
@ -955,13 +961,13 @@ export function Prompt(props: PromptProps) {
|
|||
if (workspace.creating() || move.creating()) return false
|
||||
if (auto()?.visible) return false
|
||||
if (!store.prompt.text) return false
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return false
|
||||
const trimmed = store.prompt.text.trim()
|
||||
if (trimmed === "exit" || trimmed === "quit" || trimmed === ":q") {
|
||||
void exit()
|
||||
return true
|
||||
}
|
||||
const agent = local.agent.current()
|
||||
if (!agent) return false
|
||||
const selectedModel = local.model.current()
|
||||
if (!selectedModel) {
|
||||
void promptModelWarning()
|
||||
|
|
@ -991,7 +997,7 @@ export function Prompt(props: PromptProps) {
|
|||
const selectedWorkspace = workspace.selection()
|
||||
const workspaceID = selectedWorkspace?.type === "existing" ? selectedWorkspace.workspaceID : undefined
|
||||
|
||||
const directory = await move.getDirectory(store.prompt.text)
|
||||
const directory = await move.getDirectory()
|
||||
if (move.pending() && !directory) return false
|
||||
finishMoveProgress = Boolean(move.progress())
|
||||
const location = data.location.default()
|
||||
|
|
@ -1022,7 +1028,7 @@ export function Prompt(props: PromptProps) {
|
|||
|
||||
sessionID = created.id
|
||||
// oxlint-disable-next-line typescript-eslint/no-unsafe-type-assertion -- generated client output is readonly; prompt state still uses legacy mutable session types.
|
||||
session = structuredClone(created) as SessionV2Info
|
||||
session = structuredClone(created) as SessionInfo
|
||||
}
|
||||
|
||||
const inputText = expandTrackedPastedText(
|
||||
|
|
@ -1093,7 +1099,7 @@ export function Prompt(props: PromptProps) {
|
|||
} else if (
|
||||
inputText.startsWith("/") &&
|
||||
(data.location.skill.list(currentLocation()) ?? []).some(
|
||||
(skill) => skill.slash === true && skill.name === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
(skill) => skill.slash === true && skill.id === inputText.split("\n")[0].split(" ")[0].slice(1),
|
||||
)
|
||||
) {
|
||||
move.startSubmit()
|
||||
|
|
@ -1121,7 +1127,7 @@ export function Prompt(props: PromptProps) {
|
|||
})
|
||||
}
|
||||
if (session?.revert) {
|
||||
const error = await sdk.api.session.revertCommit({ sessionID }).then(
|
||||
const error = await sdk.api.session.revert.commit({ sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
|
|
@ -1621,11 +1627,11 @@ export function Prompt(props: PromptProps) {
|
|||
<text fg={theme.text}>
|
||||
{agentShortcut()} <span style={{ fg: theme.textMuted }}>agents</span>
|
||||
</text>
|
||||
<text fg={theme.text}>
|
||||
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={theme.text}>
|
||||
{paletteShortcut()} <span style={{ fg: theme.textMuted }}>commands</span>
|
||||
</text>
|
||||
</Match>
|
||||
<Match when={store.mode === "shell"}>
|
||||
<text fg={theme.text}>
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import { useTuiPaths } from "../../context/runtime"
|
|||
import { errorMessage } from "../../util/error"
|
||||
import { useDialog } from "../../ui/dialog"
|
||||
import { useSDK } from "../../context/sdk"
|
||||
import { useSync } from "../../context/sync"
|
||||
import { useToast } from "../../ui/toast"
|
||||
import { DialogMoveSession, type MoveSessionSelection } from "../dialog-move-session"
|
||||
import { DialogWorkspaceFileChanges } from "../dialog-workspace-file-changes"
|
||||
import { useHomeSessionDestination } from "../../routes/home/session-destination"
|
||||
import { useProject } from "../../context/project"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
function moveReminderText(directory: string) {
|
||||
return `<system-reminder>The user has changed the current working directory to "${directory}". This is still the same project but at a possibly new location; take this into account when working with any files from now on.</system-reminder>`
|
||||
|
|
@ -18,38 +18,33 @@ function moveReminderText(directory: string) {
|
|||
export function usePromptMove(input: { projectID: () => string | undefined; sessionID: () => string | undefined }) {
|
||||
const dialog = useDialog()
|
||||
const sdk = useSDK()
|
||||
const sync = useSync()
|
||||
const toast = useToast()
|
||||
const homeDestination = useHomeSessionDestination()
|
||||
const project = useProject()
|
||||
const data = useData()
|
||||
const paths = useTuiPaths()
|
||||
const [creating, setCreating] = createSignal(false)
|
||||
const [creatingDots, setCreatingDots] = createSignal(3)
|
||||
const [progress, setProgress] = createSignal<string>()
|
||||
|
||||
async function create(context?: string) {
|
||||
const projectID = input.projectID()
|
||||
async function create(name: string) {
|
||||
const projectID = await resolveProjectID()
|
||||
if (!projectID) return
|
||||
setCreating(true)
|
||||
setProgress("Creating copy")
|
||||
try {
|
||||
const generated = await sdk.client.experimental.projectCopy.generateName(
|
||||
{ projectID, context },
|
||||
{ throwOnError: true },
|
||||
)
|
||||
const result = await sdk.api.projectCopy.create({
|
||||
projectID,
|
||||
location: { directory: project.instance.directory() || paths.cwd },
|
||||
strategy: "git_worktree",
|
||||
directory: path.join(paths.worktree, projectID.slice(0, 6)),
|
||||
name: generated.data.name,
|
||||
name,
|
||||
})
|
||||
const directory = result.directory
|
||||
if (!directory) throw new Error("No project copy directory returned")
|
||||
|
||||
// Call a location-based route to make sure it's bootstrapped
|
||||
// before moving on
|
||||
await sdk.client.path.get({ directory }, { throwOnError: true })
|
||||
// Call a location-based route to make sure it's bootstrapped before moving on.
|
||||
await sdk.api.location.get({ location: { directory } })
|
||||
|
||||
setProgress("Creating session")
|
||||
return directory
|
||||
|
|
@ -62,11 +57,14 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
}
|
||||
}
|
||||
|
||||
function open() {
|
||||
const projectID = input.projectID()
|
||||
if (!projectID) return
|
||||
async function open() {
|
||||
const projectID = await resolveProjectID()
|
||||
if (!projectID) {
|
||||
toast.show({ message: "Unable to determine current project", variant: "error" })
|
||||
return
|
||||
}
|
||||
const sessionID = input.sessionID()
|
||||
const session = sessionID ? sync.session.get(sessionID) : undefined
|
||||
const session = sessionID ? await resolveSession(sessionID) : undefined
|
||||
dialog.replace(() => (
|
||||
<DialogMoveSession
|
||||
projectID={projectID}
|
||||
|
|
@ -75,8 +73,8 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
(session
|
||||
? {
|
||||
type: "directory",
|
||||
directory: session.directory,
|
||||
subdirectory: !!session.path,
|
||||
directory: session.location.directory,
|
||||
subdirectory: !!session.subpath,
|
||||
}
|
||||
: {
|
||||
type: "directory",
|
||||
|
|
@ -98,26 +96,13 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
))
|
||||
}
|
||||
|
||||
function sessionContext(sessionID: string) {
|
||||
const session = sync.session.get(sessionID)
|
||||
const messages = (sync.data.message[sessionID] ?? [])
|
||||
.slice(-6)
|
||||
.map((message) =>
|
||||
[
|
||||
message.role + ":",
|
||||
...(sync.data.part[message.id] ?? []).flatMap((part) => (part.type === "text" ? [part.text] : [])),
|
||||
].join(" "),
|
||||
)
|
||||
return [session?.title, ...messages].filter(Boolean).join("\n") || undefined
|
||||
}
|
||||
|
||||
async function moveExistingSession(sessionID: string, selection: MoveSessionSelection) {
|
||||
const session = sync.session.get(sessionID)
|
||||
const status = await sdk.client.vcs.status({ directory: session?.directory }).catch(() => undefined)
|
||||
const session = await resolveSession(sessionID)
|
||||
const status = await sdk.client.vcs.status({ directory: session?.location.directory }).catch(() => undefined)
|
||||
const choice = status?.data?.length ? await DialogWorkspaceFileChanges.show(dialog, status.data) : "no"
|
||||
if (!choice) return
|
||||
dialog.clear()
|
||||
const directory = selection.type === "new" ? await create(sessionContext(sessionID)) : selection.directory
|
||||
const directory = selection.type === "new" ? await create(selection.name) : selection.directory
|
||||
if (!directory) {
|
||||
setProgress(undefined)
|
||||
dialog.clear()
|
||||
|
|
@ -125,27 +110,9 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
}
|
||||
setProgress("Moving session")
|
||||
try {
|
||||
await sdk.client.experimental.controlPlane.moveSession(
|
||||
{
|
||||
sessionID,
|
||||
destination: { directory },
|
||||
moveChanges: choice === "yes",
|
||||
},
|
||||
{ throwOnError: true },
|
||||
)
|
||||
await sdk.client.session
|
||||
.promptAsync({
|
||||
sessionID,
|
||||
directory,
|
||||
noReply: true,
|
||||
parts: [
|
||||
{
|
||||
type: "text",
|
||||
text: moveReminderText(directory),
|
||||
synthetic: true,
|
||||
},
|
||||
],
|
||||
})
|
||||
await sdk.api.session.move({ sessionID, destination: { directory }, moveChanges: choice === "yes" })
|
||||
await sdk.api.session
|
||||
.synthetic({ sessionID, text: moveReminderText(directory), resume: false })
|
||||
.catch(() => undefined)
|
||||
dialog.clear()
|
||||
} catch (error) {
|
||||
|
|
@ -157,16 +124,34 @@ export function usePromptMove(input: { projectID: () => string | undefined; sess
|
|||
}
|
||||
}
|
||||
|
||||
async function resolveProjectID() {
|
||||
const projectID = input.projectID()
|
||||
if (projectID) return projectID
|
||||
const sessionID = input.sessionID()
|
||||
if (sessionID) return (await resolveSession(sessionID))?.projectID
|
||||
return sdk.api.project
|
||||
.current({ location: { directory: project.instance.directory() || paths.cwd } })
|
||||
.then((project) => project.id)
|
||||
.catch(() => undefined)
|
||||
}
|
||||
|
||||
async function resolveSession(sessionID: string) {
|
||||
const session = data.session.get(sessionID)
|
||||
if (session) return session
|
||||
await data.session.refresh(sessionID).catch(() => undefined)
|
||||
return data.session.get(sessionID)
|
||||
}
|
||||
|
||||
const pending = createMemo(() => Boolean(homeDestination?.destination()))
|
||||
const pendingNew = createMemo(() => homeDestination?.destination()?.type === "new")
|
||||
|
||||
async function getDirectory(context?: string) {
|
||||
async function getDirectory() {
|
||||
const value = homeDestination?.destination()
|
||||
if (!value) return
|
||||
if (value.type === "directory") {
|
||||
return value.directory
|
||||
}
|
||||
return await create(context)
|
||||
return await create(value.name)
|
||||
}
|
||||
|
||||
function startSubmit() {
|
||||
|
|
|
|||
|
|
@ -207,6 +207,7 @@ export const Definitions = {
|
|||
"dialog.select.end": keybind("end", "Move to last dialog item"),
|
||||
"dialog.select.submit": keybind("return", "Submit selected dialog item"),
|
||||
"dialog.prompt.submit": keybind("return", "Submit dialog prompt"),
|
||||
"dialog.project_copy.generate": keybind("tab", "Generate project copy name"),
|
||||
"dialog.mcp.toggle": keybind("space", "Toggle MCP in MCP dialog"),
|
||||
"dialog.move_session.new": keybind("ctrl+m", "New project copy"),
|
||||
"dialog.move_session.delete": keybind("ctrl+d", "Delete project copy"),
|
||||
|
|
|
|||
|
|
@ -1,56 +1,71 @@
|
|||
// Client data layer: apply server events and cache API reads into a Solid store.
|
||||
// Prefer straightforward projection. Do not add generation counters, stale-response
|
||||
// merges, live/history overlays, or other race machinery here—last write wins.
|
||||
// Reconnect may re-bootstrap; that is enough. UI and the server own ordering concerns.
|
||||
|
||||
import type {
|
||||
AgentV2Info,
|
||||
CommandV2Info,
|
||||
AgentInfo,
|
||||
CommandInfo,
|
||||
FormFormInfo,
|
||||
FormIntegrationInfo,
|
||||
FormUrlInfo,
|
||||
IntegrationInfo,
|
||||
LocationRef,
|
||||
McpServer,
|
||||
ModelV2Info,
|
||||
ModelInfo,
|
||||
PermissionSavedInfo,
|
||||
PermissionV2Request,
|
||||
ProviderV2Info,
|
||||
ReferenceInfo,
|
||||
SessionMessage,
|
||||
SessionMessageInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionV2Info,
|
||||
SessionInfo,
|
||||
Shell,
|
||||
SkillV2Info,
|
||||
SkillInfo,
|
||||
V2Event,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createSimpleContext } from "./helper"
|
||||
import { useSDK } from "./sdk"
|
||||
import { createSignal, onCleanup } from "solid-js"
|
||||
import { batch, createSignal, onCleanup } from "solid-js"
|
||||
|
||||
export type DataSessionStatus = "idle" | "running"
|
||||
|
||||
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
|
||||
const MESSAGE_PAGE_SIZE = 25
|
||||
|
||||
export type FormInfo = FormFormInfo | FormUrlInfo | FormIntegrationInfo
|
||||
|
||||
// Per-session message timeline plus older-history paging. `items` is ascending;
|
||||
// `cursor` is the opaque server cursor for the next older page after a desc first load.
|
||||
type SessionMessages = {
|
||||
items: SessionMessageInfo[]
|
||||
cursor?: string
|
||||
complete: boolean
|
||||
loading: boolean
|
||||
}
|
||||
|
||||
type LocationData = {
|
||||
agent?: AgentV2Info[]
|
||||
command?: CommandV2Info[]
|
||||
agent?: AgentInfo[]
|
||||
command?: CommandInfo[]
|
||||
integration?: IntegrationInfo[]
|
||||
mcp?: McpServer[]
|
||||
model?: ModelV2Info[]
|
||||
model?: ModelInfo[]
|
||||
provider?: ProviderV2Info[]
|
||||
reference?: ReferenceInfo[]
|
||||
searchProvider?: string | null
|
||||
// Currently running shell commands for this location, keyed by shell id. Entries are removed
|
||||
// once the command exits or is deleted, so this only ever holds in-flight shells.
|
||||
shell?: Record<string, Shell>
|
||||
skill?: SkillV2Info[]
|
||||
skill?: SkillInfo[]
|
||||
}
|
||||
|
||||
type Data = {
|
||||
session: {
|
||||
info: Record<string, SessionV2Info>
|
||||
info: Record<string, SessionInfo>
|
||||
// Family index keyed by a family's root (or furthest-known-ancestor when the
|
||||
// true root is not yet loaded). The value is a flat deduplicated list of every
|
||||
// session ID in that family, including the key itself once its info arrives.
|
||||
|
|
@ -58,7 +73,7 @@ type Data = {
|
|||
status: Record<string, DataSessionStatus>
|
||||
compaction: Partial<Record<string, string>>
|
||||
compactionReason: Partial<Record<string, "auto" | "manual">>
|
||||
message: Record<string, SessionMessage[]>
|
||||
message: Record<string, SessionMessages>
|
||||
input: Record<string, string[]>
|
||||
permission: Record<string, PermissionV2Request[]>
|
||||
// Pending forms keyed by session ID.
|
||||
|
|
@ -70,6 +85,10 @@ type Data = {
|
|||
location: Record<string, LocationData>
|
||||
}
|
||||
|
||||
function emptyMessages(): SessionMessages {
|
||||
return { items: [], complete: false, loading: false }
|
||||
}
|
||||
|
||||
function locationKey(location: LocationRef) {
|
||||
return JSON.stringify([location.directory, location.workspaceID])
|
||||
}
|
||||
|
|
@ -112,47 +131,42 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
directory: process.cwd(),
|
||||
})
|
||||
const messageIndex = new Map<string, Map<string, number>>()
|
||||
let connectionGeneration = 0
|
||||
let statusChanges: Set<string> | undefined
|
||||
let bootstrapping: Promise<void> | undefined
|
||||
|
||||
function setSessionStatus(sessionID: string, status: DataSessionStatus) {
|
||||
statusChanges?.add(sessionID)
|
||||
setStore("session", "status", sessionID, status)
|
||||
}
|
||||
|
||||
const message = {
|
||||
update(sessionID: string, fn: (messages: SessionMessage[], index: Map<string, number>) => void) {
|
||||
update(sessionID: string, fn: (messages: SessionMessageInfo[], index: Map<string, number>) => void) {
|
||||
setStore(
|
||||
"session",
|
||||
"message",
|
||||
produce((draft) => {
|
||||
fn((draft[sessionID] ??= []), index(sessionID))
|
||||
fn((draft[sessionID] ??= emptyMessages()).items, index(sessionID))
|
||||
}),
|
||||
)
|
||||
},
|
||||
append(messages: SessionMessage[], index: Map<string, number>, item: SessionMessage) {
|
||||
append(messages: SessionMessageInfo[], index: Map<string, number>, item: SessionMessageInfo) {
|
||||
if (index.has(item.id)) return
|
||||
index.set(item.id, messages.length)
|
||||
messages.push(item)
|
||||
},
|
||||
activeAssistant(messages: SessionMessage[]) {
|
||||
activeAssistant(messages: SessionMessageInfo[]) {
|
||||
const item = messages.findLast((item) => item.type === "assistant" && !item.time.completed)
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
assistant(messages: SessionMessage[], index: Map<string, number>, messageID: string) {
|
||||
assistant(messages: SessionMessageInfo[], index: Map<string, number>, messageID: string) {
|
||||
const position = index.get(messageID)
|
||||
const item = position === undefined ? undefined : messages[position]
|
||||
return item?.type === "assistant" ? item : undefined
|
||||
},
|
||||
shell(messages: SessionMessage[], shellID: string) {
|
||||
const item = messages.findLast((item) => item.type === "shell" && item.shell.id === shellID)
|
||||
shell(messages: SessionMessageInfo[], shellID: string) {
|
||||
const item = messages.findLast((item) => item.type === "shell" && item.shellID === shellID)
|
||||
return item?.type === "shell" ? item : undefined
|
||||
},
|
||||
compaction(messages: SessionMessage[]) {
|
||||
const item = messages.findLast(
|
||||
(item) => item.type === "compaction" && (item.status === "queued" || item.status === "running"),
|
||||
)
|
||||
compaction(messages: SessionMessageInfo[]) {
|
||||
const item = messages.findLast((item) => item.type === "compaction" && item.status === "running")
|
||||
return item?.type === "compaction" ? item : undefined
|
||||
},
|
||||
latestTool(assistant: SessionMessageAssistant | undefined, callID?: string) {
|
||||
|
|
@ -252,6 +266,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
case "session.deleted":
|
||||
removeSession(event.data.sessionID)
|
||||
break
|
||||
case "session.usage.updated":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, {
|
||||
cost: event.data.cost,
|
||||
tokens: event.data.tokens,
|
||||
})
|
||||
break
|
||||
case "catalog.updated":
|
||||
void Promise.all([
|
||||
result.location.model.refresh(event.location),
|
||||
|
|
@ -306,6 +327,12 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
if (store.session.info[event.data.sessionID])
|
||||
setStore("session", "info", event.data.sessionID, "title", event.data.title)
|
||||
break
|
||||
case "session.moved":
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
setStore("session", "info", event.data.sessionID, "location", mutable(event.data.location))
|
||||
setStore("session", "info", event.data.sessionID, "subpath", event.data.subpath)
|
||||
}
|
||||
break
|
||||
case "session.prompt.promoted": {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const position = index.get(event.data.inputID)
|
||||
|
|
@ -360,7 +387,6 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "synthetic",
|
||||
sessionID: event.data.sessionID,
|
||||
text: event.data.text,
|
||||
description: event.data.description,
|
||||
time: { created: event.created },
|
||||
|
|
@ -372,7 +398,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.append(draft, index, {
|
||||
id: messageIDFromEvent(event.id),
|
||||
type: "shell",
|
||||
shell: event.data.shell,
|
||||
shellID: event.data.shell.id,
|
||||
command: event.data.shell.command,
|
||||
status: event.data.shell.status,
|
||||
exit: event.data.shell.exit,
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
|
|
@ -381,7 +410,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.update(event.data.sessionID, (draft) => {
|
||||
const match = message.shell(draft, event.data.shell.id)
|
||||
if (!match) return
|
||||
match.shell = event.data.shell
|
||||
match.command = event.data.shell.command
|
||||
match.status = event.data.shell.status
|
||||
match.exit = event.data.shell.exit
|
||||
match.output = event.data.output
|
||||
match.time.completed = event.created
|
||||
})
|
||||
|
|
@ -416,7 +447,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
})
|
||||
})
|
||||
break
|
||||
case "session.step.ended":
|
||||
case "session.step.ended": {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
if (!currentAssistant) return
|
||||
|
|
@ -428,6 +459,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
currentAssistant.snapshot = { ...currentAssistant.snapshot, end: event.data.snapshot }
|
||||
})
|
||||
break
|
||||
}
|
||||
case "session.step.failed":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const currentAssistant = message.assistant(draft, index, event.data.assistantMessageID)
|
||||
|
|
@ -436,6 +468,10 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
currentAssistant.finish = "error"
|
||||
currentAssistant.error = event.data.error
|
||||
currentAssistant.retry = undefined
|
||||
if (event.data.cost !== undefined && event.data.tokens !== undefined) {
|
||||
currentAssistant.cost = event.data.cost
|
||||
currentAssistant.tokens = event.data.tokens
|
||||
}
|
||||
})
|
||||
break
|
||||
case "session.text.started":
|
||||
|
|
@ -465,7 +501,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
id: event.data.callID,
|
||||
name: event.data.name,
|
||||
time: { created: event.created },
|
||||
state: { status: "pending", input: "" },
|
||||
state: { status: "streaming", input: "" },
|
||||
})
|
||||
})
|
||||
break
|
||||
|
|
@ -475,7 +511,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "pending") match.state.input += event.data.delta
|
||||
if (match?.state.status === "streaming") match.state.input += event.data.delta
|
||||
})
|
||||
break
|
||||
case "session.tool.input.ended":
|
||||
|
|
@ -484,7 +520,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (match?.state.status === "pending") match.state.input = event.data.text
|
||||
if (match?.state.status === "streaming") match.state.input = event.data.text
|
||||
})
|
||||
break
|
||||
case "session.tool.called":
|
||||
|
|
@ -536,7 +572,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
message.assistant(draft, index, event.data.assistantMessageID),
|
||||
event.data.callID,
|
||||
)
|
||||
if (!match || (match.state.status !== "pending" && match.state.status !== "running")) return
|
||||
if (!match || (match.state.status !== "streaming" && match.state.status !== "running")) return
|
||||
match.state = {
|
||||
status: "error",
|
||||
error: event.data.error,
|
||||
|
|
@ -591,27 +627,27 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
setSessionStatus(event.data.sessionID, "running")
|
||||
break
|
||||
case "session.compaction.admitted":
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
if (message.compaction(draft)) return
|
||||
message.append(draft, index, {
|
||||
id: event.data.inputID,
|
||||
type: "compaction",
|
||||
status: "queued",
|
||||
reason: "manual",
|
||||
summary: "",
|
||||
recent: "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.compaction.started":
|
||||
setStore("session", "compaction", event.data.sessionID, "")
|
||||
setStore("session", "compactionReason", event.data.sessionID, event.data.reason)
|
||||
if (event.data.reason === "manual")
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const current = message.compaction(draft)
|
||||
if (current) current.status = "running"
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const current = message.compaction(draft)
|
||||
if (current) {
|
||||
current.status = "running"
|
||||
current.reason = event.data.reason
|
||||
return
|
||||
}
|
||||
message.append(draft, index, {
|
||||
id: event.data.inputID ?? messageIDFromEvent(event.id),
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
reason: event.data.reason,
|
||||
summary: "",
|
||||
recent: event.data.recent ?? "",
|
||||
time: { created: event.created },
|
||||
})
|
||||
})
|
||||
break
|
||||
case "session.execution.succeeded":
|
||||
case "session.execution.failed":
|
||||
|
|
@ -635,8 +671,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
break
|
||||
case "session.revert.committed":
|
||||
if (store.session.info[event.data.sessionID])
|
||||
if (store.session.info[event.data.sessionID]) {
|
||||
setStore("session", "info", event.data.sessionID, "revert", undefined)
|
||||
}
|
||||
setStore(
|
||||
"session",
|
||||
"input",
|
||||
|
|
@ -651,22 +688,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
break
|
||||
case "session.compaction.delta":
|
||||
setStore("session", "compaction", event.data.sessionID, (text) => (text ?? "") + event.data.text)
|
||||
if (store.session.compactionReason[event.data.sessionID] === "manual")
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const current = message.compaction(draft)
|
||||
if (current) current.summary += event.data.text
|
||||
})
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
const current = message.compaction(draft)
|
||||
if (current) current.summary += event.data.text
|
||||
})
|
||||
break
|
||||
case "session.compaction.ended":
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
setStore("session", "compactionReason", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const current = event.data.reason === "manual" ? message.compaction(draft) : undefined
|
||||
const current = message.compaction(draft)
|
||||
if (current) {
|
||||
current.status = "completed"
|
||||
current.reason = event.data.reason
|
||||
current.summary = event.data.text
|
||||
current.recent = event.data.recent
|
||||
const position = index.get(current.id)
|
||||
if (position !== undefined)
|
||||
draft[position] = {
|
||||
...current,
|
||||
status: "completed",
|
||||
reason: event.data.reason,
|
||||
summary: event.data.text,
|
||||
recent: event.data.recent,
|
||||
}
|
||||
return
|
||||
}
|
||||
message.append(draft, index, {
|
||||
|
|
@ -683,9 +724,17 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
case "session.compaction.failed":
|
||||
setStore("session", "compaction", event.data.sessionID, undefined)
|
||||
setStore("session", "compactionReason", event.data.sessionID, undefined)
|
||||
message.update(event.data.sessionID, (draft) => {
|
||||
message.update(event.data.sessionID, (draft, index) => {
|
||||
const current = message.compaction(draft)
|
||||
if (current) current.status = "failed"
|
||||
if (!current) return
|
||||
const position = index.get(current.id)
|
||||
if (position !== undefined)
|
||||
draft[position] = {
|
||||
...current,
|
||||
status: "failed",
|
||||
reason: event.data.reason,
|
||||
error: event.data.error,
|
||||
}
|
||||
})
|
||||
break
|
||||
case "permission.v2.asked":
|
||||
|
|
@ -815,35 +864,38 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
},
|
||||
message: {
|
||||
ids(sessionID: string) {
|
||||
return (store.session.message[sessionID] ?? []).map((message) => message.id)
|
||||
return (store.session.message[sessionID]?.items ?? []).map((message) => message.id)
|
||||
},
|
||||
list(sessionID: string) {
|
||||
return store.session.message[sessionID] ?? []
|
||||
return store.session.message[sessionID]?.items ?? []
|
||||
},
|
||||
get(sessionID: string, messageID: string) {
|
||||
const messages = store.session.message[sessionID]
|
||||
const messages = store.session.message[sessionID]?.items
|
||||
const position = messageIndex.get(sessionID)?.get(messageID)
|
||||
return position === undefined ? undefined : messages?.[position]
|
||||
},
|
||||
cursor(sessionID: string) {
|
||||
return store.session.message[sessionID]?.cursor
|
||||
},
|
||||
complete(sessionID: string) {
|
||||
return store.session.message[sessionID]?.complete ?? false
|
||||
},
|
||||
loading(sessionID: string) {
|
||||
return store.session.message[sessionID]?.loading ?? false
|
||||
},
|
||||
async refresh(sessionID: string) {
|
||||
const live = [...(store.session.message[sessionID] ?? [])]
|
||||
setStore("session", "message", sessionID, [])
|
||||
setStore("session", "message", sessionID, { ...emptyMessages(), loading: true })
|
||||
messageIndex.set(sessionID, new Map())
|
||||
const loaded = mutable(
|
||||
(await sdk.api.message.list({ sessionID, limit: 200, order: "desc" })).data,
|
||||
).toReversed()
|
||||
const loadedIDs = new Set(loaded.map((message) => message.id))
|
||||
const liveByID = new Map(live.map((message) => [message.id, message]))
|
||||
const messages = [
|
||||
...loaded.map((message) => {
|
||||
if (message.type === "user") return message
|
||||
return liveByID.get(message.id) ?? message
|
||||
}),
|
||||
...live.filter((message) => !loadedIDs.has(message.id)),
|
||||
].toSorted((a, b) => a.time.created - b.time.created)
|
||||
messageIndex.set(sessionID, new Map(messages.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, messages)
|
||||
const running = messages.find((message) => message.type === "compaction" && message.status === "running")
|
||||
const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, order: "desc" })
|
||||
const items = mutable(response.data).toReversed()
|
||||
messageIndex.set(sessionID, new Map(items.map((message, index) => [message.id, index])))
|
||||
setStore("session", "message", sessionID, {
|
||||
items,
|
||||
cursor: response.cursor.next ?? undefined,
|
||||
complete: response.data.length < MESSAGE_PAGE_SIZE,
|
||||
loading: false,
|
||||
})
|
||||
const running = items.find((message) => message.type === "compaction" && message.status === "running")
|
||||
setStore("session", "compaction", sessionID, running?.type === "compaction" ? running.summary : undefined)
|
||||
setStore(
|
||||
"session",
|
||||
|
|
@ -852,6 +904,25 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
running?.type === "compaction" ? running.reason : undefined,
|
||||
)
|
||||
},
|
||||
async more(sessionID: string) {
|
||||
const current = store.session.message[sessionID]
|
||||
if (!current || current.loading || current.complete || !current.cursor) return
|
||||
const cursor = current.cursor
|
||||
setStore("session", "message", sessionID, "loading", true)
|
||||
const response = await sdk.api.message.list({ sessionID, limit: MESSAGE_PAGE_SIZE, cursor })
|
||||
const older = mutable(response.data).toReversed()
|
||||
const prepend = older.filter((item) => !messageIndex.get(sessionID)?.has(item.id))
|
||||
const items = [...prepend, ...current.items]
|
||||
messageIndex.set(sessionID, new Map(items.map((item, position) => [item.id, position])))
|
||||
batch(() => {
|
||||
setStore("session", "message", sessionID, "items", items)
|
||||
setStore("session", "message", sessionID, {
|
||||
cursor: response.cursor.next ?? undefined,
|
||||
complete: response.data.length < MESSAGE_PAGE_SIZE,
|
||||
loading: false,
|
||||
})
|
||||
})
|
||||
},
|
||||
},
|
||||
permission: {
|
||||
list(sessionID: string) {
|
||||
|
|
@ -876,7 +947,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
return store.project.permission[projectID]
|
||||
},
|
||||
async refresh(projectID: string) {
|
||||
setStore("project", "permission", projectID, mutable(await sdk.api.permission.listSaved({ projectID })))
|
||||
setStore("project", "permission", projectID, mutable(await sdk.api.permission.saved.list({ projectID })))
|
||||
},
|
||||
},
|
||||
},
|
||||
|
|
@ -983,7 +1054,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
return store.location[locationKey(location ?? defaultLocation())]?.searchProvider ?? undefined
|
||||
},
|
||||
async refresh(ref?: LocationRef) {
|
||||
const result = await sdk.api.search.provider({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const result = await sdk.api.search.provider.get({ location: locationQuery(ref ?? defaultLocation()) })
|
||||
const key = locationKey(result.location)
|
||||
setStore("location", key, { ...store.location[key], searchProvider: result.data ?? null })
|
||||
},
|
||||
|
|
@ -1021,6 +1092,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
)
|
||||
for (const session of response.data) registerSession(session.id)
|
||||
}),
|
||||
sdk.api.permission.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
||||
const permissions = mutable(response.data).reduce<Record<string, PermissionV2Request[]>>(
|
||||
(result, request) => ({
|
||||
...result,
|
||||
[request.sessionID]: [...(result[request.sessionID] ?? []), request],
|
||||
}),
|
||||
{},
|
||||
)
|
||||
setStore("session", "permission", reconcile(permissions))
|
||||
}),
|
||||
sdk.api.form.request.list({ location: locationQuery(defaultLocation()) }).then((response) => {
|
||||
const forms = mutable(response.data).reduce<Record<string, FormInfo[]>>(
|
||||
(result, form) => ({
|
||||
...result,
|
||||
[form.sessionID]: [...(result[form.sessionID] ?? []), form],
|
||||
}),
|
||||
{},
|
||||
)
|
||||
setStore("session", "form", reconcile(forms))
|
||||
}),
|
||||
result.location.refresh(),
|
||||
result.location.agent.refresh(),
|
||||
result.location.integration.refresh(),
|
||||
|
|
@ -1044,23 +1135,16 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
|
|||
}
|
||||
|
||||
function refreshActive() {
|
||||
const generation = ++connectionGeneration
|
||||
const changed = new Set<string>()
|
||||
statusChanges = changed
|
||||
void sdk.api.session
|
||||
.active()
|
||||
.then((active) => {
|
||||
if (generation !== connectionGeneration) return
|
||||
const status: Record<string, DataSessionStatus> = Object.fromEntries(
|
||||
Object.keys(active).map((sessionID) => [sessionID, "running" as const]),
|
||||
setStore(
|
||||
"session",
|
||||
"status",
|
||||
reconcile(Object.fromEntries(Object.keys(active).map((sessionID) => [sessionID, "running" as const]))),
|
||||
)
|
||||
for (const sessionID of changed) status[sessionID] = store.session.status[sessionID]
|
||||
setStore("session", "status", reconcile(status))
|
||||
})
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (statusChanges === changed) statusChanges = undefined
|
||||
})
|
||||
}
|
||||
|
||||
onCleanup(
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import type {
|
|||
ProviderListResponse,
|
||||
QuestionRequest,
|
||||
Session,
|
||||
SnapshotFileDiff,
|
||||
FileDiffInfo,
|
||||
Todo,
|
||||
VcsInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
|
|
@ -42,9 +42,6 @@ export const {
|
|||
provider_default: Record<string, string>
|
||||
provider_next: ProviderListResponse
|
||||
console_state: ConsoleState
|
||||
capabilities: {
|
||||
experimentalBackgroundSubagents: boolean
|
||||
}
|
||||
provider_auth: Record<string, ProviderAuthMethod[]>
|
||||
agent: Agent[]
|
||||
command: Command[]
|
||||
|
|
@ -52,7 +49,7 @@ export const {
|
|||
question: Record<string, QuestionRequest[]>
|
||||
config: Config
|
||||
session: Session[]
|
||||
session_diff: Record<string, SnapshotFileDiff[]>
|
||||
session_diff: Record<string, FileDiffInfo[]>
|
||||
todo: Record<string, Todo[]>
|
||||
message: Record<string, Message[]>
|
||||
part: Record<string, Part[]>
|
||||
|
|
@ -71,9 +68,6 @@ export const {
|
|||
connected: [],
|
||||
},
|
||||
console_state: emptyConsoleState,
|
||||
capabilities: {
|
||||
experimentalBackgroundSubagents: false,
|
||||
},
|
||||
provider_auth: {},
|
||||
agent: [],
|
||||
command: [],
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import type { AssistantMessage } from "@opencode-ai/sdk/v2"
|
||||
import type { TuiPlugin, TuiPluginApi } from "@opencode-ai/plugin/tui"
|
||||
import type { BuiltinTuiPlugin } from "../builtins"
|
||||
import { createMemo } from "solid-js"
|
||||
import { useData } from "../../context/data"
|
||||
import { lastAssistantWithUsage } from "../../util/session"
|
||||
|
||||
const id = "internal:sidebar-context"
|
||||
|
||||
|
|
@ -11,13 +12,14 @@ const money = new Intl.NumberFormat("en-US", {
|
|||
})
|
||||
|
||||
function View(props: { api: TuiPluginApi; session_id: string }) {
|
||||
const data = useData()
|
||||
const theme = () => props.api.theme.current
|
||||
const msg = createMemo(() => props.api.state.session.messages(props.session_id))
|
||||
const session = createMemo(() => props.api.state.session.get(props.session_id))
|
||||
const msg = createMemo(() => data.session.message.list(props.session_id))
|
||||
const session = createMemo(() => data.session.get(props.session_id))
|
||||
const cost = createMemo(() => session()?.cost ?? 0)
|
||||
|
||||
const state = createMemo(() => {
|
||||
const last = msg().findLast((item): item is AssistantMessage => item.role === "assistant" && item.tokens.output > 0)
|
||||
const last = lastAssistantWithUsage(msg(), session()?.revert?.messageID)
|
||||
if (!last) {
|
||||
return {
|
||||
tokens: 0,
|
||||
|
|
@ -27,7 +29,9 @@ function View(props: { api: TuiPluginApi; session_id: string }) {
|
|||
|
||||
const tokens =
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
const model = props.api.state.provider.find((item) => item.id === last.providerID)?.models[last.modelID]
|
||||
const model = data.location
|
||||
.model.list(session()?.location)
|
||||
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
|
||||
return {
|
||||
tokens,
|
||||
percent: model?.limit.context ? Math.round((tokens / model.limit.context) * 100) : null,
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { useTuiPaths } from "../../context/runtime"
|
|||
|
||||
const id = "internal:sidebar-footer"
|
||||
|
||||
function View(props: { api: TuiPluginApi; sessionID: string }) {
|
||||
function View(props: { api: TuiPluginApi; directory: string }) {
|
||||
const paths = useTuiPaths()
|
||||
const theme = () => props.api.theme.current
|
||||
const has = createMemo(() =>
|
||||
|
|
@ -17,10 +17,8 @@ function View(props: { api: TuiPluginApi; sessionID: string }) {
|
|||
const done = createMemo(() => props.api.kv.get("dismissed_getting_started", false))
|
||||
const show = createMemo(() => !has() && !done())
|
||||
const path = createMemo(() => {
|
||||
const session = props.api.state.session.get(props.sessionID)
|
||||
const dir = session?.directory || props.api.state.path.directory || paths.cwd
|
||||
const out = abbreviateHome(dir, paths.home)
|
||||
const branch = session?.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
|
||||
const out = abbreviateHome(props.directory, paths.home)
|
||||
const branch = props.directory === props.api.state.path.directory ? props.api.state.vcs?.branch : undefined
|
||||
const text = branch ? out + ":" + branch : out
|
||||
const list = text.split("/")
|
||||
return {
|
||||
|
|
@ -84,7 +82,7 @@ const tui: TuiPlugin = async (api) => {
|
|||
order: 100,
|
||||
slots: {
|
||||
sidebar_footer(_ctx, props) {
|
||||
return <View api={api} sessionID={props.session_id} />
|
||||
return <View api={api} directory={props.directory} />
|
||||
},
|
||||
},
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
/** @jsxImportSource @opentui/solid */
|
||||
import type { TuiPlugin, TuiPluginApi, TuiRouteCurrent } from "@opencode-ai/plugin/tui"
|
||||
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import type { FileDiffInfo, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
|
||||
import {
|
||||
TextAttributes,
|
||||
type BorderSides,
|
||||
|
|
@ -56,7 +56,7 @@ type DiffFile = {
|
|||
readonly status: "added" | "deleted" | "modified"
|
||||
}
|
||||
|
||||
const normalizeDiffs = (diffs: readonly (VcsFileDiff | SnapshotFileDiff)[]): DiffFile[] =>
|
||||
const normalizeDiffs = (diffs: readonly (VcsFileDiff | FileDiffInfo | SnapshotFileDiff)[]): DiffFile[] =>
|
||||
diffs.flatMap((item) =>
|
||||
item.file
|
||||
? [
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import {
|
|||
import { useSync } from "../../context/sync"
|
||||
import { useTuiPaths } from "../../context/runtime"
|
||||
|
||||
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new" }
|
||||
export type HomeSessionDestination = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
|
||||
|
||||
type Context = {
|
||||
destination: Accessor<HomeSessionDestination | undefined>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ export function DialogMessage(props: { messageID: string; sessionID: string }) {
|
|||
description: "undo messages and file changes",
|
||||
onSelect: async (dialog) => {
|
||||
await sdk.api.session
|
||||
.revertStage({ sessionID: props.sessionID, messageID: props.messageID })
|
||||
.revert.stage({ sessionID: props.sessionID, messageID: props.messageID })
|
||||
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
|
||||
dialog.clear()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -26,14 +26,14 @@ import { createSyntaxStyleMemo, generateSubtleSyntax, useTheme } from "../../con
|
|||
import { BoxRenderable, ScrollBoxRenderable, addDefaultParsers, TextAttributes, RGBA } from "@opentui/core"
|
||||
import { Prompt, type PromptRef } from "../../component/prompt"
|
||||
import type {
|
||||
ModelV2Info,
|
||||
SessionMessage,
|
||||
ModelInfo,
|
||||
SessionMessageInfo,
|
||||
SessionMessageAssistant,
|
||||
SessionMessageAssistantReasoning,
|
||||
SessionMessageAssistantText,
|
||||
SessionMessageAssistantTool,
|
||||
SessionMessageUser,
|
||||
SessionV2Info,
|
||||
SessionInfo,
|
||||
} from "@opencode-ai/sdk/v2"
|
||||
import { useLocal } from "../../context/local"
|
||||
import { Locale } from "../../util/locale"
|
||||
|
|
@ -130,7 +130,7 @@ const context = createContext<{
|
|||
showGenericToolOutput: () => boolean
|
||||
groupExploration: () => boolean
|
||||
diffWrapMode: () => "word" | "none"
|
||||
models: () => ModelV2Info[]
|
||||
models: () => ModelInfo[]
|
||||
tui: ReturnType<typeof useTuiConfig>
|
||||
}>()
|
||||
|
||||
|
|
@ -172,16 +172,6 @@ export function Session() {
|
|||
})
|
||||
onCleanup(() => setEpilogue())
|
||||
const messages = sessionMessages
|
||||
const transientCompaction = createMemo(() => {
|
||||
if (
|
||||
messages().some(
|
||||
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
|
||||
)
|
||||
)
|
||||
return
|
||||
const text = data.session.compaction(route.sessionID)
|
||||
return text === undefined ? undefined : { text }
|
||||
})
|
||||
const descendantSessionIDs = createMemo(() => {
|
||||
if (session()?.parentID) return []
|
||||
return data.session.family(route.sessionID).filter((id) => id !== route.sessionID)
|
||||
|
|
@ -208,6 +198,16 @@ export function Session() {
|
|||
?.id
|
||||
})
|
||||
|
||||
// Admitted inputs and in-flight manual compaction sit after history, not in rows.
|
||||
const pendingMessages = createMemo(() => {
|
||||
const boundary = session()?.revert?.messageID
|
||||
return messages().filter((message) => {
|
||||
if (boundary && message.id >= boundary) return false
|
||||
if (data.session.input.has(route.sessionID, message.id)) return true
|
||||
return message.type === "compaction" && message.status === "running"
|
||||
})
|
||||
})
|
||||
|
||||
const lastAssistant = createMemo(() => {
|
||||
return messages().findLast((x) => x.type === "assistant")
|
||||
})
|
||||
|
|
@ -251,37 +251,44 @@ export function Session() {
|
|||
}),
|
||||
)
|
||||
|
||||
createEffect(() => {
|
||||
const sessionID = route.sessionID
|
||||
void (async () => {
|
||||
await Promise.all([
|
||||
data.session.refresh(sessionID),
|
||||
data.session.permission.refresh(sessionID),
|
||||
data.session.form.refresh(sessionID),
|
||||
])
|
||||
const info = data.session.get(sessionID)
|
||||
if (!info) {
|
||||
toast.show({
|
||||
message: `Session not found: ${sessionID}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
createEffect(
|
||||
on(
|
||||
() => route.sessionID,
|
||||
(sessionID) => {
|
||||
void (async () => {
|
||||
if (data.session.message.list(sessionID).length === 0) {
|
||||
await Promise.all([
|
||||
data.session.refresh(sessionID),
|
||||
data.session.message.refresh(sessionID),
|
||||
data.session.permission.refresh(sessionID),
|
||||
data.session.form.refresh(sessionID),
|
||||
])
|
||||
}
|
||||
const info = data.session.get(sessionID)
|
||||
if (!info) {
|
||||
toast.show({
|
||||
message: `Session not found: ${sessionID}`,
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
project.workspace.set(info.location.workspaceID)
|
||||
editor.reconnect(info.location.directory)
|
||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||
})().catch((error) => {
|
||||
if (route.sessionID !== sessionID) return
|
||||
toast.show({
|
||||
message: errorMessage(error),
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
navigate({ type: "home" })
|
||||
})
|
||||
navigate({ type: "home" })
|
||||
return
|
||||
}
|
||||
project.workspace.set(info.location.workspaceID)
|
||||
editor.reconnect(info.location.directory)
|
||||
if (route.sessionID === sessionID && scroll) scroll.scrollBy(100_000)
|
||||
})().catch((error) => {
|
||||
if (route.sessionID !== sessionID) return
|
||||
toast.show({
|
||||
message: errorMessage(error),
|
||||
variant: "error",
|
||||
duration: 5000,
|
||||
})
|
||||
navigate({ type: "home" })
|
||||
})
|
||||
})
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
let seeded = false
|
||||
let scroll: ScrollBoxRenderable
|
||||
|
|
@ -337,12 +344,14 @@ export function Session() {
|
|||
|
||||
if (!targetID) {
|
||||
scroll.scrollBy(direction === "next" ? scroll.height : -scroll.height)
|
||||
if (direction === "prev") loadOlder()
|
||||
dialog.clear()
|
||||
return
|
||||
}
|
||||
|
||||
const child = scroll.getChildren().find((c) => c.id === targetID)
|
||||
if (child) scroll.scrollBy(child.y - scroll.y - 1)
|
||||
if (direction === "prev") loadOlder()
|
||||
dialog.clear()
|
||||
}
|
||||
|
||||
|
|
@ -353,6 +362,24 @@ export function Session() {
|
|||
}, 50)
|
||||
}
|
||||
|
||||
let loadingOlder = false
|
||||
function loadOlder() {
|
||||
if (loadingOlder || scroll.scrollTop > 2) return
|
||||
loadingOlder = true
|
||||
const before = scroll.scrollHeight
|
||||
void data.session.message.more(route.sessionID).then(
|
||||
() => {
|
||||
setTimeout(() => {
|
||||
if (!scroll.isDestroyed) scroll.scrollBy(scroll.scrollHeight - before)
|
||||
loadingOlder = false
|
||||
}, 50)
|
||||
},
|
||||
() => {
|
||||
loadingOlder = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
const sessionCommandList = createMemo(() => [
|
||||
{
|
||||
title: "Share session",
|
||||
|
|
@ -437,7 +464,7 @@ export function Session() {
|
|||
dialog.clear()
|
||||
return
|
||||
}
|
||||
const error = await sdk.api.session.revertStage({ sessionID: route.sessionID, messageID: target }).then(
|
||||
const error = await sdk.api.session.revert.stage({ sessionID: route.sessionID, messageID: target }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
|
|
@ -454,7 +481,7 @@ export function Session() {
|
|||
slash: { name: "redo" },
|
||||
run: () => {
|
||||
void (async () => {
|
||||
const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then(
|
||||
const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
|
|
@ -558,6 +585,7 @@ export function Session() {
|
|||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollBy(-scroll.height / 2)
|
||||
loadOlder()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
|
@ -578,6 +606,7 @@ export function Session() {
|
|||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollBy(-1)
|
||||
loadOlder()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
|
@ -598,6 +627,7 @@ export function Session() {
|
|||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollBy(-scroll.height / 4)
|
||||
loadOlder()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
|
@ -618,6 +648,7 @@ export function Session() {
|
|||
hidden: true,
|
||||
run: () => {
|
||||
scroll.scrollTo(0)
|
||||
loadOlder()
|
||||
dialog.clear()
|
||||
},
|
||||
},
|
||||
|
|
@ -927,6 +958,9 @@ export function Session() {
|
|||
stickyStart="bottom"
|
||||
flexGrow={1}
|
||||
scrollAcceleration={scrollAcceleration()}
|
||||
onMouseScroll={(event) => {
|
||||
if (event.scroll?.direction === "up") void loadOlder()
|
||||
}}
|
||||
>
|
||||
<For each={rows}>
|
||||
{(row) => (
|
||||
|
|
@ -936,9 +970,13 @@ export function Session() {
|
|||
/>
|
||||
)}
|
||||
</For>
|
||||
<Show when={transientCompaction()}>
|
||||
{(compaction) => <CompactionMessage status="running" text={compaction().text} />}
|
||||
</Show>
|
||||
<For each={pendingMessages()}>
|
||||
{(message) => (
|
||||
<box marginTop={1} flexShrink={0}>
|
||||
<SessionMessageView message={message} />
|
||||
</box>
|
||||
)}
|
||||
</For>
|
||||
<BackgroundToolHint messages={messages()} />
|
||||
<Show when={session()?.revert?.messageID}>
|
||||
<RevertMessage
|
||||
|
|
@ -1024,7 +1062,7 @@ export function Session() {
|
|||
)
|
||||
}
|
||||
|
||||
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessage | undefined }) {
|
||||
function SessionRowView(props: { row: SessionRow; message: (messageID: string) => SessionMessageInfo | undefined }) {
|
||||
return (
|
||||
<box marginTop={1} flexShrink={0}>
|
||||
<Switch>
|
||||
|
|
@ -1062,7 +1100,7 @@ function SessionRowView(props: { row: SessionRow; message: (messageID: string) =
|
|||
)
|
||||
}
|
||||
|
||||
function BackgroundToolHint(props: { messages: SessionMessage[] }) {
|
||||
function BackgroundToolHint(props: { messages: SessionMessageInfo[] }) {
|
||||
const { theme } = useTheme()
|
||||
const shortcut = useCommandShortcut("session.background")
|
||||
const visible = createMemo(() => {
|
||||
|
|
@ -1090,14 +1128,14 @@ function BackgroundToolHint(props: { messages: SessionMessage[] }) {
|
|||
)
|
||||
}
|
||||
|
||||
function SessionMessageView(props: { message: SessionMessage }) {
|
||||
function SessionMessageView(props: { message: SessionMessageInfo }) {
|
||||
return (
|
||||
<Switch>
|
||||
<Match when={props.message.type === "user"}>
|
||||
<UserMessage message={props.message as SessionMessageUser} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "shell"}>
|
||||
<ShellMessage message={props.message as Extract<SessionMessage, { type: "shell" }>} />
|
||||
<ShellMessage message={props.message as Extract<SessionMessageInfo, { type: "shell" }>} />
|
||||
</Match>
|
||||
<Match when={props.message.type === "agent-switched" || props.message.type === "model-switched"}>
|
||||
<SessionSwitchMessageV2 message={props.message} />
|
||||
|
|
@ -1106,17 +1144,17 @@ function SessionMessageView(props: { message: SessionMessage }) {
|
|||
when={props.message.type === "system" || props.message.type === "synthetic" || props.message.type === "skill"}
|
||||
>
|
||||
<Show when={props.message.type === "skill"} fallback={<SessionNoticeMessageV2 message={props.message} />}>
|
||||
<SessionSkillMessage message={props.message as Extract<SessionMessage, { type: "skill" }>} />
|
||||
<SessionSkillMessage message={props.message as Extract<SessionMessageInfo, { type: "skill" }>} />
|
||||
</Show>
|
||||
</Match>
|
||||
<Match when={props.message.type === "compaction"}>
|
||||
<CompactionMessage message={props.message as Extract<SessionMessage, { type: "compaction" }>} />
|
||||
<CompactionMessage message={props.message as Extract<SessionMessageInfo, { type: "compaction" }>} />
|
||||
</Match>
|
||||
</Switch>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessage | undefined }) {
|
||||
function SessionPartView(props: { partRef: PartRef; message: (messageID: string) => SessionMessageInfo | undefined }) {
|
||||
const message = createMemo(() => props.message(props.partRef.messageID))
|
||||
const part = createMemo(() => {
|
||||
const item = message()
|
||||
|
|
@ -1150,7 +1188,7 @@ function SessionGroupView(props: {
|
|||
refs: PartRef[]
|
||||
pending: PartRef[]
|
||||
completed: boolean
|
||||
message: (messageID: string) => SessionMessage | undefined
|
||||
message: (messageID: string) => SessionMessageInfo | undefined
|
||||
}) {
|
||||
const { theme } = useTheme()
|
||||
const ctx = use()
|
||||
|
|
@ -1260,7 +1298,7 @@ function AssistantFooter(props: { message: SessionMessageAssistant }) {
|
|||
)
|
||||
}
|
||||
|
||||
function SessionSwitchMessageV2(props: { message: SessionMessage }) {
|
||||
function SessionSwitchMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const ctx = use()
|
||||
const { theme } = useTheme()
|
||||
const text = () => {
|
||||
|
|
@ -1276,7 +1314,7 @@ function SessionSwitchMessageV2(props: { message: SessionMessage }) {
|
|||
)
|
||||
}
|
||||
|
||||
function SessionNoticeMessageV2(props: { message: SessionMessage }) {
|
||||
function SessionNoticeMessageV2(props: { message: SessionMessageInfo }) {
|
||||
const { theme } = useTheme()
|
||||
const text = () => {
|
||||
if (props.message.type === "system") return "Instructions updated"
|
||||
|
|
@ -1290,7 +1328,7 @@ function SessionNoticeMessageV2(props: { message: SessionMessage }) {
|
|||
)
|
||||
}
|
||||
|
||||
function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "skill" }> }) {
|
||||
function SessionSkillMessage(props: { message: Extract<SessionMessageInfo, { type: "skill" }> }) {
|
||||
const { theme } = useTheme()
|
||||
return (
|
||||
<InlineToolRow icon="→" color={theme.textMuted} pending="Skill" complete={true}>
|
||||
|
|
@ -1300,7 +1338,7 @@ function SessionSkillMessage(props: { message: Extract<SessionMessage, { type: "
|
|||
}
|
||||
|
||||
function CompactionMessage(props: {
|
||||
message?: Extract<SessionMessage, { type: "compaction" }>
|
||||
message?: Extract<SessionMessageInfo, { type: "compaction" }>
|
||||
status?: "running"
|
||||
text?: string
|
||||
}) {
|
||||
|
|
@ -1308,9 +1346,10 @@ function CompactionMessage(props: {
|
|||
const kv = useKV()
|
||||
const { theme, syntax } = useTheme()
|
||||
const status = () => props.message?.status ?? props.status
|
||||
const text = () => props.message?.summary ?? props.text ?? ""
|
||||
const text = () =>
|
||||
props.message?.status === "failed" ? props.message.error.message : (props.message?.summary ?? props.text ?? "")
|
||||
const color = () => (status() === "failed" ? theme.error : status() === "completed" ? theme.success : theme.textMuted)
|
||||
const border = () => (status() === "queued" ? theme.border : color())
|
||||
const border = color
|
||||
return (
|
||||
<box>
|
||||
<box flexDirection="row" alignItems="center">
|
||||
|
|
@ -1328,11 +1367,8 @@ function CompactionMessage(props: {
|
|||
<Match when={status() === "failed"}>
|
||||
<text fg={color()}>✗</text>
|
||||
</Match>
|
||||
<Match when={status() === "queued"}>
|
||||
<text fg={color()}>◇</text>
|
||||
</Match>
|
||||
</Switch>
|
||||
<text fg={color()}>{status() === "queued" ? "Compaction queued" : "Compaction"}</text>
|
||||
<text fg={color()}>Compaction</text>
|
||||
</box>
|
||||
<box border={["top"]} borderColor={border()} flexGrow={1} />
|
||||
</box>
|
||||
|
|
@ -1363,7 +1399,7 @@ function statusLabel(status: "added" | "modified" | "deleted") {
|
|||
function RevertMessage(props: {
|
||||
count: number
|
||||
files: ReadonlyArray<{
|
||||
readonly path: string
|
||||
readonly file: string
|
||||
readonly status: "added" | "modified" | "deleted"
|
||||
readonly additions: number
|
||||
readonly deletions: number
|
||||
|
|
@ -1383,7 +1419,7 @@ function RevertMessage(props: {
|
|||
onMouseUp={() => {
|
||||
if (renderer.getSelection()?.getSelectedText()) return
|
||||
void (async () => {
|
||||
const error = await sdk.api.session.revertClear({ sessionID: route.sessionID }).then(
|
||||
const error = await sdk.api.session.revert.clear({ sessionID: route.sessionID }).then(
|
||||
() => undefined,
|
||||
(error) => error,
|
||||
)
|
||||
|
|
@ -1412,7 +1448,7 @@ function RevertMessage(props: {
|
|||
<box flexDirection="row" gap={1} flexShrink={0}>
|
||||
<text fg={theme.textMuted}>{statusLabel(file.status)}</text>
|
||||
<text fg={theme.text} wrapMode="none">
|
||||
{Locale.truncateLeft(file.path, 60)}
|
||||
{Locale.truncateLeft(file.file, 60)}
|
||||
</text>
|
||||
<Show when={file.additions > 0}>
|
||||
<text fg={theme.diffAdded}>+{file.additions}</text>
|
||||
|
|
@ -1433,7 +1469,7 @@ function RevertMessage(props: {
|
|||
)
|
||||
}
|
||||
|
||||
function ShellMessage(props: { message: Extract<SessionMessage, { type: "shell" }> }) {
|
||||
function ShellMessage(props: { message: Extract<SessionMessageInfo, { type: "shell" }> }) {
|
||||
const { theme } = useTheme()
|
||||
const output = createMemo(() => stripAnsi(props.message.output?.output.trim() ?? ""))
|
||||
|
||||
|
|
@ -1448,7 +1484,7 @@ function ShellMessage(props: { message: Extract<SessionMessage, { type: "shell"
|
|||
customBorderChars={SplitBorder.customBorderChars}
|
||||
borderColor={theme.background}
|
||||
>
|
||||
<text fg={theme.text}>$ {props.message.shell.command}</text>
|
||||
<text fg={theme.text}>$ {props.message.command}</text>
|
||||
<Show when={output()}>
|
||||
<text fg={theme.textMuted}>{output()}</text>
|
||||
</Show>
|
||||
|
|
@ -1560,7 +1596,7 @@ function AssistantMessage(props: { message: SessionMessageAssistant; last: boole
|
|||
.map((part) =>
|
||||
part.type === "tool" &&
|
||||
["read", "glob", "grep"].includes(toolDisplay(part.name)) &&
|
||||
part.state.status !== "pending"
|
||||
part.state.status !== "streaming"
|
||||
? part
|
||||
: undefined,
|
||||
)
|
||||
|
|
@ -1832,7 +1868,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
|||
const data = useData()
|
||||
const display = createMemo(() => toolDisplay(props.part.name))
|
||||
const activeBackgroundWork = createMemo(() => {
|
||||
if (props.part.state.status === "pending") return false
|
||||
if (props.part.state.status === "streaming") return false
|
||||
if (display() === "shell") {
|
||||
const shellID = stringValue(props.part.state.structured.shellID)
|
||||
return Boolean(shellID && data.shell.get(shellID))
|
||||
|
|
@ -1856,13 +1892,13 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
|||
|
||||
const toolprops = {
|
||||
get metadata() {
|
||||
return props.part.state.status === "pending" ? {} : props.part.state.structured
|
||||
return props.part.state.status === "streaming" ? {} : props.part.state.structured
|
||||
},
|
||||
get input() {
|
||||
return typeof props.part.state.input === "string" ? {} : props.part.state.input
|
||||
},
|
||||
get output() {
|
||||
if (props.part.state.status === "pending") return undefined
|
||||
if (props.part.state.status === "streaming") return undefined
|
||||
return props.part.state.content
|
||||
.flatMap((content) => (content.type === "text" ? [content.text] : [content.name ?? content.uri]))
|
||||
.join("\n")
|
||||
|
|
@ -1908,7 +1944,7 @@ function ToolPart(props: { part: SessionMessageAssistantTool }) {
|
|||
<Match when={display() === "execute"}>
|
||||
<Execute {...toolprops} />
|
||||
</Match>
|
||||
<Match when={display() === "apply_patch"}>
|
||||
<Match when={display() === "patch"}>
|
||||
<ApplyPatch {...toolprops} />
|
||||
</Match>
|
||||
<Match when={display() === "todowrite"}>
|
||||
|
|
@ -2187,7 +2223,7 @@ function Shell(props: ToolProps) {
|
|||
const isRunning = createMemo(() => props.part.state.status === "running" || backgroundRunning())
|
||||
const command = createMemo(() => stringValue(props.input.command))
|
||||
const output = createMemo(() => {
|
||||
if (props.part.state.status === "pending") return ""
|
||||
if (props.part.state.status === "streaming") return ""
|
||||
if (shellID()) return ""
|
||||
const content = props.part.state.content[0]
|
||||
return stripAnsi(content?.type === "text" ? content.text.trim() : "")
|
||||
|
|
@ -2209,7 +2245,7 @@ function Shell(props: ToolProps) {
|
|||
<Show
|
||||
when={command()}
|
||||
fallback={
|
||||
isRunning() || props.part.state.status === "pending" ? (
|
||||
isRunning() || props.part.state.status === "streaming" ? (
|
||||
<Spinner color={color()}>Writing command...</Spinner>
|
||||
) : (
|
||||
<text fg={theme.textMuted}>Writing command...</text>
|
||||
|
|
@ -2425,7 +2461,7 @@ function executeCalls(value: unknown): ExecuteCall[] {
|
|||
function Execute(props: ToolProps) {
|
||||
const ctx = use()
|
||||
const { theme } = useTheme()
|
||||
const isLoading = createMemo(() => props.part.state.status === "pending" || props.part.state.status === "running")
|
||||
const isLoading = createMemo(() => props.part.state.status === "streaming" || props.part.state.status === "running")
|
||||
const calls = createMemo(() => executeCalls(props.metadata.toolCalls))
|
||||
const output = createMemo(() => stripAnsi(props.output?.trim() ?? ""))
|
||||
const hasRuntimeError = createMemo(() => props.metadata.error === true)
|
||||
|
|
@ -2521,7 +2557,7 @@ function Edit(props: ToolProps) {
|
|||
: "# Preparing edit..."
|
||||
}
|
||||
part={props.part}
|
||||
spinner={props.part.state.status === "pending"}
|
||||
spinner={props.part.state.status === "streaming"}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -2613,7 +2649,7 @@ function ApplyPatch(props: ToolProps) {
|
|||
: "# Preparing patch..."
|
||||
}
|
||||
part={props.part}
|
||||
spinner={props.part.state.status === "pending"}
|
||||
spinner={props.part.state.status === "streaming"}
|
||||
/>
|
||||
</Match>
|
||||
</Switch>
|
||||
|
|
@ -2744,7 +2780,7 @@ const toolDisplays = new Set([
|
|||
"edit",
|
||||
"subagent",
|
||||
"execute",
|
||||
"apply_patch",
|
||||
"patch",
|
||||
"todowrite",
|
||||
"question",
|
||||
"skill",
|
||||
|
|
@ -2753,7 +2789,7 @@ const toolDisplays = new Set([
|
|||
export function toolDisplay(tool: string) {
|
||||
// Legacy transcripts recorded the shell tool as "bash" and the subagent tool as "task"; render
|
||||
// them with the renamed views.
|
||||
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool
|
||||
const normalized = tool === "bash" ? "shell" : tool === "task" ? "subagent" : tool === "apply_patch" ? "patch" : tool
|
||||
return toolDisplays.has(normalized) ? normalized : "generic"
|
||||
}
|
||||
|
||||
|
|
@ -2763,15 +2799,15 @@ function recordValue(value: unknown): Record<string, unknown> | undefined {
|
|||
}
|
||||
|
||||
function formatSessionTranscript(
|
||||
session: SessionV2Info,
|
||||
messages: SessionMessage[],
|
||||
session: SessionInfo,
|
||||
messages: SessionMessageInfo[],
|
||||
thinking: boolean,
|
||||
toolDetails: boolean,
|
||||
) {
|
||||
const body = messages.flatMap((message) => {
|
||||
if (message.type === "user") return [`## User\n\n${message.text}`]
|
||||
if (message.type === "shell")
|
||||
return [`## Shell\n\n\`\`\`\n$ ${message.shell.command}\n${message.output?.output ?? ""}\n\`\`\``]
|
||||
return [`## Shell\n\n\`\`\`\n$ ${message.command}\n${message.output?.output ?? ""}\n\`\`\``]
|
||||
if (message.type !== "assistant") return []
|
||||
const content = message.content.flatMap((item) => {
|
||||
if (item.type === "text") return [item.text]
|
||||
|
|
@ -2781,7 +2817,7 @@ function formatSessionTranscript(
|
|||
const output =
|
||||
item.state.status === "error"
|
||||
? item.state.error.message
|
||||
: item.state.status === "pending"
|
||||
: item.state.status === "streaming"
|
||||
? ""
|
||||
: item.state.content
|
||||
.flatMap((entry) => (entry.type === "text" ? [entry.text] : [entry.name ?? entry.uri]))
|
||||
|
|
|
|||
|
|
@ -148,7 +148,7 @@ export function PermissionPrompt(props: { request: PermissionV2Request; director
|
|||
const message = data.session.message.get(props.request.sessionID, tool.messageID)
|
||||
if (message?.type !== "assistant") return {}
|
||||
const part = message.content.find((part) => part.type === "tool" && part.id === tool.callID)
|
||||
if (part?.type === "tool" && part.state.status !== "pending") return part.state.input
|
||||
if (part?.type === "tool" && part.state.status !== "streaming") return part.state.input
|
||||
return {}
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
||||
import { createEffect, on, onCleanup, type Accessor } from "solid-js"
|
||||
import { createStore, produce, reconcile } from "solid-js/store"
|
||||
import { createStore, produce } from "solid-js/store"
|
||||
import { useData } from "../../context/data"
|
||||
|
||||
export type PartRef = {
|
||||
|
|
@ -25,11 +25,21 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
const [rows, setRows] = createStore<SessionRow[]>([])
|
||||
const revertBoundary = () => data.session.get(sessionID())?.revert?.messageID
|
||||
|
||||
function pendingIDs() {
|
||||
const inputs = data.session.input.list(sessionID())
|
||||
const pending = new Set(inputs)
|
||||
for (const message of data.session.message.list(sessionID())) {
|
||||
if (message.type === "compaction" && message.status === "running") pending.add(message.id)
|
||||
}
|
||||
return pending
|
||||
}
|
||||
|
||||
function reduce() {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const inputs = new Set(data.session.input.list(sessionID()))
|
||||
const boundary = revertBoundary()
|
||||
const rows = reduceSessionRows(boundary ? messages.filter((message) => message.id < boundary) : messages, inputs)
|
||||
const visible = boundary ? messages.filter((message) => message.id < boundary) : messages
|
||||
const pending = pendingIDs()
|
||||
const rows = reduceSessionRows(visible.filter((message) => !pending.has(message.id)))
|
||||
partitionPending(rows, pendingPermissions())
|
||||
return rows
|
||||
}
|
||||
|
|
@ -52,48 +62,30 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
})
|
||||
|
||||
createEffect(
|
||||
on(sessionID, (id) => {
|
||||
setRows(reconcile(reduce()))
|
||||
void data.session.message.refresh(id).then(
|
||||
() => {
|
||||
if (sessionID() !== id) return
|
||||
setRows(reconcile(reduce()))
|
||||
},
|
||||
() => undefined,
|
||||
)
|
||||
on(sessionID, () => {
|
||||
setRows(reduce())
|
||||
}),
|
||||
)
|
||||
|
||||
// Re-reduce when the revert boundary changes (stage/clear/commit).
|
||||
createEffect(
|
||||
on(revertBoundary, () => {
|
||||
setRows(reconcile(reduce()))
|
||||
setRows(reduce())
|
||||
}),
|
||||
)
|
||||
|
||||
// Pending inputs and compaction leaving the pending set change history membership.
|
||||
createEffect(
|
||||
on(
|
||||
() =>
|
||||
data.session.message.list(sessionID()).flatMap((message) =>
|
||||
message.type === "user"
|
||||
? [
|
||||
{
|
||||
id: message.id,
|
||||
created: message.time.created,
|
||||
input: data.session.input.has(sessionID(), message.id),
|
||||
},
|
||||
]
|
||||
: message.type === "compaction"
|
||||
? [
|
||||
{
|
||||
id: message.id,
|
||||
created: message.time.created,
|
||||
input: message.status === "queued" || message.status === "running",
|
||||
},
|
||||
]
|
||||
: [],
|
||||
),
|
||||
() => setRows(reconcile(reduce())),
|
||||
() => {
|
||||
const messages = data.session.message.list(sessionID())
|
||||
const pending = data.session.input.list(sessionID()).join("\0")
|
||||
const compaction = messages
|
||||
.filter((message) => message.type === "compaction")
|
||||
.map((message) => `${message.id}:${message.status}`)
|
||||
.join("\0")
|
||||
return `${pending}\u0001${compaction}`
|
||||
},
|
||||
() => setRows(reduce()),
|
||||
),
|
||||
)
|
||||
|
||||
|
|
@ -101,12 +93,9 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "message" && row.messageID === messageID)) return
|
||||
const pending = isPending(messageID)
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
const index =
|
||||
message?.type === "compaction" && pending ? queuedStart(draft) : pending ? draft.length : queuedStart(draft)
|
||||
if (!pending) completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "message", messageID })
|
||||
if (pendingIDs().has(messageID)) return
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "message", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -114,15 +103,14 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
setRows(
|
||||
produce((draft) => {
|
||||
if (hasPart(draft, ref)) return
|
||||
const index = queuedStart(draft)
|
||||
if (name && exploration(name)) {
|
||||
const previous = draft[index - 1]
|
||||
const previous = draft.at(-1)
|
||||
if (previous?.type === "group" && previous.kind === "exploration") {
|
||||
previous.refs.push(ref)
|
||||
return
|
||||
}
|
||||
completePrevious(draft, index)
|
||||
draft.splice(index, 0, {
|
||||
completePrevious(draft)
|
||||
draft.push({
|
||||
type: "group",
|
||||
kind: "exploration",
|
||||
refs: [ref],
|
||||
|
|
@ -131,8 +119,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
})
|
||||
return
|
||||
}
|
||||
completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "part", ref })
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "part", ref })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -140,9 +128,8 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
setRows(
|
||||
produce((draft) => {
|
||||
if (draft.some((row) => row.type === "assistant-footer" && row.messageID === messageID)) return
|
||||
const index = queuedStart(draft)
|
||||
completePrevious(draft, index)
|
||||
draft.splice(index, 0, { type: "assistant-footer", messageID })
|
||||
completePrevious(draft)
|
||||
draft.push({ type: "assistant-footer", messageID })
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
@ -154,26 +141,13 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
}),
|
||||
)
|
||||
|
||||
const isPending = (messageID: string) => {
|
||||
const message = data.session.message.get(sessionID(), messageID)
|
||||
if (message?.type === "user") return data.session.input.has(sessionID(), messageID)
|
||||
return message?.type === "compaction" && (message.status === "queued" || message.status === "running")
|
||||
}
|
||||
|
||||
const queuedStart = (rows: SessionRow[]) => {
|
||||
const index = rows.findIndex((row) => row.type === "message" && isPending(row.messageID))
|
||||
return index === -1 ? rows.length : index
|
||||
}
|
||||
|
||||
const message = (event: { id: string; data: { sessionID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.id.replace(/^evt_/, "msg_"))
|
||||
}
|
||||
const input = (event: { data: { sessionID: string; inputID: string } }) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
|
||||
}
|
||||
const subscriptions = [
|
||||
data.on("session.prompt.admitted", input),
|
||||
data.on("session.compaction.admitted", input),
|
||||
data.on("session.prompt.promoted", (event) => {
|
||||
if (event.data.sessionID === sessionID()) appendMessage(event.data.inputID)
|
||||
}),
|
||||
data.on("session.instructions.updated", message),
|
||||
data.on("session.synthetic", (event) => {
|
||||
if (event.data.sessionID === sessionID() && event.data.description?.trim())
|
||||
|
|
@ -182,9 +156,7 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
data.on("session.shell.started", message),
|
||||
data.on("session.agent.selected", message),
|
||||
data.on("session.model.selected", message),
|
||||
data.on("session.compaction.ended", (event) => {
|
||||
if (event.data.reason !== "manual") message(event)
|
||||
}),
|
||||
|
||||
data.on("session.text.delta", (event) => {
|
||||
if (event.data.sessionID === sessionID())
|
||||
appendPart({ messageID: event.data.assistantMessageID, partID: `text:${event.data.ordinal}` })
|
||||
|
|
@ -224,20 +196,11 @@ export function createSessionRows(sessionID: Accessor<string>) {
|
|||
return rows
|
||||
}
|
||||
|
||||
export function reduceSessionRows(messages: SessionMessage[], inputs = new Set<string>()) {
|
||||
const isInput = (message: SessionMessage) => inputs.has(message.id)
|
||||
const pendingCompactions = messages.filter(
|
||||
(message) => message.type === "compaction" && (message.status === "queued" || message.status === "running"),
|
||||
)
|
||||
const pending = new Set([...pendingCompactions.map((message) => message.id), ...inputs])
|
||||
return [
|
||||
...messages.filter((message) => !pending.has(message.id)),
|
||||
...pendingCompactions,
|
||||
...messages.filter(isInput),
|
||||
].reduce<SessionRow[]>((rows, message) => {
|
||||
export function reduceSessionRows(messages: SessionMessageInfo[]) {
|
||||
return messages.reduce<SessionRow[]>((rows, message) => {
|
||||
if (message.type !== "assistant") {
|
||||
if (message.type === "synthetic" && !message.description?.trim()) return rows
|
||||
if (!pending.has(message.id)) completePrevious(rows)
|
||||
completePrevious(rows)
|
||||
rows.push({ type: "message", messageID: message.id })
|
||||
return rows
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,7 +85,12 @@ export function Sidebar(props: { sessionID: string; overlay?: boolean }) {
|
|||
</scrollbox>
|
||||
|
||||
<box flexShrink={0} gap={1} paddingTop={1}>
|
||||
<pluginRuntime.Slot name="sidebar_footer" mode="single_winner" session_id={props.sessionID}>
|
||||
<pluginRuntime.Slot
|
||||
name="sidebar_footer"
|
||||
mode="single_winner"
|
||||
session_id={props.sessionID}
|
||||
directory={session()?.location.directory ?? ""}
|
||||
>
|
||||
<text fg={theme.textMuted}>
|
||||
<span style={{ fg: theme.success }}>•</span> <b>Open</b>
|
||||
<span style={{ fg: theme.text }}>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { SplitBorder } from "../../ui/border"
|
|||
import { Locale } from "../../util/locale"
|
||||
import { useTerminalDimensions } from "@opentui/solid"
|
||||
import { useCommandShortcut, useOpencodeKeymap } from "../../keymap"
|
||||
import { lastAssistantWithUsage } from "../../util/session"
|
||||
|
||||
export function SubagentFooter() {
|
||||
const route = useRouteData("session")
|
||||
|
|
@ -22,17 +23,15 @@ export function SubagentFooter() {
|
|||
const usage = createMemo(() => {
|
||||
const current = session()
|
||||
if (!current) return
|
||||
const last = lastAssistantWithUsage(data.session.message.list(route.sessionID), current.revert?.messageID)
|
||||
if (!last) return
|
||||
const tokens =
|
||||
current.tokens.input +
|
||||
current.tokens.output +
|
||||
current.tokens.reasoning +
|
||||
current.tokens.cache.read +
|
||||
current.tokens.cache.write
|
||||
last.tokens.input + last.tokens.output + last.tokens.reasoning + last.tokens.cache.read + last.tokens.cache.write
|
||||
if (tokens <= 0) return
|
||||
|
||||
const model = data.location
|
||||
.model.list(current.location)
|
||||
?.find((model) => model.providerID === current.model?.providerID && model.id === current.model.id)
|
||||
?.find((model) => model.providerID === last.model.providerID && model.id === last.model.id)
|
||||
const pct = model?.limit.context ? `${Math.round((tokens / model.limit.context) * 100)}%` : undefined
|
||||
const cost = current.cost
|
||||
|
||||
|
|
@ -83,10 +82,10 @@ export function SubagentFooter() {
|
|||
</box>
|
||||
<box flexDirection="row" gap={2}>
|
||||
<box
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseOver={() => setHover("parent")}
|
||||
onMouseOut={() => setHover(null)}
|
||||
onMouseUp={() => keymap.dispatchCommand("session.parent")}
|
||||
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
backgroundColor={hover() === "parent" ? theme.backgroundElement : theme.backgroundPanel}
|
||||
>
|
||||
<text fg={theme.text}>
|
||||
Parent <span style={{ fg: theme.textMuted }}>{parentShortcut()}</span>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,14 @@
|
|||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
||||
|
||||
export function isDefaultTitle(title: string) {
|
||||
return /^(New session - |Child session - )\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(title)
|
||||
}
|
||||
|
||||
export function lastAssistantWithUsage(messages: ReadonlyArray<SessionMessageInfo>, boundary?: string) {
|
||||
const boundaryIndex = boundary ? messages.findIndex((message) => message.id === boundary) : -1
|
||||
if (boundary && boundaryIndex === -1) return undefined
|
||||
return messages.findLast(
|
||||
(message, index): message is SessionMessageAssistant & { tokens: NonNullable<SessionMessageAssistant["tokens"]> } =>
|
||||
message.type === "assistant" && message.tokens !== undefined && (boundaryIndex === -1 || index < boundaryIndex),
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export function webSearchProviderLabel(provider: unknown) {
|
|||
|
||||
export function toolDisplayMetadata(state: unknown): Record<string, unknown> {
|
||||
if (!state || typeof state !== "object" || Array.isArray(state)) return {}
|
||||
if (!("status" in state) || state.status === "pending") return {}
|
||||
if (!("status" in state) || state.status === "streaming") return {}
|
||||
if (!("structured" in state) || !state.structured || typeof state.structured !== "object") return {}
|
||||
if (Array.isArray(state.structured)) return {}
|
||||
return state.structured as Record<string, unknown>
|
||||
|
|
|
|||
|
|
@ -93,7 +93,7 @@ function permission(id: string, sessionID = "session"): PermissionRequest {
|
|||
}
|
||||
}
|
||||
|
||||
function durable(sessionID: string) {
|
||||
function durable(sessionID: string): { aggregateID: string; seq: number; version: 1 } {
|
||||
return { aggregateID: sessionID, seq: 0, version: 1 }
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,12 @@ function emitEvent(events: ReturnType<typeof createEventStream>, event: V2Event)
|
|||
events.emit({ ...event, location: { directory } })
|
||||
}
|
||||
|
||||
function durable(sessionID: string, seq?: number): { aggregateID: string; seq: number; version: 1 }
|
||||
function durable<const Version extends number>(
|
||||
sessionID: string,
|
||||
seq: number,
|
||||
version: Version,
|
||||
): { aggregateID: string; seq: number; version: Version }
|
||||
function durable(sessionID: string, seq = 0, version = 1) {
|
||||
return { aggregateID: sessionID, seq, version }
|
||||
}
|
||||
|
|
@ -108,6 +114,356 @@ test("refreshes resources into reactive getters", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("pages older messages through nested message state", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "ses_message_page"
|
||||
const pages: Array<{ limit?: string | null; order?: string | null; cursor?: string | null }> = []
|
||||
// Full first page (desc) so complete stays false until a short older page arrives.
|
||||
const first = Array.from({ length: 50 }, (_, index) => {
|
||||
const n = 51 - index
|
||||
return { id: `msg_${n}`, type: "user" as const, text: String(n), time: { created: n } }
|
||||
})
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname !== `/api/session/${sessionID}/message`) return
|
||||
pages.push({
|
||||
limit: url.searchParams.get("limit"),
|
||||
order: url.searchParams.get("order"),
|
||||
cursor: url.searchParams.get("cursor"),
|
||||
})
|
||||
if (!url.searchParams.get("cursor"))
|
||||
return json({
|
||||
data: first,
|
||||
cursor: { next: "cursor-older" },
|
||||
})
|
||||
return json({
|
||||
data: [{ id: "msg_1", type: "user", text: "one", time: { created: 1 } }],
|
||||
cursor: {},
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await data.session.message.refresh(sessionID)
|
||||
expect(pages).toEqual([{ limit: "50", order: "desc", cursor: null }])
|
||||
expect(data.session.message.ids(sessionID)).toEqual(first.toReversed().map((message) => message.id))
|
||||
expect(data.session.message.cursor(sessionID)).toBe("cursor-older")
|
||||
expect(data.session.message.complete(sessionID)).toBe(false)
|
||||
expect(data.session.message.loading(sessionID)).toBe(false)
|
||||
|
||||
await data.session.message.more(sessionID)
|
||||
expect(pages).toEqual([
|
||||
{ limit: "50", order: "desc", cursor: null },
|
||||
{ limit: "50", order: null, cursor: "cursor-older" },
|
||||
])
|
||||
expect(data.session.message.ids(sessionID)[0]).toBe("msg_1")
|
||||
expect(data.session.message.ids(sessionID)).toHaveLength(51)
|
||||
expect(data.session.message.cursor(sessionID)).toBeUndefined()
|
||||
expect(data.session.message.complete(sessionID)).toBe(true)
|
||||
|
||||
await data.session.message.more(sessionID)
|
||||
expect(pages).toHaveLength(2)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("applies absolute usage events to session info", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "ses_usage_refresh"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}`)
|
||||
return json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Usage",
|
||||
location: { directory },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await data.session.refresh(sessionID)
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_2",
|
||||
created: 2,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID,
|
||||
cost: 0.5,
|
||||
tokens: { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.cost === 0.5)
|
||||
expect(data.session.get(sessionID)?.tokens).toEqual({
|
||||
input: 5,
|
||||
output: 2,
|
||||
reasoning: 1,
|
||||
cache: { read: 1, write: 1 },
|
||||
})
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_3",
|
||||
created: 3,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID,
|
||||
cost: 1,
|
||||
tokens: { input: 10, output: 4, reasoning: 1, cache: { read: 1, write: 1 } },
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.cost === 1)
|
||||
expect(data.session.get(sessionID)?.title).toBe("Usage")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_usage_deleted",
|
||||
created: 9,
|
||||
type: "session.deleted",
|
||||
durable: durable(sessionID, 9, 2),
|
||||
data: { sessionID },
|
||||
})
|
||||
await wait(() => data.session.get(sessionID) === undefined)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("truncates committed revert messages without changing lifetime usage", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "ses_revert_usage"
|
||||
let cost = 0
|
||||
let tokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
if (url.pathname !== `/api/session/${sessionID}`) return
|
||||
return json({
|
||||
data: {
|
||||
id: sessionID,
|
||||
projectID: "proj_test",
|
||||
cost,
|
||||
tokens,
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Revert usage",
|
||||
location: { directory },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await data.session.refresh(sessionID)
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_boundary_started",
|
||||
created: 1,
|
||||
type: "session.step.started",
|
||||
durable: durable(sessionID, 1),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_revert_boundary",
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
},
|
||||
})
|
||||
cost = 0.5
|
||||
tokens = { input: 5, output: 2, reasoning: 1, cache: { read: 1, write: 1 } }
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_boundary_ended",
|
||||
created: 2,
|
||||
type: "session.step.ended",
|
||||
durable: durable(sessionID, 2),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_revert_boundary",
|
||||
finish: "stop",
|
||||
cost: 0.5,
|
||||
tokens,
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_boundary_usage",
|
||||
created: 2,
|
||||
type: "session.usage.updated",
|
||||
data: { sessionID, cost, tokens },
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.cost === 0.5)
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_later_started",
|
||||
created: 3,
|
||||
type: "session.step.started",
|
||||
durable: durable(sessionID, 3),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_revert_later",
|
||||
agent: "build",
|
||||
model: { providerID: "provider", id: "model" },
|
||||
},
|
||||
})
|
||||
cost = 0.75
|
||||
tokens = { input: 8, output: 3, reasoning: 1, cache: { read: 1, write: 1 } }
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_later_ended",
|
||||
created: 4,
|
||||
type: "session.step.ended",
|
||||
durable: durable(sessionID, 4),
|
||||
data: {
|
||||
sessionID,
|
||||
assistantMessageID: "msg_revert_later",
|
||||
finish: "stop",
|
||||
cost: 0.25,
|
||||
tokens: { input: 3, output: 1, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_later_usage",
|
||||
created: 4,
|
||||
type: "session.usage.updated",
|
||||
data: { sessionID, cost, tokens },
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.cost === 0.75)
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_staged",
|
||||
created: 5,
|
||||
type: "session.revert.staged",
|
||||
durable: durable(sessionID, 5),
|
||||
data: { sessionID, revert: { messageID: "msg_revert_later" } },
|
||||
})
|
||||
await wait(() => data.session.get(sessionID)?.revert?.messageID === "msg_revert_later")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_revert_committed",
|
||||
created: 6,
|
||||
type: "session.revert.committed",
|
||||
durable: durable(sessionID, 6),
|
||||
data: { sessionID, to: "msg_revert_later" },
|
||||
})
|
||||
await wait(() => data.session.message.ids(sessionID).length === 1)
|
||||
expect(data.session.get(sessionID)?.cost).toBe(0.75)
|
||||
expect(data.session.message.ids(sessionID)).toEqual(["msg_revert_boundary"])
|
||||
expect(data.session.get(sessionID)?.revert).toBeUndefined()
|
||||
expect(data.session.get(sessionID)?.tokens).toEqual(tokens)
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("updates session location when moved", async () => {
|
||||
const events = createEventStream()
|
||||
const destination = "/tmp/opencode-moved"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/ses_test")
|
||||
return json({
|
||||
data: {
|
||||
id: "ses_test",
|
||||
projectID: "proj_test",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Test session",
|
||||
location: { directory },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let ready!: () => void
|
||||
const mounted = new Promise<void>((resolve) => {
|
||||
ready = resolve
|
||||
})
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
onMount(ready)
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(calls.fetch)} api={createApi(calls.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await mounted
|
||||
await data.session.refresh("ses_test")
|
||||
emitEvent(events, {
|
||||
id: "evt_moved_1",
|
||||
created: 1,
|
||||
type: "session.moved",
|
||||
durable: durable("ses_test"),
|
||||
data: {
|
||||
sessionID: "ses_test",
|
||||
location: { directory: destination },
|
||||
subpath: "packages/cli",
|
||||
},
|
||||
})
|
||||
await wait(() => data.session.get("ses_test")?.location.directory === destination)
|
||||
expect(data.session.get("ses_test")?.subpath).toBe("packages/cli")
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("restores running manual compaction before applying live deltas", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
|
|
@ -148,7 +504,11 @@ test("restores running manual compaction before applying live deltas", async ()
|
|||
|
||||
try {
|
||||
await data.session.message.refresh("session-compaction")
|
||||
expect(data.session.compaction("session-compaction")).toBe("Existing ")
|
||||
expect(data.session.message.get("session-compaction", "message-compaction")).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "running",
|
||||
summary: "Existing ",
|
||||
})
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_delta",
|
||||
|
|
@ -159,7 +519,7 @@ test("restores running manual compaction before applying live deltas", async ()
|
|||
|
||||
await wait(() => {
|
||||
const message = data.session.message.get("session-compaction", "message-compaction")
|
||||
return message?.type === "compaction" && message.summary === "Existing summary"
|
||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Existing summary"
|
||||
})
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
|
@ -234,15 +594,7 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||
expect(data.connection.error()).toBe("Event stream disconnected")
|
||||
|
||||
await wait(() => requests.active === 2 && data.connection.status() === "connected", 4000)
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_started_after_reconnect",
|
||||
created: 1,
|
||||
type: "session.execution.started",
|
||||
durable: durable("session-new"),
|
||||
data: { sessionID: "session-new" },
|
||||
})
|
||||
await wait(() => data.session.status("session-new") === "running")
|
||||
resolveActive(json({ data: {} }))
|
||||
resolveActive(json({ data: { "session-new": { type: "running" } } }))
|
||||
|
||||
await wait(() => data.location.model.list()?.[0]?.id === "model-2", 4000)
|
||||
await wait(() => data.session.status("session-stale") === "idle")
|
||||
|
|
@ -256,16 +608,18 @@ test("reconnects the event stream and bootstraps fresh data", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("completes exploration when a queued prompt is promoted", async () => {
|
||||
test("keeps pending prompts out of history rows until promoted", async () => {
|
||||
const events = createEventStream()
|
||||
const sessionID = "session-promotion"
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === `/api/session/${sessionID}/message`) return json({ data: [], cursor: {} })
|
||||
}, events)
|
||||
let rows!: ReturnType<typeof createSessionRows>
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
rows = createSessionRows(() => sessionID)
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
|
|
@ -320,7 +674,8 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
|||
delivery: "steer",
|
||||
},
|
||||
})
|
||||
await wait(() => rows.at(-1)?.type === "message")
|
||||
await wait(() => data.session.input.has(sessionID, "message-user"))
|
||||
expect(rows.some((row) => row.type === "message" && row.messageID === "message-user")).toBe(false)
|
||||
expect(rows.find((row) => row.type === "group")?.completed).toBe(false)
|
||||
|
||||
emitEvent(events, {
|
||||
|
|
@ -330,7 +685,8 @@ test("completes exploration when a queued prompt is promoted", async () => {
|
|||
durable: durable(sessionID, 3),
|
||||
data: { sessionID, inputID: "message-user" },
|
||||
})
|
||||
await wait(() => rows.find((row) => row.type === "group")?.completed === true)
|
||||
await wait(() => rows.some((row) => row.type === "message" && row.messageID === "message-user"))
|
||||
expect(data.session.input.has(sessionID, "message-user")).toBe(false)
|
||||
expect(rows.at(-1)).toEqual({ type: "message", messageID: "message-user" })
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
|
|
@ -455,8 +811,35 @@ test("connectedOnce is false until first connect and persists across disconnect"
|
|||
|
||||
test("tracks session status from active sessions and execution events", async () => {
|
||||
const events = createEventStream()
|
||||
let settled = false
|
||||
const calls = createFetch((url) => {
|
||||
if (url.pathname === "/api/session/active") return json({ data: { "session-active": { type: "running" } } })
|
||||
if (url.pathname === "/api/session/session-live")
|
||||
return json({
|
||||
data: {
|
||||
id: "session-live",
|
||||
projectID: "proj_test",
|
||||
cost: settled ? 0.75 : 0,
|
||||
tokens: settled
|
||||
? { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } }
|
||||
: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Live session",
|
||||
location: { directory },
|
||||
},
|
||||
})
|
||||
if (url.pathname === "/api/session/session-failed")
|
||||
return json({
|
||||
data: {
|
||||
id: "session-failed",
|
||||
projectID: "proj_test",
|
||||
cost: 0.25,
|
||||
tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
|
||||
time: { created: 0, updated: 0 },
|
||||
title: "Failed session",
|
||||
location: { directory },
|
||||
},
|
||||
})
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
let rows!: SessionRow[]
|
||||
|
|
@ -484,7 +867,9 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
try {
|
||||
await wait(() => data.session.status("session-active") === "running")
|
||||
expect(data.session.status("session-idle")).toBe("idle")
|
||||
await data.session.refresh("session-live")
|
||||
|
||||
settled = true
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_started",
|
||||
created: 0,
|
||||
|
|
@ -510,30 +895,46 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
id: "evt_step_ended",
|
||||
created: 0,
|
||||
type: "session.step.ended",
|
||||
durable: durable("session-live", 1, 2),
|
||||
durable: durable("session-live", 1),
|
||||
data: {
|
||||
sessionID: "session-live",
|
||||
assistantMessageID: "message-live",
|
||||
finish: "stop",
|
||||
cost: 0,
|
||||
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
cost: 0.75,
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_step_usage",
|
||||
created: 0,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID: "session-live",
|
||||
cost: 0.75,
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
const assistant = data.session.message.get("session-live", "message-live")
|
||||
return assistant?.type === "assistant" && assistant.finish === "stop"
|
||||
})
|
||||
await wait(() => data.session.get("session-live")?.cost === 0.75)
|
||||
expect(data.session.status("session-live")).toBe("running")
|
||||
expect(data.session.get("session-live")).toMatchObject({
|
||||
cost: 0.75,
|
||||
tokens: { input: 10, output: 4, reasoning: 2, cache: { read: 3, write: 1 } },
|
||||
})
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_execution_succeeded",
|
||||
created: 0,
|
||||
type: "session.execution.succeeded",
|
||||
durable: durable("session-live", 1, 3),
|
||||
durable: durable("session-live", 1),
|
||||
data: { sessionID: "session-live" },
|
||||
})
|
||||
await wait(() => data.session.status("session-live") === "idle")
|
||||
|
||||
await data.session.refresh("session-failed")
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_execution_started",
|
||||
created: 0,
|
||||
|
|
@ -559,11 +960,23 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
id: "evt_step_failed",
|
||||
created: 0,
|
||||
type: "session.step.failed",
|
||||
durable: durable("session-failed", 1, 2),
|
||||
durable: durable("session-failed", 1),
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
assistantMessageID: "message-failed",
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
cost: 0.25,
|
||||
tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
|
||||
},
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_step_usage",
|
||||
created: 0,
|
||||
type: "session.usage.updated",
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
cost: 0.25,
|
||||
tokens: { input: 5, output: 1, reasoning: 1, cache: { read: 1, write: 0 } },
|
||||
},
|
||||
})
|
||||
await wait(() => {
|
||||
|
|
@ -574,13 +987,20 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
assistant.error?.type === "provider.content-filter"
|
||||
)
|
||||
})
|
||||
await wait(() => data.session.get("session-failed")?.cost === 0.25)
|
||||
expect(data.session.get("session-failed")?.tokens).toEqual({
|
||||
input: 5,
|
||||
output: 1,
|
||||
reasoning: 1,
|
||||
cache: { read: 1, write: 0 },
|
||||
})
|
||||
expect(data.session.status("session-failed")).toBe("running")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_failed_execution_failed",
|
||||
created: 0,
|
||||
type: "session.execution.failed",
|
||||
durable: durable("session-failed", 1, 3),
|
||||
durable: durable("session-failed", 1),
|
||||
data: {
|
||||
sessionID: "session-failed",
|
||||
error: { type: "provider.content-filter", message: "Provider blocked the response" },
|
||||
|
|
@ -599,7 +1019,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
id: "evt_retry_step_started",
|
||||
created: 0,
|
||||
type: "session.step.started",
|
||||
durable: durable("session-retry", 1, 2),
|
||||
durable: durable("session-retry", 1),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
|
|
@ -611,7 +1031,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
id: "evt_retry_scheduled",
|
||||
created: 0,
|
||||
type: "session.retry.scheduled",
|
||||
durable: durable("session-retry", 1, 3),
|
||||
durable: durable("session-retry", 1),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
|
|
@ -629,7 +1049,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
id: "evt_retry_next_step",
|
||||
created: 2_000,
|
||||
type: "session.step.started",
|
||||
durable: durable("session-retry", 1, 4),
|
||||
durable: durable("session-retry", 1),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
|
|
@ -647,7 +1067,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
id: "evt_retry_scheduled_again",
|
||||
created: 2_000,
|
||||
type: "session.retry.scheduled",
|
||||
durable: durable("session-retry", 1, 5),
|
||||
durable: durable("session-retry", 1),
|
||||
data: {
|
||||
sessionID: "session-retry",
|
||||
assistantMessageID: "message-retry",
|
||||
|
|
@ -664,29 +1084,18 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
id: "evt_retry_interrupted",
|
||||
created: 2_000,
|
||||
type: "session.execution.interrupted",
|
||||
durable: durable("session-retry", 1, 6),
|
||||
durable: durable("session-retry", 1),
|
||||
data: { sessionID: "session-retry", reason: "shutdown" },
|
||||
})
|
||||
await wait(() => data.session.status("session-retry") === "idle")
|
||||
expect(data.session.message.get("session-retry", "message-retry")).not.toHaveProperty("retry")
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_admitted",
|
||||
created: 0,
|
||||
type: "session.compaction.admitted",
|
||||
durable: durable("session-manual", 1),
|
||||
data: { sessionID: "session-manual", inputID: "message-compaction" },
|
||||
})
|
||||
await wait(() => {
|
||||
const message = data.session.message.get("session-manual", "message-compaction")
|
||||
return message?.type === "compaction" && message.status === "queued"
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_manual_compaction_started",
|
||||
created: 1,
|
||||
type: "session.compaction.started",
|
||||
durable: durable("session-manual", 2),
|
||||
data: { sessionID: "session-manual", reason: "manual" },
|
||||
data: { sessionID: "session-manual", reason: "manual", recent: "", inputID: "message-compaction" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_manual_compaction_delta",
|
||||
|
|
@ -696,13 +1105,13 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
})
|
||||
await wait(() => {
|
||||
const message = data.session.message.get("session-manual", "message-compaction")
|
||||
return message?.type === "compaction" && message.summary === "Streamed summary"
|
||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Streamed summary"
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_manual_compaction_ended",
|
||||
created: 3,
|
||||
type: "session.compaction.ended",
|
||||
durable: durable("session-manual", 3),
|
||||
durable: durable("session-manual", 4),
|
||||
data: { sessionID: "session-manual", reason: "manual", text: "Streamed summary", recent: "recent" },
|
||||
})
|
||||
await wait(() => {
|
||||
|
|
@ -718,7 +1127,7 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
created: 0,
|
||||
type: "session.compaction.started",
|
||||
durable: durable("session-live", 2),
|
||||
data: { sessionID: "session-live", reason: "auto" },
|
||||
data: { sessionID: "session-live", reason: "auto", recent: "" },
|
||||
})
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_delta_1",
|
||||
|
|
@ -732,18 +1141,25 @@ test("tracks session status from active sessions and execution events", async ()
|
|||
type: "session.compaction.delta",
|
||||
data: { sessionID: "session-live", text: "summary" },
|
||||
})
|
||||
await wait(() => data.session.compaction("session-live") === "Live summary")
|
||||
await wait(() => {
|
||||
const message = data.session.message.get("session-live", "msg_compaction_started")
|
||||
return message?.type === "compaction" && message.status === "running" && message.summary === "Live summary"
|
||||
})
|
||||
|
||||
emitEvent(events, {
|
||||
id: "evt_compaction_ended",
|
||||
created: 0,
|
||||
type: "session.compaction.ended",
|
||||
durable: durable("session-live", 3),
|
||||
durable: durable("session-live", 5),
|
||||
data: { sessionID: "session-live", reason: "auto", text: "Live summary", recent: "recent" },
|
||||
})
|
||||
await wait(() => data.session.compaction("session-live") === undefined)
|
||||
expect(data.session.message.get("session-live", "msg_compaction_ended")).toMatchObject({
|
||||
await wait(() => {
|
||||
const message = data.session.message.get("session-live", "msg_compaction_started")
|
||||
return message?.type === "compaction" && message.status === "completed"
|
||||
})
|
||||
expect(data.session.message.get("session-live", "msg_compaction_started")).toMatchObject({
|
||||
type: "compaction",
|
||||
status: "completed",
|
||||
summary: "Live summary",
|
||||
})
|
||||
} finally {
|
||||
|
|
@ -1092,6 +1508,52 @@ test("adds and dismisses permission requests from live events", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("reconciles all pending permission requests when the event stream reconnects", async () => {
|
||||
const events = createEventStream()
|
||||
let requests = [
|
||||
{ id: "per_old", sessionID: "ses_old", action: "read", resources: ["old.txt"] },
|
||||
{ id: "per_keep", sessionID: "ses_keep", action: "shell", resources: ["bun test"] },
|
||||
]
|
||||
let calls = 0
|
||||
const fetch = createFetch((url) => {
|
||||
if (url.pathname !== "/api/permission/request") return
|
||||
calls++
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(fetch.fetch)} api={createApi(fetch.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => data.session.permission.list("ses_old")?.[0]?.id === "per_old")
|
||||
expect(data.session.permission.list("ses_keep")?.[0]?.id).toBe("per_keep")
|
||||
|
||||
requests = [{ id: "per_new", sessionID: "ses_new", action: "edit", resources: ["new.txt"] }]
|
||||
events.disconnect()
|
||||
|
||||
await wait(() => calls === 2 && data.session.permission.list("ses_new")?.[0]?.id === "per_new")
|
||||
expect(data.session.permission.list("ses_old")).toBeUndefined()
|
||||
expect(data.session.permission.list("ses_keep")).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("adds, dismisses, and refreshes form requests", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
|
|
@ -1162,6 +1624,52 @@ test("adds, dismisses, and refreshes form requests", async () => {
|
|||
}
|
||||
})
|
||||
|
||||
test("reconciles all pending form requests when the event stream reconnects", async () => {
|
||||
const events = createEventStream()
|
||||
let requests = [
|
||||
{ id: "frm_old", sessionID: "ses_old", mode: "form" as const, fields: [] },
|
||||
{ id: "frm_keep", sessionID: "ses_keep", mode: "url" as const, url: "https://example.com" },
|
||||
]
|
||||
let calls = 0
|
||||
const fetch = createFetch((url) => {
|
||||
if (url.pathname !== "/api/form/request") return
|
||||
calls++
|
||||
return json({ location: { directory, project: { id: "proj_test", directory } }, data: requests })
|
||||
}, events)
|
||||
let data!: ReturnType<typeof useData>
|
||||
|
||||
function Probe() {
|
||||
data = useData()
|
||||
return <box />
|
||||
}
|
||||
|
||||
const app = await testRender(() => (
|
||||
<TestTuiContexts>
|
||||
<SDKProvider client={createClient(fetch.fetch)} api={createApi(fetch.fetch)}>
|
||||
<ProjectProvider>
|
||||
<DataProvider>
|
||||
<Probe />
|
||||
</DataProvider>
|
||||
</ProjectProvider>
|
||||
</SDKProvider>
|
||||
</TestTuiContexts>
|
||||
))
|
||||
|
||||
try {
|
||||
await wait(() => data.session.form.list("ses_old")?.[0]?.id === "frm_old")
|
||||
expect(data.session.form.list("ses_keep")?.[0]?.id).toBe("frm_keep")
|
||||
|
||||
requests = [{ id: "frm_new", sessionID: "ses_new", mode: "form" as const, fields: [] }]
|
||||
events.disconnect()
|
||||
|
||||
await wait(() => calls === 2 && data.session.form.list("ses_new")?.[0]?.id === "frm_new")
|
||||
expect(data.session.form.list("ses_old")).toBeUndefined()
|
||||
expect(data.session.form.list("ses_keep")).toBeUndefined()
|
||||
} finally {
|
||||
app.renderer.destroy()
|
||||
}
|
||||
})
|
||||
|
||||
test("settles pending tools when a live failure arrives", async () => {
|
||||
const events = createEventStream()
|
||||
const calls = createFetch((url) => {
|
||||
|
|
|
|||
|
|
@ -122,6 +122,8 @@ describe("TUI inline tool wrapping", () => {
|
|||
// Legacy tool names normalize to their renamed views.
|
||||
expect(toolDisplay("bash")).toBe("shell")
|
||||
expect(toolDisplay("task")).toBe("subagent")
|
||||
expect(toolDisplay("apply_patch")).toBe("patch")
|
||||
expect(toolDisplay("patch")).toBe("patch")
|
||||
expect(toolDisplay("plugin_tool")).toBe("generic")
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import { expect, test } from "bun:test"
|
||||
import type { SessionMessage, SessionMessageAssistant } from "@opencode-ai/sdk/v2"
|
||||
import type { SessionMessageAssistant, SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
||||
import { reduceSessionRows } from "../../../src/routes/session/rows"
|
||||
|
||||
test("groups exploration parts across assistant messages until a delimiter", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Explore", time: { created: 0 } },
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", text: "Looking" },
|
||||
|
|
@ -35,7 +35,7 @@ test("groups exploration parts across assistant messages until a delimiter", ()
|
|||
})
|
||||
|
||||
test("keeps non-exploration tools as individual part rows", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } },
|
||||
{ type: "tool", id: "bash-1", name: "bash", state: pending(), time: { created: 2 } },
|
||||
|
|
@ -63,7 +63,7 @@ test("keeps non-exploration tools as individual part rows", () => {
|
|||
})
|
||||
|
||||
test("assigns stable kind ordinals within an assistant message", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "text", text: "First" },
|
||||
{ type: "reasoning", text: "Think" },
|
||||
|
|
@ -81,7 +81,7 @@ test("assigns stable kind ordinals within an assistant message", () => {
|
|||
})
|
||||
|
||||
test("groups across empty assistant reasoning parts", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", [
|
||||
{ type: "reasoning", text: "Looking" },
|
||||
{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 2 } },
|
||||
|
|
@ -112,7 +112,7 @@ test("completes exploration groups when another row follows", () => {
|
|||
{ type: "tool", id: "grep-1", name: "grep", state: pending(), time: { created: 3 } },
|
||||
])
|
||||
finished.finish = "stop"
|
||||
const messages: SessionMessage[] = [
|
||||
const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||
{ type: "user", id: "user-1", text: "Continue", time: { created: 2 } },
|
||||
finished,
|
||||
|
|
@ -139,12 +139,11 @@ test("completes exploration groups when another row follows", () => {
|
|||
})
|
||||
|
||||
test("hides synthetic messages without descriptions", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||
{
|
||||
type: "synthetic",
|
||||
id: "synthetic-1",
|
||||
sessionID: "session-1",
|
||||
text: "internal context",
|
||||
time: { created: 2 },
|
||||
},
|
||||
|
|
@ -166,12 +165,11 @@ test("hides synthetic messages without descriptions", () => {
|
|||
})
|
||||
|
||||
test("renders synthetic messages with descriptions", () => {
|
||||
const messages: SessionMessage[] = [
|
||||
const messages: SessionMessageInfo[] = [
|
||||
assistant("assistant-1", [{ type: "tool", id: "read-1", name: "read", state: pending(), time: { created: 1 } }]),
|
||||
{
|
||||
type: "synthetic",
|
||||
id: "synthetic-1",
|
||||
sessionID: "session-1",
|
||||
text: "internal context",
|
||||
description: "Explicit notice",
|
||||
time: { created: 2 },
|
||||
|
|
@ -209,31 +207,25 @@ test("renders a footer for a pre-output retry assistant after replay", () => {
|
|||
expect(reduceSessionRows([message])).toEqual([{ type: "assistant-footer", messageID: "assistant-retry" }])
|
||||
})
|
||||
|
||||
test("places a pending compaction barrier before every queued user message", () => {
|
||||
const queued = (id: string, text: string, created: number): SessionMessage => ({
|
||||
type: "user",
|
||||
id,
|
||||
text,
|
||||
time: { created },
|
||||
})
|
||||
const messages: SessionMessage[] = [
|
||||
queued("user-before", "Before", 1),
|
||||
test("history reduce keeps chronological order without pending reordering", () => {
|
||||
const messages: SessionMessageInfo[] = [
|
||||
{ type: "user", id: "user-1", text: "Before", time: { created: 1 } },
|
||||
{
|
||||
type: "compaction",
|
||||
id: "compaction",
|
||||
status: "queued",
|
||||
status: "completed",
|
||||
reason: "manual",
|
||||
summary: "",
|
||||
summary: "done",
|
||||
recent: "",
|
||||
time: { created: 2 },
|
||||
},
|
||||
queued("user-after", "After", 3),
|
||||
{ type: "user", id: "user-2", text: "After", time: { created: 3 } },
|
||||
]
|
||||
|
||||
expect(reduceSessionRows(messages, new Set(["user-before", "user-after"]))).toEqual([
|
||||
expect(reduceSessionRows(messages)).toEqual([
|
||||
{ type: "message", messageID: "user-1" },
|
||||
{ type: "message", messageID: "compaction" },
|
||||
{ type: "message", messageID: "user-before" },
|
||||
{ type: "message", messageID: "user-after" },
|
||||
{ type: "message", messageID: "user-2" },
|
||||
])
|
||||
})
|
||||
|
||||
|
|
@ -249,5 +241,5 @@ function assistant(id: string, content: SessionMessageAssistant["content"]): Ses
|
|||
}
|
||||
|
||||
function pending() {
|
||||
return { status: "pending" as const, input: "" }
|
||||
return { status: "streaming" as const, input: "" }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,7 +90,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||
return json({})
|
||||
if (url.pathname === "/config/providers") return json({ providers: {}, default: {} })
|
||||
if (url.pathname === "/experimental/console") return json({ consoleManagedProviders: [], switchableOrgCount: 0 })
|
||||
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: false })
|
||||
if (url.pathname === "/experimental/capabilities") return json({ backgroundSubagents: true })
|
||||
if (url.pathname === "/path") return json({ home: "", state: "", config: "", worktree, directory })
|
||||
if (url.pathname === "/api/location") return json({ directory, project: { id: "proj_test", directory: worktree } })
|
||||
if (url.pathname === "/api/project/current") return json({ id: "proj_test", directory: worktree })
|
||||
|
|
@ -101,6 +101,10 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
|
|||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/session") return json({ data: [], cursor: {} })
|
||||
if (url.pathname === "/api/session/active") return json({ data: {} })
|
||||
if (url.pathname === "/api/permission/request")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (url.pathname === "/api/form/request")
|
||||
return json({ location: { directory, project: { id: "proj_test", directory: worktree } }, data: [] })
|
||||
if (/^\/api\/session\/[^/]+\/form$/.test(url.pathname)) return json({ data: [] })
|
||||
if (
|
||||
["/api/agent", "/api/model", "/api/provider", "/api/integration", "/api/command", "/api/skill"].includes(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { describe, expect, test } from "bun:test"
|
||||
import { isDefaultTitle } from "../../src/util/session"
|
||||
import type { SessionMessageInfo } from "@opencode-ai/sdk/v2"
|
||||
import { isDefaultTitle, lastAssistantWithUsage } from "../../src/util/session"
|
||||
|
||||
describe("util.session", () => {
|
||||
test("recognizes generated parent and child titles", () => {
|
||||
|
|
@ -7,4 +8,22 @@ describe("util.session", () => {
|
|||
expect(isDefaultTitle("Child session - 2026-06-06T12:34:56.789Z")).toBeTrue()
|
||||
expect(isDefaultTitle("New session - custom")).toBeFalse()
|
||||
})
|
||||
|
||||
test("tracks usage across undo and redo boundaries", () => {
|
||||
const assistant = (id: string, input: number): SessionMessageInfo => ({
|
||||
id,
|
||||
type: "assistant",
|
||||
agent: "build",
|
||||
model: { id: "model", providerID: "provider" },
|
||||
content: [],
|
||||
tokens: { input, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
|
||||
time: { created: 0 },
|
||||
})
|
||||
const messages = [assistant("msg_z", 10), assistant("msg_a", 30)]
|
||||
|
||||
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
|
||||
expect(lastAssistantWithUsage(messages, "msg_a")?.tokens.input).toBe(10)
|
||||
expect(lastAssistantWithUsage(messages, "msg_missing")).toBeUndefined()
|
||||
expect(lastAssistantWithUsage(messages)?.tokens.input).toBe(30)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ describe("toolDisplayMetadata", () => {
|
|||
})
|
||||
|
||||
test("does not expose pending or malformed metadata", () => {
|
||||
expect(toolDisplayMetadata({ status: "pending", structured: { provider: "exa" } })).toEqual({})
|
||||
expect(toolDisplayMetadata({ status: "streaming", structured: { provider: "exa" } })).toEqual({})
|
||||
expect(toolDisplayMetadata({ status: "completed" })).toEqual({})
|
||||
expect(toolDisplayMetadata({ status: "completed", structured: null })).toEqual({})
|
||||
expect(toolDisplayMetadata({ status: "completed", structured: [] })).toEqual({})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue