Skip to content
Merged
Show file tree
Hide file tree
Changes from 23 commits
Commits
Show all changes
65 commits
Select commit Hold shift + click to select a range
937f9d2
feat(session): add execution_context column to session table
Astro-Han May 1, 2026
b18e96c
feat(session): synthesize executionContext for new and legacy sessions
Astro-Han May 1, 2026
ebda2c8
feat(session): persist execution_context through projectors
Astro-Han May 1, 2026
5155b3b
feat(session): add Session.updateExecutionContext helper
Astro-Han May 1, 2026
1bc52b3
feat(session): accept legacy string path on inbound messages
Astro-Han May 1, 2026
32ccf95
feat(session): route message path through executionContext
Astro-Han May 1, 2026
78ad32f
feat(instance): add Instance.activate for session-scoped binding
Astro-Han May 1, 2026
557c9a9
feat(session): add state-machine guard helpers
Astro-Han May 1, 2026
4bed20a
feat(tool): add EnterWorktree and ExitWorktree agent tools
Astro-Han May 1, 2026
4d5b59c
feat(subagent): inherit parent activeDirectory at dispatch
Astro-Han May 1, 2026
fabf418
feat(sdk): expose Session.executionContext to clients
Astro-Han May 1, 2026
62dc483
feat(app): titlebar worktree badge + Settings → Worktrees panel
Astro-Han May 1, 2026
fd58cf9
fix(worktree): use PawWork managed worktree convention
Astro-Han May 1, 2026
6691c98
fix(worktree): preserve registry source semantics
Astro-Han May 1, 2026
9e053b8
fix(session): root execution context at project owner
Astro-Han May 1, 2026
71d115d
fix(worktree): block transitions during subagents
Astro-Han May 1, 2026
12db7ff
fix(worktree): guard managed worktree ignore entry
Astro-Han May 1, 2026
ec189a7
fix(worktree): align settings with registry entries
Astro-Han May 1, 2026
c64db08
fix(app): mount worktree badge inside directory context
Astro-Han May 1, 2026
51d510a
fix(app): use global context for worktree settings
Astro-Han May 1, 2026
82c4c2c
fix(app): simplify worktree titlebar badge
Astro-Han May 1, 2026
a0250cc
fix(app): unify worktree title typography
Astro-Han May 1, 2026
452a089
fix(app): preserve composer scroll dock height
Astro-Han May 1, 2026
0cd3a8e
fix(app): address worktree settings review
Astro-Han May 1, 2026
ab288d9
fix(worktree): close execution context review gaps
Astro-Han May 1, 2026
993fcfa
fix(sdk): require active worktree branch
Astro-Han May 1, 2026
387bdd0
fix(opencode): harden worktree execution context
Astro-Han May 1, 2026
b041374
fix(app): refine worktree UI boundaries
Astro-Han May 1, 2026
be21701
chore(i18n): unify zh worktree term to 工作树
Astro-Han May 1, 2026
84111b8
fix(app): hide worktrees from workspace chip
Astro-Han May 1, 2026
213300c
refactor(app): redesign Settings → Worktrees with editorial layout
Astro-Han May 1, 2026
65311e1
refactor(app): unify titlebar folder and worktree chips
Astro-Han May 1, 2026
8c2a306
chore(i18n): polish worktree term and voice
Astro-Han May 1, 2026
c4de7bb
fix(ui): clarify enter/exit-worktree subtitles in chat
Astro-Han May 1, 2026
5fed954
feat(opencode): expose previous worktree on exit metadata
Astro-Han May 1, 2026
814de6b
feat(ui): show exited branch in worktree exit subtitle
Astro-Han May 1, 2026
96c210d
fix(app): tighten titlebar new-session button
Astro-Han May 1, 2026
a3935ef
fix(app): close settings overlay when route changes
Astro-Han May 1, 2026
1063607
fix(ui): restore titlebar icon hover, drop persistent bg
Astro-Han May 1, 2026
5d4d7ea
fix(ui): clarify worktree tool labels
Astro-Han May 1, 2026
a754462
fix(app): polish worktree settings list
Astro-Han May 1, 2026
d05d8a4
fix(app): tighten session timeline spacing
Astro-Han May 1, 2026
055c413
chore(app): remove unused worktree column labels
Astro-Han May 1, 2026
753eb80
fix: address worktree review feedback
Astro-Han May 1, 2026
35679f7
fix: address follow-up worktree review
Astro-Han May 1, 2026
f001381
fix: keep worktree context synchronized
Astro-Han May 1, 2026
11cfcb3
test: cover worktree badge branch label
Astro-Han May 1, 2026
21dea07
refactor: split settings worktree row
Astro-Han May 1, 2026
530aff5
fix: address worktree review followups
Astro-Han May 1, 2026
68074b7
fix: address worktree review issues
Astro-Han May 1, 2026
f6ba832
refactor: tighten tool metadata access
Astro-Han May 1, 2026
c69cc77
fix: tolerate partial worktree list failures
Astro-Han May 1, 2026
61e2427
fix: harden worktree gitignore status check
Astro-Han May 1, 2026
5f881fc
fix: tighten worktree settings helpers
Astro-Han May 1, 2026
37520f5
fix: address worktree review regressions
Astro-Han May 1, 2026
0d7ac5b
fix: guard deleted gitignore before worktree setup
Astro-Han May 1, 2026
e2c593d
fix: address latest worktree review comments
Astro-Han May 1, 2026
06716ee
fix: harden worktree execution context
Astro-Han May 1, 2026
f054a86
fix: preserve partial worktree context
Astro-Han May 1, 2026
22944d3
fix: require session row project fallback
Astro-Han May 1, 2026
66a4d01
fix: harden session execution context recovery
Astro-Han May 2, 2026
2126653
fix: canonicalize persisted execution context
Astro-Han May 2, 2026
131872e
fix: address worktree review regressions
Astro-Han May 2, 2026
8595bc9
fix: address worktree path review
Astro-Han May 2, 2026
574b6a5
fix: preserve worktree path entry metadata
Astro-Han May 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 20 additions & 7 deletions packages/app/src/components/prompt-input/workspace-chip-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,23 @@
import { effectiveWorkspaceOrder, workspaceKey } from "@/pages/layout/helpers"

