chore: merge dev into v2 (#39290)

Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <219766164+opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aarav Sareen <96787824+arvsrn@users.noreply.github.com>
Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com>
Co-authored-by: opencode-agent[bot] <opencode-agent[bot]@users.noreply.github.com>
Co-authored-by: Aiden Cline <rekram1-node@users.noreply.github.com>
Co-authored-by: opencode <opencode@sst.dev>
Co-authored-by: Jay <53023+jayair@users.noreply.github.com>
Co-authored-by: David Hill <1879069+iamdavidhill@users.noreply.github.com>
Co-authored-by: BB84 <110078428+BB-84C@users.noreply.github.com>
Co-authored-by: Aiden Cline <aidenpcline@gmail.com>
Co-authored-by: Dax <mail@thdxr.com>
Co-authored-by: Brendan Allan <git@brendonovich.dev>
Co-authored-by: Jay V <air@live.ca>
Co-authored-by: Adam <2363879+adamdotdevin@users.noreply.github.com>
Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com>
Co-authored-by: Dustin Deus <deusdustin@gmail.com>
Co-authored-by: Frank <frank@anoma.ly>
Co-authored-by: usrnk1 <7547651+usrnk1@users.noreply.github.com>
Co-authored-by: Jack <jack@anoma.ly>
Co-authored-by: Sebastian <hasta84@gmail.com>
Co-authored-by: Jérôme Benoit <jerome.benoit@sap.com>
Co-authored-by: Test User <test@test.com>
Co-authored-by: Simon Klee <hello@simonklee.dk>
Co-authored-by: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com>
Co-authored-by: Qiping Li <liqiping1991@gmail.com>
Co-authored-by: liqiping <liqiping@msh.team>
Co-authored-by: OpeOginni <107570612+OpeOginni@users.noreply.github.com>
Co-authored-by: Matthias Reso <13337103+mreso@users.noreply.github.com>
Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com>
Co-authored-by: Daniel Polito <danielbpolito@gmail.com>
Co-authored-by: opencode <noreply@opencode.ai>
Co-authored-by: Devin R Leopold <devin.leopold@gmail.com>
Co-authored-by: Zach Bruggeman <mail@bruggie.com>
Co-authored-by: Zach Bruggeman <zbruggeman@ramp.com>
Co-authored-by: Kit Langton <kit.langton@gmail.com>
Co-authored-by: David Siewert <david1gruppenplan@gmail.com>
Co-authored-by: Andrei Dziahel <develop7@develop7.info>
Co-authored-by: adityachaudhary99 <adityaachaudhary2003@gmail.com>
Co-authored-by: Vladimir Glafirov <vglafirov@gitlab.com>
Co-authored-by: Matt Carey <mcarey@cloudflare.com>
This commit is contained in:
opencode-agent[bot] 2026-07-28 18:36:55 +10:00 committed by GitHub
commit 302e9b45ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
263 changed files with 12516 additions and 4822 deletions

View file

@ -21,7 +21,7 @@ describe("message-file", () => {
expect(attached(file())).toBe(false)
})
test("treats only non-attachment source ranges as inline references", () => {
test("keeps data-backed file mentions inline", () => {
expect(
inline(
file({
@ -34,18 +34,16 @@ describe("message-file", () => {
),
).toBe(true)
expect(
inline(
file({
url: "data:text/plain;base64,SGVsbG8=",
source: {
type: "file",
path: "/repo/README.txt",
text: { value: "@README.txt", start: 0, end: 11 },
},
}),
),
).toBe(false)
const mentioned = file({
url: "data:text/plain;base64,SGVsbG8=",
source: {
type: "file",
path: "/repo/README.txt",
text: { value: "@README.txt", start: 0, end: 11 },
},
})
expect(inline(mentioned)).toBe(true)
expect(attached(mentioned)).toBe(false)
})
test("separates image and file attachment kinds", () => {

View file

@ -3,11 +3,10 @@ import { getFilename } from "@opencode-ai/core/util/path"
import type { FilePart } from "@opencode-ai/sdk/v2"
export function attached(part: FilePart) {
return part.url.startsWith("data:")
return part.url.startsWith("data:") && !inline(part)
}
export function inline(part: FilePart) {
if (attached(part)) return false
return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined
}

View file

@ -598,6 +598,27 @@
}
}
[data-component="todos"] {
padding: 10px 0 24px 0;
display: flex;
flex-direction: column;
gap: 8px;
[data-component="checkbox"] {
--checkbox-align: flex-start;
--checkbox-offset: 0.5px;
}
[data-slot="message-part-todo-content"] {
line-height: var(--line-height-normal);
&[data-completed="completed"] {
text-decoration: line-through;
color: var(--v2-text-text-faint);
}
}
}
[data-component="context-tool-group-trigger"] {
width: 100%;
min-height: 24px;
@ -681,6 +702,7 @@
[data-component="bash-output"],
[data-component="edit-content"],
[data-component="write-content"],
[data-component="todos"],
[data-component="diagnostics"],
.error-card {
-webkit-user-select: text;
@ -1438,6 +1460,10 @@ body:not([data-new-layout]) {
color: var(--text-interactive-base);
}
[data-slot="message-part-todo-content"][data-completed="completed"] {
color: var(--text-weaker);
}
[data-component="diagnostics"] {
background-color: var(--surface-critical-weak);
border-top: 1px solid var(--border-critical-base);

View file

@ -27,6 +27,7 @@ import {
TextPart,
ToolPart,
UserMessage,
Todo,
QuestionAnswer,
QuestionInfo,
} from "@opencode-ai/sdk/v2"
@ -41,6 +42,7 @@ import { Collapsible } from "@opencode-ai/ui/collapsible"
import { FileIcon } from "@opencode-ai/ui/file-icon"
import { Icon } from "@opencode-ai/ui/icon"
import { ToolErrorCard } from "./tool-error-card"
import { Checkbox } from "@opencode-ai/ui/checkbox"
import { DiffChanges } from "@opencode-ai/ui/diff-changes"
import { Markdown } from "./markdown"
import { ImagePreview } from "@opencode-ai/ui/image-preview"
@ -519,6 +521,7 @@ export function getToolInfo(
}
}
case "bash":
case "shell":
return {
icon: "console",
title: i18n.t("ui.tool.shell"),
@ -545,6 +548,11 @@ export function getToolInfo(
? `${input.files.length} ${i18n.t(input.files.length > 1 ? "ui.common.file.other" : "ui.common.file.one")}`
: undefined,
}
case "todowrite":
return {
icon: "checklist",
title: i18n.t("ui.tool.todos"),
}
case "question":
return {
icon: "bubble-5",
@ -608,6 +616,8 @@ function taskSession(
}
const CONTEXT_GROUP_TOOLS = new Set(["read", "glob", "grep", "list"])
const HIDDEN_TOOLS = new Set(["todowrite"])
function list<T>(value: T[] | undefined | null, fallback: T[]) {
if (Array.isArray(value)) return value
return fallback
@ -711,6 +721,7 @@ function index<T extends { id: string }>(items: readonly T[]) {
export function renderable(part: PartType, showReasoningSummaries = true) {
if (part.type === "tool") {
if (HIDDEN_TOOLS.has(part.tool)) return false
if (part.tool === "question") return part.state.status !== "pending" && part.state.status !== "running"
return true
}
@ -720,7 +731,7 @@ export function renderable(part: PartType, showReasoningSummaries = true) {
}
function toolDefaultOpen(tool: string, shell = false, edit = false) {
if (tool === "bash") return shell
if (tool === "bash" || tool === "shell") return shell
if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit
}
@ -1081,11 +1092,7 @@ export function ContextToolGroup(props: {
<div data-component="context-tool-group-trigger">
<span
data-slot="context-tool-group-title"
class="min-w-0 flex items-center gap-2 text-14-medium"
classList={{
"text-text-strong": pending(),
"text-text-weak": !pending(),
}}
class="min-w-0 flex items-center gap-2 text-14-medium text-text-strong"
>
<span data-slot="context-tool-group-label" class="shrink-0">
<ToolStatusTitle
@ -1097,11 +1104,7 @@ export function ContextToolGroup(props: {
</span>
<span
data-slot="context-tool-group-summary"
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal"
classList={{
"text-text-base": pending(),
"text-text-weak": !pending(),
}}
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-normal text-text-base"
>
<AnimatedCountList
items={[
@ -1505,7 +1508,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) {
}
export function getTool(name: string) {
return state[name === "apply_patch" ? "patch" : name]?.render
return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render
}
export const ToolRegistry = {
@ -1553,6 +1556,8 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) {
const data = useData()
const i18n = useI18n()
const part = () => props.part as ToolPart
if (part().tool === "todowrite") return null
const hideQuestion = createMemo(
() => part().tool === "question" && (part().state.status === "pending" || part().state.status === "running"),
)
@ -2098,7 +2103,7 @@ ToolRegistry.register({
})
ToolRegistry.register({
name: "bash",
name: "shell",
render(props) {
const i18n = useI18n()
const pending = () => props.status === "pending" || props.status === "running"
@ -2537,6 +2542,57 @@ ToolRegistry.register({
},
})
ToolRegistry.register({
name: "todowrite",
render(props) {
const i18n = useI18n()
const todos = createMemo(() => {
const meta = props.metadata?.todos
if (Array.isArray(meta)) return meta
const input = props.input.todos
if (Array.isArray(input)) return input
return []
})
const subtitle = createMemo(() => {
const list = todos()
if (list.length === 0) return ""
return `${list.filter((t: Todo) => t.status === "completed").length}/${list.length}`
})
return (
<BasicTool
{...props}
defaultOpen
icon="checklist"
trigger={{
title: i18n.t("ui.tool.todos"),
subtitle: subtitle(),
}}
>
<Show when={todos().length}>
<div data-component="todos">
<For each={todos()}>
{(todo: Todo) => (
<Checkbox readOnly checked={todo.status === "completed"}>
<span
data-slot="message-part-todo-content"
data-completed={todo.status === "completed" ? "completed" : undefined}
>
{todo.content}
</span>
</Checkbox>
)}
</For>
</div>
</Show>
</BasicTool>
)
},
})
ToolRegistry.register({
name: "question",
render(props) {

View file

@ -1,6 +1,7 @@
import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs"
import { parsePatch } from "diff"
import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
type LegacyDiff = {
file: string
@ -12,7 +13,8 @@ type LegacyDiff = {
status?: "added" | "deleted" | "modified"
}
type ReviewDiff = (SnapshotFileDiff | VcsFileDiff | LegacyDiff) & { file: string }
type SnapshotDiff = SnapshotFileDiff & { file: string }
type ReviewDiff = SnapshotDiff | FileDiffInfo | VcsFileDiff | LegacyDiff
export type DiffSource = Pick<LegacyDiff, "file" | "patch" | "before" | "after">
export type ViewDiff = {

View file

@ -16,6 +16,7 @@ import { checksum } from "@opencode-ai/core/util/encode"
import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js"
import { createStore } from "solid-js/store"
import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
import { type SelectedLineRange } from "@pierre/diffs"
import { Dynamic } from "solid-js/web"
@ -62,14 +63,14 @@ export type SessionReviewCommentActions = {
export type SessionReviewFocus = { file: string; id: string }
type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & {
file: string
type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & {
preloaded?: PreloadMultiFileDiffResult<any>
}
type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & {
file: string
type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & {
preloaded?: PreloadMultiFileDiffResult<any>
}
type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult<any> }
function diff(value: unknown): value is ReviewDiff {
if (!value || typeof value !== "object" || Array.isArray(value)) return false
if (!("file" in value) || typeof value.file !== "string") return false
@ -185,7 +186,7 @@ export const SessionReview = (props: SessionReviewProps) => {
const itemsMap = createMemo(() =>
Object.fromEntries(list(props.diffs).map((diff) => [diff.file, { ...normalize(diff), preloaded: diff.preloaded }])),
)
const files = createMemo(() => props.diffs.map((diff) => diff.file))
const files = createMemo(() => props.diffs.map((diff) => diff.file!))
const grouped = createMemo(() => {
const next = new Map<string, SessionReviewComment[]>()
for (const comment of props.comments ?? []) {

View file

@ -3,8 +3,8 @@ import {
type SnapshotFileDiff,
Message as MessageType,
Part as PartType,
type UserMessage,
} from "@opencode-ai/sdk/v2/client"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import type { SessionStatus } from "@opencode-ai/sdk/v2"
import { useData } from "../context"
import { useFileComponent } from "@opencode-ai/ui/context/file"
@ -91,21 +91,17 @@ function list<T>(value: T[] | undefined | null, fallback: T[]) {
return fallback
}
type SummaryDiffInput = NonNullable<NonNullable<UserMessage["summary"]>["diffs"]>[number]
type SummaryDiff = SnapshotFileDiff & { file: string; patch: string }
type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo
function summaryDiff(value: SummaryDiffInput): value is SummaryDiff {
return (
typeof value.file === "string" &&
typeof value.patch === "string" &&
typeof value.additions === "number" &&
typeof value.deletions === "number" &&
value.status !== undefined
)
function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff {
return typeof value.file === "string"
}
const hidden = new Set(["todowrite"])
function partState(part: PartType, showReasoningSummaries: boolean) {
if (part.type === "tool") {
if (hidden.has(part.tool)) return
if (part.tool === "question" && (part.state.status === "pending" || part.state.status === "running")) return
return "visible" as const
}

View file

@ -427,6 +427,25 @@ const TOOL_SAMPLES = {
title: "playwriter",
metadata: {},
},
todowrite: {
tool: "todowrite",
input: {
todos: [
{ content: "Create data generators", status: "completed", priority: "high" },
{ content: "Build UI controls", status: "in_progress", priority: "high" },
{ content: "Add CSS export", status: "pending", priority: "medium" },
],
},
output: "",
title: "Todos",
metadata: {
todos: [
{ content: "Create data generators", status: "completed", priority: "high" },
{ content: "Build UI controls", status: "in_progress", priority: "high" },
{ content: "Add CSS export", status: "pending", priority: "medium" },
],
},
},
}
// ---------------------------------------------------------------------------

View file

@ -5,7 +5,7 @@ const docs = `### Overview
Tool call failure summary styled like a tool trigger.
### API
- Required: \`tool\` (tool id, e.g. patch, bash)
- Required: \`tool\` (tool id, e.g. apply_patch, bash)
- Required: \`error\` (error string)
### Behavior
@ -14,9 +14,9 @@ Tool call failure summary styled like a tool trigger.
const samples = [
{
tool: "patch",
tool: "apply_patch",
error:
"patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx",
"apply_patch verification failed: Failed to find expected lines in /Users/davidhill/Documents/Local/opencode/packages/ui/src/components/session-turn.tsx",
},
{
tool: "bash",
@ -62,13 +62,13 @@ export default {
},
},
args: {
tool: "patch",
tool: "apply_patch",
error: samples[0].error,
},
argTypes: {
tool: {
control: "select",
options: ["patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"],
options: ["apply_patch", "bash", "read", "glob", "grep", "webfetch", "websearch", "question"],
},
error: {
control: "text",

View file

@ -51,6 +51,7 @@ export function ToolErrorCard(props: ToolErrorCardProps) {
webfetch: "ui.tool.webfetch",
websearch: "ui.tool.websearch",
bash: "ui.tool.shell",
shell: "ui.tool.shell",
patch: "ui.tool.patch",
apply_patch: "ui.tool.patch",
question: "ui.tool.questions",

View file

@ -1,4 +1,5 @@
import type { Message, Part, Provider, Session, SessionStatus, SnapshotFileDiff } from "@opencode-ai/sdk/v2"
import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { createSimpleContext } from "@opencode-ai/ui/context"
import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr"
@ -21,7 +22,7 @@ type Data = {
[sessionID: string]: SessionStatus
}
session_diff: {
[sessionID: string]: SnapshotFileDiff[]
[sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[]
}
session_diff_preload?: {
[sessionID: string]: PreloadMultiFileDiffResult<any>[]

View file

@ -7,6 +7,7 @@ import { useI18n } from "@opencode-ai/ui/context/i18n"
import { mediaKindFromPath } from "../../pierre/media"
import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge"
import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2"
import type { FileDiffInfo } from "@opencode-ai/client/promise"
import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js"
import { createStore } from "solid-js/store"
import { Dynamic } from "solid-js/web"
@ -27,7 +28,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2"
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import "./session-review-v2.css"
type ReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { file: string }
type ReviewDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff
export type SessionReviewFilePreviewV2Props = {
file: string