Compare commits

..

20 Commits

Author SHA1 Message Date
Brendan Allan 68e3888bc3 fix(app): preserve session header while loading (#44346) 2026-08-23 16:09:13 +08:00
Brendan Allan 27a53969d6 fix(app): stabilize initial timeline rendering (#44333) 2026-08-23 16:06:50 +08:00
Brendan Allan 1e3d3fcaca fix(app): complete leading paginated turns (#44322) 2026-08-23 15:27:33 +08:00
opencode-agent[bot] 7420903859 chore: update nix node_modules hashes 2026-08-23 07:25:41 +00:00
Simon Klee 853d3534e5 cli: share app archive across Node builds (#44331) 2026-08-23 09:11:54 +02:00
Brendan Allan 6cd3da1d4c fix(app): preserve scroll across history prepends (#44317) 2026-08-23 15:00:29 +08:00
Brendan Allan 9692bcbc21 fix(session-ui): align status tool typography (#44330) 2026-08-23 14:55:53 +08:00
Brendan Allan 79d5359d11 fix(app): align background hint spacing (#44329) 2026-08-23 14:50:32 +08:00
Brendan Allan b9ca77c5ff fix(app): suspend composer until restored (#44320) 2026-08-23 14:46:28 +08:00
Brendan Allan 7a72e51710 fix(app): reuse global model state (#44319) 2026-08-23 14:26:53 +08:00
Brendan Allan 0fda7d1b33 fix(session-ui): reduce tool error spacing (#44318) 2026-08-23 14:25:48 +08:00
Brendan Allan 6ba707d305 fix(app): synchronize composer model controls (#44316) 2026-08-23 14:25:27 +08:00
Brendan Allan d144c81d17 fix(app): preserve session shell while loading (#44315) 2026-08-23 14:18:38 +08:00
Brendan Allan b8fb894ec7 fix(server): compress responses with correct content types (#44321) 2026-08-23 06:18:20 +00:00
Brendan Allan b7167aaab0 feat(app): add project icon setting (#44323) 2026-08-23 06:18:15 +00:00
Luke Parker ad59ccc2e2 fix(ui): standardize safe text metrics (#44106) 2026-08-23 13:47:17 +10: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
88 changed files with 1388 additions and 509 deletions
+33 -3
View File
@@ -195,9 +195,33 @@ jobs:
path: packages/cli/dist/cli-*
if-no-files-found: error
build-node-cli:
build-node-app-archive:
needs: version
if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled
runs-on: blacksmith-4vcpu-ubuntu-2404
timeout-minutes: 30
if: github.repository == 'anomalyco/opencode'
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0
- uses: ./.github/actions/setup-bun
- name: Build app archive
run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
- uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2
with:
name: opencode-node-app-archive
path: packages/cli/.cache/app-archive.bin
if-no-files-found: error
build-node-cli:
needs:
- version
- build-node-app-archive
if: github.repository == 'anomalyco/opencode'
strategy:
fail-fast: false
matrix:
@@ -227,8 +251,13 @@ jobs:
with:
node-version: "26.4.0"
- uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0
with:
name: opencode-node-app-archive
path: packages/cli/.cache
- name: Build
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node
run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin
env:
OPENCODE_VERSION: ${{ needs.version.outputs.version }}
OPENCODE_RELEASE: ${{ needs.version.outputs.release }}
@@ -543,6 +572,7 @@ jobs:
- version
- build-cli
- sign-cli-macos
- build-node-app-archive
- build-node-cli
- sign-cli-windows
- build-electron
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-tvhHO7NdDnBWtyaOj+kVX0Tzcv3O0uISbHC4V71kA0M=",
"aarch64-linux": "sha256-x3F43TL7BisEuXlJw7QS/DJoSzZWuNxbgn7hFCsTdXU=",
"aarch64-darwin": "sha256-y2r5Qy/XNgnvuzpnGMtwV5ZmhksUN2AUPLjbb40HYIE=",
"x86_64-darwin": "sha256-TS68JE40IaEa7ny0ATPUnEj8EV1CKtnGTPpyvZFwO7A="
"x86_64-linux": "sha256-2+fEzSA/1LPv/Hw7TP1Gu8boByTqoKyAvS1TKX2vLpo=",
"aarch64-linux": "sha256-+xOGlHZjkaddY91AOxq7xyUDFFKFWwaYGJOZQkV/Rtk=",
"aarch64-darwin": "sha256-HS9mG3OBsCBw2CjIQ4OE6fHOZ07gt4B1bS3YJrXG7Mw=",
"x86_64-darwin": "sha256-+gZT6aNbQRWPI7+hEhNOoE+/6uuIEUcEOVndtuFs7Fs="
}
}
+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)
}),
)
+6
View File
@@ -19,6 +19,12 @@
- Always prefer `createStore` over multiple `createSignal` calls
## Typography
- Use `--line-height-compact` (`16px`) for `13px` compact UI text and `--line-height-base` (`20px`) for body text.
- Do not use `leading-none`, `line-height: 1`, or a `13px` line height for normal text. Inter descenders clip inside truncation and overflow containers.
- Keep control and row heights explicit. Fix font metrics directly rather than using transforms, negative margins, or clip-padding compensation.
## Localization
- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors.
@@ -53,6 +53,37 @@ benchmark.describe("performance: first navigation paint", () => {
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a session from the new session page without a blank frame", async ({ page, report }) => {
await mockStressTimeline(page)
await installTimelineSettings(page)
await installStressSessionTabs(page, { draftID })
await page.goto("/")
const draftHref = stressDraftHref(draftID)
const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftHref}"]`)
await expect(draftTab).toHaveCount(1)
await draftTab.click()
await expect(page.locator('[data-component="new-session"]')).toBeVisible()
const href = stressSessionHref(fixture.targetID)
const sessionTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`)
await expect(sessionTab).toHaveCount(1)
const result = await measureFirstNavigation(page, {
href,
destinationPath: href,
sourceSelector: '[data-component="new-session"]',
destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!),
contentSelector,
navigate: async () => {
await sessionTab.click()
await expectSessionTitle(page, fixture.expected.targetTitle)
},
})
report(result)
expect(result.summary.blankSamples).toBe(0)
expect(result.summary.unknownSamples).toBe(0)
})
benchmark("opens a child session without a blank frame", async ({ page, report }) => {
await setup(page)
const href = stressSessionHref(fixture.childID)
@@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => {
expect(siblingProbe).toEqual({
fileMarker: "before",
frameMarker: "before",
rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`,
rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`,
rowMarker: "before",
shadowRoots: 0,
toolMarker: "before",
@@ -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)
@@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server"
import { installSseTransport } from "../utils/sse-transport"
import { expectSessionTitle } from "../utils/waits"
const messagePageSize = 200
const messagePageSize = 20
const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}`
const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => {
const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user`
@@ -86,10 +86,12 @@ test("shows a delegating row while subagent input streams", async ({ page }) =>
const delegating = page.locator('[data-component="task-tool-delegating"]')
await expect(delegating).toBeVisible()
await expect(delegating.locator('[data-component="text-shimmer"]')).toHaveAttribute(
const shimmer = delegating.locator('[data-component="text-shimmer"]')
await expect(shimmer).toHaveAttribute(
"aria-label",
"Delegating agent...",
)
await expect(shimmer).toHaveCSS("line-height", "16px")
const icon = delegating.locator('[data-slot="icon-svg"]')
await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible()
await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)")
@@ -127,11 +129,11 @@ test("renders the moved location notice in its compact timeline style", async ({
await expect(notice).toHaveCSS("padding-bottom", "4px")
await expect(label).toHaveCSS("font-size", "13px")
await expect(label).toHaveCSS("font-weight", "530")
await expect(label).toHaveCSS("line-height", "13px")
await expect(label).toHaveCSS("line-height", "16px")
await expect(label).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("font-size", "13px")
await expect(value).toHaveCSS("font-weight", "440")
await expect(value).toHaveCSS("line-height", "13px")
await expect(value).toHaveCSS("line-height", "16px")
await expect(value).toHaveCSS("color", "rgb(128, 128, 128)")
await expect(value).toHaveCSS("text-overflow", "ellipsis")
await expect(value).toHaveCSS("white-space", "nowrap")
@@ -98,7 +98,7 @@ test("labels completed searches with result counts", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${glob},${grep}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
const rows = group.locator('[data-component="tool-trigger"]')
const rows = group.locator('[data-component="context-tool-group-list"] [data-component="tool-trigger"]')
await expect(rows.nth(0)).toContainText("(1 match)")
await expect(rows.nth(1)).toContainText("(12 matches)")
})
@@ -111,7 +111,9 @@ test("labels read tools from their path input", async ({ page }) => {
const group = page.locator(`[data-timeline-part-ids="${id}"]`)
await group.locator('[data-slot="collapsible-trigger"]').click()
await expect(group.locator('[data-slot="basic-tool-tool-subtitle"]')).toHaveText("a.ts")
await expect(
group.locator('[data-component="context-tool-group-list"] [data-slot="basic-tool-tool-subtitle"]'),
).toHaveText("a.ts")
})
test("labels skill tools from IDs and result metadata", async ({ page }) => {
@@ -121,19 +123,20 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => {
messages: [
userMessage(),
assistantMessage([
toolPart(pending, "skill", "running", { id: "sample-skill" }),
toolPart(pending, "skill", "running", { id: "frontend-design" }),
toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }),
]),
],
})
for (const [id, name] of [
[pending, "sample-skill"],
[pending, "frontend-design"],
[completed, "OpenCode"],
] as const) {
const skill = page.locator(`[data-timeline-part-id="${id}"]`)
const loaded = skill.locator('[data-component="tool-loaded-item"]')
await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`)
await expect(loaded).toHaveCSS("line-height", "16px")
await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded")
await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill")
await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name)
+2 -1
View File
@@ -51,6 +51,7 @@ export type ComposerSession = {
location: { command: Pick<Data["location"]["command"], "list"> }
session: {
prompt: (input: Parameters<Data["session"]["prompt"]>[0]) => Promise<unknown>
setStatus: Data["session"]["setStatus"]
}
}
current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined>
@@ -77,7 +78,7 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & {
start: (
selection: ComposerSelection,
submission: ReturnType<typeof createComposerSubmission>,
) => Promise<ComposerSession | undefined>
) => Promise<{ session: ComposerSession; cleanupReady: Promise<void> } | undefined>
}
export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter
+1 -1
View File
@@ -29,7 +29,7 @@ export function Composer(props: {
accentSubmit={props.accentSubmit}
borderUnderlay={props.borderUnderlay}
class={props.class}
variantControlVisible={!props.model.model.loading}
modelControlsVisible={!props.model.model.loading}
attachKeybind={command.keybindParts("file.attach")}
attachShortcut={command.keybind("file.attach")}
modelControl={
+14 -12
View File
@@ -42,7 +42,7 @@ export type ComposerEditorProps = {
borderUnderlay?: boolean
class?: string
modelControl?: JSX.Element
variantControlVisible?: boolean
modelControlsVisible?: boolean
attachKeybind?: string[]
attachShortcut?: string
}
@@ -233,17 +233,19 @@ export function ComposerEditor(props: ComposerEditorProps) {
/>
)}
</Show>
{props.modelControl}
<Show when={(props.variantControlVisible ?? true) && view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
/>
</Show>
)}
<Show when={props.modelControlsVisible ?? true}>
{props.modelControl}
<Show when={view.variant} keyed>
{(control) => (
<Show when={control.options().length > 1}>
<ComposerEditorConfiguredSelect
title={i18n.t("ui.promptInput.chooseVariant")}
keybind={["Shift", "Mod", "D"]}
control={control}
/>
</Show>
)}
</Show>
</Show>
</div>
<ComposerEditorSubmitButton
+19 -6
View File
@@ -69,6 +69,7 @@ function submitInput(
function session(input: {
calls: string[]
prompt: (value: Parameters<ComposerSession["data"]["session"]["prompt"]>[0]) => Promise<void>
statuses?: ("idle" | "running")[]
current?: ComposerSession["current"]
admitted?: (messageID: string) => boolean
shell?: () => Promise<unknown>
@@ -92,6 +93,7 @@ function session(input: {
data: {
location: { command: { list: () => [] } },
session: {
setStatus: (_sessionID, status) => input.statuses?.push(status),
prompt: async (value) => {
input.calls.push("prompt")
await input.prompt(value)
@@ -140,10 +142,12 @@ describe("Composer submission", () => {
test("starts and promotes a New Session once before admitting its first prompt", async () => {
const draft = createMemoryComposerState({ prompt: "first prompt" }).capture()
const promoted = createMemoryComposerState().capture()
const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture()
const calls: string[] = []
const statuses: ("idle" | "running")[] = []
const admitted = Promise.withResolvers<Parameters<ComposerSession["data"]["session"]["prompt"]>[0]>()
const target = session({ calls, prompt: async (value) => admitted.resolve(value) })
const cleanupReady = Promise.withResolvers<void>()
const target = session({ calls, statuses, prompt: async (value) => admitted.resolve(value) })
const adapter: NewSessionComposerAdapter = {
kind: "new-session",
state: draft,
@@ -156,14 +160,20 @@ describe("Composer submission", () => {
async start(_selection, submission) {
calls.push("start")
submission.retarget(promoted)
return target
return { session: target, cleanupReady: cleanupReady.promise }
},
}
await submitInput(adapter).submit(new Event("submit"))
const submitted = submitInput(adapter).submit(new Event("submit"))
const request = await admitted.promise
expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"])
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt"])
expect(statuses).toEqual(["running"])
expect(promoted.current()).toMatchObject([{ type: "text", content: "restored draft" }])
cleanupReady.resolve()
await submitted
expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt", "submitted"])
expect(request.delivery).toBe("steer")
expect(request.text).toBe("first prompt")
expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }])
@@ -233,7 +243,7 @@ describe("Composer submission", () => {
submitted() {},
async start(_selection, submission) {
submission.retarget(promoted)
return target
return { session: target, cleanupReady: Promise.resolve() }
},
}
@@ -250,10 +260,12 @@ describe("Composer submission", () => {
test("reuses the message ID when an unacknowledged admission is retried", async () => {
const state = createMemoryComposerState({ prompt: "retry me" }).capture()
const attempts: string[] = []
const statuses: ("idle" | "running")[] = []
const first = Promise.withResolvers<void>()
const second = Promise.withResolvers<void>()
const target = session({
calls: [],
statuses,
prompt: async (value) => {
attempts.push(value.id ?? "")
throw new Error("network unavailable")
@@ -283,6 +295,7 @@ describe("Composer submission", () => {
expect(attempts).toHaveLength(4)
expect(new Set(attempts).size).toBe(1)
expect(statuses).toEqual(["running", "idle", "running", "idle"])
expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }])
})
+32 -12
View File
@@ -65,15 +65,42 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
const comments = input.comments.capture()
try {
const session =
const started =
input.adapter.kind === "active-session"
? input.adapter.session()
? { session: input.adapter.session(), cleanupReady: Promise.resolve() }
: await input.adapter.start(value.selection, submission)
if (!session) return
if (!started) return
const session = started.session
input.addToHistory(value.prompt, value.mode)
input.resetHistory()
const restore = () => restoreSubmission(input, submission, value, comments)
const command = value.mode === "normal" ? findCommand(session, value.text) : undefined
if (value.mode === "normal" && !command) {
const optimisticBusy = !input.adapter.working()
if (optimisticBusy) session.data.session.setStatus(session.id, "running")
const sending = sendPrompt(session, value).then(
() => ({ ok: true as const }),
(error) => ({ ok: false as const, error }),
)
await started.cleanupReady
input.adapter.submitted()
submission.context
.filter((item) => !!item.comment?.trim())
.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
void sending.then((result) => {
if (!result.ok)
failSubmission(input, session, "prompt", result.error, restore, value.id, () => {
if (optimisticBusy) session.data.session.setStatus(session.id, "idle")
})
})
return
}
await started.cleanupReady
input.adapter.submitted()
if (value.mode === "shell") {
@@ -82,7 +109,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
return
}
const command = findCommand(session, value.text)
if (command) {
clearSubmission(input, submission)
void sendCommand(session, value, command).catch((error) =>
@@ -91,14 +117,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) {
return
}
submission.context
.filter((item) => !!item.comment?.trim())
.forEach((item) => submission.target().context.remove(item.key))
input.comments.clear()
clearSubmission(input, submission)
void sendPrompt(session, value).catch((error) =>
failSubmission(input, session, "prompt", error, restore, value.id),
)
} finally {
submitting.delete(input.adapter.state)
}
@@ -313,8 +331,10 @@ function failSubmission(
error: unknown,
restore: () => boolean,
messageID?: string,
rollback?: () => void,
) {
if (messageID && session.admitted(messageID)) return
rollback?.()
restore()
input.notify.failed(kind, error)
}
+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,
+1 -1
View File
@@ -509,7 +509,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
<div class="flex min-h-full flex-col items-center gap-4 px-6 pt-[52px] text-center">
<div
class={`
shrink-0 text-[13px] leading-[13px] tracking-[-0.04px]
shrink-0 text-[13px] leading-text-compact tracking-[-0.04px]
text-v2-text-text-base [font-weight:530]
`}
>
@@ -53,26 +53,32 @@ export function createNewSessionComposerAdapter(props: {
})
if (!sessionDirectory) return
const created = await serverSDK.api.session
.create({
agent: selection.agent,
model: {
id: selection.model.modelID,
providerID: selection.model.providerID,
variant: selection.variant,
},
location: { directory: sessionDirectory },
})
.catch((error) => {
const created = data.session.create({
agent: selection.agent,
model: {
id: selection.model.modelID,
providerID: selection.model.providerID,
variant: selection.variant,
},
location: { directory: sessionDirectory },
})
const creation = created.request.then(
() => ({ ok: true as const }),
(error) => {
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(language, error),
})
})
if (!created) return
return { ok: false as const, error }
},
)
const afterCreation = async <T,>(run: () => Promise<T>) => {
const result = await creation
if (!result.ok) throw result.error
return run()
}
data.session.remember(created)
await startTransition(() => {
const cleanupReady = startTransition(() => {
tabs.updateDraft(props.draftID, { worktree: undefined })
if (permission.isAutoAcceptingDirectory(projectDirectory)) {
permission.enableAutoAccept(created.id, sessionDirectory)
@@ -92,13 +98,31 @@ export function createNewSessionComposerAdapter(props: {
})
return {
id: created.id,
directory: sessionDirectory,
api: serverSDK.api.session,
data,
current: () => data.session.get(created.id) ?? created,
admitted: (messageID) =>
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
cleanupReady,
session: {
id: created.id,
directory: sessionDirectory,
api: {
command: (input) => afterCreation(() => serverSDK.api.session.command(input)),
shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)),
switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)),
switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)),
},
data: {
location: data.location,
session: {
setStatus: data.session.setStatus,
prompt: (input) =>
data.session.prompt({
...input,
gate: Promise.all([input.gate, afterCreation(async () => undefined)]),
}),
},
},
current: () => data.session.get(created.id),
admitted: (messageID) =>
data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID),
},
}
},
}
+1 -1
View File
@@ -139,7 +139,7 @@ function ProviderTip() {
>
<button
type="button"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
class="flex h-6 min-w-0 items-center rounded-[4px] pl-1.5 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint transition-[background-color,color] duration-150 ease-in-out hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-muted focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-muted focus-visible:outline-none"
onClick={openProviders}
>
<span class="truncate">{language.t("home.providerTip")}</span>
@@ -190,7 +190,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
{(group) => (
<Show when={group.items().length > 0}>
<section class="flex flex-col">
<div class="px-3 pb-2 text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-muted">
<div class="px-3 pb-2 text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-muted">
{group.title}
</div>
<For each={group.items()}>
@@ -198,7 +198,7 @@ function ProviderPicker(props: { directory?: string; onSelect: (provider: string
<button
type="button"
data-provider-id={provider.id}
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-none tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
class="flex min-h-9 w-full items-center gap-2 rounded-md px-3 py-2.5 text-left text-[13px] leading-text-compact tracking-[-0.04px] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
classList={{ "bg-v2-overlay-simple-overlay-hover": store.active === provider.id }}
onMouseEnter={() => setStore("active", provider.id)}
disabled={store.connecting !== undefined}
+7 -36
View File
@@ -1,44 +1,24 @@
import { type Accessor, createMemo, createResource } from "solid-js"
import { createStore } from "solid-js/store"
import { type Accessor, createMemo } from "solid-js"
import { DateTime } from "luxon"
import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { useProviders } from "@/providers/catalog/providers"
import { Persist, persisted } from "@/runtime/persistence/storage"
import { useGlobal } from "@/runtime/server/runtime"
export type ModelKey = { providerID: string; modelID: string }
type Visibility = "show" | "hide"
type User = ModelKey & { visibility: Visibility; favorite?: boolean }
type Store = {
user: User[]
recent: ModelKey[]
variant?: Record<string, string | undefined>
}
const RECENT_LIMIT = 5
function modelKey(model: ModelKey) {
return `${model.providerID}:${model.modelID}`
}
const createModelsPersistedState = () => {
const [store, setStore, _, ready] = persisted(
Persist.global("model"),
createStore<Store>({
user: [],
recent: [],
variant: {},
}),
)
return [store, setStore, ready] as const
}
const createModelsController = (directory: Accessor<string | undefined>) => {
const providers = useProviders(() => directory())
const [store, setStore, ready] = createModelsPersistedState()
const models = useGlobal().models
const store = models.store
const setStore = models.set
const available = createMemo(() =>
providers.connected().flatMap((p) =>
@@ -148,23 +128,14 @@ const createModelsController = (directory: Accessor<string | undefined>) => {
setStore("variant", key, value)
}
const [recentModels] = createResource(
async () => {
const recent = store.recent
await ready.promise
return recent
},
(p) => p,
{ initialValue: [] },
)
return {
ready,
ready: models.ready,
list,
find,
visible,
setVisibility,
recent: {
list: () => recentModels()!,
list: models.recent,
push,
},
variant: {
+2
View File
@@ -948,6 +948,8 @@ export const dict = {
"settings.general.row.showTerminal.description": "Show the terminal button in the desktop title bar",
"settings.general.row.showStatus.title": "Server status",
"settings.general.row.showStatus.description": "Show the server status button in the title bar",
"settings.general.row.showProjectIcon.title": "Project icon",
"settings.general.row.showProjectIcon.description": "Show the project icon in the session header",
"settings.general.row.mobileTitlebarBottom.title": "Bottom navigation",
"settings.general.row.mobileTitlebarBottom.description":
"Place the title bar and session tabs at the bottom of the screen on mobile",
+35 -1
View File
@@ -1,5 +1,5 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { Accessor, createEffect, createMemo, createRoot, getOwner } from "solid-js"
import { Accessor, createEffect, createMemo, createResource, createRoot, getOwner } from "solid-js"
import { createStore } from "solid-js/store"
import { createServerProjects, RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServers } from "./registry"
import { pathKey } from "@/workspaces/path-key"
@@ -10,6 +10,7 @@ import { createData } from "@opencode-ai/client/solid"
import type { ServerScope } from "@/runtime/server/scope"
import { createServerPermissionState } from "@/session/requests/server-permission"
import { createServerNotificationState } from "@/shell/notifications/notification"
import { Persist, persisted } from "@/runtime/persistence/storage"
export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext({
name: "Global",
@@ -24,6 +25,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
serverKey: undefined as ServerConnection.Key | undefined,
},
})
const models = createGlobalModels()
const settingsServer = createMemo(() => {
const list = server.list
@@ -86,6 +88,7 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
},
},
models,
ensureServerCtx(conn: ServerConnection.Any) {
return ensureServerCtx(conn)
},
@@ -93,6 +96,37 @@ export const { use: useGlobal, provider: GlobalProvider } = createSimpleContext(
},
})
function createGlobalModels() {
const [store, setStore, _, ready] = persisted(
Persist.global("model"),
createStore<{
user: Array<{ providerID: string; modelID: string; visibility: "show" | "hide"; favorite?: boolean }>
recent: Array<{ providerID: string; modelID: string }>
variant?: Record<string, string | undefined>
}>({
user: [],
recent: [],
variant: {},
}),
)
const [recent] = createResource(
async () => {
const value = store.recent
await ready.promise
return value
},
(value) => value,
{ initialValue: [] },
)
return {
store,
set: setStore,
ready,
recent: () => recent()!,
}
}
function createServerController(
conn: ServerConnection.Any,
scope: ServerScope,
+1 -1
View File
@@ -386,7 +386,7 @@
padding: 48px 24px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
text-align: center;
}
@@ -16,6 +16,7 @@ export function createActiveComposerAdapter(input: {
if (!id) throw new Error("Active Composer requires a Session ID")
const prompt = useComposerState()
prompt.current()
const state = prompt.capture()
const data = useData()
const server = useServerSDK()
@@ -204,9 +204,7 @@ export function ActiveSessionComposerRegion(props: {
}) {
const region = createSessionComposerRegionController({
state: props.model.region.state,
sessionKey: props.session.identity.sessionKey,
sessionID: () => props.session.identity.params.id,
prompt: props.model.region.prompt,
centered: props.model.region.centered,
onResponseSubmit: props.onResponseSubmit,
openParent: props.model.region.openParent,
@@ -1,14 +1,10 @@
import { type Accessor, createEffect, createMemo, createResource } from "solid-js"
import type { useComposerState } from "@/composer/persistence"
import { type Accessor, createMemo } from "solid-js"
import { useData } from "@/runtime/server/current"
import { getSessionHandoff, setSessionHandoff } from "@/session/handoff"
import type { SessionRequestModel } from "../requests/model"
export function createSessionComposerRegionController(input: {
state: SessionRequestModel
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
prompt: ReturnType<typeof useComposerState>
centered: Accessor<boolean>
onResponseSubmit: () => void
openParent: () => void
@@ -16,32 +12,10 @@ export function createSessionComposerRegionController(input: {
setDockRef: (el: HTMLDivElement) => void
}) {
const data = useData()
createEffect(() => {
if (!input.prompt.ready()) return
setSessionHandoff(input.sessionKey(), {
prompt: input.prompt
.current()
.map((part) => {
if (part.type === "file") return `[file:${part.path}]`
if (part.type === "agent") return `@${part.name}`
if (part.type === "image") return `[image:${part.filename}]`
return part.content
})
.join("")
.trim(),
})
})
const parentID = createMemo(() => {
const id = input.sessionID()
return id ? data.session.get(id)?.parentID : undefined
})
const ready = Promise.resolve()
const [promptReady] = createResource(
() => input.prompt.ready.promise ?? ready,
(promise) => promise.then(() => true),
)
return {
state: input.state,
centered: input.centered,
@@ -52,8 +26,6 @@ export function createSessionComposerRegionController(input: {
parentID,
child: () => !!parentID(),
showComposer: () => !input.state.blocked() || !!parentID(),
handoffPrompt: () => getSessionHandoff(input.sessionKey())?.prompt,
promptReady: () => input.prompt.ready() || promptReady(),
}
}
@@ -19,8 +19,6 @@ export type SessionComposerRegionViewController = Pick<
| "parentID"
| "child"
| "showComposer"
| "handoffPrompt"
| "promptReady"
> & { state: SessionComposerRegionState }
export function SessionComposerRegion(props: {
@@ -65,43 +63,32 @@ export function SessionComposerRegion(props: {
</Show>
<Show when={controller.showComposer()}>
<Show
when={controller.promptReady()}
fallback={
<>
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{controller.handoffPrompt() || language.t("prompt.loading")}
</div>
</>
}
<div
classList={{
"relative z-[70]": true,
}}
>
<div
classList={{
"relative z-[70]": true,
}}
<Show
when={controller.child()}
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
>
<Show
when={controller.child()}
fallback={<Show when={!controller.state.blocked()}>{props.composer}</Show>}
<div
ref={controller.setPromptRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
>
<div
ref={controller.setPromptRef}
class="w-full rounded-[12px] border border-border-weak-base bg-background-base p-3 text-16-regular text-text-weak"
>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={controller.parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={controller.openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
</div>
</Show>
</div>
</Show>
<span>{language.t("session.child.promptDisabled")} </span>
<Show when={controller.parentID()}>
<button
type="button"
class="text-text-base transition-colors hover:text-text-strong"
onClick={controller.openParent}
>
{language.t("session.child.backToParent")}
</button>
</Show>
</div>
</Show>
</div>
</Show>
</div>
</div>
+1 -2
View File
@@ -1,7 +1,6 @@
import type { SelectedLineRange } from "@/workspaces/files/model"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
@@ -23,7 +22,7 @@ const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
}
export const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = store.session.get(key) ?? { prompt: "", files: {} }
const prev = store.session.get(key) ?? { files: {} }
touch(store.session, key, { ...prev, ...patch })
}
+29 -16
View File
@@ -15,7 +15,8 @@ import { SessionUIProvider } from "@/shell/routes/session-ui-provider"
import { useTabs } from "@/shell/tabs/tabs"
import { requireServerKey } from "@/shell/routes/session"
import { useSessionModel } from "./model"
import { SessionPanelFrame, SessionRouteFrame } from "./session-frame"
import { SessionPanelFrame } from "./session-frame"
import { SessionIdentityHeader } from "./session-identity-header"
import { IncompatibleServerPanel } from "./incompatible-server-panel"
import { SessionErrorFallback } from "./route-error"
import { createSessionResolution } from "./session-resolution"
@@ -31,7 +32,7 @@ export function TargetSessionRouteContent() {
<MarkSessionNotificationsViewed sessionID={() => params.id} />
<ModelsProvider directory={directory}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)} padded>
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
<ResolvedTargetSessionRoute />
</SessionRouteErrorBoundary>
</ModelsProvider>
@@ -45,16 +46,14 @@ function TargetSessionSettingsCommand() {
}
function SessionRouteErrorBoundary(
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>,
props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>,
) {
return (
<ErrorBoundary
fallback={(error) => (
<SessionRouteFrame padded={props.padded}>
<SessionPanelFrame raised={!!props.sessionID}>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionPanelFrame>
</SessionRouteFrame>
<SessionStatePanel>
<SessionErrorFallback error={error} sessionID={props.sessionID} serverKey={props.serverKey} />
</SessionStatePanel>
)}
>
{props.children}
@@ -78,16 +77,14 @@ function ResolvedTargetSessionRoute() {
<Show
when={!server.health?.incompatible}
fallback={
<SessionRouteFrame padded>
<SessionPanelFrame raised>
<IncompatibleServerPanel
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
/>
</SessionPanelFrame>
</SessionRouteFrame>
<SessionStatePanel>
<IncompatibleServerPanel
onClose={() => tabs.removeSessionTab({ server: server.key, sessionId: params.id })}
/>
</SessionStatePanel>
}
>
<Show when={directory()}>
<Show when={directory()} fallback={<PendingSessionState sessionID={params.id} />}>
{(value) => (
<LocationProvider directory={value()}>
<SessionUIProvider directory={value()} server={server.key}>
@@ -100,6 +97,22 @@ function ResolvedTargetSessionRoute() {
)
}
function PendingSessionState(props: { sessionID: string }) {
return (
<SessionStatePanel>
<SessionIdentityHeader sessionID={props.sessionID} />
</SessionStatePanel>
)
}
function SessionStatePanel(props: ParentProps) {
return (
<div class="flex min-h-0 flex-1 p-2">
<SessionPanelFrame raised>{props.children}</SessionPanelFrame>
</div>
)
}
function TargetSessionPage() {
const location = useWorkspaceLocation()
const server = useServerSDK()
+20 -5
View File
@@ -8,7 +8,7 @@ import { useSettings } from "@/settings/model"
import { MessageTimeline } from "@/session/timeline/message-timeline"
import type { SessionModel } from "@/session/model"
import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { SessionPanelFrame } from "@/session/session-frame"
import { TerminalPanel } from "@/session/terminal/panel"
import { useUsageExceededDialogs } from "./usage-exceeded-dialogs"
import { SessionErrorFallback } from "./route-error"
@@ -17,6 +17,7 @@ import { createSessionReview } from "./review/model"
import { SessionDesktopReview, SessionMobileReview, SessionMobileTabs } from "./review/view"
import { createSessionTimelineInteraction } from "./timeline/interaction"
import { ActiveSessionComposerRegion, createActiveSessionRegion } from "./composer/region"
import { SessionIdentityHeader } from "./session-identity-header"
export function SessionScreen(props: { session: SessionModel }) {
const session = props.session
@@ -58,17 +59,31 @@ 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>
<Show when={!messagesReady()}>
<SessionIdentityHeader
sessionID={session.identity.params.id ?? ""}
session={session.data.info()}
/>
</Show>
<Show
when={messagesReady() ? session.identity.params.id : undefined}
keyed
>
{(_id) => (
<MessageTimeline
session={session}
@@ -117,7 +132,7 @@ export function SessionScreen(props: { session: SessionModel }) {
)
return (
<SessionRouteFrame>
<>
<SessionHeader />
<div class="flex-1 min-h-0 flex flex-col gap-2 p-2">
<div ref={screen.panel.ref} class="flex-1 min-h-0 flex flex-col md:flex-row gap-2">
@@ -220,6 +235,6 @@ export function SessionScreen(props: { session: SessionModel }) {
</div>
</Show>
</div>
</SessionRouteFrame>
</>
)
}
@@ -0,0 +1,88 @@
import type { SessionInfo } from "@opencode-ai/client/promise"
import { Icon } from "@opencode-ai/ui/icon"
import { ProjectAvatar } from "@opencode-ai/ui/project-avatar"
import { createMemo, Show, type ParentProps } from "solid-js"
import { useServer } from "@/runtime/server/current"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { tabKey, useTabs } from "@/shell/tabs/tabs"
import { useSettings } from "@/settings/model"
import { pathKey } from "@/workspaces/path-key"
import { isWorkspaceDirectory } from "@/workspaces/paths"
import { sessionTitle } from "./title"
export function SessionTitleHeader(props: ParentProps) {
return (
<div
data-session-title
class="sticky top-0 z-30 w-full bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] pb-4 pe-3 ps-2.5"
>
{props.children}
</div>
)
}
export function SessionIdentityHeader(props: { sessionID: string; session?: SessionInfo }) {
const server = useServer()
const tabs = useTabs()
const settings = useSettings()
const info = createMemo(
() => tabs.info[tabKey({ type: "session", server: server.key, sessionId: props.sessionID })],
)
const directory = createMemo(() => props.session?.location.directory ?? info()?.directory)
const title = createMemo(() => sessionTitle(props.session?.title ?? info()?.title))
const project = createMemo(() => {
const projects = server.ctx.projects.list()
if (props.session) return projectForSession(props.session, projects)
const value = directory()
if (!value) return undefined
const key = pathKey(value)
return projects.find(
(item) => pathKey(item.worktree) === key || item.sandboxes?.some((sandbox) => pathKey(sandbox) === key),
)
})
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() && !!directory()
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), directory() ?? ""))
return (
<Show when={title() || showProjectIcon()}>
<SessionTitleHeader>
<div class="flex h-12 w-full items-center justify-between gap-2">
<div class="flex min-w-0 flex-1 items-center gap-1">
<div class="flex min-w-0 w-full flex-1 items-center">
<span
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": workspaceSession() && !showProjectIcon(),
"text-v2-icon-icon-muted": !workspaceSession() && !showProjectIcon(),
}}
>
<Show
when={showProjectIcon()}
fallback={<Icon name={workspaceSession() ? "workspace-isolated" : "monitor"} />}
>
<ProjectAvatar
fallback={displayName(project() ?? { worktree: directory() ?? "" })}
src={getProjectAvatarSource(project()?.id, project()?.icon)}
variant={getProjectAvatarVariant(project()?.icon?.color)}
/>
</Show>
</span>
<Show when={title()}>
{(value) => (
<h1
dir="auto"
class="w-fit truncate rounded-[6px] px-2 py-1 text-[13px] font-[530] leading-4 tracking-[-0.04px] text-v2-text-text-base"
>
{value()}
</h1>
)}
</Show>
</div>
</div>
</div>
</SessionTitleHeader>
</Show>
)
}
-2
View File
@@ -208,8 +208,6 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void
parentID: () => props.child?.parentID,
child: () => !!props.child,
showComposer: () => true,
handoffPrompt: () => undefined,
promptReady: () => true,
} satisfies SessionComposerRegionViewController
return (
@@ -1,7 +1,7 @@
import type { SessionMessageUser } from "@opencode-ai/client/promise"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { useLocation } from "@solidjs/router"
import { createEffect, createSignal, on, onCleanup } from "solid-js"
import { createEffect, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store"
import { useLayout } from "@/shell/state/layout"
import type { SessionModel } from "../model"
@@ -19,6 +19,10 @@ export function createSessionTimelineInteraction(session: SessionModel) {
overflow: false,
jump: false,
},
follow: {
sessionKey: session.identity.sessionKey(),
pinned: true,
},
refs: {
content: undefined as HTMLDivElement | undefined,
dock: undefined as HTMLDivElement | undefined,
@@ -26,11 +30,11 @@ export function createSessionTimelineInteraction(session: SessionModel) {
})
// The single source of truth for "follow the newest content". The virtualizer pins and unpins
// it from scroll geometry; everything else only expresses explicit intent.
const [pinned, setPinned] = createSignal(true)
const pin = () => setPinned(true)
const pinned = () => state.follow.sessionKey !== session.identity.sessionKey() || state.follow.pinned
const pin = () => setState("follow", { sessionKey: session.identity.sessionKey(), pinned: true })
const unpin = () => {
if (!scroller || scroller.scrollHeight - scroller.clientHeight <= 1) return
setPinned(false)
setState("follow", { sessionKey: session.identity.sessionKey(), pinned: false })
}
let scroller: HTMLDivElement | undefined
let dockHeight = 0
@@ -209,8 +213,10 @@ export function createSessionTimelineInteraction(session: SessionModel) {
on(
session.identity.sessionKey,
() => {
pin()
setState("messageID", undefined)
setState("pendingMessage", undefined)
setState("scroll", { overflow: false, jump: false })
},
{ defer: true },
),
@@ -1,4 +1,4 @@
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor } from "solid-js"
import { createEffect, createMemo, createSignal, For, on, Show, type Accessor, type JSX } from "solid-js"
import createPresence from "solid-presence"
import { createStore } from "solid-js/store"
import type { SessionUserActions } from "@opencode-ai/session-ui/actions"
@@ -17,7 +17,7 @@ import { getFilename } from "@opencode-ai/util/path"
import { Popover } from "@kobalte/core/popover"
import { SessionContextUsage } from "@/session/timeline/session-context-usage"
import { useLanguage } from "@/runtime/i18n/language"
import { useData } from "@/runtime/server/current"
import { useData, useServer } from "@/runtime/server/current"
import { useWorkspaceLocation } from "@/workspaces/location"
import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection"
import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row"
@@ -26,9 +26,11 @@ import { createTimelineVirtualizer } from "./virtualizer"
import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths"
import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu"
import { getProjectAvatarVariant } from "@/shell/state/layout"
import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers"
import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers"
import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note"
import { useCommand } from "@/shell/commands/command"
import { useSettings } from "@/settings/model"
import { SessionTitleHeader } from "../session-identity-header"
type BackgroundTask = {
id: string
@@ -180,6 +182,7 @@ function WorkspaceMoveAction(props: {
function SessionSummaryPanel(props: {
project: Project
avatar?: JSX.Element
directory: string
local: boolean
branch?: string
@@ -206,11 +209,13 @@ function SessionSummaryPanel(props: {
<div data-component="session-summary-panel" class="w-[280px]">
<div class="relative z-10 flex flex-col gap-1 overflow-hidden rounded-[6px] bg-v2-background-bg-base px-0.5 py-1.5 shadow-[var(--v2-elevation-raised)]">
<div class={row}>
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
{props.avatar ?? (
<ProjectAvatar
fallback={displayName(props.project)}
src={getProjectAvatarSource(props.project.id, props.project.icon)}
variant={getProjectAvatarVariant(props.project.icon?.color)}
/>
)}
<span class="min-w-0 flex-1 truncate text-v2-text-text-muted">{displayName(props.project)}</span>
</div>
<SessionWorkspaceMenu
@@ -325,6 +330,8 @@ function MessageTimelineView(
) {
const language = useLanguage()
const data = useData()
const server = useServer()
const settings = useSettings()
const sdk = useWorkspaceLocation()
const sessionID = props.data.sessionID
const sessionStatus = props.data.status
@@ -343,6 +350,21 @@ function MessageTimelineView(
return { ...value, worktree: value.canonical, worktrees: [] }
})
const workspaceSession = createMemo(() => isWorkspaceDirectory(project(), sessionDirectory()))
const showProjectIcon = () =>
import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon()
const avatarProject = createMemo(() => {
if (!showProjectIcon()) return
const session = props.session.data.info()
if (!session) return
return projectForSession(session, server.ctx.projects.list())
})
const projectAvatar = () => (
<ProjectAvatar
fallback={displayName(avatarProject() ?? { worktree: sessionDirectory() })}
src={getProjectAvatarSource(avatarProject()?.id, avatarProject()?.icon)}
variant={getProjectAvatarVariant(avatarProject()?.icon?.color)}
/>
)
createEffect(() => {
const directory = project()?.worktree
if (!directory) return
@@ -484,7 +506,7 @@ function MessageTimelineView(
ref={setBackgroundHintRef}
class="duration-150 motion-reduce:animate-none"
classList={{
[`flex h-8 items-start pt-2 ${turnPadding()}`]: true,
[`flex h-9 items-start pt-3 ${turnPadding()}`]: true,
"animate-in fade-in": backgroundHintVisibility().animate && backgroundHintVisibility().show,
"animate-out fade-out fill-mode-forwards":
backgroundHintVisibility().animate && !backgroundHintVisibility().show,
@@ -502,10 +524,7 @@ function MessageTimelineView(
}}
renderRow={(row, onSizeChange) => <rowRenderer.Row row={row} onSizeChange={onSizeChange} />}
header={
<div
data-session-title
class="sticky top-0 z-30 bg-[linear-gradient(to_bottom,var(--v2-background-bg-base)_48px,transparent)] w-full pb-4 pr-3 pl-2.5"
>
<SessionTitleHeader>
<div class="h-12 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<div class="flex items-center min-w-0 flex-1 w-full">
@@ -513,7 +532,9 @@ function MessageTimelineView(
when={workspaceSession()}
fallback={
<span class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Icon name="monitor" />
<Show when={showProjectIcon()} fallback={<Icon name="monitor" />}>
{projectAvatar()}
</Show>
</span>
}
>
@@ -525,9 +546,14 @@ function MessageTimelineView(
<span
tabIndex={0}
aria-label={sessionDirectory()}
class="flex size-6 shrink-0 items-center justify-center text-v2-icon-icon-accent"
classList={{
"flex size-6 shrink-0 items-center justify-center": true,
"text-v2-icon-icon-accent": !showProjectIcon(),
}}
>
<Icon name="workspace-isolated" />
<Show when={showProjectIcon()} fallback={<Icon name="workspace-isolated" />}>
{projectAvatar()}
</Show>
</span>
</Tooltip>
</Show>
@@ -613,6 +639,7 @@ function MessageTimelineView(
<Popover.Content class="z-50 border-0 bg-transparent p-0 outline-none">
<SessionSummaryPanel
project={project()}
avatar={showProjectIcon() ? projectAvatar() : undefined}
directory={sessionDirectory()}
local={!workspaceSession()}
branch={data.location.vcs.info({ directory: sdk().directory })?.branch.current}
@@ -687,7 +714,7 @@ function MessageTimelineView(
)}
</Show>
</div>
</div>
</SessionTitleHeader>
}
/>
)
@@ -1,6 +1,12 @@
import { describe, expect, test } from "bun:test"
import type { SessionMessageAssistant, SessionMessageInfo, SessionMessageUser } from "@opencode-ai/client/promise"
import { loadOlderTimeline, selectUserMessages, selectVisibleUserMessages } from "./model"
import {
enrichLeadingTurn,
leadingTurnNeedsParent,
loadOlderTimeline,
selectUserMessages,
selectVisibleUserMessages,
} from "./model"
const user = (id: string): SessionMessageUser => ({ id, type: "user", text: id, time: { created: 1 } })
const assistant = (id: string): SessionMessageAssistant => ({
@@ -42,6 +48,56 @@ describe("timeline model", () => {
expect(anchors).toEqual(["before", "after", true])
})
test("recognizes a leading partial assistant turn", () => {
expect(leadingTurnNeedsParent([assistant("msg_assistant"), user("msg_next")])).toBe(true)
expect(leadingTurnNeedsParent([user("msg_user"), assistant("msg_assistant")])).toBe(false)
expect(leadingTurnNeedsParent([user("msg_user")])).toBe(false)
})
test("pauses between bounded history pages until the leading turn has its parent", async () => {
const pages: SessionMessageInfo[][] = [[assistant("msg_older")], [user("msg_parent")]]
const messages: SessionMessageInfo[] = [assistant("msg_latest"), user("msg_next")]
let pauses = 0
let loads = 0
await enrichLeadingTurn({
current: () => true,
messages: () => messages,
more: () => pages.length > 0,
loading: () => false,
loadMore: async () => {
messages.unshift(...pages.shift()!)
loads += 1
},
pause: async () => {
pauses += 1
},
maxPages: 3,
})
expect(loads).toBe(2)
expect(pauses).toBe(2)
expect(leadingTurnNeedsParent(messages)).toBe(false)
})
test("caps background pages when the parent remains outside the window", async () => {
let loads = 0
await enrichLeadingTurn({
current: () => true,
messages: () => [assistant("msg_latest")],
more: () => true,
loading: () => false,
loadMore: async () => {
loads += 1
},
pause: async () => undefined,
maxPages: 3,
})
expect(loads).toBe(3)
})
test("does not restore an anchor after the session changes", async () => {
let sessionID = "ses_old"
let restore = 0
+55 -3
View File
@@ -1,7 +1,11 @@
import { createMemo, createResource, type Accessor } from "solid-js"
import type { SessionMessageInfo } from "@opencode-ai/client/promise"
import { useData } from "@/runtime/server/current"
import type { SessionModel } from "../model"
const leadingTurnPageDelay = 200
const leadingTurnPageLimit = 3
export {
selectSessionUserMessages as selectUserMessages,
selectVisibleSessionUserMessages as selectVisibleUserMessages,
@@ -9,12 +13,32 @@ export {
export function createTimelineModel(input: { session: Pick<SessionModel, "identity" | "history"> }) {
const data = useData()
const prepared = new Set<string>()
const [resource] = createResource(
() => input.session.identity.sessionID(),
(id) => (id ? Promise.all([data.session.message.sync(id), data.session.pending.sync(id)]) : undefined),
async (id) => {
if (!id) return
const key = input.session.identity.sessionKey()
await Promise.all([data.session.message.sync(id), data.session.pending.sync(id)])
await enrichLeadingTurn({
current: () => input.session.identity.sessionKey() === key,
messages: () => data.session.message.list(id),
more: () => data.session.message.more(id),
loading: () => data.session.message.loading(id),
loadMore: () => data.session.message.loadMore(id),
pause: () => new Promise((resolve) => setTimeout(resolve, leadingTurnPageDelay)),
maxPages: leadingTurnPageLimit,
}).catch(() => undefined)
if (input.session.identity.sessionKey() === key) prepared.add(key)
},
)
const ready = createMemo(() => !input.session.identity.sessionID() || !resource.loading)
const ready = createMemo(() => {
const id = input.session.identity.sessionID()
if (!id || prepared.has(input.session.identity.sessionKey()) || !resource.loading) return true
const messages = data.session.message.list(id)
return messages.length > 0 && !leadingTurnNeedsParent(messages)
})
const more = () => {
const id = input.session.identity.sessionID()
return id ? data.session.message.more(id) : false
@@ -28,7 +52,7 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
sessionID: input.session.identity.sessionID,
more,
loading,
loadMore: data.session.message.loadMore,
loadMore: (id) => data.session.message.loadMore(id),
before: options?.before,
after: options?.after,
})
@@ -44,6 +68,34 @@ export function createTimelineModel(input: { session: Pick<SessionModel, "identi
}
}
export async function enrichLeadingTurn(input: {
current: Accessor<boolean>
messages: Accessor<SessionMessageInfo[]>
more: Accessor<boolean>
loading: Accessor<boolean>
loadMore: () => Promise<void>
pause: () => Promise<void>
maxPages: number
}) {
const load = async (pages: number): Promise<void> => {
if (!input.current() || pages >= input.maxPages || !leadingTurnNeedsParent(input.messages()) || !input.more())
return
await input.pause()
if (!input.current() || !leadingTurnNeedsParent(input.messages()) || !input.more()) return
if (input.loading()) return load(pages)
await input.loadMore()
return load(pages + 1)
}
return load(0)
}
export function leadingTurnNeedsParent(messages: SessionMessageInfo[]) {
const assistant = messages.findIndex((message) => message.type === "assistant")
if (assistant === -1) return false
const boundary = messages.findIndex((message) => message.type === "user" || message.type === "shell")
return boundary === -1 || assistant < boundary
}
export async function loadOlderTimeline(input: {
sessionID: Accessor<string | undefined>
more: Accessor<boolean>
@@ -63,7 +63,8 @@ export function createTimelineProjection(input: {
input.sessionMessages().forEach((message) => {
if (message.type === "user") userID = message.id
if (message.type === "shell") userID = undefined
if (message.type !== "assistant" || !userID) return
if (message.type !== "assistant") return
if (!userID) userID = message.id
const messages = result.get(userID)
if (messages) {
messages.push(message)
@@ -20,6 +20,7 @@ import { observeElementOffsetReconnectAware } from "./observe-element-offset"
import { filterVirtualIndexes } from "./virtual-items"
const fallbackItemSize = 60
const pendingMarkdown = '[data-component="markdown"]:not([data-markdown-ready])'
// Distance from the bottom that counts as "at the end". Deliberately tight: a collapse clamps
// exactly to the end, while a one-pixel nudge upward is a deliberate move away from it.
const endEpsilon = 0.5
@@ -149,9 +150,13 @@ export function createTimelineVirtualizer(input: Input) {
if (listRoot() && input.pinned()) anchorResizedBottom()
}
virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => {
if (!instance.itemSizeCache.has(item.key) && addedKeys.delete(String(item.key))) {
return item.start < (instance.scrollOffset ?? 0) + instance.scrollAdjustments
}
// Prepended rows can resize more than once as deferred content mounts. Keep
// compensating while they remain entirely above the visible content fold.
if (addedKeys.has(String(item.key)))
return (
item.end <=
(instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin
)
const first = instance.range?.startIndex
return first !== undefined && item.index < first
}
@@ -173,10 +178,48 @@ export function createTimelineVirtualizer(input: Input) {
})
let overscanFrame: number | undefined
const pendingMeasurements = () =>
virtualizer.getVirtualItems().some((item) => !virtualizer.itemSizeCache.has(item.key))
const settleColdBottom = () => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
overscanFrame = requestAnimationFrame(settleColdBottom)
return
}
overscanFrame = requestAnimationFrame(() => {
if (input.pinned()) virtualizer.scrollToEnd()
if (virtualContent?.querySelector(pendingMarkdown) || pendingMeasurements()) {
settleColdBottom()
return
}
overscanFrame = undefined
const content = virtualContent
if (!content) return
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
content.style.removeProperty("visibility")
return
}
const animation = ["animate-in", "fade-in", "duration-150"]
const clearAnimation = (event: AnimationEvent) => {
if (event.target !== content) return
content.removeEventListener("animationend", clearAnimation)
content.removeEventListener("animationcancel", clearAnimation)
content.classList.remove(...animation)
}
content.addEventListener("animationend", clearAnimation)
content.addEventListener("animationcancel", clearAnimation)
content.classList.add(...animation)
content.style.removeProperty("visibility")
})
}
onMount(() => {
overscanFrame = requestAnimationFrame(() => {
overscanFrame = undefined
if (renderOverscan() < 20) setRenderOverscan(20)
if (!coldBottomMount) {
overscanFrame = undefined
return
}
settleColdBottom()
})
})
@@ -363,11 +406,17 @@ export function createTimelineVirtualizer(input: Input) {
<Show when={input.showHeader()}>{props.header}</Show>
<div
data-timeline-virtual-content
class="motion-reduce:animate-none"
ref={(element) => {
virtualContent = element
input.setContentRef(element)
}}
style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative", width: "100%" }}
style={{
height: `${virtualizer.getTotalSize()}px`,
position: "relative",
width: "100%",
visibility: coldBottomMount ? "hidden" : undefined,
}}
>
<For each={virtualRowKeys()}>{(rowKey) => <VirtualRow rowKey={rowKey} />}</For>
<Show when={rows().length > 0}>
@@ -338,6 +338,20 @@ export const SettingsGeneral: Component<{
</div>
</SettingsRow>
<Show when={import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.showProjectIcon.title")}
description={language.t("settings.general.row.showProjectIcon.description")}
>
<div data-action="settings-show-project-icon">
<Switch
checked={settings.general.showProjectIcon()}
onChange={(checked) => settings.general.setShowProjectIcon(checked)}
/>
</div>
</SettingsRow>
</Show>
<Show when={mobile() && import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"}>
<SettingsRow
title={language.t("settings.general.row.mobileTitlebarBottom.title")}
+6
View File
@@ -31,6 +31,7 @@ export interface Settings {
showNavigation: boolean
showSearch: boolean
showStatus: boolean
showProjectIcon: boolean
showTerminal: boolean
showReasoningSummaries: boolean
shellToolPartsExpanded: boolean
@@ -117,6 +118,7 @@ const defaultSettings: Settings = {
showNavigation: false,
showSearch: false,
showStatus: false,
showProjectIcon: false,
showTerminal: false,
showReasoningSummaries: false,
shellToolPartsExpanded: false,
@@ -207,6 +209,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont
setShowStatus(value: boolean) {
setStore("general", "showStatus", value)
},
showProjectIcon: withFallback(() => store.general?.showProjectIcon, defaultSettings.general.showProjectIcon),
setShowProjectIcon(value: boolean) {
setStore("general", "showProjectIcon", value)
},
showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal),
setShowTerminal(value: boolean) {
setStore("general", "showTerminal", value)
+15 -15
View File
@@ -134,7 +134,7 @@
font-style: normal;
font-size: 13px;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
@@ -281,7 +281,7 @@
padding-block: 20px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
@@ -289,7 +289,7 @@
padding-inline-end: 12px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
opacity: 0;
transition: opacity 200ms ease;
@@ -307,7 +307,7 @@
background: transparent;
font-size: 13px;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-accent);
cursor: pointer;
text-align: start;
@@ -329,7 +329,7 @@
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
}
.settings-providers .settings-section-title + [data-component="settings-list"] {
@@ -478,7 +478,7 @@
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
text-align: center;
}
@@ -495,7 +495,7 @@
padding-bottom: 0;
font-size: 13px;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
}
.settings-shortcuts [data-component="settings-list"] {
@@ -525,7 +525,7 @@
.settings-shortcuts [data-component="settings-list"] > div > span {
font-weight: 440;
font-size: 13px;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
@@ -577,7 +577,7 @@
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
text-align: center;
}
@@ -654,7 +654,7 @@
.settings-servers-name {
font-size: 13px;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
@@ -674,7 +674,7 @@
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
text-align: center;
}
@@ -784,7 +784,7 @@
font-family: inherit;
font-size: 13px;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
text-overflow: ellipsis;
white-space: nowrap;
@@ -796,7 +796,7 @@
.settings-workspaces-meta {
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-faint);
}
@@ -856,7 +856,7 @@
padding-block: 48px;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-muted);
}
@@ -933,7 +933,7 @@
.settings-server-dialog-label {
font-size: 13px;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
color: var(--v2-text-text-base);
}
@@ -124,7 +124,7 @@
color: var(--v2-text-text-base);
font-size: 13px;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
}
.project-settings-extension-section-header > :last-child {
@@ -96,7 +96,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
</Field>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.icon")}
</div>
<div class="flex items-center gap-3">
@@ -150,7 +150,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
<Show when={!model.store.iconOverride}>
<div class="flex w-full flex-col gap-2">
<div class="select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base">
<div class="select-none text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-base">
{language.t("dialog.project.edit.color")}
</div>
<div class="-ml-1 flex gap-1.5">
+3 -3
View File
@@ -88,9 +88,9 @@ function Cell(props: {
</div>
<div
classList={{
"uppercase leading-none font-bold tabular-nums": true,
"text-[11px]": !!props.inline,
"text-[13px] sm:text-[14px]": !props.inline,
"uppercase font-bold tabular-nums": true,
"text-[11px] leading-text-tight": !!props.inline,
"text-[13px] leading-text-compact sm:text-[14px]": !props.inline,
"text-text-on-critical-base": !!props.bad,
"opacity-70": !!props.dim,
}}
+15 -4
View File
@@ -1,9 +1,10 @@
import { Route, useParams } from "@solidjs/router"
import { createMemo, lazy, Show, type ParentProps } from "solid-js"
import { createMemo, lazy, Show, Suspense, type ParentProps } from "solid-js"
import { Home } from "@/home/route"
import { ServerProvider } from "@/runtime/server/current"
import { useGlobal } from "@/runtime/server/runtime"
import { ServerConnection } from "@/runtime/server/registry"
import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame"
import { LayoutProvider } from "@/shell/state/layout"
import Shell from "@/shell/shell"
import { requireServerKey } from "./session"
@@ -31,9 +32,19 @@ export function AppRoutes() {
<Route
path="/server/:serverKey/session/:id"
component={() => (
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
<SessionRouteFrame>
<Suspense
fallback={
<div class="flex min-h-0 flex-1 p-2">
<SessionPanelFrame raised />
</div>
}
>
<TargetServerRoute>
<TargetSessionRouteContent />
</TargetServerRoute>
</Suspense>
</SessionRouteFrame>
)}
/>
<Route path="/new-session" component={DraftRoute} />
@@ -88,7 +88,7 @@
[data-slot="server"] {
font-weight: 440;
font-size: 13px;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-muted);
overflow: hidden;
@@ -55,6 +55,40 @@ test("start anchoring preserves a stable visible item across prepends", () => {
expect(writes.at(-1)).toBe(150)
})
// A pagination boundary can re-key the row at the viewport top when the truncated
// leading turn regroups under its freshly loaded user message. The anchor must fall
// back to the next surviving key instead of leaving the offset on the new content.
test("prepend anchoring survives when the nearest keys are re-keyed", () => {
const root = document.createElement("div")
const writes: number[] = []
const options = (keys: string[]) => ({
count: keys.length,
estimateSize: () => 50,
initialOffset: 50,
initialRect: { width: 400, height: 100 },
anchorTo: "start" as const,
getItemKey: (index: number) => keys[index]!,
getScrollElement: () => root,
scrollToFn: (offset: number) => writes.push(offset),
observeElementRect: () => {},
observeElementOffset: (_element: HTMLDivElement, callback: (offset: number, isScrolling: boolean) => void) => {
callback(50, false)
},
})
// Viewport sits at offset 50: rows "orphan-c" (anchor) and "d" visible.
const virtualizer = new Virtualizer<HTMLDivElement, HTMLDivElement>(options(["orphan-c", "d", "e"]))
virtualizer._willUpdate()
virtualizer.getVirtualItems()
// Prepend re-keys the boundary row ("orphan-c" -> "c") while "d" and "e" survive.
virtualizer.setOptions(options(["a", "b", "c", "d", "e"]))
virtualizer._willUpdate()
// "d" was 50px below the anchor at old start 50; restored at new start 150 => offset 150.
expect(virtualizer.getScrollOffset()).toBe(150)
expect(writes.at(-1)).toBe(150)
})
test("reactive count updates preserve measured row sizes", () => {
createRoot((dispose) => {
const [count, setCount] = createSignal(2)
+1
View File
@@ -25,6 +25,7 @@ export default defineConfig({
desktopPlugin,
VitePWA({
strategies: "generateSW",
injectRegister: false,
manifest: false,
workbox: {
cleanupOutdatedCaches: true,
+12 -4
View File
@@ -27,6 +27,9 @@ if (outdir === path.join(dir, "dist-node")) {
const bundleOnly = process.argv.includes("--bundle-only")
const single = process.argv.includes("--single")
const skipInstall = process.argv.includes("--skip-install")
const appArchiveOnly = process.argv.includes("--app-archive-only")
const requestedArchive = process.argv.find((arg) => arg.startsWith("--app-archive="))?.slice("--app-archive=".length)
const archivePath = requestedArchive ? path.resolve(dir, requestedArchive) : undefined
const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length)
const allTargets = [
nodeTarget("linux", "arm64"),
@@ -41,6 +44,14 @@ const targets = requested
? [nodeTarget(process.platform, process.arch)]
: allTargets
process.chdir(dir)
if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"])
if (appArchiveOnly) {
if (!archivePath) throw new Error("--app-archive-only requires --app-archive=<path>")
await mkdir(path.dirname(archivePath), { recursive: true })
await writeFile(archivePath, await buildAppArchive(Script.channel))
process.exit(0)
}
if (targets.length === 0) {
if (requested === "darwin-x64") throw new Error("Node 26.4 SEA does not support macOS x64")
throw new Error(`Unknown Node target: ${requested}`)
@@ -48,15 +59,12 @@ if (targets.length === 0) {
if (!bundleOnly && targets.some((target) => target.platform === "darwin" && target.arch === "x64")) {
throw new Error("Node 26.4 SEA does not support macOS x64")
}
process.chdir(dir)
if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"])
const appArchive = archivePath ? (await Bun.file(archivePath).text()).trim() : await buildAppArchive(Script.channel)
if (!bundleOnly) await rm(outdir, { recursive: true, force: true })
const builder =
!bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch)
? await resolveHostNode()
: undefined
const appArchive = await buildAppArchive(Script.channel)
// Vite silently rewrites text imports of known asset types (.txt) to asset
// URL strings when the raw-text plugin doesn't intercept them first — the
+3 -1
View File
@@ -42,7 +42,9 @@ function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets:
"x-content-type-options": "nosniff",
}
return Effect.succeed(
request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }),
request.method === "HEAD"
? HttpServerResponse.empty({ headers })
: HttpServerResponse.raw(file, { headers, contentType: headers["content-type"] }),
)
}
+2
View File
@@ -55,6 +55,7 @@ describe("web UI", () => {
const script = yield* Effect.promise(() => fetch(`${origin}/app.js`))
expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')")
expect(script.headers.get("content-type")).toContain("javascript")
expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable")
const worker = yield* Effect.promise(() => fetch(`${origin}/sw.js`))
@@ -64,6 +65,7 @@ describe("web UI", () => {
expect(registration.headers.get("cache-control")).toBe("no-cache")
const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`))
expect(font.headers.get("content-type")).toBe("font/woff2")
expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual(
new Uint8Array([0, 1, 2, 255]),
)
+3 -2
View File
@@ -60,6 +60,7 @@ export type CreateDataInput = {
}
const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_")
const messagePageLimit = 20
// Global MCP elicitations temporarily use "global" instead of a real session ID, so the
// server cannot recover their Location when settling them. Preserve the event Location
@@ -1318,7 +1319,7 @@ export function createData(config: CreateDataInput) {
},
sync(sessionID: string) {
return sync.run(`session.message:${sessionID}`, async () => {
const response = await api().message.list({ sessionID, limit: 200, order: "desc" })
const response = await api().message.list({ sessionID, limit: messagePageLimit, order: "desc" })
const fetched = response.data.toReversed()
// Same protection as the pending sync: a re-fetch racing an
// admission must not wipe its local transcript row.
@@ -1348,7 +1349,7 @@ export function createData(config: CreateDataInput) {
if (!cursor || store.session.messageLoading[sessionID]) return
setStore("session", "messageLoading", sessionID, true)
const response = await api()
.message.list({ sessionID, limit: 200, cursor })
.message.list({ sessionID, limit: messagePageLimit, cursor })
.finally(() => setStore("session", "messageLoading", sessionID, false))
const older = response.data.toReversed()
const existing = store.session.message[sessionID] ?? []
+32
View File
@@ -103,6 +103,38 @@ test("reports optimistic sessions as creating until the request settles", async
}
})
test("loads bounded message pages", async () => {
const requests: URL[] = []
const api = OpenCode.make({
baseUrl: "http://opencode.local",
fetch: async (input, init) => {
const request = input instanceof Request ? input : new Request(input, init)
const url = new URL(request.url)
requests.push(url)
return Response.json({ data: [], cursor: requests.length === 1 ? { next: "next" } : {} })
},
})
const setup = createRoot((dispose) => ({
data: createData({
api: () => api,
directory: "/project",
event: { on: () => () => {}, listen: () => () => {} },
}),
dispose,
}))
try {
await setup.data.session.message.sync("ses_refresh")
await setup.data.session.message.loadMore("ses_refresh")
expect(requests).toHaveLength(2)
expect(Object.fromEntries(requests[0].searchParams)).toEqual({ limit: "20", order: "desc" })
expect(Object.fromEntries(requests[1].searchParams)).toEqual({ cursor: "next", limit: "20" })
} finally {
setup.dispose()
}
})
async function wait(check: () => boolean) {
const started = Date.now()
while (!check()) {
+15 -3
View File
@@ -4,7 +4,14 @@ import { NodeHttpServer } from "@effect/platform-node"
import { SessionRestart } from "@opencode-ai/core/session/execution/restart"
import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty"
import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect"
import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"
import {
HttpMiddleware,
HttpPlatform,
HttpRouter,
HttpServer,
HttpServerRequest,
HttpServerResponse,
} from "effect/unstable/http"
import { createServer } from "node:http"
import { ServerAuth } from "./auth"
import { isAllowedCorsOrigin } from "./cors"
@@ -90,7 +97,7 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
const host = address.family === "IPv6" ? `[${address.address}]` : address.address
return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname)
},
).pipe(Layer.provide(NodeHttpServer.layerHttpServices)),
).pipe(Layer.provideMerge(NodeHttpServer.layerHttpServices)),
applicationScope,
)
if (lifecycle) {
@@ -98,7 +105,12 @@ export const start = Effect.fn("ServerProcess.start")(function* <E, R>(
Effect.provideService(Scope.Scope, applicationScope),
)
}
const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect()
const app = Context.get(context, HttpRouter.HttpRouter)
.asHttpEffect()
.pipe(
HttpMiddleware.compression(),
Effect.provideService(HttpPlatform.HttpPlatform, Context.get(context, HttpPlatform.HttpPlatform)),
)
yield* Ref.set(application, Option.some(transform ? transform(app) : app))
yield* status.ready
return { address: bound.http.address, shutdown: shutdown.await }
+22 -3
View File
@@ -6,6 +6,7 @@ import { ServerProcess } from "../src/process"
it.live("allows browser preflight requests without credentials", () =>
Effect.gen(function* () {
const fallback = "fallback".repeat(256)
const server = yield* ServerProcess.start<never, never>(
{
hostname: "127.0.0.1",
@@ -19,7 +20,7 @@ it.live("allows browser preflight requests without credentials", () =>
api.pipe(
Effect.catchIf(
(error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound",
() => Effect.succeed(HttpServerResponse.text("fallback")),
() => Effect.succeed(HttpServerResponse.raw(fallback, { contentType: "text/plain" })),
),
),
)
@@ -51,12 +52,30 @@ it.live("allows browser preflight requests without credentials", () =>
expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000")
expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" })
const event = yield* Effect.promise(() =>
fetch(new URL("/api/event", HttpServer.formatAddress(server.address)), {
headers: {
"accept-encoding": "br",
authorization: `Basic ${btoa("opencode:secret")}`,
},
}),
)
expect(event.status).toBe(200)
expect(event.headers.get("content-encoding")).toBeNull()
yield* Effect.promise(() => event.body?.cancel() ?? Promise.resolve())
const missing = yield* Effect.promise(() =>
fetch(new URL("/missing", HttpServer.formatAddress(server.address)), {
headers: { authorization: `Basic ${btoa("opencode:secret")}` },
headers: {
"accept-encoding": "br",
authorization: `Basic ${btoa("opencode:secret")}`,
},
}),
)
expect(missing.status).toBe(200)
expect(yield* Effect.promise(() => missing.text())).toBe("fallback")
expect(missing.headers.get("content-encoding")).toBe("br")
expect(missing.headers.get("content-type")).toBe("text/plain")
expect(missing.headers.get("vary")?.toLowerCase()).toContain("accept-encoding")
expect(yield* Effect.promise(() => missing.text())).toBe(fallback)
}),
)
+7
View File
@@ -7,3 +7,10 @@
- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it.
- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
## Typography
- Use `--line-height-compact` (`16px`) for `13px` transcript, tool, notice, and truncation text. Use `--line-height-base` (`20px`) for body text.
- Never copy Figma's generated `leading-none` onto text. Inter descenders clip when a solid `13px` line box meets `overflow: hidden`, `overflow: clip`, or truncation.
- Keep fixed row dimensions explicit; correct inner line metrics do not require transforms, negative margins, or paint-space compensation.
- `TextShimmer` inherits font metrics, so put typography overrides on its parent.
@@ -182,7 +182,8 @@
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 530;
line-height: var(--v2-line-height-compact, 16px);
/* Keep compact text on the shared metric; solid 13px line boxes clip Inter descenders. */
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
@@ -190,7 +191,7 @@
font-family: var(--v2-font-family-sans);
font-size: 13px;
font-weight: 440;
line-height: var(--v2-line-height-compact, 16px);
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
}
@@ -50,6 +50,7 @@ type RenderedBlock =
type RenderResult = {
text: string
blocks: RenderedBlock[]
ready: boolean
}
const renderedCodeTokens = new WeakMap<HTMLDivElement, RenderedCodeState>()
@@ -367,8 +368,14 @@ function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
}
}
function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult {
if (!text) return { text, blocks: [] }
function initialResult(
text: string,
key: string | undefined,
projection: Projection,
owner: string,
deferUntilReady: boolean | undefined,
): RenderResult {
if (!text) return { text, blocks: [], ready: true }
const base = key ?? checksum(text)
if (base) {
const blocks = projection.blocks.flatMap((block, index) => {
@@ -378,10 +385,12 @@ function initialResult(text: string, key: string | undefined, projection: Projec
if (cached?.raw !== block.raw) return []
return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }]
})
if (blocks.length === projection.blocks.length) return { text, blocks }
if (blocks.length === projection.blocks.length) return { text, blocks, ready: true }
}
if (deferUntilReady) return { text, blocks: [], ready: false }
return {
text,
ready: false,
blocks: [
{
key: "initial",
@@ -403,11 +412,12 @@ export function Markdown(
text: string
cacheKey?: string
streaming?: boolean
deferUntilReady?: boolean
class?: string
classList?: Record<string, boolean>
},
) {
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "deferUntilReady", "class", "classList"])
const i18n = useI18n()
const [root, setRoot] = createSignal<HTMLDivElement>()
const owner = createUniqueId()
@@ -448,10 +458,11 @@ export function Markdown(
projection: value,
}
},
async (src) => {
async (src): Promise<RenderResult> => {
if (isServer)
return {
text: src.text,
ready: true,
blocks: [
{
key: "server",
@@ -462,7 +473,7 @@ export function Markdown(
},
],
} satisfies RenderResult
if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult
if (!src.text) return { text: src.text, blocks: [], ready: true } satisfies RenderResult
const base = src.key ?? checksum(src.text)
return Promise.all(
@@ -500,11 +511,12 @@ export function Markdown(
return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe }
}),
)
.then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult)
.then((blocks) => ({ text: src.text, blocks, ready: true }) satisfies RenderResult)
.catch(
() =>
({
text: src.text,
ready: true,
blocks: [
{
key: base ?? "fallback",
@@ -523,6 +535,7 @@ export function Markdown(
local.cacheKey,
local.streaming ? pendingProjection(local.text) : completedProjection(local.text),
owner,
local.deferUntilReady,
),
},
)
@@ -533,12 +546,14 @@ export function Markdown(
const container = root()
const result = html.latest ?? html()
const projected = currentProjection()
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : []
const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner, local.deferUntilReady) : []
if (!container) return
if (isServer) return
delete container.dataset.markdownReady
if (content.length === 0) {
disposeCopyButtons(container)
container.innerHTML = ""
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
return
}
@@ -567,6 +582,7 @@ export function Markdown(
copy: i18n.t("ui.message.copy"),
copied: i18n.t("ui.message.copied"),
}))
if (result?.ready && result.text === local.text) container.dataset.markdownReady = ""
})
onCleanup(() => {
@@ -579,7 +595,6 @@ export function Markdown(
return (
<div
data-component="markdown"
data-markdown-ready={html.loading ? undefined : ""}
dir="auto"
classList={{
...local.classList,
@@ -596,9 +611,11 @@ function pendingBlocks(
projection: Projection | undefined,
cacheKey: string | undefined,
owner: string,
deferUntilReady: boolean | undefined,
) {
if (!result) return []
if (!projection || result.text === projection.text) return result.blocks
if (deferUntilReady) return result.blocks
const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial"
return projection.blocks.map((block, index) => {
const current = initial ? undefined : result.blocks[index]
@@ -609,7 +609,7 @@
font-family: var(--font-family-sans);
font-size: 13px;
font-weight: var(--font-weight-regular, 440);
line-height: 13px;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-faint, #808080);
user-select: none;
@@ -1347,7 +1347,8 @@
max-width: 100%;
font-family: var(--font-family-sans);
font-size: 13px;
line-height: 13px;
/* Loaded values truncate, so they require the full compact line box for descenders. */
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
@@ -43,11 +43,15 @@
align-self: stretch;
}
[data-slot="session-turn-thinking-row"] {
width: 100%;
margin-top: 12px;
}
[data-slot="session-turn-thinking"] {
display: flex;
align-items: center;
gap: 8px;
margin-top: 12px;
width: 100%;
min-width: 0;
color: var(--text-weak);
@@ -1,6 +1,6 @@
[data-component="card"][data-kind="tool-error-card"] {
--card-pad-y: 8px;
--card-line-pad: 12px;
--card-pad-y: 0px;
--card-line-pad: 4px;
[data-slot="basic-tool-tool-title"] {
color: var(--v2-text-text-base);
@@ -41,7 +41,7 @@ export function SessionAssistantContent(props: {
content: SessionMessageAssistant["content"][number]
contentID: string
showAssistantCopyPartID?: string | null
turnDurationMs?: number
turnDurationMs?: number | null
defaultOpen?: boolean
toolOpen?: boolean
onToolOpenChange?: (open: boolean) => void
@@ -159,7 +159,7 @@ function PacedMarkdown(props: { text: string; cacheKey: string; streaming: boole
return (
<Show when={value()}>
<Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} />
<Markdown text={value()} cacheKey={props.cacheKey} streaming={props.streaming} deferUntilReady />
</Show>
)
}
@@ -388,7 +388,7 @@ export function AssistantTextContent(props: {
text: string
message: SessionMessageAssistant
showCopy: boolean
turnDurationMs?: number
turnDurationMs?: number | null
}) {
const data = useData()
const i18n = useI18n()
@@ -404,11 +404,13 @@ export function AssistantTextContent(props: {
const duration = createMemo(() => {
const completed = props.message.time.completed
const ms =
typeof props.turnDurationMs === "number"
? props.turnDurationMs
: typeof completed === "number"
? completed - props.message.time.created
: -1
props.turnDurationMs === null
? -1
: typeof props.turnDurationMs === "number"
? props.turnDurationMs
: typeof completed === "number"
? completed - props.message.time.created
: -1
if (!(ms >= 0)) return ""
const total = Math.round(ms / 1000)
if (total < 60) return i18n.t("ui.message.duration.seconds", { count: numfmt().format(total) })
@@ -33,63 +33,65 @@ describe("reuseTimelineRows", () => {
name: "reuses an unchanged context group",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:user-1:context:a"],
expected: ["assistant-part:context:a"],
reused: [[0, 0]],
},
{
name: "preserves the group key when a member is appended",
previous: [context("context:a", ["a"])],
rows: [context("context:a", ["a", "b"])],
expected: ["assistant-part:user-1:context:a"],
expected: ["assistant-part:context:a"],
reused: [],
},
{
name: "preserves a patch group key when a member is appended",
previous: [patch("patch:a", ["a"])],
rows: [patch("patch:a", ["a", "b"])],
expected: ["assistant-part:user-1:patch:a"],
expected: ["assistant-part:patch:a"],
reused: [],
},
{
name: "preserves the group key when the first member is removed",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"])],
expected: ["assistant-part:user-1:context:a"],
expected: ["assistant-part:context:a"],
reused: [],
},
{
name: "lets only the natural owner retain an old key after a split",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:a", ["a"]), context("context:b", ["b"])],
expected: ["assistant-part:user-1:context:a", "assistant-part:user-1:context:b"],
expected: ["assistant-part:context:a", "assistant-part:context:b"],
reused: [],
},
{
name: "chooses the earliest prior key when groups merge",
previous: [context("context:a", ["a"]), context("context:b", ["b"])],
rows: [context("context:b", ["b", "a"])],
expected: ["assistant-part:user-1:context:a"],
expected: ["assistant-part:context:a"],
reused: [],
},
{
name: "reserves an old key for its natural owner when two new groups compete",
previous: [context("context:a", ["a", "b"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"])],
expected: ["assistant-part:user-1:context:b", "assistant-part:user-1:context:a"],
expected: ["assistant-part:context:b", "assistant-part:context:a"],
reused: [],
},
{
name: "does not reuse context identity across user messages",
// A history prepend can regroup a page-boundary turn under its real user
// message; the same parts must keep their identity across that move.
name: "reuses context identity when the same parts move to another user message",
previous: [context("context:a", ["a", "b"], { userMessageID: "user-1" })],
rows: [context("context:b", ["b"], { userMessageID: "user-2" })],
expected: ["assistant-part:user-2:context:b"],
expected: ["assistant-part:context:a"],
reused: [],
},
{
name: "does not reuse context identity across assistant messages",
previous: [context("context:assistant-1:a", ["a"], { messageID: "assistant-1" })],
rows: [context("context:assistant-2:a", ["a"], { messageID: "assistant-2" })],
expected: ["assistant-part:user-1:context:assistant-2:a"],
expected: ["assistant-part:context:assistant-2:a"],
reused: [],
},
{
@@ -103,11 +105,7 @@ describe("reuseTimelineRows", () => {
name: "does not create accidental key collisions",
previous: [context("context:a", ["a", "b", "c"])],
rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])],
expected: [
"assistant-part:user-1:context:b",
"assistant-part:user-1:context:a",
"assistant-part:user-1:context:c",
],
expected: ["assistant-part:context:b", "assistant-part:context:a", "assistant-part:context:c"],
reused: [],
},
])("$name", ({ previous, rows, expected, reused }) => {
@@ -197,4 +195,36 @@ describe("createTimelineProjection", () => {
expect(second.rows[0]).toBe(first.rows[0])
expect(second.rows[1]).toBe(first.rows[1])
})
test("indexes a leading partial assistant turn under its projected turn ID", () => {
const messages = [
{
id: "assistant-1",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "partial answer" }],
time: { created: 2, completed: 3 },
},
{
id: "assistant-2",
type: "assistant",
agent: "build",
model: { id: "model", providerID: "provider" },
content: [{ type: "text", text: "final answer" }],
time: { created: 4, completed: 5 },
},
] satisfies SessionMessageInfo[]
const result = createTimelineProjection({
sessionMessages: messages,
status: { type: "idle" },
showReasoningSummaries: true,
})
expect(result.assistantMessagesByParent.get("assistant-1")?.map((message) => message.id)).toEqual([
"assistant-1",
"assistant-2",
])
})
})
@@ -318,7 +318,7 @@ export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefine
const groupByPart = new Map<string, PriorGroup>()
previous.forEach((row, index) => {
if (row._tag !== "AssistantPart" || row.group.type === "part") return
row.group.refs.forEach((ref) => groupByPart.set(groupPartKey(row.userMessageID, ref), { index, row }))
row.group.refs.forEach((ref) => groupByPart.set(groupPartKey(ref), { index, row }))
})
const reserved = new Map<string, number>()
rows.forEach((row, index) => {
@@ -392,7 +392,8 @@ function indexAssistantMessages(messages: SessionMessageInfo[]) {
messages.forEach((message) => {
if (message.type === "user") userID = message.id
if (message.type === "shell") userID = undefined
if (message.type !== "assistant" || !userID) return
if (message.type !== "assistant") return
if (!userID) userID = message.id
const existing = result.get(userID)
if (existing) {
existing.push(message)
@@ -413,7 +414,7 @@ function stabilizeGroupKey(
) {
if (row._tag !== "AssistantPart" || row.group.type === "part") return row
const existing = row.group.refs.reduce<PriorGroup | undefined>((result, ref) => {
const candidate = groupByPart.get(groupPartKey(row.userMessageID, ref))
const candidate = groupByPart.get(groupPartKey(ref))
if (!candidate) return result
const key = TimelineRow.key(candidate.row)
if (claimed.has(key)) return result
@@ -432,8 +433,10 @@ function stabilizeGroupKey(
})
}
function groupPartKey(userMessageID: string, ref: PartRef) {
return `${userMessageID}:${ref.messageID}:${ref.partID}`
// Part refs are globally unique; keying by the turn would break reuse when a
// page-boundary turn regroups under its real user message after a history prepend.
function groupPartKey(ref: PartRef) {
return `${ref.messageID}:${ref.partID}`
}
function renderable(content: Content, showReasoning: boolean) {
@@ -29,10 +29,10 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe("msg_3")
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_1",
"assistant-part:msg_1:part:msg_2:msg_2:text:0",
"assistant-part:part:msg_2:msg_2:text:0",
"turn-gap:msg_3",
"user-message:msg_3",
"assistant-part:msg_3:part:msg_4:msg_4:reasoning:0",
"assistant-part:part:msg_4:msg_4:reasoning:0",
])
})
@@ -79,7 +79,7 @@ describe("current session timeline rows", () => {
expect(result.activeMessageID).toBe("msg_assistant")
expect(result.rows.map(TimelineRow.key)).toEqual([
"notice:msg_notice",
"assistant-part:msg_assistant:part:msg_assistant:msg_assistant:text:0",
"assistant-part:part:msg_assistant:msg_assistant:text:0",
])
})
@@ -140,10 +140,10 @@ describe("current session timeline rows", () => {
expect(result.rows.map(TimelineRow.key)).toEqual([
"user-message:msg_user",
"notice:msg_agent",
"assistant-part:msg_user:part:msg_assistant_1:msg_assistant_1:text:0",
"assistant-part:part:msg_assistant_1:msg_assistant_1:text:0",
"notice:msg_background",
"notice:msg_model",
"assistant-part:msg_user:part:msg_assistant_2:msg_assistant_2:text:0",
"assistant-part:part:msg_assistant_2:msg_assistant_2:text:0",
"notice:msg_restart",
"notice:msg_skill",
"notice:msg_compaction",
@@ -414,9 +414,9 @@ describe("current session timeline rows", () => {
expect(keys).toEqual([
"user-message:msg_user",
"assistant-part:msg_user:context:msg_assistant_1:tool_0",
"assistant-part:msg_user:part:msg_assistant_2:tool_0",
"assistant-part:msg_user:context:msg_assistant_3:tool_0",
"assistant-part:context:msg_assistant_1:tool_0",
"assistant-part:part:msg_assistant_2:tool_0",
"assistant-part:context:msg_assistant_3:tool_0",
])
})
@@ -11,6 +11,7 @@ import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { Tooltip } from "@opencode-ai/ui/tooltip"
import { For, Show, createMemo, type Accessor, type JSX } from "solid-js"
import type { SessionUserActions, SessionUserComment } from "../actions"
import { BasicTool } from "../components/basic-tool"
import {
MessageDivider,
SessionAssistantContent,
@@ -54,7 +55,7 @@ export function createSessionTimelineRowRenderer(input: {
input.status().type !== "idle" && input.projection.activeMessageID() === messageID
const duration = (messageID: string) => {
const user = input.projection.messageByID().get(messageID)
if (user?.type !== "user") return undefined
if (user?.type !== "user") return null
const completed = (input.projection.assistantMessagesByParent().get(messageID) ?? emptyAssistantMessages).reduce<
number | undefined
>((latest, message) => {
@@ -354,7 +355,7 @@ export function createSessionTimelineRowRenderer(input: {
<div class="flex min-h-5 min-w-0 items-center gap-2 overflow-hidden">
<bdi
dir="auto"
class="shrink-0 text-[13px] font-[530] leading-none tracking-[-0.04px] text-v2-text-text-faint"
class="shrink-0 text-[13px] font-[530] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{content().label}
</bdi>
@@ -362,7 +363,7 @@ export function createSessionTimelineRowRenderer(input: {
{(item) => (
<bdi
dir="auto"
class="min-w-0 truncate text-[13px] font-[440] leading-none tracking-[-0.04px] text-v2-text-text-faint"
class="min-w-0 truncate text-[13px] font-[440] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint"
>
{item}
</bdi>
@@ -379,7 +380,7 @@ export function createSessionTimelineRowRenderer(input: {
<div
data-slot="session-timeline-notice"
data-type="location-switched"
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-none tracking-[-0.04px] text-v2-text-text-faint ${padding()}`}
class={`flex h-7 w-full min-w-0 items-center gap-2 py-1 text-[13px] leading-text-compact tracking-[-0.04px] text-v2-text-text-faint ${padding()}`}
>
<Tooltip
appearance="compact"
@@ -442,16 +443,35 @@ export function createSessionTimelineRowRenderer(input: {
return (
<Frame row={current()}>
<div data-slot="session-turn-message-container" class={`w-full ${padding()}`}>
<div data-slot="session-turn-thinking">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
<Show when={!input.showReasoningSummaries()}>
<TextReveal
text={current().reasoningHeading}
class="session-turn-thinking-heading"
travel={25}
duration={700}
/>
</Show>
<div data-slot="session-turn-thinking-row">
<BasicTool
icon="mcp"
status="running"
compact
locked
hideDetails
trigger={
<div data-slot="session-turn-thinking">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<span data-slot="basic-tool-tool-title">
<TextShimmer text={i18n.t("ui.sessionTurn.status.thinking")} />
</span>
<Show when={!input.showReasoningSummaries()}>
<span data-slot="basic-tool-tool-subtitle">
<TextReveal
text={current().reasoningHeading}
class="session-turn-thinking-heading"
travel={25}
duration={700}
/>
</span>
</Show>
</div>
</div>
</div>
}
/>
</div>
</div>
</Frame>
@@ -88,8 +88,11 @@ export namespace TimelineRow {
return `notice:${row.messageID}`
case "TurnDivider":
return `turn-divider:${row.userMessageID}`
// Keyed by part identity alone: a page boundary can truncate the leading turn,
// and its rows regroup under the real user message once older history loads.
// The group key already carries the owning message and part IDs.
case "AssistantPart":
return `assistant-part:${row.userMessageID}:${row.group.key}`
return `assistant-part:${row.group.key}`
case "Thinking":
return `thinking:${row.userMessageID}`
case "Error":
+39 -45
View File
@@ -487,45 +487,42 @@ export function CurrentContextToolGroup(props: {
}
return (
<Collapsible
open={props.open}
onOpenChange={change}
variant="ghost"
class="tool-collapsible"
data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}
>
<Collapsible.Trigger>
<div data-component="context-tool-group-trigger">
<span
data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
>
<span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle
active={pending()}
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
split={false}
/>
<div data-timeline-part-ids={props.tools.map((tool) => tool.id).join(",")}>
<BasicTool
icon="glasses"
status={pending() ? "running" : "completed"}
compact
allowOpenWhilePending
open={props.open}
onOpenChange={change}
trigger={
<div data-component="context-tool-group-trigger">
<span data-slot="context-tool-group-title" class="min-w-0 flex items-center gap-2">
<span data-slot="basic-tool-tool-title" class="shrink-0">
<ToolStatusTitle
active={pending()}
activeText={i18n.t("ui.sessionTurn.status.gatheringContext")}
doneText={i18n.t("ui.sessionTurn.status.gatheredContext")}
split={false}
/>
</span>
<span
data-slot="basic-tool-tool-subtitle"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap"
>
<AnimatedCountList
items={[
{ key: "ui.messagePart.context.read", count: summary().read },
{ key: "ui.messagePart.context.search", count: summary().search },
{ key: "ui.messagePart.context.list", count: summary().list },
]}
fallback=""
/>
</span>
</span>
<span
data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
>
<AnimatedCountList
items={[
{ key: "ui.messagePart.context.read", count: summary().read },
{ key: "ui.messagePart.context.search", count: summary().search },
{ key: "ui.messagePart.context.list", count: summary().list },
]}
fallback=""
/>
</span>
</span>
<Collapsible.Arrow />
</div>
</Collapsible.Trigger>
<Collapsible.Content>
</div>
}
>
<div data-component="context-tool-group-list">
<Index each={props.tools}>
{(tool) => {
@@ -557,8 +554,8 @@ export function CurrentContextToolGroup(props: {
}}
</Index>
</div>
</Collapsible.Content>
</Collapsible>
</BasicTool>
</div>
)
}
@@ -1175,13 +1172,10 @@ ToolRegistry.register({
>
<div
data-component="task-tool-delegating"
class="flex h-9 w-fit max-w-full items-center gap-2 rounded-[8px] bg-v2-background-bg-layer-01 p-2.5"
class="flex h-9 w-fit max-w-full items-center gap-2 rounded-[8px] bg-v2-background-bg-layer-01 p-2.5 text-[13px] font-[530] leading-text-compact tracking-[-0.04px]"
>
<Icon name="subagent" size="small" class="shrink-0 text-v2-icon-icon-faint" />
<TextShimmer
text={i18n.t("ui.tool.agent.delegating")}
class="min-w-0 truncate text-[13px] font-[530] leading-none tracking-[-0.04px]"
/>
<TextShimmer text={i18n.t("ui.tool.agent.delegating")} class="min-w-0 truncate" />
</div>
</Show>
)
@@ -257,7 +257,7 @@
font-style: normal;
font-weight: 440;
font-size: 11px;
line-height: 100%;
line-height: var(--line-height-tight);
letter-spacing: 0.05px;
text-transform: uppercase;
font-variant-numeric: tabular-nums lining-nums;
@@ -398,7 +398,7 @@
flex: none;
font-size: 13px;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums lining-nums;
color: var(--v2-text-text-base);
@@ -409,7 +409,7 @@
flex: none;
font-size: 13px;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums lining-nums;
color: var(--v2-text-text-muted);
@@ -471,7 +471,7 @@
flex: none;
font-size: 13px;
font-weight: 530;
line-height: 100%;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
}
@@ -510,7 +510,7 @@
margin-top: 4px;
font-size: 13px;
font-weight: 530;
line-height: 100%;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
}
@@ -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")
})
})
+7
View File
@@ -10,3 +10,10 @@
- Translate whole UI phrases in context rather than substituting glossary words. Audit recurring concepts for consistency and review every exact-English value; retain it only when it is an intentional product/provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing.
- Record the corpora used and flag uncertain or regional terminology in review notes.
- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels.
## Typography
- Use the shared typography metrics instead of copying solid line heights from design exports: `--line-height-tight` is `12px`, `--line-height-compact` is `16px`, and `--line-height-base` is `20px`.
- Inter text at `13px` must use at least the compact `16px` line height. A `13px` solid line box can clip `g`, `j`, `p`, `q`, and `y` when the text or an ancestor truncates or hides overflow.
- Reserve `line-height: 1` and `leading-none` for non-text glyphs, icons, or deliberately reviewed display marks. Do not compensate text with transforms, negative margins, or clip-padding hacks.
- `TextShimmer` inherits font metrics. Put typography overrides on its parent when they must supersede the component defaults.
@@ -49,7 +49,7 @@
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
@@ -59,7 +59,7 @@
font-size: 11px;
font-style: normal;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-tight);
letter-spacing: 0.05px;
color: var(--v2-text-text-faint);
font-variation-settings: "slnt" 0;
@@ -138,7 +138,7 @@
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
user-select: none;
+1 -1
View File
@@ -20,7 +20,7 @@
font-style: normal;
font-weight: 530;
font-size: 13px;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
@@ -92,7 +92,7 @@
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 1;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
+1 -1
View File
@@ -76,7 +76,7 @@
font-size: 13px;
font-weight: 440;
line-height: 100%;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--menu-v2-fg);
}
@@ -37,7 +37,7 @@
font-style: normal;
font-weight: 440;
font-size: 13px;
line-height: 100%;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
font-variant-numeric: tabular-nums;
font-variation-settings: "slnt" 0;
@@ -59,7 +59,7 @@
font-size: 13px;
font-weight: 440;
line-height: 100%;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
}
@@ -65,6 +65,9 @@
--leading-lg: var(--line-height-large);
--leading-xl: var(--line-height-x-large);
--leading-2xl: var(--line-height-2x-large);
--leading-text-tight: var(--line-height-tight);
--leading-text-compact: var(--line-height-compact);
--leading-text-base: var(--line-height-base);
--tracking-normal: var(--letter-spacing-normal);
--tracking-tight: var(--letter-spacing-tight);
+3
View File
@@ -131,6 +131,9 @@
--font-family-text: "Inter", sans-serif;
--v2-font-family-sans: "Inter", sans-serif;
--line-height-tight: 12px;
--line-height-compact: 16px;
--line-height-base: 20px;
}
/* OS preference fallback (no JS needed) */
+222 -11
View File
@@ -1,5 +1,5 @@
diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs
index e470032a9572b3ced764ca02238a8c6be435a9d4..65cd7ba4159d47d4c0cfb1adea69281ee30ba28a 100644
index e470032a9572b3ced764ca02238a8c6be435a9d4..93770cdc02c570ce6aaa2ce256792b940c25e4d3 100644
--- a/dist/cjs/index.cjs
+++ b/dist/cjs/index.cjs
@@ -289,7 +289,7 @@ class Virtualizer {
@@ -11,16 +11,78 @@ index e470032a9572b3ced764ca02238a8c6be435a9d4..65cd7ba4159d47d4c0cfb1adea69281e
const prevCount = prevOptions.count;
const nextCount = merged.count;
const measurements = this.getMeasurements();
@@ -303,7 +303,7 @@ class Virtualizer {
@@ -299,11 +299,20 @@ class Virtualizer {
const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey);
if (didEdgeKeysChange) {
edgeKeysChanged = true;
+ // A data change can legitimately re-key the rows around the current offset
+ // (e.g. a truncated leading chat turn regrouping once a prepended page loads
+ // its parent). Capture fallback anchors below the primary one so the scroll
+ // position survives even when the nearest keys disappear.
const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
if (item) {
anchor = [item.key, this.getScrollOffset() - item.start];
- anchor = [item.key, this.getScrollOffset() - item.start];
+ anchor = [];
+ for (let i = item.index; i < prevCount && anchor.length < 100; i++) {
+ const candidate = measurements[i];
+ if (!candidate) break;
+ anchor.push([candidate.key, this.getScrollOffset() - candidate.start]);
+ }
}
- const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null;
+ const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null;
if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) {
followOnAppend = behavior;
}
@@ -725,17 +725,20 @@ class Virtualizer {
@@ -316,30 +325,31 @@ class Virtualizer {
}
let anchorResolved = false;
let anchorDelta = 0;
- if (anchor && this.scrollOffset !== null) {
- const [anchorKey, anchorOffset] = anchor;
+ let resolvedAnchor = null;
+ if (anchor && anchor.length > 0 && this.scrollOffset !== null) {
const newMeasurements = this.getMeasurements();
const { count, getItemKey } = this.options;
- let idx = 0;
- while (idx < count && getItemKey(idx) !== anchorKey) {
- idx++;
- }
- if (idx < count) {
+ const indexByKey = new Map();
+ for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i);
+ for (const [anchorKey, anchorOffset] of anchor) {
+ const idx = indexByKey.get(anchorKey);
+ if (idx === void 0) continue;
const anchorItem = newMeasurements[idx];
- if (anchorItem) {
- const newOffset = Math.max(0, anchorItem.start + anchorOffset);
- if (newOffset !== this.scrollOffset) {
- anchorDelta = newOffset - this.scrollOffset;
- this.scrollOffset = newOffset;
- anchorResolved = true;
- }
+ if (!anchorItem) continue;
+ resolvedAnchor = [anchorKey, anchorOffset];
+ const newOffset = Math.max(0, anchorItem.start + anchorOffset);
+ if (newOffset !== this.scrollOffset) {
+ anchorDelta = newOffset - this.scrollOffset;
+ this.scrollOffset = newOffset;
+ anchorResolved = true;
}
+ break;
}
}
if (anchorResolved || followOnAppend) {
this.pendingScrollAnchor = [
- anchorResolved ? anchor[0] : null,
- anchorResolved ? anchor[1] : 0,
+ anchorResolved ? resolvedAnchor[0] : null,
+ anchorResolved ? resolvedAnchor[1] : 0,
followOnAppend,
anchorDelta
];
@@ -725,17 +735,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
@@ -71,7 +133,7 @@ index 6b43c0aea7ed9eeef75cbfb1351fcbd243913bdd..7be2680967934ddfbc4583a210a3d11e
getVirtualIndexes: {
(): number[];
diff --git a/dist/esm/index.js b/dist/esm/index.js
index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39a5b0b899 100644
index 2495b26cf2c3589213546b3958eaadf2eb6b751d..dc062edd6438f315bd8dddcf231986ad72158477 100644
--- a/dist/esm/index.js
+++ b/dist/esm/index.js
@@ -287,7 +287,7 @@ class Virtualizer {
@@ -83,16 +145,78 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39
const prevCount = prevOptions.count;
const nextCount = merged.count;
const measurements = this.getMeasurements();
@@ -301,7 +301,7 @@ class Virtualizer {
@@ -297,11 +297,20 @@ class Virtualizer {
const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey);
if (didEdgeKeysChange) {
edgeKeysChanged = true;
+ // A data change can legitimately re-key the rows around the current offset
+ // (e.g. a truncated leading chat turn regrouping once a prepended page loads
+ // its parent). Capture fallback anchors below the primary one so the scroll
+ // position survives even when the nearest keys disappear.
const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null;
if (item) {
anchor = [item.key, this.getScrollOffset() - item.start];
- anchor = [item.key, this.getScrollOffset() - item.start];
+ anchor = [];
+ for (let i = item.index; i < prevCount && anchor.length < 100; i++) {
+ const candidate = measurements[i];
+ if (!candidate) break;
+ anchor.push([candidate.key, this.getScrollOffset() - candidate.start]);
+ }
}
- const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null;
+ const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null;
if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) {
followOnAppend = behavior;
}
@@ -723,17 +723,20 @@ class Virtualizer {
@@ -314,30 +323,31 @@ class Virtualizer {
}
let anchorResolved = false;
let anchorDelta = 0;
- if (anchor && this.scrollOffset !== null) {
- const [anchorKey, anchorOffset] = anchor;
+ let resolvedAnchor = null;
+ if (anchor && anchor.length > 0 && this.scrollOffset !== null) {
const newMeasurements = this.getMeasurements();
const { count, getItemKey } = this.options;
- let idx = 0;
- while (idx < count && getItemKey(idx) !== anchorKey) {
- idx++;
- }
- if (idx < count) {
+ const indexByKey = new Map();
+ for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i);
+ for (const [anchorKey, anchorOffset] of anchor) {
+ const idx = indexByKey.get(anchorKey);
+ if (idx === void 0) continue;
const anchorItem = newMeasurements[idx];
- if (anchorItem) {
- const newOffset = Math.max(0, anchorItem.start + anchorOffset);
- if (newOffset !== this.scrollOffset) {
- anchorDelta = newOffset - this.scrollOffset;
- this.scrollOffset = newOffset;
- anchorResolved = true;
- }
+ if (!anchorItem) continue;
+ resolvedAnchor = [anchorKey, anchorOffset];
+ const newOffset = Math.max(0, anchorItem.start + anchorOffset);
+ if (newOffset !== this.scrollOffset) {
+ anchorDelta = newOffset - this.scrollOffset;
+ this.scrollOffset = newOffset;
+ anchorResolved = true;
}
+ break;
}
}
if (anchorResolved || followOnAppend) {
this.pendingScrollAnchor = [
- anchorResolved ? anchor[0] : null,
- anchorResolved ? anchor[1] : 0,
+ anchorResolved ? resolvedAnchor[0] : null,
+ anchorResolved ? resolvedAnchor[1] : 0,
followOnAppend,
anchorDelta
];
@@ -723,17 +733,20 @@ class Virtualizer {
this.getMeasurements(),
this.getSize(),
this.getScrollOffset(),
@@ -117,9 +241,18 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39
// Pass the typed array so binary search + forward-walk can read
// start/end directly from Float64Array, skipping the Proxy traps.
diff --git a/src/index.ts b/src/index.ts
index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed4cf9a00f 100644
index dc6f1010c4d4758de9c46fb8d69209e582e47171..5d8bf755e285e4d0688d5e41ed768c60e4267131 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -567,7 +567,7 @@ export class Virtualizer<
const prevOptions = this.options as
| Required<VirtualizerOptions<TScrollElement, TItemElement>>
| undefined
- let anchor: [Key, number] | null = null
+ let anchor: Array<[Key, number]> | null = null
let followOnAppend: ScrollBehavior | null = null
let edgeKeysChanged = false
@@ -575,7 +575,6 @@ export class Virtualizer<
prevOptions !== undefined &&
prevOptions.enabled &&
@@ -128,7 +261,28 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed
this.scrollElement !== null
) {
const prevCount = prevOptions.count
@@ -611,9 +610,11 @@ export class Virtualizer<
@@ -600,6 +599,10 @@ export class Virtualizer<
if (didEdgeKeysChange) {
edgeKeysChanged = true
+ // A data change can legitimately re-key the rows around the current offset
+ // (e.g. a truncated leading chat turn regrouping once a prepended page loads
+ // its parent). Capture fallback anchors below the primary one so the scroll
+ // position survives even when the nearest keys disappear.
const item =
prevCount > 0
? (this.getVirtualItemForOffset(this.getScrollOffset()) ??
@@ -607,13 +610,20 @@ export class Virtualizer<
: null
if (item) {
- anchor = [item.key, this.getScrollOffset() - item.start]
+ anchor = []
+ for (let i = item.index; i < prevCount && anchor.length < 100; i++) {
+ const candidate = measurements[i]
+ if (!candidate) break
+ anchor.push([candidate.key, this.getScrollOffset() - candidate.start])
+ }
}
const behavior =
@@ -143,7 +297,64 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed
if (
behavior &&
@@ -1410,16 +1411,25 @@ export class Virtualizer<
@@ -646,35 +656,36 @@ export class Virtualizer<
// frame, producing a visible "jump" on prepend with dynamic sizes.
let anchorResolved = false
let anchorDelta = 0
- if (anchor && this.scrollOffset !== null) {
- const [anchorKey, anchorOffset] = anchor
+ let resolvedAnchor: [Key, number] | null = null
+ if (anchor && anchor.length > 0 && this.scrollOffset !== null) {
const newMeasurements = this.getMeasurements()
const { count, getItemKey } = this.options
- let idx = 0
- while (idx < count && getItemKey(idx) !== anchorKey) {
- idx++
- }
- if (idx < count) {
+ const indexByKey = new Map<Key, number>()
+ for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i)
+ for (const [anchorKey, anchorOffset] of anchor) {
+ const idx = indexByKey.get(anchorKey)
+ if (idx === undefined) continue
const anchorItem = newMeasurements[idx]
- if (anchorItem) {
- // Clamp to the reachable range's lower bound — anchorOffset may
- // have been derived from a transiently negative scrollOffset
- // (rubber-band), and a negative tracked offset never self-heals
- // when the element cannot scroll (#1229).
- const newOffset = Math.max(0, anchorItem.start + anchorOffset)
- if (newOffset !== this.scrollOffset) {
- anchorDelta = newOffset - this.scrollOffset
- this.scrollOffset = newOffset
- anchorResolved = true
- }
+ if (!anchorItem) continue
+ resolvedAnchor = [anchorKey, anchorOffset]
+ // Clamp to the reachable range's lower bound — anchorOffset may
+ // have been derived from a transiently negative scrollOffset
+ // (rubber-band), and a negative tracked offset never self-heals
+ // when the element cannot scroll (#1229).
+ const newOffset = Math.max(0, anchorItem.start + anchorOffset)
+ if (newOffset !== this.scrollOffset) {
+ anchorDelta = newOffset - this.scrollOffset
+ this.scrollOffset = newOffset
+ anchorResolved = true
}
+ break
}
}
if (anchorResolved || followOnAppend) {
this.pendingScrollAnchor = [
- anchorResolved ? anchor![0] : null,
- anchorResolved ? anchor![1] : 0,
+ anchorResolved ? resolvedAnchor![0] : null,
+ anchorResolved ? resolvedAnchor![1] : 0,
followOnAppend,
anchorDelta,
]
@@ -1410,16 +1421,25 @@ export class Virtualizer<
this.getSize(),
this.getScrollOffset(),
this.options.lanes,