Skip to content
Merged
Show file tree
Hide file tree
Changes from 58 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
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ jobs:
--reporter-outfile=.artifacts/unit/junit-windows-config-project.xml
test/config
test/project
test/worktree
test/file
test/github
test/settings
Expand Down
22 changes: 10 additions & 12 deletions packages/app/e2e/app/shell-frame.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
desktopShellFrameSelector,
desktopShellMainSelector,
desktopShellSelector,
titlebarCenterSelector,
titlebarLeftSelector,
titlebarRightSelector,
titlebarShellSelector,
Expand All @@ -19,7 +18,7 @@ test("@smoke shell frame exposes stable desktop hooks", async ({ page, gotoSessi
await expect(page.locator(titlebarShellSelector)).toBeVisible()
await expect(page.locator(desktopShellMainSelector)).toBeVisible()
await expect(page.locator(titlebarLeftSelector)).toHaveCount(1)
await expect(page.locator(titlebarCenterSelector)).toContainText(/new session/i)
await expect(page.locator(titlebarLeftSelector)).toContainText(/new session/i)
await expect(page.locator(`${titlebarRightSelector} button`).first()).toBeVisible()
await expect(page.getByRole("button", { name: /toggle sidebar/i }).first()).toBeVisible()

Expand All @@ -31,32 +30,31 @@ test("@smoke shell frame exposes stable desktop hooks", async ({ page, gotoSessi
await closeDialog(page, palette)
})

test("home titlebar center shows the current view title instead of the old file search affordance", async ({
test("home titlebar left slot shows the current view title instead of the old file search affordance", async ({
page,
gotoSession,
}) => {
await page.setViewportSize({ width: 1440, height: 900 })
await gotoSession()

const center = page.locator(titlebarCenterSelector)
await expect(center.getByText(/^new session$/i)).toBeVisible()
await expect(center.getByRole("button", { name: /search files/i })).toHaveCount(0)
const left = page.locator(titlebarLeftSelector)
await expect(left.getByText(/^new session$/i)).toBeVisible()
await expect(left.getByRole("button", { name: /search files/i })).toHaveCount(0)
})

test("session titlebar center shows a project and session breadcrumb", async ({ page, sdk, gotoSession }) => {
test("session titlebar left slot shows a project and session breadcrumb", async ({ page, sdk, gotoSession }) => {
await page.setViewportSize({ width: 1440, height: 900 })
const title = `e2e breadcrumb ${Date.now()}`

await withSession(sdk, title, async (session) => {
await gotoSession(session.id)

const center = page.locator(titlebarCenterSelector)
const buttons = center.getByRole("button")
const left = page.locator(titlebarLeftSelector)
const buttons = left.getByRole("button")

await expect(buttons).toHaveCount(1)
await expect(buttons.first()).toContainText(/.+/)
await expect(center).toContainText(title)
await expect(center).toContainText("/")
await expect(center.getByRole("button", { name: /search files/i })).toHaveCount(0)
await expect(left).toContainText(title)
await expect(left.getByRole("button", { name: /search files/i })).toHaveCount(0)
})
})
36 changes: 17 additions & 19 deletions packages/app/src/components/prompt-input/workspace-chip-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,27 +1,33 @@
import { effectiveWorkspaceOrder, workspaceKey } from "@/pages/layout/helpers"

export type WorkspaceEntry = string | { directory: 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),
)
}

export type WorkspaceChoice = {
path: string
branch?: string
}

export function workspaceChipChoices(input: {
directory?: string
projects: WorkspaceProject[]
listed?: string[]
}): WorkspaceChoice[] {
const directory = input.directory
if (!directory) return []
Expand All @@ -30,27 +36,19 @@ 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 })
}

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 ?? [])])
: [project.worktree, ...(project.sandboxes ?? [])]

for (const item of ordered) append(item)
}

if (current && !choices.some((item) => workspaceKey(item.path) === workspaceKey(directory))) {
choices.unshift({ path: directory })
}
const roots = input.projects.map((project) => project.worktree)
const ordered = current ? effectiveWorkspaceOrder(current.worktree, roots) : roots
for (const item of ordered) append(item)

return choices
}
36 changes: 30 additions & 6 deletions packages/app/src/components/prompt-input/workspace-chip.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ test("findWorkspaceProject matches sandboxes with normalized workspace keys", ()
expect(project?.worktree).toBe("/repo/main")
})

