Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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 start location|选择开始方式/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 start location|选择开始方式/i })

await chip.click()
const menu = page.getByRole("menu", { name: /Workspaces|工作目录/i })
const menu = page.getByRole("menu", { name: /Start from|开始方式/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 start location|选择开始方式/i })

await chip.focus()
await page.keyboard.press("Enter")
const menu = page.getByRole("menu", { name: /Workspaces|工作目录/i })
const menu = page.getByRole("menu", { name: /Start from|开始方式/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 start location|选择开始方式/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 start location|选择开始方式/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 start location|选择开始方式/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 start location|选择开始方式/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.getByRole("button", { name: /Choose start location|选择开始方式/i }).click()
await settleFrames(page, 3)
await page.keyboard.press("Escape")
runs.push(await snapshotPerfProbe(page))
Expand Down
46 changes: 41 additions & 5 deletions packages/app/src/components/prompt-input/workspace-chip-helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { effectiveWorkspaceOrder, workspaceKey } from "@/pages/layout/helpers"
import { getFilename } from "@opencode-ai/util/path"

export type WorkspaceEntry = string | { directory: string }

Expand All @@ -21,34 +22,69 @@ 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)
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 (!current) append(directory)
if (input.directStartDirectory) append(input.directStartDirectory, "direct-start")
if (!directory) {
for (const project of input.projects) append(project.worktree)
return choices
}

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
}) {
if (!input.directory) {
return input.directStartDirectory ? input.directStartLabel : input.emptyLabel
}
if (isDirectStartWorkspacePath(input.directory, input.directStartDirectory)) {
return input.directStartLabel
}
return getFilename(input.directory) || input.emptyLabel
}

export function workspaceChipIconName(input: { directory?: string; directStartDirectory?: string }) {
if (isDirectStartWorkspacePath(input.directory, input.directStartDirectory)) return "bubble-5"
if (!input.directory && input.directStartDirectory) return "bubble-5"
return "folder"
}
63 changes: 62 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,59 @@ 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("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,46 @@ 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 {
isDirectStartWorkspacePath,
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 directStartActive = createMemo(() => isDirectStartWorkspacePath(current(), directStartDirectory()))
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"),
})
})

return (
Expand All @@ -54,7 +66,13 @@ 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(),
})}
class="shrink-0 text-fg-weak"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
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 start location",
"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": "Start from",
"workspace.chip.add": "Open project",
"prompt.variant.popover.title": "Reasoning effort",
"about.title": "About PawWork",
"about.version": "Version",
Expand Down
10 changes: 6 additions & 4 deletions packages/app/src/i18n/zh.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,7 @@ export const dict = {
"sidebar.pawwork.empty.description": "打开一个项目后,即可使用以会话为中心的侧边栏。",
"sidebar.pawwork.pinned": "已置顶",
"sidebar.pawwork.all": "全部会话",
"sidebar.pawwork.directStart": "直接开始",
"sidebar.pawwork.pinSession": "置顶会话",
"sidebar.pawwork.unpinSession": "取消置顶",
"sidebar.pawwork.dragToPin": "拖到这里置顶",
Expand Down Expand Up @@ -1118,10 +1119,11 @@ export const dict = {
"debugBar.mem.label": "MEM",
"debugBar.mem.tipUnavailable": "使用的 JS 堆与堆限制。仅限 Chromium。",
"debugBar.mem.tip": "使用的 JS 堆与堆限制。{{used}} / {{limit}}。",
"workspace.chip.ariaLabel": "切换工作目录",
"workspace.chip.empty": "暂无可用工作目录",
"workspace.chip.popover.title": "工作目录",
"workspace.chip.add": "添加工作目录",
"workspace.chip.ariaLabel": "选择开始方式",
"workspace.chip.directStart": "直接开始",
"workspace.chip.empty": "暂无可用工作区",
"workspace.chip.popover.title": "开始方式",
"workspace.chip.add": "打开项目",
"prompt.variant.popover.title": "思考强度",
"common.key.ctrl": "Ctrl",
"common.key.alt": "Alt",
Expand Down
2 changes: 2 additions & 0 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
import {
batch,
createEffect,
Expand Down Expand Up @@ -353,6 +353,7 @@
params,
visibleSessionDirs,
projects: () => layout.projects.list(),
directStartDirectory: () => globalSync.data.path.directory,
workspaceName,
store,
setStore,
Expand Down Expand Up @@ -574,6 +575,7 @@
releaseTransientLocks: releaseTransientShellLocks,
resolveProjectRoot: projectRoot,
currentProjectRoot: () => currentProject()?.worktree ?? projectRoot(currentDir()),
directStartRoot: () => globalSync.data.path.directory,
chooseProject,
openSettingsSurface,
closeSettingsSurface: closeSettings,
Expand Down
Loading
Loading