Compare commits

..
Author SHA1 Message Date
LukeParkerDev 5c2eaade2c perf(desktop): remember the bundled CLI version between launches
Every launch spawned the bundled 200 MB CLI executable for --version and
waited ~380 ms for it before the background service, WSL support and,
because windows are created after every layer builds, the window itself.
The version only changes when an update replaces the executable, so keep
it in the settings store keyed by the executable's path, size and mtime
and only spawn when that identity changes.

Packaged build, warm service, 5 launches (median ms since spawn):
renderer process 851 -> 512, window visible 1085 -> 778,
shell visible 1289 -> 961.
2026-09-18 22:41:37 +10:00
Shoubhit Dash db80806651 refactor(core): move native compaction mechanisms into a plugin (#49575) 2026-09-18 17:15:44 +05:30
21 changed files with 202 additions and 478 deletions
+1
View File
@@ -184,6 +184,7 @@ const table = sqliteTable("session", {
- Keep `SessionRunner`, model resolution, tool registry, permissions, and filesystem Location-scoped. Omitted `Location.workspaceID` means implicit-local placement; explicit workspace identity remains reserved for future placement semantics.
- Preserve one explicit `llm.stream(request)` call per Physical Attempt and reload projected history before durable continuation. A logical Step may use generic pre-output retries, one full-context retry after continuation rejection, incomplete-stream continuation, or one overflow-compaction rebuild. Generic retries retain the logical step number and do not consume another agent-step allowance. Do not delegate orchestration to an in-memory tool loop.
- Keep local Session drains process-local until clustering is implemented. `SessionRunCoordinator` joins explicit same-Session resumes, coalesces prompt wakeups, and allows different Sessions to run concurrently. A write-ahead execution claim marks a process-local busy period for restart recovery: terminal completion, failure, or user interruption releases it, while shutdown interruption and process death preserve it. Startup recovery resumes claimed top-level Sessions with durable per-execution attempt accounting. The claim is a recovery marker, not clustered ownership, fencing, or an exactly-once guarantee.
- Keep native compaction mechanisms out of `SessionCompaction`. Plugins register `native` strategies through the `SessionCompaction` editor that turn a prepared request into a replacement window (the built-in `NativeCompactionPlugin` handles `@opencode/ai` compaction operations); later registrations win. Core owns the provider-mode decision, route provenance, the retry policy, overflow recovery, interruption, usage accounting, and checkpoint persistence.
- Keep delivery vocabulary explicit. Prompts steer by default. At safe step boundaries, steered compaction takes priority up to the first steered move control; other steers retain enqueue order. At an idle boundary, steers take priority; otherwise exactly one queued item delivers before the runner reevaluates continuation. Inbox items may be cancelled or changed between queue and steer before delivery. Promoting new user input resets the selected agent's step allowance; a batch of steers resets it once.
- One step is one logical LLM call; its durable record covers only the model-visible span. Do not write "provider turn", and do not use bare "turn" for a single call: "turn" is reserved for the future assistant-turn unit containing all steps from prompt promotion until the session would go idle.
- Keep event replay ownership separate from clustered Session execution ownership.
@@ -409,7 +409,6 @@ export function SessionFileView(props: SessionFileViewProps) {
}}
enableLineSelection
enableGutterUtility
textSelectionAction={{ label: language.t("ui.lineComment.add") }}
selectedLines={activeSelection()}
commentedLines={commentedLines()}
onRendered={() => {
+29
View File
@@ -0,0 +1,29 @@
export * as NativeCompactionPlugin from "./compaction.js"
import { LLMClient, Message } from "@opencode/ai"
import { define } from "@opencode/plugin/effect/plugin"
import { Effect } from "effect"
import { SessionCompaction } from "../session/compaction.js"
import type { PluginInternal } from "./internal.js"
export const Plugin = define({
id: "opencode.compaction.native",
effect: Effect.fn("NativeCompactionPlugin")(function* () {
const llm = yield* LLMClient.Service
const compaction = yield* SessionCompaction.Service
yield* compaction.transform((editor) => {
editor.native((input) => {
const request = input.request
if (LLMClient.canCompact(request, { mechanism: "trigger" }))
return Effect.gen(function* () {
const retained = yield* input.retained
const result = yield* llm.compact(request, { ...input.options, mechanism: "trigger" })
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
})
if (LLMClient.canCompact(request))
return llm.compact(request, { mechanism: "endpoint", http: input.options.http })
return undefined
})
})
}),
} satisfies PluginInternal.InternalPlugin)
+6
View File
@@ -1,5 +1,6 @@
export * as PluginInternal from "./internal.js"
import { LLMClient } from "@opencode/ai"
import type { Plugin } from "@opencode/plugin/effect/plugin"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { httpClient } from "@opencode/util/effect/app-node-platform"
@@ -12,6 +13,7 @@ import { Provider } from "../provider.js"
import { Command } from "../command.js"
import { Config } from "../config.js"
import { Credential } from "../credential.js"
import { llmClient } from "../effect/app-node-platform.js"
import { ConfigAgentPlugin } from "../config/plugin/agent.js"
import { ConfigCommandPlugin } from "../config/plugin/command.js"
import { ConfigCompactionPlugin } from "../config/plugin/compaction.js"
@@ -84,6 +86,7 @@ import { WriteTool } from "../tool/plugin/write.js"
import { AgentPlugin } from "./agent.js"
import BrowserPlugin from "@opencode/plugin-browser"
import { CommandPlugin } from "./command.js"
import { NativeCompactionPlugin } from "./compaction.js"
import { IdentityPlugin } from "./identity.js"
import { PlanPlugin } from "./plan.js"
import { ModelsDevPlugin } from "./models-dev.js"
@@ -120,6 +123,7 @@ const services = [
Integration.Service,
Job.Service,
KV.Service,
LLMClient.Service,
Location.Service,
ModelsDev.Service,
Mcp.Service,
@@ -171,6 +175,7 @@ export const requirements = LayerNode.group([
Integration.node,
Job.node,
KV.node,
llmClient,
Location.node,
ModelsDev.node,
Mcp.node,
@@ -212,6 +217,7 @@ const pre = [
SkillPlugin.Plugin,
VcsHgPlugin.Plugin,
ModelsDevPlugin,
NativeCompactionPlugin.Plugin,
...ProviderPlugins,
...WebSearchPlugins,
PatchTool.Plugin,
+42 -21
View File
@@ -10,7 +10,9 @@ import {
LLMRequest,
Message,
type ContentPart,
type Usage,
} from "@opencode/ai"
import type { StreamOptions } from "@opencode/ai/route"
import type { SessionCompactionResult } from "@opencode/plugin/effect/session"
import { SessionError } from "@opencode/schema/session-error"
import { Context, Effect, Layer, Stream } from "effect"
@@ -91,8 +93,25 @@ export type Settings = {
tokens: number
}
export type NativeInput = {
readonly request: LLMRequest
readonly options: StreamOptions
/** Whole, real user messages within the retained-token allowance, for checkpoint-only mechanisms. */
readonly retained: Effect.Effect<ReadonlyArray<Message>>
}
export type NativeResult = {
readonly replacement: ReadonlyArray<Message>
readonly usage?: Usage
}
/** Returns the provider's replacement window, or `undefined` when this strategy has no mechanism for the route. */
export type NativeStrategy = (input: NativeInput) => Effect.Effect<NativeResult, AIError> | undefined
export type Editor = {
configure: (settings: Partial<Settings>) => void
/** Later registrations take precedence. */
native: (strategy: NativeStrategy) => void
}
export type AutoInput = {
@@ -380,15 +399,18 @@ export const layer = Layer.effect(
const llm = yield* LLMClient.Service
const db = (yield* Database.Service).db
const state = State.create<Settings, Editor>({
const state = State.create<Settings & { readonly native: NativeStrategy[] }, Editor>({
name: "session-compaction",
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS }),
initial: () => ({ auto: true, buffer: DEFAULT_BUFFER, tokens: DEFAULT_KEEP_TOKENS, native: [] }),
editor: (editor) => ({
configure: (settings) => {
if (settings.auto !== undefined) editor.auto = settings.auto
if (settings.buffer !== undefined) editor.buffer = settings.buffer
if (settings.tokens !== undefined) editor.tokens = settings.tokens
},
native: (strategy) => {
editor.native.push(strategy)
},
}),
})
const failed = Effect.fnUntraced(function* (input: SessionEvent.Compaction.Failed["data"]) {
@@ -504,6 +526,23 @@ export const layer = Layer.effect(
return yield* reject(
"Provider compaction requires the endpoint in provider/model settings, not a model.request rewrite",
)
const native = state
.get()
.native.toReversed()
.map((strategy) =>
strategy({
request,
options: prepared.options,
retained: original(context.session.id).pipe(
Effect.map((messages) => retainUsers(messages, context.model, state.get().tokens)),
),
}),
)
.find((effect) => effect !== undefined)
if (!native)
return yield* reject(
`No plugin provides native compaction for ${request.model.provider}/${request.model.route.id}`,
)
const transient = SessionRunnerRetry.transient(yield* SessionRunnerRetry.policy(context.session.id), {
agent: context.agent.id,
model: context.model.ref,
@@ -514,25 +553,7 @@ export const layer = Layer.effect(
Effect.gen(function* () {
// Transient provider failures retry like any other request; only a known automatic overflow permits
// local recovery, and nothing is installed until the provider returns a checkpoint.
const result = yield* restore(
Effect.gen(function* () {
if (LLMClient.canCompact(request, { mechanism: "trigger" })) {
const retained = retainUsers(yield* original(context.session.id), context.model, state.get().tokens)
const result = yield* llm
.compact(request, { ...prepared.options, mechanism: "trigger" })
.pipe(transient)
return { replacement: [...retained, Message.assistant(result.checkpoint)], usage: result.usage }
}
if (LLMClient.canCompact(request))
return yield* llm
.compact(request, { mechanism: "endpoint", http: prepared.options.http })
.pipe(transient)
// Model resolution admits provider policies only for routes with a compaction operation.
return yield* Effect.die(
new Error(`${request.model.provider}/${request.model.route.id} has no compaction operation`),
)
}),
)
const result = yield* restore(native.pipe(transient))
const usage = result.usage ? SessionUsage.record(result.usage, context.model.cost) : undefined
if (usage)
yield* bus.publish(SessionEvent.UsageRecorded, {
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test"
import { LLMClient, LanguageModel, Message, ToolDefinition } from "@opencode/ai"
import { LLMClient, LanguageModel, Message, ToolDefinition, Usage } from "@opencode/ai"
import { OpenAI } from "@opencode/ai/providers"
import { Agent } from "@opencode/core/agent"
import { Bus } from "@opencode/core/bus"
@@ -7,6 +7,7 @@ import { Database } from "@opencode/core/database/database"
import { AppNodeBuilder } from "@opencode/core/effect/app-node-builder"
import { llmClient } from "@opencode/core/effect/app-node-platform"
import { Instructions } from "@opencode/core/instructions/index"
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { Project } from "@opencode/core/project"
import { ProjectTable } from "@opencode/core/project/sql"
@@ -26,6 +27,7 @@ import { SessionStore } from "@opencode/core/session/store"
import { LayerNode } from "@opencode/util/effect/layer-node"
import { DateTime, Deferred, Effect, Fiber, Schema } from "effect"
import { testEffect } from "./lib/effect"
import { host } from "./plugin/host"
const it = testEffect(
AppNodeBuilder.build(
@@ -44,7 +46,8 @@ const it = testEffect(
),
)
const setup = Effect.fnUntraced(function* (endpoint = false) {
const setup = Effect.fnUntraced(function* (options: { endpoint?: boolean; plugin?: boolean } = {}) {
const endpoint = options.endpoint ?? false
const db = (yield* Database.Service).db
const bus = yield* Bus.Service
const inbox = yield* SessionInbox.Service
@@ -185,6 +188,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
render: { initial: String, changed: (_previous, value) => value, removed: () => "removed" },
})
yield* InstructionState.prepare(db, bus, instructions, sessionID)
if (options.plugin !== false) yield* NativeCompactionPlugin.Plugin.effect(host())
yield* hooks.register("session", "model.request", (event) =>
Effect.sync(() => {
event.headers["x-test-hook"] = event.kind
@@ -261,6 +265,7 @@ const setup = Effect.fnUntraced(function* (endpoint = false) {
store,
hooks,
model,
compaction,
}
})
@@ -348,7 +353,7 @@ it.live(
it.live("manual and automatic endpoint compaction keep the provider replacement unchanged", () =>
Effect.gen(function* () {
const fixture = yield* setup(true)
const fixture = yield* setup({ endpoint: true })
yield* fixture.prompt("Original user")
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(yield* fixture.automatic).toEqual({ status: "completed" })
@@ -445,6 +450,31 @@ it.live("rejects request-hook route rewrites before provider compaction", () =>
}),
)
it.live("provider compaction fails without a native strategy and persists a registered strategy's window", () =>
Effect.gen(function* () {
const fixture = yield* setup({ plugin: false })
yield* fixture.prompt("Original user")
expect(yield* fixture.compact).toMatchObject({
status: "failed",
error: { type: "provider.unsupported-operation", message: expect.stringContaining("openai/openai-responses") },
})
yield* fixture.compaction.transform((editor) => {
editor.native(() =>
Effect.succeed({
replacement: [Message.assistant("plugin window")],
usage: new Usage({ nonCachedInputTokens: 20, outputTokens: 4 }),
}),
)
})
expect(yield* fixture.compact).toEqual({ status: "completed" })
expect(fixture.state.calls).toBe(0)
const installed = yield* fixture.checkpoint
expect(installed.provenance).toEqual(SessionProviderContext.provenance(fixture.model)!)
expect(SessionProviderContext.decode(installed)).toEqual([Message.assistant("plugin window")])
expect(yield* fixture.store.get(fixture.sessionID)).toMatchObject({ tokens: { input: 20, output: 4 } })
}),
)
test("retained user budget counts attachments and drops whole oldest messages", () => {
const model = SessionRunnerModel.resolved(OpenAI.responses("gpt-5.4-mini"), {
capabilities: { tools: true, input: ["text", "image"], output: ["text"] },
@@ -56,6 +56,7 @@ import { Plugin } from "@opencode/core/plugin"
import { PluginHooks } from "@opencode/core/plugin/hooks"
import { OptimizePlugin } from "@opencode/core/plugin/optimize"
import { IdentityPlugin } from "@opencode/core/plugin/identity"
import { NativeCompactionPlugin } from "@opencode/core/plugin/compaction"
import { QuestionTool } from "@opencode/core/tool/plugin/question"
import { Agent } from "@opencode/core/agent"
import { Config } from "@opencode/core/config"
@@ -470,6 +471,7 @@ const layer = Layer.unwrap(
Config.node,
Snapshot.node,
SessionCompaction.node,
LayerNodePlatform.llmClient,
SessionRunnerLLM.node,
SessionExecution.node,
Session.node,
@@ -523,6 +525,7 @@ const setup = Effect.gen(function* () {
discard: true,
})
yield* IdentityPlugin.Plugin.effect(pluginHost)
yield* NativeCompactionPlugin.Plugin.effect(pluginHost)
yield* agents.transform((editor) =>
editor.update(Agent.ID.make("build"), (agent) => {
agent.mode = "primary"
@@ -3,9 +3,11 @@ export * as DesktopCli from "./desktop-cli"
import { execFile, spawn } from "node:child_process"
import { promisify } from "node:util"
import { app } from "electron"
import { Context, Effect, FileSystem, Layer, Path } from "effect"
import { Context, Effect, FileSystem, Layer, Option, Path } from "effect"
import installer from "../../../../../install?raw"
import { DesktopPaths } from "../paths"
import { BUNDLED_CLI_VERSION_KEY } from "../storage/keys"
import { getStore } from "../storage/store"
import { parseCliVersion } from "./cli-version"
const execFileAsync = promisify(execFile)
@@ -79,11 +81,36 @@ const resolveBundledCli = Effect.fn("DesktopCli.resolveBundled")(function* (isol
? path.join(process.resourcesPath, executableName())
: path.join(paths.developmentResourcesRoot, isolated ? developmentExecutableName() : executableName())
yield* Effect.logInfo("v2 CLI executable resolved", { bundled, packaged: app.isPackaged })
const version = parseCliVersion(yield* run(bundled, ["--version"]))
const version = yield* bundledVersion(bundled)
const binary = app.isPackaged || isolated ? yield* installCli(bundled, version) : bundled
return { version, binary, command: [binary] }
})
// Spawning the bundled executable for `--version` costs ~400 ms of startup on a 200 MB binary, so
// the answer is remembered per executable identity and only re-read after an update replaces it.
const bundledVersion = Effect.fn("DesktopCli.bundledVersion")(function* (bundled: string) {
const fs = yield* FileSystem.FileSystem
const stat = yield* fs.stat(bundled).pipe(Effect.orElseSucceed(() => undefined))
const identity = stat ? `${stat.size}:${Option.getOrUndefined(stat.mtime)?.getTime() ?? ""}` : undefined
const store = getStore()
const cached = store.get(BUNDLED_CLI_VERSION_KEY)
if (identity && isVersionCache(cached) && cached.path === bundled && cached.identity === identity) {
yield* Effect.logInfo("v2 CLI version reused", { version: cached.version })
return cached.version
}
const version = parseCliVersion(yield* run(bundled, ["--version"]))
if (identity) store.set(BUNDLED_CLI_VERSION_KEY, { path: bundled, identity, version } satisfies VersionCache)
return version
})
type VersionCache = { path: string; identity: string; version: string }
function isVersionCache(value: unknown): value is VersionCache {
if (!value || typeof value !== "object") return false
const cache = value as Record<string, unknown>
return typeof cache.path === "string" && typeof cache.identity === "string" && typeof cache.version === "string"
}
export const cleanStages = Effect.fn("DesktopCli.cleanStages")(function* (binary: string) {
const fs = yield* FileSystem.FileSystem
const path = yield* Path.Path
@@ -5,3 +5,4 @@ export const WSL_SERVERS_KEY = "wslServers"
export const PINCH_ZOOM_ENABLED_KEY = "pinchZoomEnabled"
export const BACKGROUND_COLOR_KEY = "backgroundColor"
export const WINDOW_IDS_KEY = "windowIds"
export const BUNDLED_CLI_VERSION_KEY = "bundledCliVersion"
@@ -1,33 +1,6 @@
import { expect, story } from "../../storybook/playwright/story"
story("renders the line comment content editor and compact actions", async ({ mount }) => {
story("renders the line comment cancel action as a ghost button", async ({ mount }) => {
const root = await mount("ui-line-comment--editor-filled")
const editor = root.getByRole("textbox")
expect(await editor.evaluate((element) => element.tagName)).toBe("TEXTAREA")
await editor.fill("Updated comment\nwith context")
await expect(editor).toHaveValue("Updated comment\nwith context")
await editor.fill("x")
await editor.press("Backspace")
await expect(editor).toHaveValue("")
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
await expect(root.locator("textarea")).toHaveCount(1)
await expect(root.locator('[data-slot="line-comment-v2-label"]')).toHaveCount(0)
await expect(root.locator('[data-slot="line-comment-v2-footer-meta"]')).toHaveCount(0)
await expect(root.locator('[data-slot="line-comment-v2-shell"]')).toHaveCSS("padding", "0px")
await expect(editor).toHaveCSS("border-top-width", "0px")
await expect(editor).toHaveCSS("background-color", "rgba(0, 0, 0, 0)")
await expect(editor).toHaveCSS("padding", "12px")
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost-muted")
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-size", "small")
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-variant", "submit")
await expect(root.getByRole("button", { name: "Comment" })).toHaveAttribute("data-size", "small")
})
story("preserves native undo in the line comment editor", async ({ mount }) => {
const root = await mount("ui-line-comment--editor")
const editor = root.getByRole("textbox")
await editor.pressSequentially("undo me")
await editor.press("Meta+z")
await expect(editor).toHaveValue("")
expect(await editor.evaluate((element) => element.matches(":placeholder-shown"))).toBe(true)
await expect(root.getByRole("button", { name: "Cancel" })).toHaveAttribute("data-variant", "ghost")
})
@@ -48,136 +48,3 @@ story("shows a comment button when a diff line is hovered", async ({ mount }) =>
await expect(review.getByRole("textbox")).toBeVisible()
await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1")
})
for (const direction of ["ltr", "rtl"]) {
story(`offers a comment action for selected review text in ${direction}`, async ({ mount, page }) => {
const root = await mount("components-session-review--interactive-comments-panel", { globals: { direction } })
const action = page.getByRole("button", { name: "Add comment", exact: true })
await expect(async () => {
await root.locator('[data-line-type="change-addition"] [data-diff-span]').selectText()
await expect(action).toBeVisible()
}).toPass()
await expect(root.getByRole("textbox")).not.toBeVisible()
await expect(action).toHaveAttribute("data-variant", "submit")
await expect(action).toHaveCSS("z-index", "110")
const box = await action.boundingBox()
const code = root.locator("[data-code]").first()
const gutterRight = await code.evaluate((element) => element.firstElementChild?.getBoundingClientRect().right)
expect((box?.x ?? 0) - (gutterRight ?? 0)).toBe(8)
expect(box?.x).toBeGreaterThanOrEqual(0)
expect((box?.x ?? 0) + (box?.width ?? 0)).toBeLessThanOrEqual(
await page.evaluate(() => document.documentElement.clientWidth),
)
await action.click()
await expect.poll(() => page.evaluate(() => window.getSelection()?.toString())).toBe("")
await expect(root.getByRole("textbox")).toBeVisible()
await expect(root.locator('[data-line="2"][data-line-type="change-addition"]')).toHaveAttribute(
"data-selected-line",
/.*/,
)
})
}
for (const direction of ["up", "down"] as const) {
story(
`positions the comment action with ${direction === "up" ? "an upward" : "a downward"} selection`,
async ({ mount, page }) => {
const root = await mount("components-session-review--interactive-comments-panel")
const action = page.getByRole("button", { name: "Add comment", exact: true })
await expect(async () => {
await root.getByText("export const first = 1", { exact: true }).evaluate((element, value) => {
const root = element.getRootNode()
if (!(root instanceof ShadowRoot)) throw new Error("Expected a shadow root")
const text = (line: number) => {
const row = root.querySelector(`[data-line="${line}"]`)
if (!row) throw new Error(`Missing line ${line}`)
const node = document.createTreeWalker(row, NodeFilter.SHOW_TEXT).nextNode()
if (!node) throw new Error(`Missing text for line ${line}`)
return node
}
const first = text(1)
const last = text(3)
const selection = window.getSelection()
if (!selection) throw new Error("Missing selection")
if (value === "up") {
selection.setBaseAndExtent(last, last.textContent?.length ?? 0, first, 0)
} else {
selection.setBaseAndExtent(first, 0, last, last.textContent?.length ?? 0)
}
document.dispatchEvent(new Event("selectionchange"))
}, direction)
await expect(action).toHaveAttribute("data-placement", direction === "up" ? "top" : "bottom")
await expect(action).toHaveClass(/transition-transform/)
await expect(action).toHaveClass(/ease-out/)
await expect(action).not.toHaveClass(/fade-in/)
await expect
.poll(() =>
action.evaluate((button) => {
const host = button.closest('[data-component="file"]')?.querySelector("diffs-container")
const root = host?.shadowRoot
if (!root) return NaN
const selection =
(root as unknown as { getSelection?: () => Selection | null }).getSelection?.() ??
window.getSelection()
const source = (
selection as unknown as {
getComposedRanges?: (options: { shadowRoots: ShadowRoot[] }) => StaticRange[]
}
)?.getComposedRanges?.({ shadowRoots: [root] })?.[0]
if (!source) return NaN
const range = new Range()
range.setStart(source.startContainer, source.startOffset)
range.setEnd(source.endContainer, source.endOffset)
const selected = range.getBoundingClientRect()
const action = button.getBoundingClientRect()
return button.getAttribute("data-placement") === "top"
? selected.top - action.bottom
: action.top - selected.bottom
}),
)
.toBeCloseTo(8, 0)
}).toPass()
},
)
}
story("leaves a review code click as regular text interaction", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments-panel")
await root.locator('[data-line-type="change-addition"] [data-diff-span]').click()
await expect(root.getByRole("textbox")).not.toBeVisible()
})
story("keeps direct line-number range comments in the review panel", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments-panel")
await root.locator('[data-column-number="1"]').dragTo(root.locator('[data-column-number="3"]'))
await expect(root.getByRole("textbox")).toBeVisible()
await expect(root.locator("[data-selected-line]")).not.toHaveCount(0)
})
story("keeps the direct gutter comment action in the review panel", async ({ mount }) => {
const root = await mount("components-session-review--interactive-comments-panel")
const comment = root.getByRole("button", { name: "Comment", exact: true, includeHidden: true })
await expect(async () => {
await root.getByText("export const first = 1", { exact: true }).hover()
await expect(comment).toBeVisible()
}).toPass()
expect(await comment.evaluate((element) => (element as HTMLElement).style.background)).toBe(
"var(--v2-background-bg-inverse)",
)
expect(await comment.evaluate((element) => (element as HTMLElement).style.left)).toBe("-4px")
await expect(comment).toHaveCSS("z-index", "110")
await expect
.poll(async () => {
const box = await comment.boundingBox()
const gutterRight = await comment.evaluate(
(element) => element.parentElement?.assignedSlot?.parentElement?.parentElement?.getBoundingClientRect().right,
)
return (box?.x ?? 0) + (box?.width ?? 0) - (gutterRight ?? 0)
})
.toBe(-4)
await comment.dispatchEvent("click")
await expect(root.getByRole("textbox")).toBeVisible()
await expect(root.locator('[data-line="1"]')).toHaveAttribute("data-selected-line", /.*/)
})
@@ -47,7 +47,6 @@ function DiffSSRViewer<T>(props: SSRDiffFileProps<T>) {
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
"textSelectionAction",
])
const getRoot = () => fileDiffRef?.shadowRoot ?? undefined
+2 -178
View File
@@ -21,12 +21,10 @@ import { type PreloadFileDiffResult, type PreloadMultiFileDiffResult } from "@pi
import { createMediaQuery } from "@solid-primitives/media"
import { makeEventListener } from "@solid-primitives/event-listener"
import { ComponentProps, createEffect, createMemo, createSignal, onCleanup, onMount, Show, splitProps } from "solid-js"
import { Button } from "@opencode/ui/button"
import { createDefaultOptions, styleVariables } from "../pierre"
import { markCommentedDiffLines, markCommentedFileLines } from "../pierre/commented-lines"
import { fixDiffSelection, findDiffSide, type DiffSelectionSide } from "../pierre/diff-selection"
import { createFileFind } from "../pierre/file-find"
import { LINE_COMMENT_ACTION_GAP } from "../pierre/comment-hover"
import {
applyViewerScheme,
clearReadyWatcher,
@@ -50,8 +48,6 @@ import { FileMedia, type FileMediaOptions } from "./file-media"
import { FileSearchBar } from "./file-search"
const VIRTUALIZE_BYTES = 500_000
const TEXT_SELECTION_ACTION_HEIGHT = 24
const TEXT_SELECTION_ACTION_GAP = 8
const codeMetrics = {
...DEFAULT_VIRTUAL_FILE_METRICS,
@@ -69,9 +65,6 @@ type SharedProps<T> = {
classList?: ComponentProps<"div">["classList"]
media?: FileMediaOptions
search?: FileSearchControl
textSelectionAction?: {
label: string
}
}
export type FileSearchHandle = {
@@ -130,7 +123,6 @@ const sharedKeys = [
"onLineNumberSelectionEnd",
"onRendered",
"preloadedDiff",
"textSelectionAction",
] as const
const textKeys = ["file", ...sharedKeys] as const
@@ -148,7 +140,6 @@ type MouseHit = {
type ViewerConfig = {
enableLineSelection: () => boolean
textSelectionAction: () => { label: string } | undefined
selectedLines: () => SelectedLineRange | null | undefined
commentedLines: () => SelectedLineRange[]
onLineSelectionEnd: (range: SelectedLineRange | null) => void
@@ -157,14 +148,6 @@ type ViewerConfig = {
lineFromMouseEvent: (event: MouseEvent) => MouseHit
setSelectedLines: (range: SelectedLineRange | null, preserve?: { root: ShadowRoot; text: Range }) => void
updateSelection: (preserveTextSelection: boolean) => void
readTextSelection: () =>
| {
range: SelectedLineRange
text: Range
direction: "up" | "down" | "same"
gutterRight?: number
}
| undefined
buildDragSelection: () => SelectedLineRange | undefined
buildClickSelection: () => SelectedLineRange | undefined
onDragStart: (hit: MouseHit) => void
@@ -179,7 +162,6 @@ function useFileViewer(config: ViewerConfig) {
let overlay!: HTMLDivElement
let selectionFrame: number | undefined
let dragFrame: number | undefined
let textSelectionFrame: number | undefined
let dragStart: number | undefined
let dragEnd: number | undefined
let dragMoved = false
@@ -189,14 +171,6 @@ function useFileViewer(config: ViewerConfig) {
const ready = createReadyWatcher()
const bridge = createLineNumberSelectionBridge()
const [rendered, setRendered] = createSignal(0)
const [textSelection, setTextSelection] = createSignal<{
range: SelectedLineRange
rect: DOMRect
label: string
below: boolean
gutterEdge: number
}>()
const hasTextSelection = createMemo(() => textSelection() !== undefined)
const getRoot = () => getViewerRoot(container)
const getHost = () => getViewerHost(container)
@@ -230,55 +204,6 @@ function useFileViewer(config: ViewerConfig) {
})
}
const updateTextSelection = () => {
textSelectionFrame = undefined
const action = config.textSelectionAction()
if (!action) {
setTextSelection(undefined)
return
}
const selected = config.readTextSelection()
if (!selected) {
setTextSelection(undefined)
return
}
const rect = selected.text.getBoundingClientRect()
if (rect.width === 0 && rect.height === 0) {
setTextSelection(undefined)
return
}
const roomBelow = rect.bottom + TEXT_SELECTION_ACTION_HEIGHT + TEXT_SELECTION_ACTION_GAP <= window.innerHeight
const roomAbove = rect.top - TEXT_SELECTION_ACTION_HEIGHT - TEXT_SELECTION_ACTION_GAP >= 0
const preferBelow = selected.direction !== "up"
const below = preferBelow ? roomBelow || !roomAbove : !roomAbove && roomBelow
const gutterEdge =
(selected.gutterRight ?? wrapper.getBoundingClientRect().left) - wrapper.getBoundingClientRect().left
setTextSelection({ range: selected.range, rect, label: action.label, below, gutterEdge })
}
const scheduleTextSelectionUpdate = () => {
if (textSelectionFrame !== undefined) return
textSelectionFrame = requestAnimationFrame(updateTextSelection)
}
const clearTextSelection = () => {
setTextSelection(undefined)
const root = getRoot()
const selection =
(root as unknown as { getSelection?: () => Selection | null } | undefined)?.getSelection?.() ??
window.getSelection()
selection?.removeAllRanges()
}
const activateTextSelection = () => {
const selected = textSelection()
if (!selected) return
clearTextSelection()
config.setSelectedLines(selected.range)
config.onLineSelectionEnd(selected.range)
}
// -- mouse handlers --
const handleMouseDown = (event: MouseEvent) => {
@@ -293,11 +218,6 @@ function useFileViewer(config: ViewerConfig) {
if (hit.line === undefined) return
bridge.begin(false, hit.line)
if (config.textSelectionAction()) {
setTextSelection(undefined)
if (lastSelection) config.setSelectedLines(null)
return
}
dragStart = hit.line
dragEnd = hit.line
dragMoved = false
@@ -330,10 +250,6 @@ function useFileViewer(config: ViewerConfig) {
const handleMouseUp = () => {
if (!config.enableLineSelection()) return
if (bridge.finish() === "numbers") return
if (config.textSelectionAction()) {
scheduleTextSelectionUpdate()
return
}
if (dragStart === undefined) return
if (!dragMoved) {
@@ -368,10 +284,6 @@ function useFileViewer(config: ViewerConfig) {
const handleSelectionChange = () => {
if (!config.enableLineSelection()) return
if (config.textSelectionAction()) {
scheduleTextSelectionUpdate()
return
}
if (dragStart === undefined) return
const selection = window.getSelection()
if (!selection || selection.isCollapsed) return
@@ -414,9 +326,7 @@ function useFileViewer(config: ViewerConfig) {
})
createEffect(() => {
const selected = config.selectedLines() ?? null
if (selected && config.textSelectionAction()) clearTextSelection()
config.setSelectedLines(selected)
config.setSelectedLines(config.selectedLines() ?? null)
})
createEffect(() => {
@@ -428,26 +338,14 @@ function useFileViewer(config: ViewerConfig) {
makeEventListener(document, "selectionchange", handleSelectionChange)
})
createEffect(() => {
if (!config.enableLineSelection() || !config.textSelectionAction() || !hasTextSelection()) return
makeEventListener(document, "scroll", scheduleTextSelectionUpdate, true)
makeEventListener(window, "resize", scheduleTextSelectionUpdate)
makeEventListener(document, "keydown", (event) => {
if (event.key !== "Escape") return
clearTextSelection()
})
})
onCleanup(() => {
clearReadyWatcher(ready)
if (selectionFrame !== undefined) cancelAnimationFrame(selectionFrame)
if (dragFrame !== undefined) cancelAnimationFrame(dragFrame)
if (textSelectionFrame !== undefined) cancelAnimationFrame(textSelectionFrame)
selectionFrame = undefined
dragFrame = undefined
textSelectionFrame = undefined
dragStart = undefined
dragEnd = undefined
dragMoved = false
@@ -495,21 +393,15 @@ function useFileViewer(config: ViewerConfig) {
getHost,
find,
scheduleSelectionUpdate,
textSelection,
activateTextSelection,
}
}
type Viewer = ReturnType<typeof useFileViewer>
type ModeAdapter = Omit<
ViewerConfig,
"enableLineSelection" | "textSelectionAction" | "selectedLines" | "commentedLines" | "onLineSelectionEnd"
>
type ModeAdapter = Omit<ViewerConfig, "enableLineSelection" | "selectedLines" | "commentedLines" | "onLineSelectionEnd">
type ModeConfig = {
enableLineSelection: () => boolean
textSelectionAction: () => { label: string } | undefined
selectedLines: () => SelectedLineRange | null | undefined
commentedLines: () => SelectedLineRange[] | undefined
onLineSelectionEnd: (range: SelectedLineRange | null) => void
@@ -532,7 +424,6 @@ type VirtualStrategy = {
function useModeViewer(config: ModeConfig, adapter: ModeAdapter) {
return useFileViewer({
enableLineSelection: config.enableLineSelection,
textSelectionAction: config.textSelectionAction,
selectedLines: config.selectedLines,
commentedLines: () => config.commentedLines() ?? [],
onLineSelectionEnd: config.onLineSelectionEnd,
@@ -837,40 +728,6 @@ function ViewerShell(props: {
</Show>
<div ref={(el) => (props.viewer.container = el)} />
<div ref={(el) => (props.viewer.overlay = el)} class="pointer-events-none absolute inset-0 z-0" />
<Show when={props.viewer.textSelection()}>
{(selection) => (
<Button
data-slot="file-text-selection-action"
data-placement={selection().below ? "bottom" : "top"}
size="small"
variant="submit"
class="z-[110] whitespace-nowrap motion-safe:transition-transform duration-100 ease-out motion-reduce:transition-none"
style={{
position: "absolute",
"--line-comment-gutter-edge": `${selection().gutterEdge}px`,
left: `calc(var(--line-comment-gutter-edge) + ${LINE_COMMENT_ACTION_GAP}px)`,
top: `${
(selection().below ? selection().rect.bottom : selection().rect.top) -
props.viewer.wrapper.getBoundingClientRect().top
}px`,
transform: selection().below
? `translateY(${TEXT_SELECTION_ACTION_GAP}px)`
: `translateY(calc(-100% - ${TEXT_SELECTION_ACTION_GAP}px))`,
}}
onPointerDown={(event: PointerEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onMouseDown={(event: MouseEvent) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={props.viewer.activateTextSelection}
>
{selection().label}
</Button>
)}
</Show>
</div>
)
}
@@ -988,23 +845,6 @@ function TextViewer<T>(props: TextFileProps<T>) {
if (!preserveTextSelection || !selected.text) return
restoreShadowTextSelection(root, selected.text)
},
readTextSelection: () => {
const root = viewer.getRoot()
if (!root) return
const selected = readShadowLineSelection({
root,
lineForNode: findFileLineNumber,
sideForNode: findCodeSelectionSide,
preserveTextSelection: true,
})
if (!selected?.text) return
return {
range: selected.range,
text: selected.text,
direction: selected.direction,
gutterRight: selected.gutterRight,
}
},
buildDragSelection: () => {
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
return { start: Math.min(viewer.dragStart, viewer.dragEnd), end: Math.max(viewer.dragStart, viewer.dragEnd) }
@@ -1022,7 +862,6 @@ function TextViewer<T>(props: TextFileProps<T>) {
viewer = useModeViewer(
{
enableLineSelection: () => props.enableLineSelection === true,
textSelectionAction: () => local.textSelectionAction,
selectedLines: () => local.selectedLines,
commentedLines: () => local.commentedLines,
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
@@ -1169,20 +1008,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
setSelectedLines(selected.range)
},
readTextSelection: () => {
const root = viewer.getRoot()
if (!root) return
const selected = readShadowLineSelection({
root,
lineForNode: findDiffLineNumber,
sideForNode: diffSelectionSide,
preserveTextSelection: true,
})
if (!selected?.text) return
const range = fixDiffSelection(root, selected.range)
if (!range) return
return { range, text: selected.text, direction: selected.direction, gutterRight: selected.gutterRight }
},
buildDragSelection: () => {
if (viewer.dragStart === undefined || viewer.dragEnd === undefined) return
const selected: SelectedLineRange = { start: viewer.dragStart, end: viewer.dragEnd }
@@ -1213,7 +1038,6 @@ function DiffViewer<T>(props: DiffFileProps<T>) {
viewer = useModeViewer(
{
enableLineSelection: () => props.enableLineSelection === true,
textSelectionAction: () => local.textSelectionAction,
selectedLines: () => local.selectedLines,
commentedLines: () => local.commentedLines,
onLineSelectionEnd: (range) => local.onLineSelectionEnd?.(range),
@@ -4,7 +4,6 @@ import { CurrentSessionProviders } from "../storybook/current-session-story"
import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures"
import { File } from "./file"
import { SessionReview, type SessionReviewComment } from "./session-review"
import { SessionReviewFilePreviewV2 } from "../v2/components/session-review-file-preview-v2"
function ReviewStory(props: { split?: boolean }) {
return (
@@ -81,36 +80,6 @@ function InteractiveCommentsStory() {
export const InteractiveComments = { render: () => <InteractiveCommentsStory /> }
function InteractiveCommentsV2Story() {
const [state, setState] = createStore({ comments: [] as SessionReviewComment[] })
const file = "src/review.ts"
const diff = {
file,
additions: 1,
deletions: 1,
status: "modified" as const,
patch:
"diff --git a/src/review.ts b/src/review.ts\n--- a/src/review.ts\n+++ b/src/review.ts\n@@ -1,3 +1,3 @@\n export const first = 1\n-export const value = 'before'\n+export const value = 'after'\n export const last = 3\n",
}
return (
<CurrentSessionProviders document={editThenTestDocument}>
<div class="mx-auto h-screen min-h-[620px] w-full max-w-[900px] overflow-auto bg-background-base">
<SessionReviewFilePreviewV2
file={file}
diff={diff}
diffStyle="unified"
comments={state.comments}
onLineComment={(comment) =>
setState("comments", (comments) => [...comments, { id: `comment-${comments.length + 1}`, ...comment }])
}
/>
</div>
</CurrentSessionProviders>
)
}
export const InteractiveCommentsPanel = { render: () => <InteractiveCommentsV2Story /> }
const gitDiffs = [
{
// OpenCode 93e1f383dd79683af4fc5ad139cea0516603c838, unchanged git-show output.
@@ -3,9 +3,6 @@ export type HoverCommentLine = {
side?: "additions" | "deletions"
}
export const LINE_COMMENT_ACTION_GAP = 8
const LINE_COMMENT_ACTION_SIZE = 20
export function createHoverCommentUtility(props: {
label: string
getHoveredLine: () => HoverCommentLine | undefined
@@ -17,22 +14,21 @@ export function createHoverCommentUtility(props: {
button.type = "button"
button.ariaLabel = props.label
button.textContent = "+"
button.style.width = `${LINE_COMMENT_ACTION_SIZE}px`
button.style.height = `${LINE_COMMENT_ACTION_SIZE}px`
button.style.width = "20px"
button.style.height = "20px"
button.style.display = "flex"
button.style.alignItems = "center"
button.style.justifyContent = "center"
button.style.border = "none"
button.style.borderRadius = "var(--radius-md)"
button.style.background = "var(--v2-background-bg-inverse)"
button.style.color = "var(--v2-icon-icon-inverse)"
button.style.background = "var(--icon-interactive-base)"
button.style.color = "var(--white)"
button.style.boxShadow = "var(--shadow-xs)"
button.style.fontSize = "14px"
button.style.lineHeight = "1"
button.style.cursor = "pointer"
button.style.position = "relative"
button.style.zIndex = "110"
button.style.left = "-4px"
button.style.left = "30px"
button.style.top = "calc((var(--diffs-line-height, 24px) - 20px) / 2)"
let line: HoverCommentLine | undefined
@@ -63,29 +59,6 @@ export function createHoverCommentUtility(props: {
props.onSelect(next)
}
const startLineSelection = (event: PointerEvent) => {
const number = button.parentElement?.assignedSlot?.parentElement?.parentElement
if (!(number instanceof HTMLElement)) return
number.dispatchEvent(
new PointerEvent("pointerdown", {
bubbles: true,
cancelable: true,
composed: true,
pointerId: event.pointerId,
pointerType: event.pointerType,
isPrimary: event.isPrimary,
button: event.button,
buttons: event.buttons,
clientX: event.clientX,
clientY: event.clientY,
ctrlKey: event.ctrlKey,
metaKey: event.metaKey,
shiftKey: event.shiftKey,
altKey: event.altKey,
}),
)
}
document.addEventListener("pointermove", onHoverInvalidated, { passive: true })
document.addEventListener("scroll", onHoverInvalidated, { passive: true, capture: true })
button.addEventListener("mouseenter", sync)
@@ -94,7 +67,6 @@ export function createHoverCommentUtility(props: {
event.preventDefault()
event.stopPropagation()
sync()
startLineSelection(event)
})
button.addEventListener("mousedown", (event) => {
event.preventDefault()
@@ -73,19 +73,6 @@ export function readShadowLineSelection(opts: {
const startSide = opts.sideForNode?.(startNode)
const endSide = opts.sideForNode?.(endNode)
const side = startSide ?? endSide
const anchorTop = findElement(selection.anchorNode)
?.closest("[data-line], [data-alt-line]")
?.getBoundingClientRect().top
const focusElement = findElement(selection.focusNode)
const focusTop = focusElement?.closest("[data-line], [data-alt-line]")?.getBoundingClientRect().top
const code = focusElement?.closest("[data-code]")
const gutterRight = code?.firstElementChild?.getBoundingClientRect().right
const direction =
anchorTop === undefined || focusTop === undefined || anchorTop === focusTop
? ("same" as const)
: focusTop < anchorTop
? ("up" as const)
: ("down" as const)
const range: SelectedLineRange = { start, end }
if (side) range.side = side
@@ -94,7 +81,5 @@ export function readShadowLineSelection(opts: {
return {
range,
text: opts.preserveTextSelection && domRange ? toRange(domRange).cloneRange() : undefined,
direction,
gutterRight,
}
}
-6
View File
@@ -130,12 +130,6 @@ const unsafeCSS = `
color: var(--diffs-selection-number-fg);
}
[data-gutter-utility-slot] {
left: unset;
right: 0;
justify-content: flex-end;
}
[data-diff] [data-column-number][data-line-type='context'][data-selected-line],
[data-diff] [data-column-number][data-line-type='context-expanded'][data-selected-line],
[data-diff] [data-column-number][data-line-type='change-addition'][data-selected-line],
@@ -228,7 +228,6 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
hunkSeparators={view().fileDiff.isPartial ? "simple" : "line-info-basic"}
enableLineSelection={lineCommentsEnabled()}
enableGutterUtility={lineCommentsEnabled()}
textSelectionAction={lineCommentsEnabled() ? { label: i18n.t("ui.lineComment.add") } : undefined}
onLineSelected={(range: SelectedLineRange | null) => {
if (!lineCommentsEnabled()) return
commentsUi.onLineSelected(range)
@@ -117,61 +117,85 @@
display: flex;
flex-direction: column;
align-items: flex-start;
padding: 0;
gap: 0;
padding: 12px;
gap: 12px;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-field"] {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0;
gap: 8px;
width: 100%;
min-width: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-label"] {
display: flex;
flex-direction: row;
align-items: center;
width: 100%;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: var(--line-height-compact);
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
user-select: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"] {
display: block;
width: 100%;
min-width: 0;
height: 80px;
padding: 12px;
min-height: 80px;
padding: 8px;
margin: 0;
resize: none;
border: 0;
background: transparent;
resize: vertical;
border: 1px solid var(--v2-border-border-base);
border-radius: 6px;
background:
linear-gradient(180deg, var(--v2-alpha-light-2) 0%, var(--v2-alpha-light-0) 100%), var(--v2-background-bg-base);
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: var(--line-height-compact);
line-height: 1.35;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
font-variation-settings: "slnt" 0;
scrollbar-width: none;
outline: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::-webkit-scrollbar {
display: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]::placeholder {
color: var(--v2-text-text-faint);
user-select: none;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-textarea"]:focus {
border-color: var(--v2-border-border-focus);
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer"] {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
gap: 4px;
padding-block: 0 12px;
padding-inline: 12px;
width: 100%;
min-width: 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-meta"] {
flex: 1 1 auto;
min-width: 0;
font-size: 11px;
font-style: normal;
font-weight: 530;
line-height: 1;
letter-spacing: 0.05px;
color: var(--v2-text-text-faint);
font-variation-settings: "slnt" 0;
}
[data-component="line-comment-v2"][data-variant="editor"] [data-slot="line-comment-v2-footer-actions"] {
display: flex;
flex-direction: row;
@@ -61,7 +61,7 @@ export type LineCommentEditorMention = {
}
export interface LineCommentEditorProps extends Omit<ComponentProps<"div">, "children" | "onInput" | "onSubmit"> {
/** Accessible editor label (default: “Comment”). */
/** Visible field label above the textarea (default: “Comment”). */
heading?: JSX.Element | string
value: string
onInput: (value: string) => void
@@ -108,6 +108,7 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
"classList",
])
const heading = () => local.heading ?? i18n.t("ui.lineComment.submit")
const canSubmit = () => local.value.trim().length > 0
const closeMention = () => {
@@ -205,19 +206,19 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
>
<div data-slot="line-comment-v2-shell">
<div data-slot="line-comment-v2-field">
<div data-slot="line-comment-v2-label">{heading()}</div>
<textarea
ref={(el) => {
textareaRef = el
}}
data-slot="line-comment-v2-textarea"
aria-label={typeof local.heading === "string" ? local.heading : i18n.t("ui.lineComment.submit")}
dir="auto"
rows={local.rows ?? 3}
placeholder={local.placeholder ?? i18n.t("ui.lineComment.contextPlaceholder")}
value={local.value}
style={{ "unicode-bidi": "plaintext", "text-align": "start" }}
onInput={(event) => {
local.onInput(event.currentTarget.value)
onInput={(e) => {
local.onInput(e.currentTarget.value)
syncMention()
}}
onClick={() => syncMention()}
@@ -291,11 +292,12 @@ export function LineCommentEditor(props: LineCommentEditorProps) {
</Show>
</div>
<div data-slot="line-comment-v2-footer">
<div data-slot="line-comment-v2-footer-meta">{local.selection}</div>
<div data-slot="line-comment-v2-footer-actions">
<Button type="button" size="small" variant="ghost-muted" onClick={() => local.onCancel()}>
<Button type="button" size="normal" variant="ghost" onClick={() => local.onCancel()}>
{local.cancelLabel ?? i18n.t("ui.lineComment.cancel")}
</Button>
<Button type="button" size="small" variant="submit" disabled={!canSubmit()} onClick={submit}>
<Button type="button" size="normal" variant="contrast" disabled={!canSubmit()} onClick={submit}>
{local.submitLabel ?? i18n.t("ui.lineComment.submit")}
</Button>
</div>
-1
View File
@@ -51,7 +51,6 @@ const source = {
"ui.lineComment.editorLabel.prefix": "Commenting on ",
"ui.lineComment.editorLabel.suffix": "",
"ui.lineComment.placeholder": "Add comment",
"ui.lineComment.add": "Add comment",
"ui.lineComment.contextPlaceholder": "Add context for this change",
"ui.lineComment.submit": "Comment",
"ui.lineComment.cancel": "Cancel",