mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-27 19:04:43 +08:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 260f342332 | |||
| 8be83a67f1 | |||
| 682e80dd0a | |||
| 19de7a2b1f | |||
| a82318019f | |||
| e9d58abd99 | |||
| 1ce7e90d3e | |||
| 92658e4389 |
@@ -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: {
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user