test("workspaceChipChoices lists all known project directories for global switching", () => {
test("workspaceChipChoices lists project roots for global switching", () => {
const result = workspaceChipChoices({
directory: "/repo/main",
projects: [
Expand All @@ -30,7 +30,7 @@ test("workspaceChipChoices lists all known project directories for global switch
],
})

expect(result.map((c) => c.path)).toEqual(["/repo/main", "/repo/feature-a", "/repo/analytics"])
expect(result.map((c) => c.path)).toEqual(["/repo/main", "/repo/analytics"])
})

test("workspaceChipChoices preserves current directory when it is not part of the known project list", () => {
Expand All @@ -47,7 +47,24 @@ test("workspaceChipChoices preserves current directory when it is not part of th
],
})

expect(result.map((c) => c.path)).toEqual(["/repo/feature-c", "/repo/main", "/repo/feature-a", "/repo/analytics"])
expect(result.map((c) => c.path)).toEqual(["/repo/feature-c", "/repo/main", "/repo/analytics"])
})

test("workspaceChipChoices omits known worktrees from the homepage workspace list", () => {
const result = workspaceChipChoices({
directory: "/repo/feature-a",
projects: [
{
worktree: "/repo/main",
sandboxes: ["/repo/feature-a"],
},
{
worktree: "/repo/analytics",
},
],
})

expect(result.map((c) => c.path)).toEqual(["/repo/main", "/repo/analytics"])
})

test("each choice exposes path field for sub-label rendering", () => {
Expand All @@ -60,11 +77,18 @@ test("each choice exposes path field for sub-label rendering", () => {
expect(typeof result[0].path).toBe("string")
})

test("branch field is optional (not required when SDK can't resolve)", () => {
test("workspaceChipChoices ignores listed worktrees", () => {
const result = workspaceChipChoices({
directory: "/repo/main",
projects: [{ worktree: "/repo/main" }],
projects: [
{
worktree: "/repo/main",
sandboxes: ["/repo/feature-a"],
},
],
// @ts-expect-error listed is intentionally no longer part of the public helper input.
listed: [{ directory: "/repo/feature-b" }],
})

expect(result[0].branch === undefined || typeof result[0].branch === "string").toBe(true)
expect(result.map((c) => c.path)).toEqual(["/repo/main"])
})
19 changes: 1 addition & 18 deletions packages/app/src/components/prompt-input/workspace-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@ import { Popover } from "@opencode-ai/ui/popover"
import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createResource, createSignal, For, type JSX, Show } from "solid-js"
import { useGlobalSDK } from "@/context/global-sdk"
import { createMemo, createSignal, For, type JSX, Show } from "solid-js"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLayoutPage } from "@/context/layout-page"
Expand All @@ -15,7 +14,6 @@ import { decode64 } from "@/utils/base64"

export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}) {
const language = useLanguage()
const globalSDK = useGlobalSDK()
const layout = useLayout()
const layoutPage = useLayoutPage()
const navigate = useNavigate()
Expand All @@ -24,24 +22,10 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}

const current = createMemo(() => decode64(params.dir))
const project = createMemo(() => findWorkspaceProject(layout.projects.list(), current()))
const root = createMemo(() => project()?.worktree ?? current())
// Fetch on mount (not gated on open) so popover content is ready when clicked.
// Previously gated on open() which caused the list to flash empty→full on every click.
const [listed] = createResource(
() => root(),
async (directory) => {
if (!directory) return []
return globalSDK.client.worktree
.list({ directory })
.then((x) => x.data ?? [])
.catch(() => [] as string[])
},
)
const workspaces = createMemo(() => {
return workspaceChipChoices({
directory: current(),
projects: layout.projects.list(),
listed: listed(),
})
})
const label = createMemo(() => {
Expand Down Expand Up @@ -132,4 +116,3 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}
</Popover>
)
}

