mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-18 14:56:27 +00:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5c2eaade2c | ||
|
|
db80806651 |
@@ -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={() => {
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user