Compare commits

..

8 Commits

Author SHA1 Message Date
Brendan Allan 260f342332 fix(app): avoid nested session loading frames 2026-08-23 12:08:57 +08:00
Brendan Allan 8be83a67f1 chore(app): remove unrelated staged changes 2026-08-23 11:51:20 +08:00
Brendan Allan 682e80dd0a fix(app): preserve session frame while loading 2026-08-23 11:50:19 +08:00
Brendan Allan 19de7a2b1f fix(app): show session title while loading 2026-08-23 11:36:51 +08:00
Luke Parker a82318019f fix(app): skip service worker in development (#44302) 2026-08-23 13:35:05 +10:00
Luke Parker e9d58abd99 test(app): stabilize file expansion anchor (#44293) 2026-08-23 13:05:31 +10:00
Brendan Allan 1ce7e90d3e fix(app): register service worker after load (#44294) 2026-08-23 10:50:56 +08:00
Aiden Cline 92658e4389 fix(ai): preserve raw provider error payload on responses streams (#44271) 2026-08-22 20:59:59 -05:00
13 changed files with 178 additions and 138 deletions
+31 -49
View File
@@ -61,6 +61,11 @@ const OpenResponsesOutputText = Schema.Struct({
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
}
const OpenResponsesReasoningSummaryText = Schema.Struct({
type: Schema.tag("summary_text"),
text: Schema.String,
@@ -242,6 +247,7 @@ const OpenResponsesErrorPayload = Schema.Struct({
message: optionalNull(Schema.String),
param: optionalNull(Schema.String),
})
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
export const WebSocketErrorEvent = Schema.StructWithRest(
@@ -306,20 +312,6 @@ export const Event = Schema.StructWithRest(
)
export type Event = Schema.Schema.Type<typeof Event>
const RefusalEvent = Schema.Union([
Schema.Struct({
type: Schema.tag("response.refusal.delta"),
item_id: Schema.String,
delta: Schema.String,
}),
Schema.Struct({
type: Schema.tag("response.refusal.done"),
item_id: Schema.String,
refusal: Schema.String,
}),
])
const isRefusalEvent = Schema.is(RefusalEvent)
export interface Extension {
readonly id: string
readonly name: string
@@ -340,7 +332,6 @@ export interface ParserState {
readonly hasFunctionCall: boolean
readonly lifecycle: Lifecycle.State
readonly messageItems: ReadonlySet<string>
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
readonly store: boolean | undefined
@@ -418,10 +409,6 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
}
}
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
return itemID(part.providerMetadata, providerMetadataKey)
}
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
part: MediaPart,
request: LLMRequest,
@@ -592,9 +579,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
}
if (part.type === "tool-result" && part.providerExecuted === true) {
flushText()
const itemID = hostedToolItemID(part, providerMetadataKey)
if (store !== false && itemID && !hostedToolReferences.has(itemID))
input.push({ type: "item_reference", id: itemID })
const id = itemID(part.providerMetadata, providerMetadataKey)
if (store !== false && id && !hostedToolReferences.has(id))
input.push({ type: "item_reference", id })
if (store === false && part.result.type === "content") {
const content: ReadonlyArray<Content> = part.result.value
input.push({
@@ -604,7 +591,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
),
})
}
if (itemID) hostedToolReferences.add(itemID)
if (id) hostedToolReferences.add(id)
continue
}
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
@@ -816,18 +803,17 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
// best-effort, not guaranteed.
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
const item = event.item
if (item?.type === "message" && item.id)
if (item?.type === "message" && item.id) {
const phase = messagePhase(item.phase)
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: (() => {
const phase = state.messagePhase(item.phase)
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
})(),
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
},
NO_EVENTS,
]
}
if (item && isReasoningItem(item)) {
const events: LLMEvent[] = []
return [
@@ -985,7 +971,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
if (!item) return [state, NO_EVENTS] satisfies StepResult
if (item.type === "message" && item.id) {
const itemPhase = state.messagePhase(item.phase)
const itemPhase = messagePhase(item.phase)
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
const events: LLMEvent[] = []
const messageItems = new Set(state.messageItems)
@@ -1093,22 +1079,26 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
})
// Build a single human-readable message from whatever the provider supplied.
// Build the prettiest summary available from whatever the provider supplied.
// When both code and message are present, prefix the code so consumers see
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
// the bare message — production rate limits and context-length failures used
// to be indistinguishable from generic stream drops.
const providerErrorMessage = (event: Event, fallback: string): string => {
const nested = event.error ?? event.response?.error ?? undefined
// to be indistinguishable from generic stream drops. Returns undefined when
// the payload carries no usable summary.
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
const message = event.message || nested?.message || undefined
const code = event.code || nested?.code || undefined
if (message && code) return `${code}: ${message}`
return message || code || fallback
return message || code
}
export const providerFailure = (id: string, event: Event, fallback: string) => {
const code = event.code || event.error?.code || event.response?.error?.code || undefined
const message = providerErrorMessage(event, fallback)
const nested = event.error ?? event.response?.error ?? undefined
const code = event.code || nested?.code || undefined
// Keep the full raw payload on the error even when the message is a summary.
const body = JSON.stringify(nested ?? event) ?? ""
const summary = providerErrorMessage(event, nested)
const message = summary ?? (body === "{}" ? fallback : body)
const status =
typeof event.status === "number"
? event.status
@@ -1118,7 +1108,8 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
return new AIError({
module: id,
method: "stream",
reason: classifyProviderFailure({ message, code, status }),
body,
reason: classifyProviderFailure({ message, code, status, rawBody: body }),
})
}
@@ -1134,21 +1125,18 @@ export const step = (state: ParserState, event: Event) => {
)
}
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
if (!event.item_id || typeof value !== "string") return ProviderShared.eventError(state.id, `${event.type} is malformed`)
return Effect.succeed(
event.type === "response.refusal.delta"
? onOutputTextDelta(state, event, event.item_id)
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
)
}
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
}
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
return Effect.succeed(onReasoningDone(state, event))
}
if (event.type === "response.reasoning_summary_part.added")
return event.item_id
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
@@ -1193,17 +1181,11 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
tools: ToolStream.empty<string>(),
lifecycle: Lifecycle.initial(),
messageItems: new Set<string>(),
messagePhase,
messagePhases: {},
reasoningItems: {},
store: OpenResponsesOptions.resolve(request).store,
})
const messagePhase = (value: unknown): MessagePhase | undefined => {
if (value === null || value === "commentary" || value === "final_answer") return value
return undefined
}
export const protocol = Protocol.make({
id: ADAPTER,
body: {
+7 -2
View File
@@ -80,6 +80,9 @@ export interface ProviderFailure {
readonly message: string
readonly status?: number | undefined
readonly code?: string | undefined
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
// that the summary message does not carry. Not shown to users.
readonly rawBody?: string | undefined
readonly retryAfterMs?: number | undefined
readonly rateLimit?: HttpRateLimitDetails | undefined
readonly http?: HttpContext | undefined
@@ -89,11 +92,13 @@ export interface ProviderFailure {
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
const body = input.http?.body ?? ""
const body = input.http?.body ?? input.rawBody ?? ""
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
.filter((code): code is string => code !== undefined)
.map((code) => code.toLowerCase())
const text = body || input.message
// Scan the raw payload too so signals missing from the summary message
// (e.g. overflow phrases nested in a JSON error body) still classify.
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
+3
View File
@@ -153,6 +153,9 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
module: Schema.String,
method: Schema.String,
reason: AIErrorReason,
// Raw provider payload as a string, so classified failures never lose the
// original error detail even when the pretty message is a summary.
body: Schema.optional(Schema.String),
}) {
override readonly cause = this.reason
+17
View File
@@ -106,3 +106,20 @@ describe("provider error classification", () => {
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
})
})
describe("provider error rawBody classification", () => {
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
const reason = classifyProviderFailure({
message: "Request failed",
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
})
expect(reason._tag).toBe("InvalidRequest")
expect(reason).toMatchObject({ classification: "context-overflow" })
})
test("extracts nested codes from the raw payload", () => {
expect(
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
).toBe("QuotaExceeded")
})
})
@@ -3016,36 +3016,42 @@ describe("OpenAI Responses route", () => {
}),
)
it.effect("falls back to a stable default when error is null", () =>
it.effect("falls back to the raw payload when error is null", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"error":null')
expect(error.body).toBe(error.reason.message)
}),
)
it.effect("falls back to a stable default when both error and response are absent", () =>
it.effect("falls back to the raw payload when both error and response are absent", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"type":"error"')
expect(error.body).toBe(error.reason.message)
}),
)
it.effect("falls back to a stable default when response.failed has no error payload", () =>
it.effect("keeps the raw response payload when response.failed has no error payload", () =>
Effect.gen(function* () {
const error = yield* LLMClient.generate(request).pipe(
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
Effect.flip,
)
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
expect(error.reason.message).toContain('"resp_failed_3"')
expect(error.body).toBe(error.reason.message)
}),
)
@@ -3,9 +3,9 @@ import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
setupTimeline,
textPart,
toolPart,
userMessage,
userText,
} from "../performance/timeline-stability/fixture"
test("renders completed write content", async ({ page }) => {
@@ -82,9 +82,9 @@ test("keeps an expanded file diff header at the same viewport position", async (
const id = "prt_file_projection_anchored_patch"
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n")
const timeline = await setupTimeline(page, {
await setupTimeline(page, {
messages: [
userMessage(),
userMessage([userText("Preceding context ".repeat(120))]),
assistantMessage([
toolPart(
id,
@@ -105,7 +105,6 @@ test("keeps an expanded file diff header at the same viewport position", async (
},
},
),
textPart("prt_after_anchored_patch", "The diff is ready.\n\n".repeat(4)),
]),
],
viewport: { width: 1200, height: 600 },
@@ -113,7 +112,21 @@ test("keeps an expanded file diff header at the same viewport position", async (
const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") })
const wrapper = page.locator(`[data-timeline-part-id="${id}"]`)
const row = page.locator("[data-timeline-key]", { has: wrapper })
const trigger = wrapper.getByRole("button")
await expect
.poll(() =>
row.evaluate((element) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
return measured
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
: Number.POSITIVE_INFINITY
}),
)
.toBeLessThanOrEqual(1)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight))
.toBeGreaterThan(1)
await scroller.evaluate((element) => {
element.scrollTop = element.scrollHeight - element.clientHeight - 0.25
})
@@ -121,11 +134,28 @@ test("keeps an expanded file diff header at the same viewport position", async (
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(0.5)
await trigger.dispatchEvent("wheel", { deltaY: -1, deltaMode: 0 })
await trigger.dispatchEvent("pointerdown")
const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop)
await scroller.hover()
await page.mouse.wheel(0, -20)
await expect
.poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop))
.toBeGreaterThan(0)
const y = await trigger.evaluate((element) => element.getBoundingClientRect().y)
await trigger.dispatchEvent("click")
const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height)
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() =>
row.evaluate((element, collapsed) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
const allocatedHeight = element.getBoundingClientRect().height
return {
grew: allocatedHeight > collapsed + 1,
measured: measured ? Math.abs(allocatedHeight - measured.getBoundingClientRect().height) <= 1 : false,
}
}, collapsedHeight),
)
.toEqual({ grew: true, measured: true })
await expect
.poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y))
.toBeLessThanOrEqual(5)
@@ -133,9 +163,10 @@ test("keeps an expanded file diff header at the same viewport position", async (
const scrollTop = await scroller.evaluate((element) => element.scrollTop)
await scroller.hover()
await page.mouse.wheel(0, 200)
await timeline.settle(40)
await expect
.poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop))
.toBeGreaterThan(50)
const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)
expect(scrolled).toBeGreaterThan(50)
expect(scrolled).toBeLessThan(400)
const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y)
@@ -150,6 +181,16 @@ test("keeps an expanded file diff header at the same viewport position", async (
await trigger.click()
await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible()
await expect
.poll(() =>
row.evaluate((element) => {
const measured = element.querySelector<HTMLElement>("[data-index]")
return measured
? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height)
: Number.POSITIVE_INFINITY
}),
)
.toBeLessThanOrEqual(1)
await expect
.poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop))
.toBeLessThanOrEqual(1)
+4
View File
@@ -42,6 +42,10 @@ const clearAuthToken = () => {
const web = createWebPlatform(pkg.version)
if (import.meta.env.PROD && "serviceWorker" in navigator) {
window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"), { once: true })
}
if (import.meta.env.VITE_SENTRY_DSN) {
init({
dsn: import.meta.env.VITE_SENTRY_DSN,
+18 -9
View File
@@ -87,15 +87,24 @@ function ResolvedTargetSessionRoute() {
</SessionRouteFrame>
}
>
<Show when={directory()}>
{(value) => (
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
</LocationProvider>
)}
</Show>
<div class="relative size-full">
<div class="absolute inset-0" classList={{ invisible: !!directory() }}>
<SessionRouteFrame padded>
<SessionPanelFrame raised />
</SessionRouteFrame>
</div>
<Show when={directory()}>
{(value) => (
<div class="absolute inset-0">
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
<TargetSessionPage />
</SessionUIProvider>
</LocationProvider>
</div>
)}
</Show>
</div>
</Show>
)
}
+30 -30
View File
@@ -26,7 +26,6 @@ export function SessionScreen(props: { session: SessionModel }) {
const isDesktop = session.isDesktop
const screen = createSessionScreenLayout(session, serverSDK.scope)
const timeline = createSessionTimelineInteraction(session)
const messagesReady = timeline.ready
const [store, setStore] = createStore({ deferRender: false })
createComputed((prev) => {
@@ -58,44 +57,45 @@ export function SessionScreen(props: { session: SessionModel }) {
const sessionPanelContent = () => (
<>
{timeline.resource() ?? ""}
<Show when={!isDesktop() && !!session.identity.params.id && !mobileTabsBottom()}>
<SessionMobileTabs review={review} compact />
</Show>
{/* Surface query errors without suspending session metadata while messages load. */}
<Show when={timeline.resource.error}>
{(error) => {
throw error()
}}
</Show>
<div class="flex-1 min-h-0 overflow-hidden">
<Switch>
<Match when={session.identity.params.id && review.mobile.changes()}>
<SessionMobileReview review={review} />
</Match>
<Match when={session.identity.params.id}>
<Show when={messagesReady() ? session.identity.params.id : undefined} keyed>
{(_id) => (
<MessageTimeline
session={session}
background={composer.region.state.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
/>
)}
</Show>
<MessageTimeline
session={session}
background={composer.region.state.background}
actions={composer.actions.timeline}
scroll={timeline.scroll}
onResumeScroll={timeline.actions.resume}
setScrollRef={timeline.view.setScrollRef}
onScheduleScrollState={timeline.view.scheduleScrollState}
onPin={timeline.view.pin}
onUnpin={timeline.view.unpin}
onUserScroll={timeline.view.markUserScroll}
onHistoryScroll={timeline.view.onHistoryScroll}
onSelectionInteraction={timeline.view.selectionInteraction}
pinned={timeline.view.pinned()}
centered={screen.centered()}
setContentRef={timeline.view.setContentRef}
diffs={review.details.diffs}
onReview={review.open}
workspaceMoveEligible={composer.workspaceMoveEligible()}
onSummaryOpenChange={review.details.setOpen}
anchor={timeline.view.anchor}
setRevealMessage={timeline.view.setRevealMessage}
setScrollToEnd={timeline.view.setScrollToEnd}
/>
</Match>
</Switch>
</div>
+1
View File
@@ -25,6 +25,7 @@ export default defineConfig({
desktopPlugin,
VitePWA({
strategies: "generateSW",
injectRegister: false,
manifest: false,
workbox: {
cleanupOutdatedCaches: true,
@@ -6,24 +6,13 @@ import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } fro
import { closeSessionTab, cycleSessionTab, moveSessionTab } from "../../../context/session-tabs-model"
import { StoryFooter } from "./footer"
import type { Story } from "./index"
import { projectName } from "../../../util/project"
type FixtureStatus = ReturnType<SessionTabsController["status"]>
const FIXTURE_TABS = [
{ sessionID: "fixture-1", title: "Implement session tabs", project: "opencode" },
{
sessionID: "fixture-2",
title: "Investigate rendering",
canonical: "C:\\",
directory: "C:\\Users\\demo\\Desktop\\Prabha",
},
{
sessionID: "fixture-3",
title: "A deliberately long session title for truncation",
canonical: "D:\\",
directory: "D:\\work\\notes",
},
{ sessionID: "fixture-2", title: "Investigate rendering", project: "opencode" },
{ sessionID: "fixture-3", title: "A deliberately long session title for truncation", project: "opencode-slack" },
{ sessionID: "fixture-4", title: "Fix provider state", project: "opencode" },
{ sessionID: "fixture-5", title: "Review animation", project: "opencode-slack" },
{ sessionID: "fixture-6", title: "Untitled behavior", project: "opencode-drive" },
@@ -109,10 +98,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) {
current: active,
add: addTab,
detail(sessionID) {
const tab = FIXTURE_TABS.find((tab) => tab.sessionID === sessionID)
if (!tab) return
if ("project" in tab) return tab.project
return projectName({ canonical: tab.canonical }, tab.directory)
return FIXTURE_TABS.find((tab) => tab.sessionID === sessionID)?.project
},
status(sessionID) {
return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS
+2 -3
View File
@@ -2,7 +2,6 @@ import path from "path"
export function projectName(project?: { canonical: string; name?: string }, fallback = "") {
const canonical = project?.canonical ?? fallback
const paths = /^(?:[a-z]:[\\/]|\\\\)/i.test(canonical) ? path.win32 : path.posix
if (paths.parse(canonical).root === canonical) return fallback ? paths.basename(fallback) : undefined
return project?.name || paths.basename(canonical)
if (canonical === "/") return fallback ? path.basename(fallback) : undefined
return project?.name || path.basename(canonical)
}
-13
View File
@@ -1,13 +0,0 @@
import { describe, expect, test } from "bun:test"
import { projectName } from "../../src/util/project"
describe("projectName", () => {
test("falls back to the folder name for filesystem-root projects", () => {
expect(projectName({ canonical: "/" }, "/home/user/Prabha")).toBe("Prabha")
expect(projectName({ canonical: "C:\\" }, "C:\\Users\\user\\Desktop\\Prabha")).toBe("Prabha")
})
test("preserves backslashes in POSIX folder names", () => {
expect(projectName({ canonical: "/tmp/foo\\bar" })).toBe("foo\\bar")
})
})