Skip to content

Commit 4a8fee3

Browse files
authored
feat(app): add new session workspace controls (anomalyco#34085)
1 parent 0d3e0fc commit 4a8fee3

5 files changed

Lines changed: 171 additions & 12 deletions

File tree

packages/app/src/components/prompt-project-selector.tsx

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,10 @@ export function createPromptProjectController(input: {
167167

168168
export type PromptProjectController = ReturnType<typeof createPromptProjectController>
169169

170-
export function PromptProjectSelector(props: { controller: PromptProjectController }) {
170+
export function PromptProjectSelector(props: {
171+
controller: PromptProjectController
172+
placement?: "bottom" | "bottom-start"
173+
}) {
171174
let contentRef: HTMLDivElement | undefined
172175
let restoreTrigger = true
173176

@@ -229,9 +232,8 @@ export function PromptProjectSelector(props: { controller: PromptProjectControll
229232
return (
230233
<DropdownMenu
231234
open={props.controller.open()}
232-
placement="bottom-start"
235+
placement={props.placement ?? "bottom"}
233236
gutter={4}
234-
shift={-6}
235237
modal={false}
236238
onOpenChange={(open) => props.controller.setOpen(open)}
237239
>
@@ -376,11 +378,12 @@ function ProjectTrigger(props: ComponentProps<"button"> & { controller: PromptPr
376378
{...rest}
377379
data-action="prompt-project"
378380
type="button"
379-
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
381+
class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 transition-colors focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
380382
classList={{
381383
...local.classList,
382384
"hover:bg-v2-overlay-simple-overlay-hover": !local.controller.open(),
383385
"bg-v2-overlay-simple-overlay-pressed": local.controller.open(),
386+
"text-v2-text-text-muted": local.controller.open(),
384387
}}
385388
onClick={local.onClick ?? (() => local.controller.setOpen(true))}
386389
onKeyDown={(event) => {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import { For, Show } from "solid-js"
2+
import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
3+
import { Icon } from "@opencode-ai/ui/icon"
4+
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
5+
import { getFilename } from "@opencode-ai/core/util/path"
6+
import { useLanguage } from "@/context/language"
7+
8+
export function PromptWorkspaceSelector(props: {
9+
value: string
10+
projectRoot: string
11+
workspaces: string[]
12+
branch?: string
13+
onChange: (value: string) => void
14+
onDone: () => void
15+
}) {
16+
const language = useLanguage()
17+
let pending: string | undefined
18+
const selected = () => (props.value === props.projectRoot ? "main" : props.value)
19+
const icon = () => {
20+
if (selected() === "main") return "monitor"
21+
if (selected() === "create") return "workspace-new"
22+
return "workspace"
23+
}
24+
const select = (value: string) => {
25+
pending = value
26+
}
27+
const onOpenChange = (open: boolean) => {
28+
if (open) return
29+
const value = pending
30+
pending = undefined
31+
if (value) props.onChange(value)
32+
props.onDone()
33+
}
34+
const label = () => {
35+
if (selected() === "main") return language.t("session.new.workspace.triggerLocal")
36+
if (props.value === "create") return language.t("workspace.new")
37+
return getFilename(props.value)
38+
}
39+
40+
return (
41+
<>
42+
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
43+
<MenuV2 placement="bottom" gutter={4} onOpenChange={onOpenChange}>
44+
<MenuV2.Trigger class="flex h-7 min-w-0 max-w-[203px] items-center gap-1.5 rounded-sm px-1.5 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none data-[expanded]:bg-v2-overlay-simple-overlay-pressed data-[expanded]:text-v2-text-text-muted">
45+
<IconV2 name={icon()} class="shrink-0 text-v2-icon-icon-muted" />
46+
<span class="min-w-0 truncate">{label()}</span>
47+
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
48+
</MenuV2.Trigger>
49+
<MenuV2.Portal>
50+
<MenuV2.Content class="w-[180px]">
51+
<MenuV2.Group>
52+
<MenuV2.GroupLabel>{language.t("session.new.workspace.runIn")}</MenuV2.GroupLabel>
53+
<MenuV2.Item onSelect={() => select("main")}>
54+
<IconV2 name="monitor" />
55+
<span class="min-w-0 flex-1 truncate">{language.t("session.new.workspace.local")}</span>
56+
<Show when={selected() === "main"}>
57+
<Icon name="check" size="small" class="shrink-0" />
58+
</Show>
59+
</MenuV2.Item>
60+
<MenuV2.Item onSelect={() => select("create")}>
61+
<IconV2 name="workspace-new" />
62+
<span class="min-w-0 flex-1 truncate">{language.t("workspace.new")}</span>
63+
<Show when={selected() === "create"}>
64+
<Icon name="check" size="small" class="shrink-0" />
65+
</Show>
66+
</MenuV2.Item>
67+
</MenuV2.Group>
68+
<Show when={props.workspaces.length > 0}>
69+
<MenuV2.Separator />
70+
<MenuV2.Sub gutter={0} overlap overflowPadding={8}>
71+
<MenuV2.SubTrigger>
72+
<IconV2 name="workspace" />
73+
{language.t("session.new.workspace.existing")}
74+
</MenuV2.SubTrigger>
75+
<MenuV2.Portal>
76+
<MenuV2.SubContent class="max-w-[200px]">
77+
<For each={props.workspaces}>
78+
{(workspace) => (
79+
<MenuV2.Item onSelect={() => select(workspace)}>
80+
<IconV2 name="workspace-isolated" />
81+
<span class="min-w-0 flex-1 truncate">{getFilename(workspace)}</span>
82+
<Show when={selected() === workspace}>
83+
<Icon name="check" size="small" class="shrink-0" />
84+
</Show>
85+
</MenuV2.Item>
86+
)}
87+
</For>
88+
</MenuV2.SubContent>
89+
</MenuV2.Portal>
90+
</MenuV2.Sub>
91+
</Show>
92+
</MenuV2.Content>
93+
</MenuV2.Portal>
94+
</MenuV2>
95+
<span class="hidden select-none opacity-50 sm:inline mx-1">/</span>
96+
<div class="flex h-7 min-w-0 max-w-[220px] items-center gap-1.5 px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px]">
97+
<Icon name="branch" size="small" class="shrink-0 text-v2-icon-icon-muted" />
98+
<span class="min-w-0 truncate">{props.branch || "main"}</span>
99+
</div>
100+
</>
101+
)
102+
}

packages/app/src/i18n/en.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -656,6 +656,10 @@ export const dict = {
656656
"session.new.worktree.main": "Main branch",
657657
"session.new.worktree.mainWithBranch": "Main branch ({{branch}})",
658658
"session.new.worktree.create": "Create new worktree",
659+
"session.new.workspace.runIn": "Run session in",
660+
"session.new.workspace.triggerLocal": "Local",
661+
"session.new.workspace.local": "Local repository",
662+
"session.new.workspace.existing": "Workspace…",
659663
"session.new.lastModified": "Last modified",
660664

661665
"session.header.search.placeholder": "Search {{project}}",

packages/app/src/pages/new-session.tsx

Lines changed: 42 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ import { createPromptInputController, createPromptProjectControls } from "@/page
1919
import { useSessionKey } from "@/pages/session/session-layout"
2020
import { useComposerCommands } from "@/pages/session/use-composer-commands"
2121
import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout"
22+
import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector"
23+
24+
const showWorkspaceBar = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod"
2225

2326
/**
2427
* The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt
@@ -51,16 +54,21 @@ export default function NewSessionPage() {
5154
onDone: () => inputRef?.focus(),
5255
})
5356

54-
const [store, setStore] = createStore({
55-
worktree: "main",
56-
})
57+
const [store, setStore] = createStore<{ worktree?: string }>({})
5758

5859
const newSessionWorktree = createMemo(() => {
59-
if (store.worktree === "create") return "create"
60+
if (store.worktree) return store.worktree
6061
const project = sync().project
6162
if (project && sdk().directory !== project.worktree) return sdk().directory
6263
return "main"
6364
})
65+
const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory)
66+
const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch)
67+
const selectedBranch = createMemo(() => {
68+
const worktree = newSessionWorktree()
69+
if (worktree === "main" || worktree === "create") return localBranch()
70+
return serverSync().child(worktree)[0].vcs?.branch ?? localBranch()
71+
})
6472

6573
createEffect(() => {
6674
if (!prompt.ready()) return
@@ -97,15 +105,15 @@ export default function NewSessionPage() {
97105
</div>
98106
}
99107
>
100-
<div class="flex flex-col gap-3">
108+
<div class="flex flex-col" classList={{ "gap-8": showWorkspaceBar, "gap-3": !showWorkspaceBar }}>
101109
<PromptInput
102110
controls={inputController()}
103111
variant="new-session"
104112
ref={(el) => {
105113
inputRef = el
106114
}}
107115
newSessionWorktree={newSessionWorktree()}
108-
onNewSessionWorktreeReset={() => setStore("worktree", "main")}
116+
onNewSessionWorktreeReset={() => setStore("worktree", undefined)}
109117
onSubmit={() => comments.clear()}
110118
toolbar={
111119
<Show when={!projectController.selected()}>
@@ -114,8 +122,34 @@ export default function NewSessionPage() {
114122
}
115123
/>
116124
<Show when={projectController.selected()}>
117-
<div class="flex h-7 min-w-0 items-center gap-0 px-2">
118-
<PromptProjectSelector controller={projectController} />
125+
<div
126+
class="flex min-h-7 min-w-0 items-center gap-0 text-v2-text-text-faint"
127+
classList={{
128+
"flex-col justify-center sm:flex-row": showWorkspaceBar,
129+
"justify-start": !showWorkspaceBar,
130+
}}
131+
>
132+
<PromptProjectSelector
133+
controller={projectController}
134+
placement={showWorkspaceBar ? "bottom" : "bottom-start"}
135+
/>
136+
<Show when={showWorkspaceBar}>
137+
<PromptWorkspaceSelector
138+
value={newSessionWorktree()}
139+
projectRoot={projectRoot()}
140+
workspaces={sync().project?.sandboxes ?? []}
141+
branch={selectedBranch()}
142+
onChange={(value) =>
143+
setStore(
144+
"worktree",
145+
value === "main" && sync().project?.worktree !== sdk().directory
146+
? sync().project?.worktree
147+
: value,
148+
)
149+
}
150+
onDone={() => inputRef?.focus()}
151+
/>
152+
</Show>
119153
</div>
120154
</Show>
121155
</div>

packages/ui/src/v2/components/icon.tsx

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,22 @@ const icons = {
5353
viewBox: "0 0 16 16",
5454
body: `<path d="M3.53613 8.17857L6.39328 11.75L12.4647 4.25" stroke="currentColor"/>`,
5555
},
56+
monitor: {
57+
viewBox: "0 0 16 16",
58+
body: `<path d="M4.05559 9.38889H0.500007C0.500007 9.38889 0.500017 8.59298 0.500017 7.61112V2.27778C0.500017 1.29594 0.500102 0.5 0.500102 0.5H13.3889C13.3889 0.5 13.3889 1.29594 13.3889 2.27778V7.61112C13.3889 8.59298 13.3889 9.38889 13.3889 9.38889H9.83336M4.05559 9.38889V11.6111H6.94448H9.83336V9.38889M4.05559 9.38889H9.83336" transform="translate(1.05556 1.94444)" stroke="currentColor"/>`,
59+
},
60+
"workspace-new": {
61+
viewBox: "0 0 16 16",
62+
body: `<path d="M2 10.7578V14.0011H5.24324M13.9991 5.24324V2H10.7559M13.9991 10.7578V14.0011H10.7559M2 5.24324V2H5.24324" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M8 4.5V11.5M4.5 8H11.5" stroke="currentColor" stroke-linejoin="round"/>`,
63+
},
64+
"workspace-isolated": {
65+
viewBox: "0 0 16 16",
66+
body: `<path d="M10.5 10.5V5.5H5.5V10.5H10.5Z" fill="currentColor"/><rect x="2.5" y="2.5" width="11" height="11" stroke="currentColor"/>`,
67+
},
68+
workspace: {
69+
viewBox: "0 0 16 16",
70+
body: `<path d="M2 10.668V14.0013H10.6667M13.9974 10.6667V2H2.66406M13.9974 10.668V14.0013H10.6641M2 10V2H5.33333" stroke="currentColor" stroke-miterlimit="10" stroke-linecap="square"/><path d="M10.6693 10.6654V5.33203H5.33594V10.6654H10.6693Z" fill="currentColor"/>`,
71+
},
5672
close: {
5773
viewBox: "0 0 20 20",
5874
body: `<path d="M14.4446 5.55566L5.55566 14.4446M5.55566 5.55566L14.4446 14.4446" stroke="currentColor" stroke-linejoin="round"/>`,

0 commit comments

Comments
 (0)