export type WorkspaceEntry = string | { directory: string; branch?: string }

export type WorkspaceProject = {
worktree: string
sandboxes?: string[]
sandboxes?: WorkspaceEntry[]
}

function workspacePath(entry: WorkspaceEntry) {
return typeof entry === "string" ? entry : entry.directory
}

export function findWorkspaceProject(projects: WorkspaceProject[], directory?: string) {
if (!directory) return
const key = workspaceKey(directory)
return projects.find(
(item) => workspaceKey(item.worktree) === key || item.sandboxes?.some((sandbox) => workspaceKey(sandbox) === key),
(item) =>
workspaceKey(item.worktree) === key ||
item.sandboxes?.some((sandbox) => workspaceKey(workspacePath(sandbox)) === key),
)
}

Expand All @@ -21,7 +29,7 @@ export type WorkspaceChoice = {
export function workspaceChipChoices(input: {
directory?: string
projects: WorkspaceProject[]
listed?: string[]
listed?: WorkspaceEntry[]
}): WorkspaceChoice[] {
const directory = input.directory
if (!directory) return []
Expand All @@ -30,19 +38,24 @@ export function workspaceChipChoices(input: {
const seen = new Set<string>()
const choices: WorkspaceChoice[] = []

const append = (value: string) => {
const key = workspaceKey(value)
const append = (value: WorkspaceEntry) => {
const path = workspacePath(value)
const key = workspaceKey(path)
if (seen.has(key)) return
seen.add(key)
choices.push({ path: value })
choices.push({ path, branch: typeof value === "string" ? undefined : value.branch })
}

if (!current) append(directory)

for (const project of input.projects) {
const ordered =
current && workspaceKey(project.worktree) === workspaceKey(current.worktree)
? effectiveWorkspaceOrder(project.worktree, [project.worktree, ...(project.sandboxes ?? []), ...(input.listed ?? [])])
? effectiveWorkspaceOrder(project.worktree, [
project.worktree,
...(project.sandboxes ?? []).map(workspacePath),
...(input.listed ?? []).map(workspacePath),
])
: [project.worktree, ...(project.sandboxes ?? [])]

for (const item of ordered) append(item)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
Expand Down
3 changes: 1 addition & 2 deletions packages/app/src/components/prompt-input/workspace-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}
return globalSDK.client.worktree
.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
.catch(() => [])
},
)
const workspaces = createMemo(() => {
Expand Down Expand Up @@ -132,4 +132,3 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}
</Popover>
)
}

27 changes: 22 additions & 5 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server"
import { useShellSurface } from "@/context/shell-surface"
import { useSync } from "@/context/sync"
import { PawworkWorktreeBadge } from "@/pages/layout/pawwork-worktree-badge"
import { useSessionLayout } from "@/pages/session/session-layout"
import { decode64 } from "@/utils/base64"
import { StatusPopover } from "../status-popover"
Expand Down Expand Up @@ -42,6 +43,11 @@ export function SessionHeader() {
})
const sessionInfo = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
const sessionTitle = createMemo(() => sessionInfo()?.title || params.id || "")
const activeWorktree = createMemo(() => {
const exec = sessionInfo()?.executionContext
if (!exec || exec.activeDirectory === exec.ownerDirectory) return
return exec.activeWorktree
})
const homeTitle = createMemo(() => language.t("command.session.new"))
const onSessionRoute = createMemo(() => location.pathname.includes("/session"))
const fileManagerLabel = createMemo(() => {
Expand Down Expand Up @@ -80,7 +86,7 @@ export function SessionHeader() {
<Show when={!shellSurface.settingsOpen() && centerMount()}>
{(mount) => (
<Portal mount={mount()}>
<div class="hidden md:flex min-w-0 items-center gap-1.5 text-13-medium">
<div class="hidden md:flex max-w-[min(72vw,760px)] min-w-0 items-center gap-1 text-13-medium">
<Show
when={params.id}
fallback={<div class="min-w-0 truncate text-text-strong">{homeTitle()}</div>}
Expand All @@ -90,7 +96,7 @@ export function SessionHeader() {
type="button"
variant="ghost"
size="small"
class="max-w-[180px] min-w-0 items-center gap-1 rounded-md px-1.5 shadow-none text-text-weak hover:text-text-strong"
class="h-6 max-w-[180px] min-w-0 items-center gap-1 rounded px-1 shadow-none text-13-medium text-text-weak hover:text-text-strong"
onClick={openProjectDirectory}
aria-label={
canOpenProjectDirectory() ? language.t("session.header.open.ariaLabel", { app: fileManagerLabel() }) : undefined
Expand All @@ -102,14 +108,25 @@ export function SessionHeader() {
}
disabled={!canOpenProjectDirectory()}
>
<Icon name="folder" size="small" class="shrink-0 text-icon-weak" />
<span class="min-w-0 truncate">{name()}</span>
</Button>
</Show>
<Show when={projectDirectory()}>
<span class="shrink-0 text-text-weaker">/</span>
<span class="shrink-0 text-13-regular text-text-weaker">/</span>
</Show>
<Show when={activeWorktree()}>
{(worktree) => (
<>
<PawworkWorktreeBadge
name={worktree().name}
branch={worktree().branch}
directory={worktree().directory}
/>
<span class="shrink-0 text-13-regular text-text-weaker">/</span>
</>
)}
</Show>
<span class="min-w-0 truncate text-text-strong">{sessionTitle()}</span>
<span class="min-w-0 truncate text-13-medium text-text-strong">{sessionTitle()}</span>
</Show>
</div>
</Portal>
Expand Down
21 changes: 19 additions & 2 deletions packages/app/src/components/settings-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,9 @@ import { SettingsGeneral } from "./settings-general"
import { SettingsKeybinds } from "./settings-keybinds"
import { SettingsModels } from "./settings-models"
import { SettingsProviders } from "./settings-providers"
import { SettingsWorktrees } from "./settings-worktrees"

export type SettingsPageTab = "general" | "shortcuts" | "providers" | "models"
export type SettingsPageTab = "general" | "shortcuts" | "providers" | "models" | "worktrees"

export const SettingsPage: Component<{
active: SettingsPageTab
Expand Down Expand Up @@ -86,7 +87,14 @@ export const SettingsPage: Component<{
variant="settings"
value={props.active}
onChange={(value) => {
if (value !== "general" && value !== "shortcuts" && value !== "providers" && value !== "models") return
if (
value !== "general" &&
value !== "shortcuts" &&
value !== "providers" &&
value !== "models" &&
value !== "worktrees"
)
return
props.onSelect(value)
}}
class="h-full w-full"
Expand Down Expand Up @@ -134,6 +142,10 @@ export const SettingsPage: Component<{
<Icon name="models" />
{language.t("settings.models.title")}
</Tabs.Trigger>
<Tabs.Trigger value="worktrees">
<Icon name="worktree" />
{language.t("settings.tab.worktrees")}
</Tabs.Trigger>
</div>
</div>
</div>
Expand Down Expand Up @@ -165,6 +177,11 @@ export const SettingsPage: Component<{
<SettingsModels />
</div>
</Tabs.Content>
<Tabs.Content value="worktrees" class="no-scrollbar">
<div class="mx-auto w-full max-w-[760px]">
<SettingsWorktrees />
</div>
</Tabs.Content>
</Tabs>
</section>
)
Expand Down
201 changes: 201 additions & 0 deletions packages/app/src/components/settings-worktrees.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import { type Component, createResource, createSignal, For, Show } from "solid-js"
import { Button } from "@opencode-ai/ui/button"
import { Icon } from "@opencode-ai/ui/icon"
import { showToast } from "@opencode-ai/ui/toast"
import { useGlobalSDK } from "@/context/global-sdk"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { SettingsList } from "./settings-list"

type WorktreeInfo = {
name: string
branch: string
directory: string
ownerDirectory: string
source?: "created" | "existing"
}

function basename(p: string): string {
const trimmed = p.replace(/[/\\]+$/, "")
const last = trimmed.split(/[/\\]/).pop()
return last || p
}

/**
* Settings → Worktrees panel.
*
* Lists every PawWork-tracked worktree directory for the current project (via
* `client.worktree.list()`); offers a per-row delete with two-step confirm. Delete is disabled
* when an open session in this app instance has the worktree as its activeDirectory; the user
* must call ExitWorktree from that session first.
*/
export const SettingsWorktrees: Component = () => {
const language = useLanguage()
const sdk = useGlobalSDK()
const sync = useGlobalSync()

const projectRoots = () =>
sync.data.project.filter((project) => project.vcs === "git").map((project) => project.worktree).filter(Boolean)

const [data, { refetch }] = createResource(
() => projectRoots().join("\0"),
async () => {
const rows = await Promise.all(
projectRoots().map(async (ownerDirectory) => {
const res = await sdk.client.worktree.list({ directory: ownerDirectory })
return (res.data ?? []).map((worktree) => ({ ...worktree, ownerDirectory }) as WorktreeInfo)
}),
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
const byDirectory = new Map<string, WorktreeInfo>()
for (const row of rows.flat()) byDirectory.set(row.directory, row)
return Array.from(byDirectory.values())
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
},
)

// Sessions whose activeDirectory points at a worktree path block its deletion.
const boundSessions = (): Map<string, string> => {
const map = new Map<string, string>()
const directories = new Set<string>()
for (const project of sync.data.project) {
directories.add(project.worktree)
for (const sandbox of project.sandboxes ?? []) directories.add(sandbox)
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
}
for (const worktree of data() ?? []) {
directories.add(worktree.ownerDirectory)
directories.add(worktree.directory)
}

for (const directory of directories) {
const [store] = sync.child(directory, { bootstrap: false })
for (const s of store.session ?? []) {
const exec = s.executionContext
if (!exec) continue
if (exec.activeDirectory && exec.activeDirectory !== exec.ownerDirectory) {
map.set(exec.activeDirectory, s.title)
}
}
}
return map
}

const [confirming, setConfirming] = createSignal<string | undefined>(undefined)
const [deleting, setDeleting] = createSignal<string | undefined>(undefined)

const handleDelete = async (directory: string) => {
setDeleting(directory)
try {
const ownerDirectory = data()?.find((worktree) => worktree.directory === directory)?.ownerDirectory ?? directory
const res = await sdk.client.worktree.remove({ directory: ownerDirectory, worktreeRemoveInput: { directory } })
if (res.error) throw new Error(JSON.stringify(res.error))
setConfirming(undefined)
void refetch()
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
showToast({
title: language.t("settings.worktrees.deleteFailed", { message }),
})
} finally {
setDeleting(undefined)
}
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved

return (
<SettingsList>
<div class="flex flex-col gap-3 py-4">
<div class="flex flex-col gap-1">
<h2 class="text-16-medium text-text-strong">{language.t("settings.worktrees.title")}</h2>
<p class="text-13-regular text-text-weak">{language.t("settings.worktrees.description")}</p>
</div>

<Show
when={!data.loading}
fallback={
<div class="text-13-regular text-text-weak py-6 text-center">{language.t("common.loading")}</div>
}
>
<Show
when={(data() ?? []).length > 0}
fallback={
<div class="text-13-regular text-text-weak py-6 text-center">
{language.t("settings.worktrees.empty")}
</div>
}
>
<ul
class="flex flex-col divide-y divide-line-base rounded border border-line-base"
data-component="settings-worktrees-list"
>
<For each={data() ?? []}>
{(worktree) => {
const directory = () => worktree.directory
const name = () => worktree.name || basename(worktree.directory)
const branch = () => worktree.branch || "-"
const blocker = () => boundSessions().get(worktree.directory)
const blocked = () => !!blocker()
const isConfirming = () => confirming() === worktree.directory
const isDeleting = () => deleting() === worktree.directory

return (
<li class="flex items-center gap-3 px-3 py-2.5">
<Icon name="worktree" size="small" class="text-text-weak shrink-0" />
<div class="flex flex-col min-w-0 flex-1">
<span class="text-13-medium text-text-strong truncate" title={directory()}>
{name()}
</span>
<span class="text-13-regular text-text-weak truncate" title={directory()}>
{branch()} · {worktree.source ?? "created"} · {directory()}
</span>
</div>
<Show
when={isConfirming()}
fallback={
<Button
variant="ghost"
size="small"
disabled={blocked() || isDeleting()}
title={
blocked()
? language.t("settings.worktrees.deleteDisabled.tooltip", {
session: blocker() ?? "",
})
: undefined
}
onClick={() => setConfirming(directory())}
>
{language.t("settings.worktrees.delete")}
</Button>
}
>
<div class="flex items-center gap-1">
<span class="text-13-regular text-text-weak">
{language.t("settings.worktrees.confirmDelete.body", { name: name() })}
</span>
<Button
variant="ghost"
size="small"
disabled={isDeleting()}
onClick={() => setConfirming(undefined)}
>
{language.t("settings.worktrees.confirmDelete.cancelLabel")}
</Button>
<Button
variant="primary"
size="small"
disabled={isDeleting()}
onClick={() => handleDelete(directory())}
>
{language.t("settings.worktrees.confirmDelete.confirmLabel")}
</Button>
</div>
</Show>
</li>
)
}}
</For>
</ul>
</Show>
</Show>
</div>
</SettingsList>
)
}
Loading
Loading