Compare commits

...

10 Commits

Author SHA1 Message Date
Ryan Vogel
f49834f301 Merge branch 'dev' into docs-export 2026-02-04 09:38:12 -05:00
R44VC0RP
93356021f4 docs(cli): add example for redirecting export output to file 2026-02-04 09:37:23 -05:00
Adam
c8622df762 fix(app): file tree not staying in sync across projects/sessions 2026-02-04 07:59:46 -06:00
Adam
c277ee8cbf fix(app): move session options to the session page 2026-02-04 07:12:19 -06:00
Adam
a2face30f4 wip(app): session options 2026-02-04 07:12:18 -06:00
Adam
a219615fe5 fix(app): opened tabs follow created session 2026-02-04 06:22:55 -06:00
opencode-agent[bot]
af06175b1f chore: generate 2026-02-04 11:35:05 +00:00
Goni Zahavy
2e8d8de58b fix(desktop): removed compression from rpm bundle to save 15m in CI (#12097) 2026-02-04 05:34:18 -06:00
opencode-agent[bot]
310de8b1ea chore: update nix node_modules hashes 2026-02-04 11:12:01 +00:00
Lucas (TaeYoung) Jo
891875402c fix(terminal): support remote server connections and fix GLIBC compatibility (#11906) 2026-02-04 05:01:10 -06:00
11 changed files with 573 additions and 230 deletions

View File

@@ -309,7 +309,7 @@
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.4",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",
@@ -2108,7 +2108,7 @@
"bun-ffi-structs": ["bun-ffi-structs@0.1.2", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-Lh1oQAYHDcnesJauieA4UNkWGXY9hYck7OA5IaRwE3Bp6K2F2pJSNYqq+hIy7P3uOvo3km3oxS8304g5gDMl/w=="],
"bun-pty": ["bun-pty@0.4.4", "", {}, "sha512-WK4G6uWsZgu1v4hKIlw6G1q2AOf8Rbga2Yr7RnxArVjjyb+mtVa/CFc9GOJf+OYSJSH8k7LonAtQOVeNAddRyg=="],
"bun-pty": ["bun-pty@0.4.8", "", {}, "sha512-rO70Mrbr13+jxHHHu2YBkk2pNqrJE5cJn29WE++PUr+GFA0hq/VgtQPZANJ8dJo6d7XImvBk37Innt8GM7O28w=="],
"bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],

View File

@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-Uc9UFWrG9bVROt+DmXduXoY409wBBLtBe0G7R41NF8Q=",
"aarch64-linux": "sha256-KTUsuPfWaw2qb26GmEa5tcSeF3+Kx2X5ZP5DE8jJuvQ=",
"aarch64-darwin": "sha256-C650/LVIoeymKnRw9lVO3f5ve9xYZPrO0vOM5pqY2nE=",
"x86_64-darwin": "sha256-xLLI2mNn222ktx6s8rwej3rMzQGl1S1jV/NXmLFg2DU="
"x86_64-linux": "sha256-9XlAYCNdBhw8NmfJoYNjvQYhSn02rFhWvbJtlOnnCjc=",
"aarch64-linux": "sha256-Mdz3gAy8auN7mhMHRaWyH/exHGO9eYDyUMQKqscg6Xc=",
"aarch64-darwin": "sha256-NDB6+NVZ4+9+Yds/cjEGQAn9Tl/LRuEjEH6wV5dTdVg=",
"x86_64-darwin": "sha256-LGJ5TJYgyK8Vn0BliEeJdoblcubj5ZIjvJoUtdVXfvU="
}
}

View File

@@ -1220,7 +1220,10 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
})
return undefined
})
if (session) navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
if (session) {
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
}
}
if (!session) return

View File

