mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-23 14:05:04 -04:00
Compare commits
20 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 68e3888bc3 | |||
| 27a53969d6 | |||
| 1e3d3fcaca | |||
| 7420903859 | |||
| 853d3534e5 | |||
| 6cd3da1d4c | |||
| 9692bcbc21 | |||
| 79d5359d11 | |||
| b9ca77c5ff | |||
| 7a72e51710 | |||
| 0fda7d1b33 | |||
| 6ba707d305 | |||
| d144c81d17 | |||
| b8fb894ec7 | |||
| b7167aaab0 | |||
| ad59ccc2e2 | |||
| a82318019f | |||
| e9d58abd99 | |||
| 1ce7e90d3e | |||
| 92658e4389 |
@@ -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
@@ -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="
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +61,11 @@ const OpenResponsesOutputText = Schema.Struct({
|
||||
export const MessagePhase = Schema.NullOr(Schema.Literals(["commentary", "final_answer"]))
|
||||
type MessagePhase = Schema.Schema.Type<typeof MessagePhase>
|
||||
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
const OpenResponsesReasoningSummaryText = Schema.Struct({
|
||||
type: Schema.tag("summary_text"),
|
||||
text: Schema.String,
|
||||
@@ -242,6 +247,7 @@ const OpenResponsesErrorPayload = Schema.Struct({
|
||||
message: optionalNull(Schema.String),
|
||||
param: optionalNull(Schema.String),
|
||||
})
|
||||
type OpenResponsesErrorPayload = Schema.Schema.Type<typeof OpenResponsesErrorPayload>
|
||||
|
||||
const WebSocketErrorHeader = Schema.Union([Schema.String, Schema.Number, Schema.Boolean])
|
||||
export const WebSocketErrorEvent = Schema.StructWithRest(
|
||||
@@ -306,20 +312,6 @@ export const Event = Schema.StructWithRest(
|
||||
)
|
||||
export type Event = Schema.Schema.Type<typeof Event>
|
||||
|
||||
const RefusalEvent = Schema.Union([
|
||||
Schema.Struct({
|
||||
type: Schema.tag("response.refusal.delta"),
|
||||
item_id: Schema.String,
|
||||
delta: Schema.String,
|
||||
}),
|
||||
Schema.Struct({
|
||||
type: Schema.tag("response.refusal.done"),
|
||||
item_id: Schema.String,
|
||||
refusal: Schema.String,
|
||||
}),
|
||||
])
|
||||
const isRefusalEvent = Schema.is(RefusalEvent)
|
||||
|
||||
export interface Extension {
|
||||
readonly id: string
|
||||
readonly name: string
|
||||
@@ -340,7 +332,6 @@ export interface ParserState {
|
||||
readonly hasFunctionCall: boolean
|
||||
readonly lifecycle: Lifecycle.State
|
||||
readonly messageItems: ReadonlySet<string>
|
||||
readonly messagePhase: (value: unknown) => MessagePhase | null | undefined
|
||||
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>
|
||||
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>
|
||||
readonly store: boolean | undefined
|
||||
@@ -418,10 +409,6 @@ const lowerReasoning = (part: ReasoningPart, providerMetadataKey: string): OpenR
|
||||
}
|
||||
}
|
||||
|
||||
const hostedToolItemID = (part: ToolResultPart, providerMetadataKey: string) => {
|
||||
return itemID(part.providerMetadata, providerMetadataKey)
|
||||
}
|
||||
|
||||
const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (
|
||||
part: MediaPart,
|
||||
request: LLMRequest,
|
||||
@@ -592,9 +579,9 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
}
|
||||
if (part.type === "tool-result" && part.providerExecuted === true) {
|
||||
flushText()
|
||||
const itemID = hostedToolItemID(part, providerMetadataKey)
|
||||
if (store !== false && itemID && !hostedToolReferences.has(itemID))
|
||||
input.push({ type: "item_reference", id: itemID })
|
||||
const id = itemID(part.providerMetadata, providerMetadataKey)
|
||||
if (store !== false && id && !hostedToolReferences.has(id))
|
||||
input.push({ type: "item_reference", id })
|
||||
if (store === false && part.result.type === "content") {
|
||||
const content: ReadonlyArray<Content> = part.result.value
|
||||
input.push({
|
||||
@@ -604,7 +591,7 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
|
||||
),
|
||||
})
|
||||
}
|
||||
if (itemID) hostedToolReferences.add(itemID)
|
||||
if (id) hostedToolReferences.add(id)
|
||||
continue
|
||||
}
|
||||
return yield* ProviderShared.unsupportedContent(extension.name, "assistant", [
|
||||
@@ -816,18 +803,17 @@ const reasoningMetadata = (state: ParserState, item: StreamItem & { id: string }
|
||||
// best-effort, not guaranteed.
|
||||
const onOutputItemAdded = (state: ParserState, event: Event): StepResult => {
|
||||
const item = event.item
|
||||
if (item?.type === "message" && item.id)
|
||||
if (item?.type === "message" && item.id) {
|
||||
const phase = messagePhase(item.phase)
|
||||
return [
|
||||
{
|
||||
...state,
|
||||
messageItems: new Set([...state.messageItems, item.id]),
|
||||
messagePhases: (() => {
|
||||
const phase = state.messagePhase(item.phase)
|
||||
return phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase }
|
||||
})(),
|
||||
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
|
||||
},
|
||||
NO_EVENTS,
|
||||
]
|
||||
}
|
||||
if (item && isReasoningItem(item)) {
|
||||
const events: LLMEvent[] = []
|
||||
return [
|
||||
@@ -985,7 +971,7 @@ const onOutputItemDone = Effect.fn("OpenResponses.onOutputItemDone")(function* (
|
||||
if (!item) return [state, NO_EVENTS] satisfies StepResult
|
||||
|
||||
if (item.type === "message" && item.id) {
|
||||
const itemPhase = state.messagePhase(item.phase)
|
||||
const itemPhase = messagePhase(item.phase)
|
||||
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase
|
||||
const events: LLMEvent[] = []
|
||||
const messageItems = new Set(state.messageItems)
|
||||
@@ -1093,22 +1079,26 @@ const onResponseFinish = Effect.fn("OpenResponses.onResponseFinish")(function* (
|
||||
return [{ ...state, lifecycle, hasFunctionCall, tools: pending.tools }, events] satisfies StepResult
|
||||
})
|
||||
|
||||
// Build a single human-readable message from whatever the provider supplied.
|
||||
// Build the prettiest summary available from whatever the provider supplied.
|
||||
// When both code and message are present, prefix the code so consumers see
|
||||
// the failure mode (e.g. `rate_limit_exceeded: Slow down`) instead of just
|
||||
// the bare message — production rate limits and context-length failures used
|
||||
// to be indistinguishable from generic stream drops.
|
||||
const providerErrorMessage = (event: Event, fallback: string): string => {
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
// to be indistinguishable from generic stream drops. Returns undefined when
|
||||
// the payload carries no usable summary.
|
||||
const providerErrorMessage = (event: Event, nested: OpenResponsesErrorPayload | undefined): string | undefined => {
|
||||
const message = event.message || nested?.message || undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
if (message && code) return `${code}: ${message}`
|
||||
return message || code || fallback
|
||||
return message || code
|
||||
}
|
||||
|
||||
export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
const code = event.code || event.error?.code || event.response?.error?.code || undefined
|
||||
const message = providerErrorMessage(event, fallback)
|
||||
const nested = event.error ?? event.response?.error ?? undefined
|
||||
const code = event.code || nested?.code || undefined
|
||||
// Keep the full raw payload on the error even when the message is a summary.
|
||||
const body = JSON.stringify(nested ?? event) ?? ""
|
||||
const summary = providerErrorMessage(event, nested)
|
||||
const message = summary ?? (body === "{}" ? fallback : body)
|
||||
const status =
|
||||
typeof event.status === "number"
|
||||
? event.status
|
||||
@@ -1118,7 +1108,8 @@ export const providerFailure = (id: string, event: Event, fallback: string) => {
|
||||
return new AIError({
|
||||
module: id,
|
||||
method: "stream",
|
||||
reason: classifyProviderFailure({ message, code, status }),
|
||||
body,
|
||||
reason: classifyProviderFailure({ message, code, status, rawBody: body }),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1134,21 +1125,18 @@ export const step = (state: ParserState, event: Event) => {
|
||||
)
|
||||
}
|
||||
if (event.type === "response.refusal.delta" || event.type === "response.refusal.done") {
|
||||
if (!isRefusalEvent(event)) return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
const value = event.type === "response.refusal.delta" ? event.delta : event.refusal
|
||||
if (!event.item_id || typeof value !== "string") return ProviderShared.eventError(state.id, `${event.type} is malformed`)
|
||||
return Effect.succeed(
|
||||
event.type === "response.refusal.delta"
|
||||
? onOutputTextDelta(state, event, event.item_id)
|
||||
: onOutputTextDone(state, { ...event, text: event.refusal }, event.item_id),
|
||||
: onOutputTextDone(state, { ...event, text: value }, event.item_id),
|
||||
)
|
||||
}
|
||||
if (event.type === "response.reasoning.delta" || event.type === "response.reasoning_summary_text.delta") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDelta(state, event, event.item_id))
|
||||
}
|
||||
if (event.type === "response.reasoning.done" || event.type === "response.reasoning_summary_text.done") {
|
||||
if (!event.item_id) return ProviderShared.eventError(state.id, `${event.type} is missing item_id`)
|
||||
return Effect.succeed(onReasoningDone(state, event))
|
||||
}
|
||||
if (event.type === "response.reasoning_summary_part.added")
|
||||
return event.item_id
|
||||
? Effect.succeed(onReasoningSummaryPartAdded(state, event))
|
||||
@@ -1193,17 +1181,11 @@ export const initial = (request: LLMRequest, extension: Extension = BASE): Parse
|
||||
tools: ToolStream.empty<string>(),
|
||||
lifecycle: Lifecycle.initial(),
|
||||
messageItems: new Set<string>(),
|
||||
messagePhase,
|
||||
messagePhases: {},
|
||||
reasoningItems: {},
|
||||
store: OpenResponsesOptions.resolve(request).store,
|
||||
})
|
||||
|
||||
const messagePhase = (value: unknown): MessagePhase | undefined => {
|
||||
if (value === null || value === "commentary" || value === "final_answer") return value
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const protocol = Protocol.make({
|
||||
id: ADAPTER,
|
||||
body: {
|
||||
|
||||
@@ -80,6 +80,9 @@ export interface ProviderFailure {
|
||||
readonly message: string
|
||||
readonly status?: number | undefined
|
||||
readonly code?: string | undefined
|
||||
// Raw wire payload, scanned for failure signals (codes, overflow phrases)
|
||||
// that the summary message does not carry. Not shown to users.
|
||||
readonly rawBody?: string | undefined
|
||||
readonly retryAfterMs?: number | undefined
|
||||
readonly rateLimit?: HttpRateLimitDetails | undefined
|
||||
readonly http?: HttpContext | undefined
|
||||
@@ -89,11 +92,13 @@ export interface ProviderFailure {
|
||||
// Keep HTTP failures and provider-reported stream failures on one typed path so
|
||||
// session retry policy never needs provider-specific string matching.
|
||||
export function classifyProviderFailure(input: ProviderFailure): AIError["reason"] {
|
||||
const body = input.http?.body ?? ""
|
||||
const body = input.http?.body ?? input.rawBody ?? ""
|
||||
const codes = [input.code, ...providerCodes(body), ...providerCodes(input.message)]
|
||||
.filter((code): code is string => code !== undefined)
|
||||
.map((code) => code.toLowerCase())
|
||||
const text = body || input.message
|
||||
// Scan the raw payload too so signals missing from the summary message
|
||||
// (e.g. overflow phrases nested in a JSON error body) still classify.
|
||||
const text = [input.message, body].filter((value) => value.length > 0).join("\n")
|
||||
const common = { message: input.message, providerMetadata: input.providerMetadata, http: input.http }
|
||||
const clientScoped = input.status === undefined || (input.status >= 400 && input.status < 500)
|
||||
|
||||
|
||||
@@ -153,6 +153,9 @@ export class AIError extends Schema.TaggedError<AIError>()("AI.Error", {
|
||||
module: Schema.String,
|
||||
method: Schema.String,
|
||||
reason: AIErrorReason,
|
||||
// Raw provider payload as a string, so classified failures never lose the
|
||||
// original error detail even when the pretty message is a summary.
|
||||
body: Schema.optional(Schema.String),
|
||||
}) {
|
||||
override readonly cause = this.reason
|
||||
|
||||
|
||||
@@ -106,3 +106,20 @@ describe("provider error classification", () => {
|
||||
expect(classifyProviderFailure({ message: "not-json" })._tag).toBe("UnknownProvider")
|
||||
})
|
||||
})
|
||||
|
||||
describe("provider error rawBody classification", () => {
|
||||
test("classifies overflow signals buried in the raw payload when the summary is vague", () => {
|
||||
const reason = classifyProviderFailure({
|
||||
message: "Request failed",
|
||||
rawBody: '{"error":{"message":"This model\'s maximum context length is 40960 tokens"}}',
|
||||
})
|
||||
expect(reason._tag).toBe("InvalidRequest")
|
||||
expect(reason).toMatchObject({ classification: "context-overflow" })
|
||||
})
|
||||
|
||||
test("extracts nested codes from the raw payload", () => {
|
||||
expect(
|
||||
classifyProviderFailure({ message: "Request failed", rawBody: '{"error":{"code":"insufficient_quota"}}' })._tag,
|
||||
).toBe("QuotaExceeded")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3016,36 +3016,42 @@ describe("OpenAI Responses route", () => {
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when error is null", () =>
|
||||
it.effect("falls back to the raw payload when error is null", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error", error: null }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"error":null')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when both error and response are absent", () =>
|
||||
it.effect("falls back to the raw payload when both error and response are absent", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "error" }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses stream error" })
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"type":"error"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
}),
|
||||
)
|
||||
|
||||
it.effect("falls back to a stable default when response.failed has no error payload", () =>
|
||||
it.effect("keeps the raw response payload when response.failed has no error payload", () =>
|
||||
Effect.gen(function* () {
|
||||
const error = yield* LLMClient.generate(request).pipe(
|
||||
Effect.provide(fixedResponse(sseEvents({ type: "response.failed", response: { id: "resp_failed_3" } }))),
|
||||
Effect.flip,
|
||||
)
|
||||
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider", message: "OpenAI Responses response failed" })
|
||||
expect(error.reason).toMatchObject({ _tag: "UnknownProvider" })
|
||||
expect(error.reason.message).toContain('"resp_failed_3"')
|
||||
expect(error.body).toBe(error.reason.message)
|
||||
}),
|
||||
)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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" }])
|
||||
})
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,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 })
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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")}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
}}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -25,6 +25,7 @@ export default defineConfig({
|
||||
desktopPlugin,
|
||||
VitePWA({
|
||||
strategies: "generateSW",
|
||||
injectRegister: false,
|
||||
manifest: false,
|
||||
workbox: {
|
||||
cleanupOutdatedCaches: true,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"] }),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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]),
|
||||
)
|
||||
|
||||
@@ -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] ?? []
|
||||
|
||||
@@ -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()) {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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,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":
|
||||
|
||||
@@ -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,7 +2,6 @@ import path from "path"
|
||||
|
||||
export function projectName(project?: { canonical: string; name?: string }, fallback = "") {
|
||||
const canonical = project?.canonical ?? fallback
|
||||
const paths = /^(?:[a-z]:[\\/]|\\\\)/i.test(canonical) ? path.win32 : path.posix
|
||||
if (paths.parse(canonical).root === canonical) return fallback ? paths.basename(fallback) : undefined
|
||||
return project?.name || paths.basename(canonical)
|
||||
if (canonical === "/") return fallback ? path.basename(fallback) : undefined
|
||||
return project?.name || path.basename(canonical)
|
||||
}
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
import { projectName } from "../../src/util/project"
|
||||
|
||||
describe("projectName", () => {
|
||||
test("falls back to the folder name for filesystem-root projects", () => {
|
||||
expect(projectName({ canonical: "/" }, "/home/user/Prabha")).toBe("Prabha")
|
||||
expect(projectName({ canonical: "C:\\" }, "C:\\Users\\user\\Desktop\\Prabha")).toBe("Prabha")
|
||||
})
|
||||
|
||||
test("preserves backslashes in POSIX folder names", () => {
|
||||
expect(projectName({ canonical: "/tmp/foo\\bar" })).toBe("foo\\bar")
|
||||
})
|
||||
})
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) */
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user