Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
16 changes: 8 additions & 8 deletions packages/app/e2e/app/a11y-chip.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { test, expect } from "../fixtures"

test("WorkspaceChip renders in home, absent in session", async ({ page, project }) => {
await project.open()
const chip = page.getByRole("button", { name: /Switch workspace|切换工作目录/i })
const chip = page.getByRole("button", { name: /Choose workspace|选择工作目录/i })
await expect(chip).toBeVisible()

await project.prompt("Start a session from home")
Expand All @@ -12,16 +12,16 @@ test("WorkspaceChip renders in home, absent in session", async ({ page, project

test("WorkspaceChip selection navigates to the chosen workspace", async ({ page, project }) => {
await project.open()
const chip = page.getByRole("button", { name: /Switch workspace|切换工作目录/i })
const chip = page.getByRole("button", { name: /Choose workspace|选择工作目录/i })

await chip.click()
const menu = page.getByRole("menu", { name: /Workspaces|工作目录/i })
const menu = page.getByRole("menu", { name: /Workspace|工作目录/i })
await expect(menu).toBeVisible()
const options = menu.getByRole("menuitemradio")
const options = menu.locator('[role="menuitemradio"][aria-checked="false"]')
const optionCount = await options.count()
test.skip(optionCount < 2, "need at least 2 workspaces seeded to exercise a switch")
test.skip(optionCount < 1, "need at least 1 inactive workspace option seeded to exercise a switch")

const target = options.nth(1)
const target = options.first()
const targetLabel = (await target.locator("span").first().textContent())?.trim()
expect(targetLabel, "workspace option label should be non-empty").toBeTruthy()
const urlBefore = page.url()
Expand All @@ -34,11 +34,11 @@ test("WorkspaceChip selection navigates to the chosen workspace", async ({ page,

test("WorkspaceChip popover ESC + outside-click dismiss", async ({ page, project }) => {
await project.open()
const chip = page.getByRole("button", { name: /Switch workspace|切换工作目录/i })
const chip = page.getByRole("button", { name: /Choose workspace|选择工作目录/i })

await chip.focus()
await page.keyboard.press("Enter")
const menu = page.getByRole("menu", { name: /Workspaces|工作目录/i })
const menu = page.getByRole("menu", { name: /Workspace|工作目录/i })
await expect(menu).toBeVisible()
await page.keyboard.press("Escape")
await expect(menu).toHaveCount(0)
Expand Down
6 changes: 3 additions & 3 deletions packages/app/e2e/app/home.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ test("@smoke home renders hero composer with updated welcome heading", async ({

const home = page.locator('[data-component="session-new-home"]')
const composer = home.locator(sessionComposerDockSelector)
const workspaceChip = page.getByRole("button", { name: /Switch workspace|切换工作目录/i })
const workspaceChip = page.getByRole("button", { name: /Choose workspace|选择工作目录/i })
await expect(home).toBeVisible()
await expect(page.getByRole("heading", { name: /今天我们做点什么|What should we work on/ })).toBeVisible()
await expect(page.locator(sessionComposerDockSelector)).toHaveCount(1)
Expand Down Expand Up @@ -92,7 +92,7 @@ test("@smoke home composer shows unified single-row bar with brand orange send",
await expect(send).toBeDisabled()

// WorkspaceChip present on home
const workspaceChip = page.getByRole("button", { name: /Switch workspace|切换工作目录/i })
const workspaceChip = page.getByRole("button", { name: /Choose workspace|选择工作目录/i })
await expect(workspaceChip).toBeVisible()
})

Expand All @@ -103,7 +103,7 @@ test("home model chip keeps the single-row controls visible", async ({ page, pro
const composer = home.locator(sessionComposerDockSelector)
const attach = composer.locator('[data-action="prompt-attach"]').first()
const chip = composer.locator('[data-component="prompt-model-control"] [data-action="prompt-model"]').first()
const workspace = composer.getByRole("button", { name: /Switch workspace|切换工作目录/i })
const workspace = composer.getByRole("button", { name: /Choose workspace|选择工作目录/i })
const send = composer.locator('[data-action="prompt-submit"]').first()

await expect(chip).toBeVisible()
Expand Down
2 changes: 1 addition & 1 deletion packages/app/e2e/app/session.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ test("@smoke session composer matches home structure without docktray or agent c
await expect(page.locator('[data-component="prompt-agent-control"]')).toHaveCount(0)

// WorkspaceChip hidden in session (breadcrumb replaces it)
await expect(page.getByRole("button", { name: /Switch workspace|切换工作目录/i })).toHaveCount(0)
await expect(page.getByRole("button", { name: /Choose workspace|选择工作目录/i })).toHaveCount(0)

// Model control is inside the unified bar; variant selection is now folded
// into the model picker popover (see prompt-input/model-controls.tsx) and
Expand Down
2 changes: 1 addition & 1 deletion packages/app/e2e/perf/perf-probe.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -641,7 +641,7 @@ test.describe("PR0.1 perf probe baseline", () => {
const prompt = page.locator(promptSelector).first()
await expect(prompt).toBeVisible()
await prompt.click()
await page.getByRole("button", { name: /Switch workspace|切换工作目录/i }).click()
await page.locator('[data-action="prompt-workspace"]').click()
await settleFrames(page, 3)
await page.keyboard.press("Escape")
runs.push(await snapshotPerfProbe(page))
Expand Down
61 changes: 56 additions & 5 deletions packages/app/src/components/prompt-input/workspace-chip-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { effectiveWorkspaceOrder, workspaceKey } from "@/pages/layout/helpers"
import { getFilename } from "@opencode-ai/util/path"

export type WorkspaceEntry = string | { directory: string }

export type WorkspaceProject = {
name?: string
worktree: string
sandboxes?: WorkspaceEntry[]
}
Expand All @@ -21,34 +23,83 @@ export function findWorkspaceProject(projects: WorkspaceProject[], directory?: s
)
}

export type WorkspaceChoiceKind = "direct-start" | "workspace"

export type WorkspaceChoice = {
path: string
kind: WorkspaceChoiceKind
}

export function isDirectStartWorkspacePath(path: string | undefined, directStartDirectory?: string) {
return !!path && !!directStartDirectory && workspaceKey(path) === workspaceKey(directStartDirectory)
}

export function workspaceChipChoices(input: {
directory?: string
directStartDirectory?: string
projects: WorkspaceProject[]
}): WorkspaceChoice[] {
const directory = input.directory
if (!directory) return []

const current = findWorkspaceProject(input.projects, directory)
const currentIsDirectStart =
isDirectStartWorkspacePath(directory, input.directStartDirectory) && !findWorkspaceProject(input.projects, directory)
const current = currentIsDirectStart ? undefined : findWorkspaceProject(input.projects, directory)
const seen = new Set<string>()
const choices: WorkspaceChoice[] = []

const append = (value: WorkspaceEntry) => {
const append = (value: WorkspaceEntry, kind: WorkspaceChoiceKind = "workspace") => {
const path = workspacePath(value)
const key = workspaceKey(path)
if (seen.has(key)) return
seen.add(key)
choices.push({ path })
choices.push({ path, kind })
}

if (input.directStartDirectory && !findWorkspaceProject(input.projects, input.directStartDirectory)) {
append(input.directStartDirectory, "direct-start")
}
if (!directory) {
for (const project of input.projects) append(project.worktree)
return choices
}

if (!current) append(directory)
if (!current && !currentIsDirectStart) append(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
}

export function workspaceChipLabel(input: {
directory?: string
directStartDirectory?: string
directStartLabel: string
emptyLabel: string
projects?: WorkspaceProject[]
}) {
const matchingProject = findWorkspaceProject(input.projects ?? [], input.directory || input.directStartDirectory)
if (!input.directory) {
if (matchingProject) return matchingProject.name || getFilename(matchingProject.worktree)
return input.directStartDirectory ? input.directStartLabel : input.emptyLabel
}
if (isDirectStartWorkspacePath(input.directory, input.directStartDirectory) && matchingProject) {
return matchingProject.name || getFilename(matchingProject.worktree)
}
if (isDirectStartWorkspacePath(input.directory, input.directStartDirectory) && !matchingProject) {
return input.directStartLabel
}
return getFilename(input.directory) || input.emptyLabel
}

export function workspaceChipIconName(input: {
directory?: string
directStartDirectory?: string
projects?: WorkspaceProject[]
}) {
const matchingProject = findWorkspaceProject(input.projects ?? [], input.directory || input.directStartDirectory)
if (isDirectStartWorkspacePath(input.directory, input.directStartDirectory) && !matchingProject) return "bubble-5"
if (!input.directory && input.directStartDirectory && !matchingProject) return "bubble-5"
return "folder"
}
83 changes: 82 additions & 1 deletion packages/app/src/components/prompt-input/workspace-chip.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { expect, test } from "bun:test"

import { findWorkspaceProject, workspaceChipChoices } from "./workspace-chip-helpers"
import {
findWorkspaceProject,
workspaceChipChoices,
workspaceChipIconName,
workspaceChipLabel,
} from "./workspace-chip-helpers"

test("findWorkspaceProject matches sandboxes with normalized workspace keys", () => {
const project = findWorkspaceProject(
Expand Down Expand Up @@ -92,3 +97,79 @@ test("workspaceChipChoices ignores listed worktrees", () => {

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

test("workspaceChipChoices offers direct start before projects when no workspace is active", () => {
const result = workspaceChipChoices({
directStartDirectory: "/Users/demo/.pawwork",
projects: [{ worktree: "/repo/main" }],
})

expect(result).toEqual([
{ path: "/Users/demo/.pawwork", kind: "direct-start" },
{ path: "/repo/main", kind: "workspace" },
])
})

test("workspaceChipChoices does not expose the direct-start backing folder as an unknown workspace", () => {
const result = workspaceChipChoices({
directory: "/Users/demo/.pawwork",
directStartDirectory: "/Users/demo/.pawwork/",
projects: [{ worktree: "/repo/main" }],
})

expect(result).toEqual([
{ path: "/Users/demo/.pawwork/", kind: "direct-start" },
{ path: "/repo/main", kind: "workspace" },
])
})

test("workspaceChip treats the direct-start backing folder as a project when it is opened", () => {
const directStartDirectory = "/Users/demo/PawWork"
const project = { name: "Default Project", worktree: directStartDirectory }
const source = {
directory: directStartDirectory,
directStartDirectory: `${directStartDirectory}/`,
projects: [project],
}

expect(workspaceChipChoices(source)).toEqual([{ path: directStartDirectory, kind: "workspace" }])
expect(
workspaceChipLabel({
...source,
directStartLabel: "Direct start",
emptyLabel: "No workspace",
}),
).toBe("Default Project")
expect(workspaceChipIconName(source)).toBe("folder")
})

test("workspaceChipLabel names the empty and default-directory state as direct start", () => {
expect(
workspaceChipLabel({
directory: "",
directStartDirectory: "/Users/demo/.pawwork",
directStartLabel: "Direct start",
emptyLabel: "No workspace",
}),
).toBe("Direct start")

expect(
workspaceChipLabel({
directory: "/Users/demo/.pawwork",
directStartDirectory: "/Users/demo/.pawwork/",
directStartLabel: "Direct start",
emptyLabel: "No workspace",
}),
).toBe("Direct start")
})

test("workspaceChipIconName keeps the empty no-directory state folder-shaped", () => {
expect(workspaceChipIconName({ directory: "", directStartDirectory: "" })).toBe("folder")
expect(workspaceChipIconName({ directory: "", directStartDirectory: "/Users/demo/.pawwork" })).toBe("bubble-5")
expect(
workspaceChipIconName({
directory: "/Users/demo/.pawwork",
directStartDirectory: "/Users/demo/.pawwork/",
}),
).toBe("bubble-5")
})
40 changes: 32 additions & 8 deletions packages/app/src/components/prompt-input/workspace-chip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,34 +4,45 @@ import { base64Encode } from "@opencode-ai/util/encode"
import { getFilename } from "@opencode-ai/util/path"
import { useNavigate } from "@solidjs/router"
import { createMemo, createSignal, For, type JSX, Show } from "solid-js"
import { useGlobalSync } from "@/context/global-sync"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLayoutPage } from "@/context/layout-page"
import { useSessionLayout } from "@/pages/session/session-layout"
import { findWorkspaceProject, workspaceChipChoices } from "./workspace-chip-helpers"
import {
workspaceChipChoices,
workspaceChipIconName,
workspaceChipLabel,
} from "./workspace-chip-helpers"
import { workspaceKey } from "@/pages/layout/helpers"
import { decode64 } from "@/utils/base64"

export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}) {
const language = useLanguage()
const globalSync = useGlobalSync()
const layout = useLayout()
const layoutPage = useLayoutPage()
const navigate = useNavigate()
const { params } = useSessionLayout()
const [open, setOpen] = createSignal(false)

const current = createMemo(() => decode64(params.dir))
const project = createMemo(() => findWorkspaceProject(layout.projects.list(), current()))
const directStartDirectory = createMemo(() => globalSync.data.path.directory)
const workspaces = createMemo(() => {
return workspaceChipChoices({
directory: current(),
directStartDirectory: directStartDirectory(),
projects: layout.projects.list(),
})
})
const label = createMemo(() => {
const directory = current()
if (!directory) return language.t("workspace.chip.empty")
return getFilename(directory)
return workspaceChipLabel({
directory: current(),
directStartDirectory: directStartDirectory(),
directStartLabel: language.t("workspace.chip.directStart"),
emptyLabel: language.t("workspace.chip.empty"),
projects: layout.projects.list(),
})
})

return (
Expand All @@ -54,7 +65,14 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}
}
trigger={
<>
<Icon name="folder" class="shrink-0 text-fg-weak" />
<Icon
name={workspaceChipIconName({
directory: current(),
directStartDirectory: directStartDirectory(),
projects: layout.projects.list(),
})}
class="shrink-0 text-fg-weak"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
/>
<span class="max-w-[120px] truncate transition-[max-width] duration-200 ease-out @max-[24rem]/composer:max-w-0">{label()}</span>
<Icon name="chevron-down" class="shrink-0 text-fg-weak" />
</>
Expand All @@ -74,6 +92,7 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}
{(workspace) => {
const active = createMemo(() => {
const c = current()
if (workspace.kind === "direct-start" && !c) return true
return c ? workspaceKey(workspace.path) === workspaceKey(c) : false
})
return (
Expand All @@ -89,9 +108,14 @@ export function WorkspaceChip(props: { style?: JSX.CSSProperties | string } = {}
setOpen(false)
}}
>
<Icon name="folder" class="shrink-0 text-fg-weak" />
<Icon
name={workspace.kind === "direct-start" ? "bubble-5" : "folder"}
class="shrink-0 text-fg-weak"
/>
<span class="min-w-0 flex-1 truncate">
{getFilename(workspace.path)}
{workspace.kind === "direct-start"
? language.t("workspace.chip.directStart")
: getFilename(workspace.path)}
</span>
</button>
)
Expand Down
8 changes: 5 additions & 3 deletions packages/app/src/i18n/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -870,6 +870,7 @@ export const dict = {
"sidebar.pawwork.empty.description": "Open a project to start using the session-first sidebar.",
"sidebar.pawwork.pinned": "Pinned",
"sidebar.pawwork.all": "All sessions",
"sidebar.pawwork.directStart": "Direct start",
"sidebar.pawwork.pinSession": "Pin session",
"sidebar.pawwork.unpinSession": "Unpin session",
"sidebar.pawwork.dragToPin": "Drag here to pin",
Expand Down Expand Up @@ -1170,10 +1171,11 @@ export const dict = {
"workspace.reset.archived.one": "1 session will be archived.",
"workspace.reset.archived.many": "{{count}} sessions will be archived.",
"workspace.reset.note": "This will reset the workspace to match the default branch.",
"workspace.chip.ariaLabel": "Switch workspace",
"workspace.chip.ariaLabel": "Choose workspace",
"workspace.chip.directStart": "Direct start",
"workspace.chip.empty": "No workspaces available",
"workspace.chip.popover.title": "Workspaces",
"workspace.chip.add": "Add workspace",
"workspace.chip.popover.title": "Workspace",
"workspace.chip.add": "Open project",
"prompt.variant.popover.title": "Reasoning effort",
"about.title": "About PawWork",
"about.version": "Version",
Expand Down
Loading
Loading