@@ -146,6 +146,7 @@ export const Terminal = (props: TerminalProps) => {
const once = { value: false }
const url = new URL(sdk.url + `/pty/${local.pty.id}/connect?directory=${encodeURIComponent(sdk.directory)}`)
url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
if (window.__OPENCODE__?.serverPassword) {
url.username = "opencode"
url.password = window.__OPENCODE__?.serverPassword

View File

@@ -33,6 +33,14 @@ type SessionTabs = {
type SessionView = {
scroll: Record<string, SessionScroll>
reviewOpen?: string[]
pendingMessage?: string
pendingMessageAt?: number
}
type TabHandoff = {
dir: string
id: string
at: number
}
export type LocalProject = Partial<Project> & { worktree: string; expanded: boolean }
@@ -115,10 +123,14 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
},
sessionTabs: {} as Record<string, SessionTabs>,
sessionView: {} as Record<string, SessionView>,
handoff: {
tabs: undefined as TabHandoff | undefined,
},
}),
)
const MAX_SESSION_KEYS = 50
const PENDING_MESSAGE_TTL_MS = 2 * 60 * 1000
const meta = { active: undefined as string | undefined, pruned: false }
const used = new Map<string, number>()
@@ -411,6 +423,16 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
return {
ready,
handoff: {
tabs: createMemo(() => store.handoff?.tabs),
setTabs(dir: string, id: string) {
setStore("handoff", "tabs", { dir, id, at: Date.now() })
},
clearTabs() {
if (!store.handoff?.tabs) return
setStore("handoff", "tabs", undefined)
},
},
projects: {
list,
open(directory: string) {
@@ -536,6 +558,49 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext(
setStore("mobileSidebar", "opened", (x) => !x)
},
},
pendingMessage: {
set(sessionKey: string, messageID: string) {
const at = Date.now()
touch(sessionKey)
const current = store.sessionView[sessionKey]
if (!current) {
setStore("sessionView", sessionKey, {
scroll: {},
pendingMessage: messageID,
pendingMessageAt: at,
})
prune(meta.active ?? sessionKey)
return
}
setStore(
"sessionView",
sessionKey,
produce((draft) => {
draft.pendingMessage = messageID
draft.pendingMessageAt = at
}),
)
},
consume(sessionKey: string) {
const current = store.sessionView[sessionKey]
const message = current?.pendingMessage
const at = current?.pendingMessageAt
if (!message || !at) return
setStore(
"sessionView",
sessionKey,
produce((draft) => {
delete draft.pendingMessage
delete draft.pendingMessageAt
}),
)
if (Date.now() - at > PENDING_MESSAGE_TTL_MS) return
return message
},
},
view(sessionKey: string | Accessor<string>) {
const key = typeof sessionKey === "function" ? sessionKey : () => sessionKey

View File

@@ -1000,69 +1000,6 @@ export default function Layout(props: ParentProps) {
}
}
async function deleteSession(session: Session) {
const [store, setStore] = globalSync.child(session.directory)
const sessions = (store.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === session.id)
const nextSession = sessions[index + 1] ?? sessions[index - 1]
const result = await globalSDK.client.session
.delete({ directory: session.directory, sessionID: session.id })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return
setStore(
produce((draft) => {
const removed = new Set<string>([session.id])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [session.id]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
if (session.id === params.id) {
if (nextSession) {
navigate(`/${params.dir}/session/${nextSession.id}`)
} else {
navigate(`/${params.dir}/session`)
}
}
}
command.register(() => {
const commands: CommandOption[] = [
{
@@ -1316,15 +1253,6 @@ export default function Layout(props: ParentProps) {
globalSync.project.meta(project.worktree, { name })
}
async function renameSession(session: Session, next: string) {
if (next === session.title) return
await globalSDK.client.session.update({
directory: session.directory,
sessionID: session.id,
title: next,
})
}
const renameWorkspace = (directory: string, next: string, projectId?: string, branch?: string) => {
const current = workspaceName(directory, projectId, branch) ?? branch ?? getFilename(directory)
if (current === next) return
@@ -1475,33 +1403,6 @@ export default function Layout(props: ParentProps) {
})
}
function DialogDeleteSession(props: { session: Session }) {
const handleDelete = async () => {
await deleteSession(props.session)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: props.session.title })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
function DialogDeleteWorkspace(props: { root: string; directory: string }) {
const name = createMemo(() => getFilename(props.directory))
const [data, setData] = createStore({
@@ -1855,10 +1756,6 @@ export default function Layout(props: ParentProps) {
const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded())
const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed())
const isActive = createMemo(() => props.session.id === params.id)
const [menu, setMenu] = createStore({
open: false,
pendingRename: false,
})
const hoverPrefetch = { current: undefined as ReturnType<typeof setTimeout> | undefined }
const cancelHoverPrefetch = () => {
@@ -1885,7 +1782,7 @@ export default function Layout(props: ParentProps) {
const item = (
<A
href={`${props.slug}/session/${props.session.id}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menu.open ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${props.mobile ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`}
onPointerEnter={scheduleHoverPrefetch}
onPointerLeave={cancelHoverPrefetch}
onMouseEnter={scheduleHoverPrefetch}
@@ -1917,14 +1814,9 @@ export default function Layout(props: ParentProps) {
</Match>
</Switch>
</div>
<InlineEditor
id={`session:${props.session.id}`}
value={() => props.session.title}
onSave={(next) => renameSession(props.session, next)}
class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
displayClass="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate"
stopPropagation
/>
<span class="text-14-regular text-text-strong grow-1 min-w-0 overflow-hidden text-ellipsis truncate">
{props.session.title}
</span>
<Show when={props.session.summary}>
{(summary) => (
<div class="group-hover/session:hidden group-active/session:hidden group-focus-within/session:hidden">
@@ -1972,7 +1864,10 @@ export default function Layout(props: ParentProps) {
getLabel={messageLabel}
onMessageSelect={(message) => {
if (!isActive()) {
sessionStorage.setItem("opencode.pendingMessage", `${props.session.id}|${message.id}`)
layout.pendingMessage.set(
`${base64Encode(props.session.directory)}/${props.session.id}`,
message.id,
)
navigate(`${props.slug}/session/${props.session.id}`)
return
}
@@ -1989,49 +1884,25 @@ export default function Layout(props: ParentProps) {
<div
class={`absolute ${props.dense ? "top-0.5 right-0.5" : "top-1 right-1"} flex items-center gap-0.5 transition-opacity`}
classList={{
"opacity-100 pointer-events-auto": menu.open,
"opacity-0 pointer-events-none": !menu.open,
"opacity-100 pointer-events-auto": !!props.mobile,
"opacity-0 pointer-events-none": !props.mobile,
"group-hover/session:opacity-100 group-hover/session:pointer-events-auto": true,
"group-focus-within/session:opacity-100 group-focus-within/session:pointer-events-auto": true,
}}
>
<DropdownMenu modal={!sidebarHovering()} open={menu.open} onOpenChange={(open) => setMenu("open", open)}>
<Tooltip value={language.t("common.moreOptions")} placement="top">
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
</Tooltip>
<DropdownMenu.Portal mount={!props.mobile ? state.nav : undefined}>
<DropdownMenu.Content
onCloseAutoFocus={(event) => {
if (!menu.pendingRename) return
event.preventDefault()
setMenu("pendingRename", false)
openEditor(`session:${props.session.id}`, props.session.title)
}}
>
<DropdownMenu.Item
onSelect={() => {
setMenu("pendingRename", true)
setMenu("open", false)
}}
>
<DropdownMenu.ItemLabel>{language.t("common.rename")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => archiveSession(props.session)}>
<DropdownMenu.ItemLabel>{language.t("common.archive")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item onSelect={() => dialog.show(() => <DialogDeleteSession session={props.session} />)}>
<DropdownMenu.ItemLabel>{language.t("common.delete")}</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
<Tooltip value={language.t("common.archive")} placement="top">
<IconButton
icon="archive"
variant="ghost"
class="size-6 rounded-md"
aria-label={language.t("common.archive")}
onClick={(event) => {
event.preventDefault()
event.stopPropagation()
void archiveSession(props.session)
}}
/>
</Tooltip>
</div>
</div>
)

View File

@@ -16,13 +16,16 @@ import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
import { useLocal } from "@/context/local"
import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange } from "@/context/file"
import { createStore } from "solid-js/store"
import { createStore, produce } from "solid-js/store"
import { PromptInput } from "@/components/prompt-input"
import { SessionContextUsage } from "@/components/session-context-usage"
import { IconButton } from "@opencode-ai/ui/icon-button"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
import { Dialog } from "@opencode-ai/ui/dialog"
import { InlineInput } from "@opencode-ai/ui/inline-input"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
import { Tabs } from "@opencode-ai/ui/tabs"
import { useCodeComponent } from "@opencode-ai/ui/context/code"
@@ -73,10 +76,31 @@ import { same } from "@/utils/same"
type DiffStyle = "unified" | "split"
type HandoffSession = {
prompt: string
files: Record<string, SelectedLineRange | null>
}
const HANDOFF_MAX = 40
const handoff = {
prompt: "",
terminals: [] as string[],
files: {} as Record<string, SelectedLineRange | null>,
session: new Map<string, HandoffSession>(),
terminal: new Map<string, string[]>(),
}
const touch = <K, V>(map: Map<K, V>, key: K, value: V) => {
map.delete(key)
map.set(key, value)
while (map.size > HANDOFF_MAX) {
const first = map.keys().next().value
if (first === undefined) return
map.delete(first)
}
}
const setSessionHandoff = (key: string, patch: Partial<HandoffSession>) => {
const prev = handoff.session.get(key) ?? { prompt: "", files: {} }
touch(handoff.session, key, { ...prev, ...patch })
}
interface SessionReviewTabProps {
@@ -280,9 +304,47 @@ export default function Page() {
.finally(() => setUi("responding", false))
}
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const workspaceKey = createMemo(() => params.dir ?? "")
const workspaceTabs = createMemo(() => layout.tabs(workspaceKey))
const tabs = createMemo(() => layout.tabs(sessionKey))
const view = createMemo(() => layout.view(sessionKey))
createEffect(
on(
() => params.id,
(id, prev) => {
if (!id) return
if (prev) return
const pending = layout.handoff.tabs()
if (!pending) return
if (Date.now() - pending.at > 60_000) {
layout.handoff.clearTabs()
return
}
if (pending.id !== id) return
layout.handoff.clearTabs()
if (pending.dir !== (params.dir ?? "")) return
const from = workspaceTabs().tabs()
if (from.all.length === 0 && !from.active) return
const current = tabs().tabs()
if (current.all.length > 0 || current.active) return
const all = normalizeTabs(from.all)
const active = from.active ? normalizeTab(from.active) : undefined
tabs().setAll(all)
tabs().setActive(active && all.includes(active) ? active : all[0])
workspaceTabs().setAll([])
workspaceTabs().setActive(undefined)
},
{ defer: true },
),
)
if (import.meta.env.DEV) {
createEffect(
on(
@@ -398,6 +460,213 @@ export default function Page() {
if (!id) return false
return sync.session.history.loading(id)
})
const [title, setTitle] = createStore({
draft: "",
editing: false,
saving: false,
menuOpen: false,
pendingRename: false,
})
let titleRef: HTMLInputElement | undefined
const errorMessage = (err: unknown) => {
if (err && typeof err === "object" && "data" in err) {
const data = (err as { data?: { message?: string } }).data
if (data?.message) return data.message
}
if (err instanceof Error) return err.message
return language.t("common.requestFailed")
}
createEffect(
on(
() => params.id,
() => setTitle({ draft: "", editing: false, saving: false, menuOpen: false, pendingRename: false }),
{ defer: true },
),
)
const openTitleEditor = () => {
if (!params.id) return
setTitle({ editing: true, draft: info()?.title ?? "" })
requestAnimationFrame(() => {
titleRef?.focus()
titleRef?.select()
})
}
const closeTitleEditor = () => {
if (title.saving) return
setTitle({ editing: false, saving: false })
}
const saveTitleEditor = async () => {
const sessionID = params.id
if (!sessionID) return
if (title.saving) return
const next = title.draft.trim()
if (!next || next === (info()?.title ?? "")) {
setTitle({ editing: false, saving: false })
return
}
setTitle("saving", true)
await sdk.client.session
.update({ sessionID, title: next })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session[index].title = next
}),
)
setTitle({ editing: false, saving: false })
})
.catch((err) => {
setTitle("saving", false)
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function archiveSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return
const sessions = sync.data.session ?? []
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
await sdk.client.session
.update({ sessionID, time: { archived: Date.now() } })
.then(() => {
sync.set(
produce((draft) => {
const index = draft.session.findIndex((s) => s.id === sessionID)
if (index !== -1) draft.session.splice(index, 1)
}),
)
if (params.id !== sessionID) return
if (session.parentID) {
navigate(`/${params.dir}/session/${session.parentID}`)
return
}
if (nextSession) {
navigate(`/${params.dir}/session/${nextSession.id}`)
return
}
navigate(`/${params.dir}/session`)
})
.catch((err) => {
showToast({
title: language.t("common.requestFailed"),
description: errorMessage(err),
})
})
}
async function deleteSession(sessionID: string) {
const session = sync.session.get(sessionID)
if (!session) return false
const sessions = (sync.data.session ?? []).filter((s) => !s.parentID && !s.time?.archived)
const index = sessions.findIndex((s) => s.id === sessionID)
const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1])
const result = await sdk.client.session
.delete({ sessionID })
.then((x) => x.data)
.catch((err) => {
showToast({
title: language.t("session.delete.failed.title"),
description: errorMessage(err),
})
return false
})
if (!result) return false
sync.set(
produce((draft) => {
const removed = new Set<string>([sessionID])
const byParent = new Map<string, string[]>()
for (const item of draft.session) {
const parentID = item.parentID
if (!parentID) continue
const existing = byParent.get(parentID)
if (existing) {
existing.push(item.id)
continue
}
byParent.set(parentID, [item.id])
}
const stack = [sessionID]
while (stack.length) {
const parentID = stack.pop()
if (!parentID) continue
const children = byParent.get(parentID)
if (!children) continue
for (const child of children) {
if (removed.has(child)) continue
removed.add(child)
stack.push(child)
}
}
draft.session = draft.session.filter((s) => !removed.has(s.id))
}),
)
if (params.id !== sessionID) return true
if (session.parentID) {
navigate(`/${params.dir}/session/${session.parentID}`)
return true
}
if (nextSession) {
navigate(`/${params.dir}/session/${nextSession.id}`)
return true
}
navigate(`/${params.dir}/session`)
return true
}
function DialogDeleteSession(props: { sessionID: string }) {
const title = createMemo(() => sync.session.get(props.sessionID)?.title ?? language.t("command.session.new"))
const handleDelete = async () => {
await deleteSession(props.sessionID)
dialog.close()
}
return (
<Dialog title={language.t("session.delete.title")} fit>
<div class="flex flex-col gap-4 pl-6 pr-2.5 pb-3">
<div class="flex flex-col gap-1">
<span class="text-14-regular text-text-strong">
{language.t("session.delete.confirm", { name: title() })}
</span>
</div>
<div class="flex justify-end gap-2">
<Button variant="ghost" size="large" onClick={() => dialog.close()}>
{language.t("common.cancel")}
</Button>
<Button variant="primary" size="large" onClick={handleDelete}>
{language.t("session.delete.button")}
</Button>
</div>
</div>
</Dialog>
)
}
const emptyUserMessages: UserMessage[] = []
const userMessages = createMemo(
() => messages().filter((m) => m.role === "user") as UserMessage[],
@@ -545,8 +814,10 @@ export default function Page() {
const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs
createEffect(() => {
if (!params.id) return
sync.session.sync(params.id)
sdk.directory
const id = params.id
if (!id) return
sync.session.sync(id)
})
createEffect(() => {
@@ -614,10 +885,22 @@ export default function Page() {
createEffect(
on(
() => params.id,
sessionKey,
() => {
setStore("messageId", undefined)
setStore("expanded", {})
setUi("autoCreated", false)
},
{ defer: true },
),
)
createEffect(
on(
() => params.dir,
(dir) => {
if (!dir) return
setStore("newSessionWorktree", "main")
},
{ defer: true },
),
@@ -1125,12 +1408,15 @@ export default function Page() {
activeDiff: undefined as string | undefined,
})
const reviewScroll = () => tree.reviewScroll
const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value)
const pendingDiff = () => tree.pendingDiff
const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value)
const activeDiff = () => tree.activeDiff
const setActiveDiff = (value: string | undefined) => setTree("activeDiff", value)
createEffect(
on(
sessionKey,
() => {
setTree({ reviewScroll: undefined, pendingDiff: undefined, activeDiff: undefined })
},
{ defer: true },
),
)
const showAllFiles = () => {
if (fileTreeTab() !== "changes") return
@@ -1151,8 +1437,8 @@ export default function Page() {
view={view}
diffStyle={layout.review.diffStyle()}
onDiffStyleChange={layout.review.setDiffStyle}
onScrollRef={setReviewScroll}
focusedFile={activeDiff()}
onScrollRef={(el) => setTree("reviewScroll", el)}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
@@ -1202,7 +1488,7 @@ export default function Page() {
}
const reviewDiffTop = (path: string) => {
const root = reviewScroll()
const root = tree.reviewScroll
if (!root) return
const id = reviewDiffId(path)
@@ -1218,7 +1504,7 @@ export default function Page() {
}
const scrollToReviewDiff = (path: string) => {
const root = reviewScroll()
const root = tree.reviewScroll
if (!root) return false
const top = reviewDiffTop(path)
@@ -1232,24 +1518,23 @@ export default function Page() {
const focusReviewDiff = (path: string) => {
const current = view().review.open() ?? []
if (!current.includes(path)) view().review.setOpen([...current, path])
setActiveDiff(path)
setPendingDiff(path)
setTree({ activeDiff: path, pendingDiff: path })
}
createEffect(() => {
const pending = pendingDiff()
const pending = tree.pendingDiff
if (!pending) return
if (!reviewScroll()) return
if (!tree.reviewScroll) return
if (!diffsReady()) return
const attempt = (count: number) => {
if (pendingDiff() !== pending) return
if (tree.pendingDiff !== pending) return
if (count > 60) {
setPendingDiff(undefined)
setTree("pendingDiff", undefined)
return
}
const root = reviewScroll()
const root = tree.reviewScroll
if (!root) {
requestAnimationFrame(() => attempt(count + 1))
return
@@ -1267,7 +1552,7 @@ export default function Page() {
}
if (Math.abs(root.scrollTop - top) <= 1) {
setPendingDiff(undefined)
setTree("pendingDiff", undefined)
return
}
@@ -1310,13 +1595,17 @@ export default function Page() {
void sync.session.diff(id)
})
let treeDir: string | undefined
createEffect(() => {
const dir = sdk.directory
if (!isDesktop()) return
if (!layout.fileTree.opened()) return
if (sync.status === "loading") return
fileTreeTab()
void file.tree.list("")
const refresh = treeDir !== dir
treeDir = dir
void (refresh ? file.tree.refresh("") : file.tree.list(""))
})
const autoScroll = createAutoScroll({
@@ -1351,6 +1640,18 @@ export default function Page() {
let scrollSpyFrame: number | undefined
let scrollSpyTarget: HTMLDivElement | undefined
createEffect(
on(
sessionKey,
() => {
if (scrollSpyFrame !== undefined) cancelAnimationFrame(scrollSpyFrame)
scrollSpyFrame = undefined
scrollSpyTarget = undefined
},
{ defer: true },
),
)
const anchor = (id: string) => `message-${id}`
const setScrollRef = (el: HTMLDivElement | undefined) => {
@@ -1465,20 +1766,14 @@ export default function Page() {
window.history.replaceState(null, "", `#${anchor(id)}`)
}
createEffect(() => {
const sessionID = params.id
if (!sessionID) return
const raw = sessionStorage.getItem("opencode.pendingMessage")
if (!raw) return
const parts = raw.split("|")
const pendingSessionID = parts[0]
const messageID = parts[1]
if (!pendingSessionID || !messageID) return
if (pendingSessionID !== sessionID) return
sessionStorage.removeItem("opencode.pendingMessage")
setUi("pendingMessage", messageID)
})
createEffect(
on(sessionKey, (key) => {
if (!params.id) return
const messageID = layout.pendingMessage.consume(key)
if (!messageID) return
setUi("pendingMessage", messageID)
}),
)
const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => {
const root = scroller
@@ -1692,7 +1987,7 @@ export default function Page() {
createEffect(() => {
if (!prompt.ready()) return
handoff.prompt = previewPrompt()
setSessionHandoff(sessionKey(), { prompt: previewPrompt() })
})
createEffect(() => {
@@ -1712,20 +2007,22 @@ export default function Page() {
return language.t("terminal.title")
}
handoff.terminals = terminal.all().map(label)
touch(handoff.terminal, params.dir!, terminal.all().map(label))
})
createEffect(() => {
if (!file.ready()) return
handoff.files = Object.fromEntries(
tabs()
.all()
.flatMap((tab) => {
const path = file.pathFromTab(tab)
if (!path) return []
return [[path, file.selectedLines(path) ?? null] as const]
}),
)
setSessionHandoff(sessionKey(), {
files: Object.fromEntries(
tabs()
.all()
.flatMap((tab) => {
const path = file.pathFromTab(tab)
if (!path) return []
return [[path, file.selectedLines(path) ?? null] as const]
}),
),
})
})
onCleanup(() => {
@@ -1801,7 +2098,7 @@ export default function Page() {
diffs={diffs}
view={view}
diffStyle="unified"
focusedFile={activeDiff()}
focusedFile={tree.activeDiff}
onLineComment={(comment) => addCommentToContext({ ...comment, origin: "review" })}
comments={comments.all()}
focusedComment={comments.focus()}
@@ -1954,20 +2251,108 @@ export default function Page() {
centered(),
}}
>
<div class="h-10 flex items-center gap-1">
<Show when={info()?.parentID}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={info()?.title}>
<h1 class="text-16-medium text-text-strong truncate">{info()?.title}</h1>
<div class="h-10 w-full flex items-center justify-between gap-2">
<div class="flex items-center gap-1 min-w-0 flex-1">
<Show when={info()?.parentID}>
<IconButton
tabIndex={-1}
icon="arrow-left"
variant="ghost"
onClick={() => {
navigate(`/${params.dir}/session/${info()?.parentID}`)
}}
aria-label={language.t("common.goBack")}
/>
</Show>
<Show when={info()?.title || title.editing}>
<Show
when={title.editing}
fallback={
<h1
class="text-16-medium text-text-strong truncate min-w-0"
onDblClick={openTitleEditor}
>
{info()?.title}
</h1>
}
>
<InlineInput
ref={(el) => {
titleRef = el
}}
value={title.draft}
disabled={title.saving}
class="text-16-medium text-text-strong grow-1 min-w-0"
onInput={(event) => setTitle("draft", event.currentTarget.value)}
onKeyDown={(event) => {
event.stopPropagation()
if (event.key === "Enter") {
event.preventDefault()
void saveTitleEditor()
return
}
if (event.key === "Escape") {
event.preventDefault()
closeTitleEditor()
}
}}
onBlur={() => closeTitleEditor()}
/>
</Show>
</Show>
</div>
<Show when={params.id}>
{(id) => (
<div class="shrink-0 flex items-center">
<DropdownMenu
open={title.menuOpen}
onOpenChange={(open) => setTitle("menuOpen", open)}
>
<Tooltip value={language.t("common.moreOptions")} placement="top">
<DropdownMenu.Trigger
as={IconButton}
icon="dot-grid"
variant="ghost"
class="size-6 rounded-md data-[expanded]:bg-surface-base-active"
aria-label={language.t("common.moreOptions")}
/>
</Tooltip>
<DropdownMenu.Portal>
<DropdownMenu.Content
onCloseAutoFocus={(event) => {
if (!title.pendingRename) return
event.preventDefault()
setTitle("pendingRename", false)
openTitleEditor()
}}
>
<DropdownMenu.Item
onSelect={() => {
setTitle({ pendingRename: true, menuOpen: false })
}}
>
<DropdownMenu.ItemLabel>
{language.t("common.rename")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => void archiveSession(id())}>
<DropdownMenu.ItemLabel>
{language.t("common.archive")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
<DropdownMenu.Separator />
<DropdownMenu.Item
onSelect={() => dialog.show(() => <DialogDeleteSession sessionID={id()} />)}
>
<DropdownMenu.ItemLabel>
{language.t("common.delete")}
</DropdownMenu.ItemLabel>
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu>
</div>
)}
</Show>
</div>
</div>
@@ -2147,7 +2532,7 @@ export default function Page() {
when={prompt.ready()}
fallback={
<div class="w-full min-h-32 md:min-h-40 rounded-md border border-border-weak-base bg-background-base/50 px-4 py-3 text-text-weak whitespace-pre-wrap pointer-events-none">
{handoff.prompt || language.t("prompt.loading")}
{handoff.session.get(sessionKey())?.prompt || language.t("prompt.loading")}
</div>
}
>
@@ -2398,7 +2783,7 @@ export default function Page() {
const p = path()
if (!p) return null
if (file.ready()) return file.selectedLines(p) ?? null
return handoff.files[p] ?? null
return handoff.session.get(sessionKey())?.files[p] ?? null
})
let wrap: HTMLDivElement | undefined
@@ -2892,7 +3277,7 @@ export default function Page() {
allowed={diffFiles()}
kinds={kinds()}
draggable={false}
active={activeDiff()}
active={tree.activeDiff}
onFileClick={(node) => focusReviewDiff(node.path)}
/>
</Show>
@@ -2952,7 +3337,7 @@ export default function Page() {
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weak-base bg-background-stronger overflow-hidden">
<For each={handoff.terminals}>
<For each={handoff.terminal.get(params.dir!) ?? []}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
{title}

View File

@@ -42,6 +42,13 @@
"active": true,
"targets": ["deb", "rpm", "dmg", "nsis", "app"],
"externalBin": ["sidecars/opencode-cli"],
"linux": {
"rpm": {
"compression": {
"type": "none"
}
}
},
"macOS": {
"entitlements": "./entitlements.plist"
},

View File

@@ -21,6 +21,11 @@
"files": {
"/usr/share/metainfo/ai.opencode.opencode.metainfo.xml": "release/appstream.metainfo.xml"
}
},
"rpm": {
"compression": {
"type": "none"
}
}
}
},

View File

@@ -94,7 +94,7 @@
"ai": "catalog:",
"ai-gateway-provider": "2.3.1",
"bonjour-service": "1.3.0",
"bun-pty": "0.4.4",
"bun-pty": "0.4.8",
"chokidar": "4.0.3",
"clipboardy": "4.0.0",
"decimal.js": "10.5.0",

View File

@@ -427,6 +427,12 @@ opencode export [sessionID]
If you don't provide a session ID, you'll be prompted to select from available sessions.
You can redirect the output to save to a file for further investigation:
```bash
opencode export > export.json
```
---
### import