{
+ const { Instance } = await import("../src/project/instance")
+ const { InstanceBootstrap } = await import("../src/project/bootstrap")
+ const { Session } = await import("../src/session")
+ const { Identifier } = await import("../src/id/id")
+ const { Project } = await import("../src/project/project")
+
+ await Instance.provide({
+ directory: dir,
+ init: InstanceBootstrap,
+ fn: async () => {
+ const session = await Session.create({ title })
+ const messageID = Identifier.descending("message")
+ const partID = Identifier.descending("part")
+ const message = {
+ id: messageID,
+ sessionID: session.id,
+ role: "user" as const,
+ time: { created: now },
+ agent: "build",
+ model: {
+ providerID,
+ modelID,
+ },
+ }
+ const part = {
+ id: partID,
+ sessionID: session.id,
+ messageID,
+ type: "text" as const,
+ text,
+ time: { start: now },
+ }
+ await Session.updateMessage(message)
+ await Session.updatePart(part)
+ await Project.update({ projectID: Instance.project.id, name: "E2E Project" })
+ },
+ })
+}
+
+await seed()
diff --git a/packages/opencode/src/format/formatter.ts b/packages/opencode/src/format/formatter.ts
index 667a954c03..1a3aa1bb15 100644
--- a/packages/opencode/src/format/formatter.ts
+++ b/packages/opencode/src/format/formatter.ts
@@ -226,7 +226,7 @@ export const rlang: Info = {
}
export const uvformat: Info = {
- name: "uv format",
+ name: "uv",
command: ["uv", "format", "--", "$FILE"],
extensions: [".py", ".pyi"],
async enabled() {
@@ -337,23 +337,6 @@ export const rustfmt: Info = {
command: ["rustfmt", "$FILE"],
extensions: [".rs"],
async enabled() {
- if (!Bun.which("rustfmt")) return false
- const configs = ["rustfmt.toml", ".rustfmt.toml"]
- for (const config of configs) {
- const found = await Filesystem.findUp(config, Instance.directory, Instance.worktree)
- if (found.length > 0) return true
- }
- return false
- },
-}
-
-export const cargofmt: Info = {
- name: "cargofmt",
- command: ["cargo", "fmt", "--", "$FILE"],
- extensions: [".rs"],
- async enabled() {
- if (!Bun.which("cargo")) return false
- const found = await Filesystem.findUp("Cargo.toml", Instance.directory, Instance.worktree)
- return found.length > 0
+ return Bun.which("rustfmt") !== null
},
}
diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts
index f6b7ec8cbc..c983bf32c4 100644
--- a/packages/opencode/src/provider/transform.ts
+++ b/packages/opencode/src/provider/transform.ts
@@ -123,11 +123,8 @@ export namespace ProviderTransform {
return result
}
- if (
- model.capabilities.interleaved &&
- typeof model.capabilities.interleaved === "object" &&
- model.capabilities.interleaved.field === "reasoning_content"
- ) {
+ if (typeof model.capabilities.interleaved === "object" && model.capabilities.interleaved.field) {
+ const field = model.capabilities.interleaved.field
return msgs.map((msg) => {
if (msg.role === "assistant" && Array.isArray(msg.content)) {
const reasoningParts = msg.content.filter((part: any) => part.type === "reasoning")
@@ -136,7 +133,7 @@ export namespace ProviderTransform {
// Filter out reasoning parts from content
const filteredContent = msg.content.filter((part: any) => part.type !== "reasoning")
- // Include reasoning_content directly on the message for all assistant messages
+ // Include reasoning_content | reasoning_details directly on the message for all assistant messages
if (reasoningText) {
return {
...msg,
@@ -145,7 +142,7 @@ export namespace ProviderTransform {
...msg.providerOptions,
openaiCompatible: {
...(msg.providerOptions as any)?.openaiCompatible,
- reasoning_content: reasoningText,
+ [field]: reasoningText,
},
},
}
diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts
index 95271f8c82..dddce95cb4 100644
--- a/packages/opencode/src/share/share-next.ts
+++ b/packages/opencode/src/share/share-next.ts
@@ -15,7 +15,10 @@ export namespace ShareNext {
return Config.get().then((x) => x.enterprise?.url ?? "https://opncd.ai")
}
+ const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
+
export async function init() {
+ if (disabled) return
Bus.subscribe(Session.Event.Updated, async (evt) => {
await sync(evt.properties.info.id, [
{
@@ -63,6 +66,7 @@ export namespace ShareNext {
}
export async function create(sessionID: string) {
+ if (disabled) return { id: "", url: "", secret: "" }
log.info("creating share", { sessionID })
const result = await fetch(`${await url()}/api/share`, {
method: "POST",
@@ -110,6 +114,7 @@ export namespace ShareNext {
const queue = new Map
}>()
async function sync(sessionID: string, data: Data[]) {
+ if (disabled) return
const existing = queue.get(sessionID)
if (existing) {
for (const item of data) {
@@ -145,6 +150,7 @@ export namespace ShareNext {
}
export async function remove(sessionID: string) {
+ if (disabled) return
log.info("removing share", { sessionID })
const share = await get(sessionID)
if (!share) return
diff --git a/packages/opencode/src/share/share.ts b/packages/opencode/src/share/share.ts
index 1006b23d55..f7bf4b3fa5 100644
--- a/packages/opencode/src/share/share.ts
+++ b/packages/opencode/src/share/share.ts
@@ -11,6 +11,7 @@ export namespace Share {
const pending = new Map()
export async function sync(key: string, content: any) {
+ if (disabled) return
const [root, ...splits] = key.split("/")
if (root !== "session") return
const [sub, sessionID] = splits
@@ -69,7 +70,10 @@ export namespace Share {
process.env["OPENCODE_API"] ??
(Installation.isPreview() || Installation.isLocal() ? "https://api.dev.opencode.ai" : "https://api.opencode.ai")
+ const disabled = process.env["OPENCODE_DISABLE_SHARE"] === "true" || process.env["OPENCODE_DISABLE_SHARE"] === "1"
+
export async function create(sessionID: string) {
+ if (disabled) return { url: "", secret: "" }
return fetch(`${URL}/share_create`, {
method: "POST",
body: JSON.stringify({ sessionID: sessionID }),
@@ -79,6 +83,7 @@ export namespace Share {
}
export async function remove(sessionID: string, secret: string) {
+ if (disabled) return {}
return fetch(`${URL}/share_delete`, {
method: "POST",
body: JSON.stringify({ sessionID, secret }),
diff --git a/packages/opencode/src/tool/apply_patch.txt b/packages/opencode/src/tool/apply_patch.txt
index e195cd9cb1..5b2d95608c 100644
--- a/packages/opencode/src/tool/apply_patch.txt
+++ b/packages/opencode/src/tool/apply_patch.txt
@@ -1,4 +1,4 @@
-Use the `apply_patch` tool to edit files. This is a FREEFORM tool, so do not wrap the patch in JSON. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
+Use the `apply_patch` tool to edit files. Your patch language is a stripped‑down, file‑oriented diff format designed to be easy to parse and safe to apply. You can think of it as a high‑level envelope:
*** Begin Patch
[ one or more file sections ]
diff --git a/packages/ui/src/components/hover-card.css b/packages/ui/src/components/hover-card.css
index 43a26c98f0..f1172dfc7d 100644
--- a/packages/ui/src/components/hover-card.css
+++ b/packages/ui/src/components/hover-card.css
@@ -1,5 +1,7 @@
[data-slot="hover-card-trigger"] {
- display: inline-flex;
+ display: flex;
+ width: 100%;
+ min-width: 0;
}
[data-component="hover-card-content"] {
@@ -8,6 +10,7 @@
max-width: 320px;
border-radius: var(--radius-md);
background-color: var(--surface-raised-stronger-non-alpha);
+ pointer-events: auto;
border: 1px solid color-mix(in oklch, var(--border-base) 50%, transparent);
background-clip: padding-box;
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx
index b59497b6ff..a66e4ca5ef 100644
--- a/packages/ui/src/components/icon.tsx
+++ b/packages/ui/src/components/icon.tsx
@@ -64,6 +64,8 @@ const icons = {
help: ``,
"settings-gear": ``,
dash: ``,
+ "cloud-upload": ``,
+ trash: ``,
}
export interface IconProps extends ComponentProps<"svg"> {
diff --git a/packages/ui/src/components/list.tsx b/packages/ui/src/components/list.tsx
index 76e5ebb9a6..9bf395e2ae 100644
--- a/packages/ui/src/components/list.tsx
+++ b/packages/ui/src/components/list.tsx
@@ -36,6 +36,25 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
mouseActive: false,
})
+ const scrollIntoView = (container: HTMLDivElement, node: HTMLElement, block: "center" | "nearest") => {
+ const containerRect = container.getBoundingClientRect()
+ const nodeRect = node.getBoundingClientRect()
+ const top = nodeRect.top - containerRect.top + container.scrollTop
+ const bottom = top + nodeRect.height
+ const viewTop = container.scrollTop
+ const viewBottom = viewTop + container.clientHeight
+ const target =
+ block === "center"
+ ? top - container.clientHeight / 2 + nodeRect.height / 2
+ : top < viewTop
+ ? top
+ : bottom > viewBottom
+ ? bottom - container.clientHeight
+ : viewTop
+ const max = Math.max(0, container.scrollHeight - container.clientHeight)
+ container.scrollTop = Math.max(0, Math.min(target, max))
+ }
+
const { filter, grouped, flat, active, setActive, onKeyDown, onInput } = useFilteredList(props)
const searchProps = () => (typeof props.search === "object" ? props.search : {})
@@ -66,24 +85,31 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
)
createEffect(() => {
- if (!scrollRef()) return
+ const scroll = scrollRef()
+ if (!scroll) return
if (!props.current) return
const key = props.key(props.current)
requestAnimationFrame(() => {
- const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(key)}"]`)
- element?.scrollIntoView({ block: "center" })
+ const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
+ if (!(element instanceof HTMLElement)) return
+ scrollIntoView(scroll, element, "center")
})
})
createEffect(() => {
const all = flat()
if (store.mouseActive || all.length === 0) return
+ const scroll = scrollRef()
+ if (!scroll) return
if (active() === props.key(all[0])) {
- scrollRef()?.scrollTo(0, 0)
+ scroll.scrollTo(0, 0)
return
}
- const element = scrollRef()?.querySelector(`[data-key="${CSS.escape(active()!)}"]`)
- element?.scrollIntoView({ block: "center" })
+ const key = active()
+ if (!key) return
+ const element = scroll.querySelector(`[data-key="${CSS.escape(key)}"]`)
+ if (!(element instanceof HTMLElement)) return
+ scrollIntoView(scroll, element, "center")
})
createEffect(() => {
@@ -213,6 +239,7 @@ export function List(props: ListProps & { ref?: (ref: ListRef) => void })
setActive(props.key(item))
}}
onMouseLeave={() => {
+ if (!store.mouseActive) return
setActive(null)
}}
>
diff --git a/packages/ui/src/components/message-nav.css b/packages/ui/src/components/message-nav.css
index 45c3103434..01ab252954 100644
--- a/packages/ui/src/components/message-nav.css
+++ b/packages/ui/src/components/message-nav.css
@@ -48,6 +48,10 @@
0 1px 2px 0 rgba(19, 16, 16, 0.06),
0 1px 3px 0 rgba(19, 16, 16, 0.08);
}
+
+ &[data-size="compact"] {
+ width: 24px;
+ }
}
[data-slot="message-nav-item-button"] {
diff --git a/packages/ui/src/components/message-nav.tsx b/packages/ui/src/components/message-nav.tsx
index 66f0942752..589d401f1a 100644
--- a/packages/ui/src/components/message-nav.tsx
+++ b/packages/ui/src/components/message-nav.tsx
@@ -96,8 +96,16 @@ const stopScrollAnimation = (state: ScrollAnimationState | null, containerEl?: H
}
}
-export const MessageNav = (props: MessageNavProps) => {
- const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect"])
+export function MessageNav(
+ props: ComponentProps<"nav"> & {
+ messages: UserMessage[]
+ current?: UserMessage
+ size: "normal" | "compact"
+ onMessageSelect: (message: UserMessage) => void
+ getLabel?: (message: UserMessage) => string | undefined
+ },
+) {
+ const [local, others] = splitProps(props, ["messages", "current", "size", "onMessageSelect", "getLabel"])
let navRef: HTMLElement | undefined
let listRef: HTMLUListElement | undefined
diff --git a/packages/ui/src/components/message-part.tsx b/packages/ui/src/components/message-part.tsx
index 47403786b2..b3fd01c2d8 100644
--- a/packages/ui/src/components/message-part.tsx
+++ b/packages/ui/src/components/message-part.tsx
@@ -46,6 +46,7 @@ import { checksum } from "@opencode-ai/util/encode"
import { Tooltip } from "./tooltip"
import { IconButton } from "./icon-button"
import { createAutoScroll } from "../hooks"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
interface Diagnostic {
range: {
@@ -297,6 +298,23 @@ export function AssistantMessageDisplay(props: { message: AssistantMessage; part
export function UserMessageDisplay(props: { message: UserMessage; parts: PartType[] }) {
const dialog = useDialog()
const [copied, setCopied] = createSignal(false)
+ const [expanded, setExpanded] = createSignal(false)
+ const [canExpand, setCanExpand] = createSignal(false)
+ let textRef: HTMLDivElement | undefined
+
+ const updateCanExpand = () => {
+ const el = textRef
+ if (!el) return
+ if (expanded()) return
+ setCanExpand(el.scrollHeight > el.clientHeight + 2)
+ }
+
+ createResizeObserver(
+ () => textRef,
+ () => {
+ updateCanExpand()
+ },
+ )
const textPart = createMemo(
() => props.parts?.find((p) => p.type === "text" && !(p as TextPart).synthetic) as TextPart | undefined,
@@ -304,6 +322,11 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
const text = createMemo(() => textPart()?.text || "")
+ createEffect(() => {
+ text()
+ updateCanExpand()
+ })
+
const files = createMemo(() => (props.parts?.filter((p) => p.type === "file") as FilePart[]) ?? [])
const attachments = createMemo(() =>
@@ -335,7 +358,7 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
}
return (
-
+
0}>
@@ -365,8 +388,16 @@ export function UserMessageDisplay(props: { message: UserMessage; parts: PartTyp
-
+
(textRef = el)}>
+
diff --git a/packages/ui/src/components/session-message-rail.css b/packages/ui/src/components/session-message-rail.css
deleted file mode 100644
index 3585258c3c..0000000000
--- a/packages/ui/src/components/session-message-rail.css
+++ /dev/null
@@ -1,29 +0,0 @@
-[data-slot="session-message-rail-anchor"] {
- position: absolute;
- left: 0;
- top: 0;
- bottom: 0;
- width: 40px;
- height: -webkit-fill-available;
- pointer-events: none;
-}
-
-[data-slot="session-message-rail-portal"] {
- z-index: 100;
- margin-left: 12px;
-}
-
-[data-component="session-message-rail"] {
- display: contents;
- position: relative;
-}
-
-[data-component="session-message-rail"][data-wide] {
- margin-top: 0.125rem;
- left: calc(((100% - min(100%, 50rem)) / 2) - 1.5rem);
- transform: translateX(-100%);
-}
-
-[data-component="session-message-rail"]:not([data-wide]) {
- margin-top: 0.625rem;
-}
diff --git a/packages/ui/src/components/session-message-rail.tsx b/packages/ui/src/components/session-message-rail.tsx
deleted file mode 100644
index 8ab9d3b278..0000000000
--- a/packages/ui/src/components/session-message-rail.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import { UserMessage } from "@opencode-ai/sdk/v2"
-import { ComponentProps, Show, splitProps, createSignal, onMount, onCleanup } from "solid-js"
-import { MessageNav } from "./message-nav"
-import "./session-message-rail.css"
-import { Portal } from "solid-js/web"
-
-export interface SessionMessageRailProps extends ComponentProps<"div"> {
- messages: UserMessage[]
- current?: UserMessage
- wide?: boolean
- onMessageSelect: (message: UserMessage) => void
-}
-
-export function SessionMessageRail(props: SessionMessageRailProps) {
- const [local, others] = splitProps(props, ["messages", "current", "wide", "onMessageSelect", "class", "classList"])
- let anchorRef: HTMLDivElement | undefined
- const [position, setPosition] = createSignal({ top: 0, left: 0, height: 0 })
-
- const updatePosition = () => {
- if (anchorRef) {
- const rect = anchorRef.getBoundingClientRect()
- setPosition({ top: rect.top, left: rect.left, height: rect.height })
- }
- }
-
- onMount(() => {
- updatePosition()
- window.addEventListener("scroll", updatePosition, true)
- window.addEventListener("resize", updatePosition)
- })
-
- onCleanup(() => {
- window.removeEventListener("scroll", updatePosition, true)
- window.removeEventListener("resize", updatePosition)
- })
-
- return (
- 1}>
-
-
(anchorRef = el)} data-slot="session-message-rail-anchor" />
-
-
-
-
-
-
-
- )
-}
diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css
index 1e3cc0b292..5f8c0a16f6 100644
--- a/packages/ui/src/components/session-turn.css
+++ b/packages/ui/src/components/session-turn.css
@@ -1,4 +1,6 @@
[data-component="session-turn"] {
+ --session-turn-sticky-height: 0px;
+ --sticky-header-height: calc(var(--session-title-height, 0px) + var(--session-turn-sticky-height, 0px) + 12px);
/* flex: 1; */
height: 100%;
min-height: 0;
@@ -29,23 +31,6 @@
gap: 28px;
overflow-anchor: none;
- [data-slot="session-turn-user-badges"] {
- position: absolute;
- right: 0;
- display: flex;
- gap: 6px;
- padding-left: 16px;
- background: linear-gradient(to right, transparent, var(--background-stronger) 12px);
- opacity: 0;
- transition: opacity 0.15s ease;
- pointer-events: none;
- }
-
- &:hover [data-slot="session-turn-user-badges"] {
- opacity: 1;
- pointer-events: auto;
- }
-
[data-slot="session-turn-badge"] {
display: inline-flex;
align-items: center;
@@ -61,23 +46,39 @@
}
}
- [data-slot="session-turn-sticky-title"] {
+ [data-slot="session-turn-attachments"] {
width: 100%;
+ min-width: 0;
+ align-self: stretch;
+ }
+
+ [data-slot="session-turn-sticky"] {
+ width: calc(100% + 9px);
position: sticky;
- top: 0;
+ top: var(--session-title-height, 0px);
+ z-index: 20;
background-color: var(--background-stronger);
- z-index: 21;
+ margin-left: -9px;
+ padding-left: 9px;
+ padding-bottom: 12px;
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+
+ &::before {
+ content: "";
+ position: absolute;
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: var(--background-stronger);
+ z-index: -1;
+ }
}
[data-slot="session-turn-response-trigger"] {
- position: sticky;
- top: 32px;
- background-color: var(--background-stronger);
- z-index: 20;
- width: calc(100% + 9px);
- margin-left: -9px;
- padding-left: 9px;
- padding-bottom: 8px;
+ width: fit-content;
}
[data-slot="session-turn-message-header"] {
@@ -88,10 +89,72 @@
}
[data-slot="session-turn-message-content"] {
- margin-top: -18px;
+ margin-top: 0;
max-width: 100%;
}
+ [data-component="user-message"] [data-slot="user-message-text"] {
+ max-height: var(--user-message-collapsed-height, 64px);
+ }
+
+ [data-component="user-message"][data-expanded="true"] [data-slot="user-message-text"] {
+ max-height: none;
+ }
+
+ [data-component="user-message"][data-can-expand="true"] [data-slot="user-message-text"] {
+ padding-right: 36px;
+ padding-bottom: 28px;
+ }
+
+ [data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"] {
+ display: none;
+ position: absolute;
+ bottom: 6px;
+ right: 6px;
+ padding: 0;
+ }
+
+ [data-component="user-message"][data-can-expand="true"]
+ [data-slot="user-message-text"]
+ [data-slot="user-message-expand"],
+ [data-component="user-message"][data-expanded="true"]
+ [data-slot="user-message-text"]
+ [data-slot="user-message-expand"] {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ height: 22px;
+ width: 22px;
+ border: none;
+ border-radius: 6px;
+ background: transparent;
+ cursor: pointer;
+ color: var(--text-weak);
+
+ [data-slot="icon-svg"] {
+ transition: transform 0.15s ease;
+ }
+ }
+
+ [data-component="user-message"][data-expanded="true"]
+ [data-slot="user-message-text"]
+ [data-slot="user-message-expand"]
+ [data-slot="icon-svg"] {
+ transform: rotate(180deg);
+ }
+
+ [data-component="user-message"] [data-slot="user-message-text"] [data-slot="user-message-expand"]:hover {
+ background: var(--surface-raised-base);
+ color: var(--text-base);
+ }
+
+ [data-slot="session-turn-user-badges"] {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ padding-left: 16px;
+ }
+
[data-slot="session-turn-message-title"] {
width: 100%;
font-size: var(--font-size-large);
@@ -276,10 +339,9 @@
}
[data-component="sticky-accordion-header"] {
- top: var(--sticky-header-height, 40px);
-
+ top: var(--sticky-header-height, 0px);
&[data-expanded]::before {
- top: calc(-1 * var(--sticky-header-height, 40px));
+ top: calc(-1 * var(--sticky-header-height, 0px));
}
}
diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx
index ae1321bac1..a918f0ae4f 100644
--- a/packages/ui/src/components/session-turn.tsx
+++ b/packages/ui/src/components/session-turn.tsx
@@ -1,5 +1,6 @@
import {
AssistantMessage,
+ FilePart,
Message as MessageType,
Part as PartType,
type PermissionRequest,
@@ -13,17 +14,13 @@ import { getDirectory, getFilename } from "@opencode-ai/util/path"
import { Binary } from "@opencode-ai/util/binary"
import { createEffect, createMemo, createSignal, For, Match, on, onCleanup, ParentProps, Show, Switch } from "solid-js"
-import { createResizeObserver } from "@solid-primitives/resize-observer"
import { DiffChanges } from "./diff-changes"
-import { Typewriter } from "./typewriter"
import { Message, Part } from "./message-part"
import { Markdown } from "./markdown"
import { Accordion } from "./accordion"
import { StickyAccordionHeader } from "./sticky-accordion-header"
import { FileIcon } from "./file-icon"
import { Icon } from "./icon"
-import { ProviderIcon } from "./provider-icon"
-import type { IconName } from "./provider-icons/types"
import { IconButton } from "./icon-button"
import { Tooltip } from "./tooltip"
import { Card } from "./card"
@@ -33,6 +30,7 @@ import { Spinner } from "./spinner"
import { createStore } from "solid-js/store"
import { DateTime, DurationUnit, Interval } from "luxon"
import { createAutoScroll } from "../hooks"
+import { createResizeObserver } from "@solid-primitives/resize-observer"
function computeStatusFromPart(part: PartType | undefined): string | undefined {
if (!part) return undefined
@@ -79,6 +77,12 @@ function same
(a: readonly T[], b: readonly T[]) {
return a.every((x, i) => x === b[i])
}
+function isAttachment(part: PartType | undefined) {
+ if (part?.type !== "file") return false
+ const mime = (part as FilePart).mime ?? ""
+ return mime.startsWith("image/") || mime === "application/pdf"
+}
+
function AssistantMessageItem(props: {
message: AssistantMessage
responsePartId: string | undefined
@@ -119,6 +123,7 @@ function AssistantMessageItem(props: {
export function SessionTurn(
props: ParentProps<{
sessionID: string
+ sessionTitle?: string
messageID: string
lastUserMessageID?: string
stepsExpanded?: boolean
@@ -136,6 +141,7 @@ export function SessionTurn(
const emptyMessages: MessageType[] = []
const emptyParts: PartType[] = []
+ const emptyFiles: FilePart[] = []
const emptyAssistant: AssistantMessage[] = []
const emptyPermissions: PermissionRequest[] = []
const emptyPermissionParts: { part: ToolPart; message: AssistantMessage }[] = []
@@ -183,6 +189,19 @@ export function SessionTurn(
return data.store.part[msg.id] ?? emptyParts
})
+ const attachmentParts = createMemo(() => {
+ const msgParts = parts()
+ if (msgParts.length === 0) return emptyFiles
+ return msgParts.filter((part) => isAttachment(part)) as FilePart[]
+ })
+
+ const stickyParts = createMemo(() => {
+ const msgParts = parts()
+ if (msgParts.length === 0) return emptyParts
+ if (attachmentParts().length === 0) return msgParts
+ return msgParts.filter((part) => !isAttachment(part))
+ })
+
const assistantMessages = createMemo(
() => {
const msg = message()
@@ -330,10 +349,19 @@ export function SessionTurn(
const response = createMemo(() => lastTextPart()?.text)
const responsePartId = createMemo(() => lastTextPart()?.id)
- const hasDiffs = createMemo(() => message()?.summary?.diffs?.length)
+ const hasDiffs = createMemo(() => (data.store.session_diff?.[props.sessionID]?.length ?? 0) > 0)
const hideResponsePart = createMemo(() => !working() && !!responsePartId())
const [responseCopied, setResponseCopied] = createSignal(false)
+ const [rootRef, setRootRef] = createSignal()
+ const [stickyRef, setStickyRef] = createSignal()
+
+ const updateStickyHeight = (height: number) => {
+ const root = rootRef()
+ if (!root) return
+ const next = Math.ceil(height)
+ root.style.setProperty("--session-turn-sticky-height", `${next}px`)
+ }
const handleCopyResponse = async () => {
const content = response()
if (!content) return
@@ -364,13 +392,28 @@ export function SessionTurn(
onUserInteracted: props.onUserInteracted,
})
+ createResizeObserver(
+ () => stickyRef(),
+ ({ height }) => {
+ updateStickyHeight(height)
+ },
+ )
+
+ createEffect(() => {
+ const root = rootRef()
+ if (!root) return
+ const sticky = stickyRef()
+ if (!sticky) {
+ root.style.setProperty("--session-turn-sticky-height", "0px")
+ return
+ }
+ updateStickyHeight(sticky.getBoundingClientRect().height)
+ })
+
const diffInit = 20
const diffBatch = 20
const [store, setStore] = createStore({
- stickyTitleRef: undefined as HTMLDivElement | undefined,
- stickyTriggerRef: undefined as HTMLDivElement | undefined,
- stickyHeaderHeight: 0,
retrySeconds: 0,
diffsOpen: [] as string[],
diffLimit: diffInit,
@@ -404,22 +447,6 @@ export function SessionTurn(
onCleanup(() => clearInterval(timer))
})
- createResizeObserver(
- () => store.stickyTitleRef,
- ({ height }) => {
- const triggerHeight = store.stickyTriggerRef?.offsetHeight ?? 0
- setStore("stickyHeaderHeight", height + triggerHeight + 8)
- },
- )
-
- createResizeObserver(
- () => store.stickyTriggerRef,
- ({ height }) => {
- const titleHeight = store.stickyTitleRef?.offsetHeight ?? 0
- setStore("stickyHeaderHeight", titleHeight + height + 8)
- },
- )
-
createEffect(() => {
const timer = setInterval(() => {
setStore("duration", duration())
@@ -460,7 +487,7 @@ export function SessionTurn(
})
return (
-
+
- {/* Title (sticky) */}
- setStore("stickyTitleRef", el)} data-slot="session-turn-sticky-title">
-
-
-
-
-
-
-
- {msg().summary?.title}
-
-
-
-
-
- {(msg() as UserMessage).agent}
-
-
-
-
- {(msg() as UserMessage).model?.modelID}
-
-
-
{(msg() as UserMessage).variant || "default"}
-
-
-
- {/* User Message */}
-
-
-
- {/* Trigger (sticky) */}
-
- setStore("stickyTriggerRef", el)} data-slot="session-turn-response-trigger">
-
+
0}>
+
+
+
+ {/* User Message */}
+
+
+
+
+ {/* Trigger (sticky) */}
+
+
+
+
+
+
{/* Response */}
0}>
@@ -612,7 +616,7 @@ export function SessionTurn(
setStore("diffsOpen", value)
}}
>
-
+
{(diff) => (
@@ -658,13 +662,13 @@ export function SessionTurn(
)}
- store.diffLimit}>
+ store.diffLimit}>
diff --git a/packages/ui/src/components/text-field.css b/packages/ui/src/components/text-field.css
index a739c4eb21..15f5fd4fbb 100644
--- a/packages/ui/src/components/text-field.css
+++ b/packages/ui/src/components/text-field.css
@@ -52,6 +52,7 @@
background: var(--input-base);
&:focus-within {
+ border-color: transparent;
/* border/shadow-xs/select */
box-shadow:
0 0 0 3px var(--border-weak-selected),
diff --git a/packages/web/src/content/docs/acp.mdx b/packages/web/src/content/docs/acp.mdx
index 9129db1359..43d89eae18 100644
--- a/packages/web/src/content/docs/acp.mdx
+++ b/packages/web/src/content/docs/acp.mdx
@@ -125,7 +125,7 @@ To use OpenCode as an ACP agent in [CodeCompanion.nvim](https://github.com/olimo
```lua
require("codecompanion").setup({
- strategies = {
+ interactions = {
chat = {
adapter = {
name = "opencode",
@@ -138,7 +138,7 @@ require("codecompanion").setup({
This config sets up CodeCompanion to use OpenCode as the ACP agent for chat.
-If you need to pass environment variables (like `OPENCODE_API_KEY`), refer to [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/configuration/adapters#environment-variables-setting-an-api-key) in the CodeCompanion.nvim documentation for full details.
+If you need to pass environment variables (like `OPENCODE_API_KEY`), refer to [Configuring Adapters: Environment Variables](https://codecompanion.olimorris.dev/getting-started#setting-an-api-key) in the CodeCompanion.nvim documentation for full details.
## Support
diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx
index 6022d174a7..2a7d2ffb42 100644
--- a/packages/web/src/content/docs/providers.mdx
+++ b/packages/web/src/content/docs/providers.mdx
@@ -654,21 +654,77 @@ GitLab Duo provides AI-powered agentic chat with native tool calling capabilitie
- **duo-chat-sonnet-4-5** - Balanced performance for most workflows
- **duo-chat-opus-4-5** - Most capable for complex analysis
+:::note
+You can also specify 'GITLAB_TOKEN' environment variable if you don't want
+to store token in opencode auth storage.
+:::
+
##### Self-Hosted GitLab
+:::note[compliance note]
+OpenCode uses a small model for some AI tasks like generating the session title.
+It is configured to use gpt-5-nano by default, hosted by Zen. To lock OpenCode
+to only use your own GitLab-hosted instance, add the following to your
+`opencode.json` file. It is also recommended to disable session sharing.
+
+```json
+{
+ "$schema": "https://opencode.ai/config.json",
+ "small_model": "gitlab/duo-chat-haiku-4-5",
+ "share": "disabled"
+}
+```
+
+:::
+
For self-hosted GitLab instances:
```bash
-GITLAB_INSTANCE_URL=https://gitlab.company.com GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx opencode
+export GITLAB_INSTANCE_URL=https://gitlab.company.com
+export GITLAB_TOKEN=glpat-...
+```
+
+If your instance runs a custom AI Gateway:
+
+```bash
+GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com
```
Or add to your bash profile:
```bash title="~/.bash_profile"
export GITLAB_INSTANCE_URL=https://gitlab.company.com
-export GITLAB_TOKEN=glpat-xxxxxxxxxxxxxxxxxxxx
+export GITLAB_AI_GATEWAY_URL=https://ai-gateway.company.com
+export GITLAB_TOKEN=glpat-...
```
+:::note
+Your GitLab administrator must enable the following:
+
+1. [Duo Agent Platform](https://docs.gitlab.com/user/gitlab_duo/turn_on_off/) for the user, group, or instance
+2. Feature flags (via Rails console):
+ - `agent_platform_claude_code`
+ - `third_party_agents_enabled`
+ :::
+
+##### OAuth for Self-Hosted instances
+
+In order to make Oauth working for your self-hosted instance, you need to create
+a new application (Settings → Applications) with the
+callback URL `http://127.0.0.1:8080/callback` and following scopes:
+
+- api (Access the API on your behalf)
+- read_user (Read your personal information)
+- read_repository (Allows read-only access to the repository)
+
+Then expose application ID as environment variable:
+
+```bash
+export GITLAB_OAUTH_CLIENT_ID=your_application_id_here
+```
+
+More documentation on [opencode-gitlab-auth](https://www.npmjs.com/package/@gitlab/opencode-gitlab-auth) homepage.
+
##### Configuration
Customize through `opencode.json`:
@@ -690,7 +746,7 @@ Customize through `opencode.json`:
}
```
-##### GitLab API Tools (Optional)
+##### GitLab API Tools (Optional, but highly recommended)
To access GitLab tools (merge requests, issues, pipelines, CI/CD, etc.):
diff --git a/turbo.json b/turbo.json
index 6b1c9b3242..5de1b8d751 100644
--- a/turbo.json
+++ b/turbo.json
@@ -9,6 +9,10 @@
"opencode#test": {
"dependsOn": ["^build"],
"outputs": []
+ },
+ "@opencode-ai/app#test": {
+ "dependsOn": ["^build"],
+ "outputs": []
}
}
}