Compare commits

..

2 Commits

Author SHA1 Message Date
Kit Langton c3111fe15f fix(tui): undo latest pending prompt 2026-08-08 21:18:27 -04:00
Kit Langton 84fd347afa fix(codegen): write prettier-stable generated manifests (#41343) 2026-08-08 20:52:04 -04:00
10 changed files with 94 additions and 100 deletions
+9 -1
View File
@@ -1316,7 +1316,15 @@ export function write(
}).pipe(Effect.flatMap((content) => fs.writeFileString(join(directory, file.path), content))),
{ concurrency: 8, discard: true },
)
yield* fs.writeFileString(manifest, JSON.stringify(output.files.map((file) => file.path).sort(), null, 2) + "\n")
// Format the manifest with the same prettier settings as the repo-wide
// format pass, so `check:generated` stays clean after the generate bot
// reformats the tree.
const manifestJson = JSON.stringify(output.files.map((file) => file.path).sort())
const manifestContent = yield* Effect.tryPromise({
try: () => format(manifestJson, { filepath: manifest, parser: "json", printWidth: 120 }),
catch: (error) => new GenerationError({ reason: `Failed to format ${manifest}: ${String(error)}` }),
})
yield* fs.writeFileString(manifest, manifestContent)
})
}
+1 -1
View File
@@ -16,7 +16,7 @@ describe("HttpApiCodegen.write", () => {
expect(writes).toEqual([
{ path: "/generated/session.ts", content: "export const session = {}\n" },
{ path: "/generated/.httpapi-codegen.json", content: '[\n "session.ts"\n]\n' },
{ path: "/generated/.httpapi-codegen.json", content: '["session.ts"]\n' },
])
}).pipe(
Effect.provideService(
+3 -18
View File
@@ -10,7 +10,6 @@ import {
moveSessionTab,
NEW_SESSION_TAB_TITLE,
sessionTabComplete,
sessionTabDetail,
sessionTabShortcutLabel,
seedSessionTabMotion,
sessionTabOverflowWidth,
@@ -149,15 +148,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle()))
const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const detail = createMemo(() => {
if (tab === NEW_SESSION_TAB) return "Start a new session"
if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth())
const value = session()
const projectLabel = projectName(project(), value?.location.directory) ?? ""
const vcs = value ? data.location.vcs.info(value.location) : undefined
return sessionTabDetail(projectLabel, vcs?.branch.current, vcs?.branch.default)
return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth())
})
const visibleDetail = createMemo(() => Locale.takeWidth(detail(), titleWidth()))
const visibleDetailParts = createMemo(() => Locale.graphemes(visibleDetail()))
const detailFades = createMemo(() => stringWidth(detail()) >= titleWidth() && titleWidth() > FADE_WIDTH)
const background = createMemo(() => {
if (selected()) return theme.background.action.primary.selected
if (hovered() === tab.sessionID || dragging() === tab.sessionID)
@@ -190,11 +184,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const detailPulseColor = createMemo(() => tint(pulseBackground(), theme.text.default, 0.13))
const detailGlowColor = createMemo(() => tint(pulseBackground(), glowHue(), 0.25))
const detailColor = createMemo(() => tint(theme.text.subdued, pulseBackground(), 0.35))
const detailTextColor = (index: number) => {
if (!detailFades() || index < visibleDetailParts().length - FADE_WIDTH) return detailColor()
const position = index - (visibleDetailParts().length - FADE_WIDTH)
return tint(detailColor(), pulseBackground(), 0.2 + 0.72 * (position / Math.max(1, FADE_WIDTH - 1)))
}
const glows = () => status().glows
const previous = createMemo(() => items()[index() - 1])
const previousStatus = createMemo(() => {
@@ -371,11 +360,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
/>
<box zIndex={1} width="100%" flexDirection="row" paddingLeft={numberWidth() + 1} paddingRight={2}>
<text fg={detailColor()} wrapMode="none" selectable={false}>
<Show when={detailFades()} fallback={visibleDetail()}>
<For each={visibleDetailParts()}>
{(character, index) => <span style={{ fg: detailTextColor(index()) }}>{character}</span>}
</For>
</Show>
{detail()}
</text>
</box>
</box>
@@ -13,16 +13,6 @@ export function sessionTabShortcutLabel(index: number) {
return "·"
}
export function sessionTabBranch(current: string | undefined, defaultBranch: string | undefined) {
if (!current || current === defaultBranch) return undefined
return current
}
export function sessionTabDetail(project: string, current: string | undefined, defaultBranch: string | undefined) {
const branch = sessionTabBranch(current, defaultBranch)
return branch && project ? `${project}:${branch}` : (branch ?? project)
}
export type SessionTabHistory = {
entries: readonly string[]
index: number
+4 -15
View File
@@ -157,9 +157,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
// Load lightweight session and location metadata concurrently so persisted tabs can resolve
// their project and branch labels. Delay the heavier per-tab data so the visible session keeps
// the first connection slots and switches still render from a warm cache.
// Load lightweight session metadata concurrently so persisted tabs can resolve their project
// labels immediately. Delay the heavier per-tab data so the visible session keeps the first
// connection slots and switches still render from a warm cache.
const openTabSessions = createMemo(() =>
state()
.tabs.map((tab) => tab.sessionID)
@@ -171,19 +171,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (client.connection.status() !== "connected") return
const sessionIDs = openTabSessions()
if (sessionIDs === "") return
void Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
let stale = false
void (async () => {
await Promise.allSettled(sessionIDs.split("\n").map((sessionID) => data.session.sync(sessionID)))
if (stale) return
const locations = new Map(
sessionIDs
.split("\n")
.map((sessionID) => data.session.get(sessionID)?.location)
.filter((location) => location !== undefined)
.map((location) => [`${location.directory}\n${location.workspaceID ?? ""}`, location]),
)
await Promise.allSettled(Array.from(locations.values(), (location) => data.location.vcs.sync(location)))
})()
const timer = setTimeout(async () => {
const sessions = state()
.tabs.map((tab) => tab.sessionID)
+13 -7
View File
@@ -84,6 +84,7 @@ import { usePathFormatter } from "../../context/path-format"
import { useLocation } from "../../context/location"
import { PluginSlot } from "../../plugin/render"
import { usePlugin } from "../../plugin/context"
import { undoMessage } from "./undo"
import {
cacheReuseDrop,
createSessionRows,
@@ -656,19 +657,24 @@ export function Session() {
group: "Session",
slash: { name: "undo" },
run: () => {
const admitted = pendingUsers().at(-1)
const boundary = session()?.revert?.messageID
const message = messages().findLast(
(message): message is SessionMessageUser =>
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
)
const message = admitted
? { id: admitted.id, ...admitted.data }
: messages().findLast(
(message): message is SessionMessageUser =>
message.type === "user" && !!message.text.trim() && (!boundary || message.id < boundary),
)
if (!message) {
toast.show({ message: "Nothing to undo", variant: "error", duration: 3000 })
dialog.clear()
return
}
void client.api.session.revert
.stage({ sessionID: route.sessionID, messageID: message.id })
.catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
void undoMessage(client.api, {
sessionID: route.sessionID,
messageID: message.id,
pending: admitted !== undefined,
}).catch((error) => toast.show({ message: errorMessage(error), variant: "error", duration: 5000 }))
prompt()?.set({
...projectedPromptInput(message),
pasted: [],
+14
View File
@@ -0,0 +1,14 @@
import type { OpenCodeClient } from "@opencode-ai/client"
export async function undoMessage(
client: OpenCodeClient,
input: { readonly sessionID: string; readonly messageID: string; readonly pending: boolean },
) {
const revert = () => client.session.revert.stage(input).then(() => undefined)
if (!input.pending) return revert()
return client.session.pending.cancel({ sessionID: input.sessionID, inputID: input.messageID }).catch((error) => {
if (typeof error !== "object" || error === null || !("_tag" in error) || error._tag !== "ConflictError") throw error
return revert()
})
}
+48
View File
@@ -0,0 +1,48 @@
import { expect, test } from "bun:test"
import { OpenCode } from "@opencode-ai/client"
import { undoMessage } from "../../../src/routes/session/undo"
test.each([
{ name: "projected", pending: false, cancelStatus: 204, expected: ["revert"] },
{ name: "pending", pending: true, cancelStatus: 204, expected: ["cancel"] },
{ name: "promoted race", pending: true, cancelStatus: 409, expected: ["cancel", "revert"] },
])("undo routes $name messages", async ({ pending, cancelStatus, expected }) => {
const calls: string[] = []
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async (input: URL | RequestInfo, init?: BunFetchRequestInit | RequestInit) => {
const request = input instanceof Request ? input : new Request(input, init)
const operation = request.method === "DELETE" ? "cancel" : "revert"
calls.push(operation)
if (operation === "cancel") {
if (cancelStatus === 409)
return Response.json({ _tag: "ConflictError", message: "Input was promoted" }, { status: 409 })
return new Response(null, { status: 204 })
}
return Response.json({ data: { messageID: "msg_user" } })
},
{ preconnect: fetch.preconnect },
),
})
await undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending })
expect(calls).toEqual([...expected])
})
test("undo does not reinterpret transport failures as promotion races", async () => {
const client = OpenCode.make({
baseUrl: "http://localhost:3000",
fetch: Object.assign(
async () => {
throw new Error("offline")
},
{ preconnect: fetch.preconnect },
),
})
await expect(
undoMessage(client, { sessionID: "ses_test", messageID: "msg_user", pending: true }),
).rejects.toMatchObject({ reason: "Transport" })
})
@@ -11,25 +11,11 @@ import {
reopenSessionTab,
seedSessionTabMotion,
sessionTabComplete,
sessionTabBranch,
sessionTabDetail,
sessionTabOverflowWidth,
sessionTabShortcutLabel,
} from "../../src/context/session-tabs-model"
describe("session tabs", () => {
test("shows only non-default session branches", () => {
expect(sessionTabBranch("main", "main")).toBeUndefined()
expect(sessionTabBranch("feature/sidebar", "main")).toBe("feature/sidebar")
expect(sessionTabBranch("feature/sidebar", undefined)).toBe("feature/sidebar")
expect(sessionTabBranch(undefined, "main")).toBeUndefined()
})
test("separates the project and branch with a colon", () => {
expect(sessionTabDetail("opencode", "feature/sidebar", "main")).toBe("opencode:feature/sidebar")
expect(sessionTabDetail("opencode", "main", "main")).toBe("opencode")
})
test("labels direct shortcut tabs and marks unbound tabs with a dot", () => {
expect(Array.from({ length: 12 }, (_, index) => sessionTabShortcutLabel(index))).toEqual([
"1",
@@ -27,14 +27,7 @@ async function wait(fn: () => boolean | Promise<boolean>, timeout = 2_000) {
async function renderSessionTabs(
initialSessionID: string,
options?: {
state?: string
title?: string
home?: boolean
persisted?: string[]
sessionGate?: Promise<void>
sessionDirectories?: Record<string, string>
},
options?: { state?: string; title?: string; home?: boolean; persisted?: string[]; sessionGate?: Promise<void> },
) {
const temporary = options?.state ? undefined : await tmpdir()
const state = options?.state ?? temporary!.path
@@ -51,16 +44,7 @@ async function renderSessionTabs(
}
const events = createEventStream()
const sessions: string[] = []
const vcsLocations: string[] = []
const calls = createFetch(async (url) => {
if (url.pathname === "/api/vcs") {
const requested = url.searchParams.get("location[directory]") ?? directory
vcsLocations.push(requested)
return json({
location: { directory: requested },
data: { branch: { current: "main", default: "main" } },
})
}
const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
if (!sessionID) return undefined
sessions.push(sessionID)
@@ -70,7 +54,7 @@ async function renderSessionTabs(
id: sessionID,
title: sessionID === initialSessionID ? options?.title : undefined,
projectID: "project",
location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
@@ -120,7 +104,6 @@ async function renderSessionTabs(
route,
data,
sessions,
vcsLocations,
state,
emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
async destroy() {
@@ -151,21 +134,6 @@ test("loads persisted tab metadata concurrently on connect", async () => {
}
})
test("loads VCS metadata for each persisted tab location", async () => {
const other = `${directory}/other-worktree`
const setup = await renderSessionTabs("first", {
home: true,
persisted: ["first", "second"],
sessionDirectories: { second: other },
})
try {
await wait(() => setup.vcsLocations.includes(other))
} finally {
await setup.destroy()
}
})
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")