93 changes: 58 additions & 35 deletions packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,13 @@ import { createMediaQuery } from "@solid-primitives/media"
import { createMemo, Show } from "solid-js"
import { useLocation } from "@solidjs/router"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
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,16 +42,23 @@ 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(() => {
if (platform.os === "windows") return language.t("session.header.open.fileExplorer")
if (platform.os === "linux") return language.t("session.header.open.fileManager")
return language.t("session.header.open.finder")
})
const canOpenProjectDirectory = createMemo(
() => platform.platform === "desktop" && !!platform.openPath && server.isLocal() && !!projectDirectory(),
)
const canOpenDirectory = (directory?: string) =>
platform.platform === "desktop" && !!platform.openPath && server.isLocal() && !!directory
const activeWorktreeDirectory = createMemo(() => activeWorktree()?.directory ?? "")
const canOpenProjectDirectory = createMemo(() => canOpenDirectory(projectDirectory()))
const canOpenActiveWorktreeDirectory = createMemo(() => canOpenDirectory(activeWorktreeDirectory()))
const rightPanelOpen = createMemo(() => view().sidePanel.opened())
const toggleRightPanel = () => {
if (rightPanelOpen()) {
Expand All @@ -60,9 +67,8 @@ export function SessionHeader() {
}
view().sidePanel.open()
}
const openProjectDirectory = () => {
const directory = projectDirectory()
if (!directory || !platform.openPath || !canOpenProjectDirectory()) return
const openDirectory = (directory: string) => {
if (!canOpenDirectory(directory) || !platform.openPath) return
void platform.openPath(directory).catch((error) => {
showToast({
variant: "error",
Expand All @@ -71,45 +77,62 @@ export function SessionHeader() {
})
})
}
const openProjectDirectory = () => openDirectory(projectDirectory())
const openActiveWorktree = () => {
openDirectory(activeWorktreeDirectory())
}

const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center"))
const leftMount = createMemo(() => document.getElementById("opencode-titlebar-left"))
const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right"))

return (
<>
<Show when={!shellSurface.settingsOpen() && centerMount()}>
<Show when={!shellSurface.settingsOpen() && leftMount()}>
{(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 w-full min-w-0 max-w-[720px] items-center overflow-hidden text-13-medium">
<Show
when={params.id}
fallback={<div class="min-w-0 truncate text-text-strong">{homeTitle()}</div>}
>
<Show when={projectDirectory()}>
<Button
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"
onClick={openProjectDirectory}
aria-label={
canOpenProjectDirectory() ? language.t("session.header.open.ariaLabel", { app: fileManagerLabel() }) : undefined
}
title={
canOpenProjectDirectory()
? `${projectDirectory()} (${language.t("session.header.open.ariaLabel", { app: fileManagerLabel() })})`
: projectDirectory()
}
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>
</Show>
<span class="min-w-0 truncate text-text-strong">{sessionTitle()}</span>
<span class="max-w-full shrink-0 truncate text-13-medium text-text-strong" title={sessionTitle()}>
{sessionTitle()}
</span>
<div class="ml-3 flex min-w-0 flex-1 items-center gap-1.5 overflow-hidden">
<Show when={projectDirectory()}>
<Button
type="button"
variant="ghost"
size="small"
class="group h-6 max-w-[180px] min-w-0 shrink items-center gap-1 rounded px-1 shadow-none text-13-regular text-text-weak hover:text-text-strong"
onClick={openProjectDirectory}
aria-label={
canOpenProjectDirectory() ? language.t("session.header.open.ariaLabel", { app: fileManagerLabel() }) : undefined
}
title={
canOpenProjectDirectory()
? `${projectDirectory()} (${language.t("session.header.open.ariaLabel", { app: fileManagerLabel() })})`
: projectDirectory()
}
disabled={!canOpenProjectDirectory()}
>
<Icon name="folder" size="small" class="shrink-0 text-text-weak transition-colors group-hover:text-text-strong" />
<span class="min-w-0 truncate">{name()}</span>
</Button>
</Show>
<Show when={activeWorktree()}>
{(worktree) => (
<PawworkWorktreeBadge
name={worktree().name}
branch={worktree().branch}
directory={worktree().directory}
onClick={openActiveWorktree}
ariaLabel={language.t("session.header.worktree.open")}
disabled={!canOpenActiveWorktreeDirectory()}
/>
)}
</Show>
</div>
</Show>
</div>
</Portal>
Expand Down
Loading
Loading