Compare commits

..
8 changed files with 167 additions and 108 deletions
@@ -0,0 +1,63 @@
---
name: rtl-aware-development
description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars.
---
# RTL-Aware Development
Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content.
## Guidelines
- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL.
- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout.
- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement.
```css
/* Avoid */
padding-left: 12px;
right: 0;
border-right: 1px solid;
text-align: left;
/* Prefer */
padding-inline-start: 12px;
inset-inline-end: 0;
border-inline-end: 1px solid;
text-align: start;
```
- Isolate mixed-direction text. Use `dir="auto"` or `<bdi>` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR.
```html
<span class="file-row"><bdi dir="auto">README.md</bdi></span> <bdi dir="ltr"><code>C:\src\app.ts</code></bdi>
```
- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly.
- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern.
- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper.
- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`.
- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints.
## Test Matrix
- English + LTR
- English + forced RTL
- A real RTL locale + RTL
- Mixed RTL/LTR content, long labels, numbers, code, and paths
- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions
## References
- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/)
- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/)
- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/)
- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir)
- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/)
- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values)
- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir)
- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft)
- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/)
- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar)
- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/)
- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/)
+1 -17
View File
@@ -636,30 +636,14 @@ const layer = Layer.effect(
yield* Effect.gen(function* () {
ctx.currentText = undefined
ctx.reasoningMap = {}
let generated = false
yield* status.set(ctx.sessionID, { type: "busy" })
const stream = llm.stream(streamInput)
yield* stream.pipe(
Stream.tap((event) => {
if (
(event.type === "text-delta" && event.text.length > 0) ||
(event.type === "reasoning-delta" && event.text.length > 0) ||
event.type === "tool-input-start" ||
event.type === "tool-call"
) {
generated = true
}
return handleEvent(event)
}),
Stream.tap((event) => handleEvent(event)),
Stream.takeUntil(() => ctx.needsCompaction),
Stream.runDrain,
)
if (ctx.assistantMessage.finish === "unknown" && !generated) {
yield* new SessionRetry.EmptyResponseError({
message: "The model returned an empty response with an unknown finish reason",
})
}
}).pipe(
Effect.onInterrupt(() =>
Effect.gen(function* () {
+2 -7
View File
@@ -1,6 +1,6 @@
import type { NamedError } from "@opencode-ai/core/util/error"
import { SessionV1 } from "@opencode-ai/core/v1/session"
import { Cause, Clock, Duration, Effect, Schedule, Schema } from "effect"
import { Cause, Clock, Duration, Effect, Schedule } from "effect"
import { MessageV2 } from "./message-v2"
import { iife } from "@/util/iife"
import { isRecord } from "@/util/record"
@@ -23,10 +23,6 @@ export type Retryable = {
}
}
export class EmptyResponseError extends Schema.TaggedErrorClass<EmptyResponseError>()("SessionEmptyResponseError", {
message: Schema.String,
}) {}
export const RETRY_INITIAL_DELAY = 2000
export const RETRY_BACKOFF_FACTOR = 2
export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds
@@ -185,8 +181,7 @@ export function policy(opts: {
return Schedule.fromStepWithMetadata(
Effect.succeed((meta: Schedule.InputMetadata<unknown>) => {
const error = opts.parse(meta.input)
const retry =
meta.input instanceof EmptyResponseError ? { message: meta.input.message } : retryable(error, opts.provider)
const retry = retryable(error, opts.provider)
if (!retry) return Cause.done(meta.attempt)
return Effect.gen(function* () {
const wait = delay(meta.attempt, SessionV1.APIError.isInstance(error) ? error : undefined)
@@ -81,11 +81,11 @@ describe("opencode run (non-interactive subprocess)", () => {
30_000,
)
// The test provider's SSE error item is interpreted by the SDK as an empty
// response with an unknown finish. That attempt should retry while preserving
// output from the preceding tool-call step.
// The test provider's SSE error item is interpreted by the SDK as an unknown
// finish, not a fatal provider/session error. Lock that distinction in so it
// is not accidentally used as the failure compatibility oracle.
cliIt.concurrent(
"empty unknown stream finish retries and preserves partial output",
"unknown stream finish preserves partial output and exits 0",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
@@ -95,10 +95,9 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
)
yield* llm.fail("upstream provider exploded mid-stream")
yield* llm.text("recovered response")
const result = yield* opencode.run("trigger midstream error", { timeoutMs: 30_000 })
expect(result.exitCode).toBe(0)
expect(result.stdout).toBe("partial response\nrecovered response\n")
expect(result.stdout).toBe("partial response\n")
expect(result.stderr).not.toContain("upstream provider exploded mid-stream")
}),
60_000,
@@ -214,7 +213,7 @@ describe("opencode run (non-interactive subprocess)", () => {
)
cliIt.concurrent(
"--format json records an empty unknown stream retry",
"--format json records partial output for an unknown stream finish",
({ llm, opencode }) =>
Effect.gen(function* () {
yield* llm.push(
@@ -224,7 +223,6 @@ describe("opencode run (non-interactive subprocess)", () => {
}),
)
yield* llm.fail("provider failed")
yield* llm.text("recovered json")
const result = yield* opencode.run("fail after output", { format: "json" })
const events = opencode.parseJsonEvents(result.stdout)
@@ -236,13 +234,9 @@ describe("opencode run (non-interactive subprocess)", () => {
"step_finish",
"step_start",
"step_finish",
"step_start",
"text",
"step_finish",
])
expect(events[1]?.part).toEqual(expect.objectContaining({ type: "text", text: "partial json" }))
expect(events.at(-2)?.part).toEqual(expect.objectContaining({ type: "text", text: "recovered json" }))
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "stop" }))
expect(events.at(-1)?.part).toEqual(expect.objectContaining({ type: "step-finish", reason: "unknown" }))
}),
60_000,
)
@@ -604,68 +604,6 @@ it.live("session.processor effect tests retry recognized structured json errors"
),
)
it.live("session.processor effect tests retry empty responses with unknown finish reasons", () =>
provideTmpdirServer(
({ dir, llm }) =>
Effect.gen(function* () {
const { processors, session, provider } = yield* boot()
yield* llm.push(
raw({
chunks: [
{
id: "chatcmpl-test",
object: "chat.completion.chunk",
choices: [{ delta: { role: "assistant" }, finish_reason: null }],
},
{
id: "chatcmpl-test",
object: "chat.completion.chunk",
choices: [{ delta: {}, finish_reason: "unknown_reason" }],
},
],
}),
reply().text("after").stop(),
)
const chat = yield* session.create({})
const parent = yield* user(chat.id, "retry empty")
const msg = yield* assistant(chat.id, parent.id, path.resolve(dir))
const mdl = yield* provider.getModel(ref.providerID, ref.modelID)
const handle = yield* processors.create({
assistantMessage: msg,
sessionID: chat.id,
model: mdl,
})
const value = yield* handle.process({
user: {
id: parent.id,
sessionID: chat.id,
role: "user",
time: parent.time,
agent: parent.agent,
model: { providerID: ref.providerID, modelID: ref.modelID },
} satisfies SessionV1.User,
sessionID: chat.id,
model: mdl,
agent: agent(),
system: [],
messages: [{ role: "user", content: "retry empty" }],
tools: {},
})
const parts = yield* MessageV2.parts(msg.id)
expect(value).toBe("continue")
expect(yield* llm.calls).toBe(2)
expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true)
expect(handle.message.error).toBeUndefined()
}),
{ config: (url) => providerCfg(url) },
),
)
it.live("session.processor effect tests publish retry status updates", () =>
provideTmpdirServer(
({ dir, llm }) =>
@@ -61,6 +61,7 @@ import { TextShimmer } from "@opencode-ai/ui/text-shimmer"
import { AnimatedCountList } from "./tool-count-summary"
import { ToolStatusTitle } from "./tool-status-title"
import { patchFiles } from "./apply-patch-file"
import { partDefaultOpen } from "./part-default-open"
import { animate } from "motion"
import { attached, inline, kind, typeLabel } from "./message-file"
import { readPartText } from "./message-part-text"
@@ -718,15 +719,7 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
return !!PART_MAPPING[part.type]
}
function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash" || tool === "shell") return shell
if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
}
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
if (part.type !== "tool") return
return toolDefaultOpen(part.tool, shell, edit)
}
export { partDefaultOpen } from "./part-default-open"
export function AssistantParts(props: {
messages: AssistantMessage[]
@@ -0,0 +1,66 @@
import { describe, expect, test } from "bun:test"
import type { Part as PartType } from "@opencode-ai/sdk/v2"
import { partDefaultOpen } from "./part-default-open"
describe("partDefaultOpen", () => {
test("keeps edited files expanded when enabled", () => {
expect(partDefaultOpen(tool("edit", { filediff: { additions: 1, deletions: 1 } }), false, true)).toBe(true)
})
test("collapses deletion-only edits when enabled", () => {
expect(partDefaultOpen(tool("edit", { filediff: { additions: 0, deletions: 1_200 } }), false, true)).toBe(false)
})
test("collapses patches containing only deleted files when enabled", () => {
expect(
partDefaultOpen(
tool("apply_patch", {
files: [
{ filePath: "one.ts", type: "delete" },
{ filePath: "two.ts", type: "delete" },
],
}),
false,
true,
),
).toBe(false)
})
test("keeps mixed patches expanded when enabled", () => {
expect(
partDefaultOpen(
tool("apply_patch", {
files: [
{ filePath: "one.ts", type: "delete" },
{ filePath: "two.ts", type: "update" },
],
}),
false,
true,
),
).toBe(true)
})
test("preserves shell defaults", () => {
expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true)
})
})
function tool(name: string, metadata: Record<string, unknown>): PartType {
return {
id: `part_${name}`,
sessionID: "session",
messageID: "message",
type: "tool",
callID: `call_${name}`,
tool: name,
state: {
status: "completed",
input: {},
output: "",
title: name,
metadata,
time: { start: 0, end: 1 },
},
}
}
@@ -0,0 +1,26 @@
import type { Part as PartType, ToolPart } from "@opencode-ai/sdk/v2"
function deletionOnly(part: ToolPart) {
if (!("metadata" in part.state)) return false
const metadata = part.state.metadata
if (!metadata) return false
const files = metadata.files
if (Array.isArray(files) && files.length > 0) {
return files.every((file) => !!file && typeof file === "object" && "type" in file && file.type === "delete")
}
const filediff = metadata.filediff
if (!filediff || typeof filediff !== "object") return false
if (!("additions" in filediff) || !("deletions" in filediff)) return false
return filediff.additions === 0 && typeof filediff.deletions === "number" && filediff.deletions > 0
}
export function partDefaultOpen(part: PartType, shell = false, edit = false) {
if (part.type !== "tool") return
if (part.tool === "bash" || part.tool === "shell") return shell
if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") {
if (!edit) return false
return !deletionOnly(part)
}
}