From 44806777ca74614e4393a7e893b8d7816528743f Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 26 Jun 2026 01:47:45 +0800 Subject: [PATCH] feat(app): group prompt projects by server (#33941) --- packages/app/src/components/prompt-input.tsx | 192 ++++++++++++++---- .../composer/session-composer-controls.ts | 51 +++-- 2 files changed, 192 insertions(+), 51 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 20bee2dbb1..811551e3f4 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -30,7 +30,7 @@ import { AgentPart, FileAttachmentPart, } from "@/context/prompt" -import { useLayout } from "@/context/layout" +import { getProjectAvatarVariant, useLayout } from "@/context/layout" import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useComments } from "@/context/comments" @@ -38,6 +38,7 @@ import { Button } from "@opencode-ai/ui/button" import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface" import { Icon, type IconProps } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" @@ -71,7 +72,7 @@ import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" import { pathKey } from "@/utils/path-key" -import { displayName } from "@/pages/layout/helpers" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" export type PromptInputState = ReturnType @@ -100,10 +101,18 @@ export type PromptInputControls = { loading: boolean } projects: { - available: { name?: string; worktree: string; sandboxes?: string[] }[] + available: { + name?: string + id?: string + worktree: string + sandboxes?: string[] + icon?: { color?: string; url?: string; override?: string } + server?: { key: string; name: string } + }[] directory: string - select: (worktree: string) => void - add: (title: string) => void + server?: string + select: (worktree: string, server?: string) => void + add: (title: string, server?: string) => void } session: { id?: string @@ -1397,7 +1406,9 @@ export const PromptInput: Component = (props) => { if (!directory) return const key = pathKey(directory) return projects().find( - (project) => pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key), + (project) => + (!project.server || project.server.key === props.controls.projects.server) && + (pathKey(project.worktree) === key || project.sandboxes?.some((sandbox) => pathKey(sandbox) === key)), ) } const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory)) @@ -1407,47 +1418,114 @@ export const PromptInput: Component = (props) => { return projects().filter((project) => displayName(project).toLowerCase().includes(search)) }) const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) - const selectProject = (worktree: string) => { + const selectProject = (worktree: string, server?: string) => { setPicker({ projectOpen: false, projectSearch: "", }) - if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) { + if ( + pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "") && + server === selectedProject()?.server?.key + ) { restoreFocus() return } - props.controls.projects.select(worktree) + props.controls.projects.select(worktree, server) restoreFocus() } - const addProject = () => { - props.controls.projects.add(language.t("command.project.open")) + const addProject = (server?: string) => { + props.controls.projects.add(language.t("command.project.open"), server) } - const projectPickerState = createMemo(() => ({ - open: picker.projectOpen, + const projectItems = createMemo(() => + projectResults().map((project) => ({ + leading: ( + + ), + label: displayName(project), + selected: + selectedProject()?.worktree === project.worktree && + selectedProject()?.server?.key === project.server?.key, + onSelect: () => selectProject(project.worktree, project.server?.key), + })), + ) + const projectServers = createMemo(() => + projects() + .map((project) => project.server) + .filter((server, index, all) => server && all.findIndex((item) => item?.key === server.key) === index), + ) + const addProjectLabel = createMemo(() => language.t("session.new.project.add")) + const projectGroups = createMemo(() => { + if (projectServers().length <= 1) return + return projectServers() + .map((server) => ({ + label: server!.name, + server: server!.key, + items: projectItems().filter((_, index) => projectResults()[index]?.server?.key === server!.key), + action: { + icon: "plus" as const, + label: addProjectLabel(), + onSelect: () => { + setPicker("projectOpen", false) + addProject(server!.key) + }, + }, + })) + .filter((group) => group.items.length > 0) + }) + const projectPickerState: ComposerPickerState = { + get open() { + return picker.projectOpen + }, trigger: { action: "prompt-project", - icon: "folder", - label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), + get icon() { + return selectedProject() ? undefined : "folder" + }, + get leading() { + const project = selectedProject() + if (!project) return + return ( + + ) + }, + get label() { + const project = selectedProject() + return project ? displayName(project) : language.t("session.new.project.new") + }, class: "max-w-[203px]", - style: control(), + get style() { + return control() + }, onPress: () => setPicker("projectOpen", true), }, - search: picker.projectSearch, + get search() { + return picker.projectSearch + }, searchPlaceholder: language.t("session.new.project.search"), clearLabel: language.t("common.clear"), - items: projectResults().map((project) => ({ - icon: "folder", - label: displayName(project), - selected: selectedProject()?.worktree === project.worktree, - onSelect: () => selectProject(project.worktree), - })), + get items() { + return projectItems() + }, + get groups() { + return projectGroups() + }, action: { icon: "plus", - label: language.t("session.new.project.add"), + get label() { + return addProjectLabel() + }, onSelect: () => { setPicker("projectOpen", false) - void addProject() + addProject(projectServers()[0]?.key) }, }, onOpenChange: (open) => { @@ -1457,7 +1535,7 @@ export const PromptInput: Component = (props) => { onSearchInput: (value) => setPicker("projectSearch", value), onSearchClear: () => setPicker("projectSearch", ""), searchRef: (el) => (projectSearchRef = el), - })) + } const agentControlState = createMemo(() => ({ title: language.t("command.agent.cycle"), keybind: command.keybind("agent.cycle"), @@ -1666,7 +1744,7 @@ export const PromptInput: Component = (props) => {
- +
@@ -2011,7 +2089,8 @@ export const PromptInput: Component = (props) => { } type ComposerPickerItemState = { - icon: IconProps["name"] + icon?: IconProps["name"] + leading?: JSX.Element label: string selected?: boolean onSelect: () => void @@ -2020,6 +2099,7 @@ type ComposerPickerItemState = { type ComposerPickerTriggerState = { action: string icon?: IconProps["name"] + leading?: JSX.Element label: string class?: string style: JSX.CSSProperties | undefined @@ -2033,6 +2113,7 @@ type ComposerPickerState = { searchPlaceholder: string clearLabel: string items: ComposerPickerItemState[] + groups?: { label: string; server: string; items: ComposerPickerItemState[]; action: ComposerPickerItemState }[] action: ComposerPickerItemState listClass?: string searchRef: (el: HTMLInputElement) => void @@ -2070,12 +2151,19 @@ function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: Compos {...rest} data-action={local.state.action} type="button" - class={`flex h-7 min-w-0 items-center gap-1.5 rounded px-2 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint transition-colors hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`} + class={`flex h-7 min-w-0 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 hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none ${local.state.class ?? ""}`} style={local.state.style} onClick={() => local.state.onPress()} > - - {(icon) => } + + {(icon) => } + + } + > + {(leading) => leading()} {local.state.label} @@ -2087,10 +2175,19 @@ function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) { return ( - {(item) => } - -
-
- + {(item) => }} + > + {(groups) => ( + + {(group) => ( +
+
+ {group.label} +
+ {(item) => } + +
+ )} +
+ )} +
+ +
+
+ +
+ diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index 44ad636a21..21b753e6ac 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -8,7 +8,7 @@ import { useGlobal } from "@/context/global" import { useLayout } from "@/context/layout" import { useLocal } from "@/context/local" import type { QueryOptionsApi } from "@/context/server-sync" -import { ServerConnection, useServer } from "@/context/server" +import { serverName, ServerConnection, useServer } from "@/context/server" import { useSDK } from "@/context/sdk" import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" @@ -49,38 +49,61 @@ export function createSessionComposerControls(input: { const conn = projectServer() if (conn) return global.ensureServerCtx(conn) }) - const projects = createMemo(() => - search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(), - ) + const projects = createMemo(() => { + if (server.list.length <= 1) { + return search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list() + } + return server.list.flatMap((conn) => { + const server = { key: ServerConnection.key(conn), name: serverName(conn) } + return global + .ensureServerCtx(conn) + .projects.list() + .map((project) => ({ ...project, server })) + }) + }) const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) - const selectProject = (worktree: string) => { - const conn = projectServer() - const target = projectServerCtx() + const selectProject = (worktree: string, serverKey?: string) => { + const conn = serverKey + ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) + : projectServer() if (search.draftId) { - if (!conn || !target) return + if (!conn) return + const target = global.ensureServerCtx(conn) target.projects.open(worktree) target.projects.touch(worktree) tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree }) return } - layout.projects.open(worktree) - server.projects.touch(worktree) + if (!serverKey) { + layout.projects.open(worktree) + server.projects.touch(worktree) + navigate(`/${base64Encode(worktree)}/session`) + return + } + + if (!conn) return + const target = global.ensureServerCtx(conn) + target.projects.open(worktree) + target.projects.touch(worktree) + server.setActive(ServerConnection.key(conn)) navigate(`/${base64Encode(worktree)}/session`) } - const addProject = (title: string) => { - const conn = projectServer() + const addProject = (title: string, serverKey?: string) => { + const conn = serverKey + ? server.list.find((conn) => ServerConnection.key(conn) === serverKey) + : projectServer() if (!conn) return pickDirectory({ server: conn, title, onSelect: (result) => { const directory = Array.isArray(result) ? result[0] : result - if (directory) selectProject(directory) + if (directory) selectProject(directory, serverKey) }, }) } @@ -102,6 +125,8 @@ export function createSessionComposerControls(input: { projects: { available: projects(), directory: sdk().directory, + server: + server.list.length > 1 && projectServer() ? ServerConnection.key(projectServer()!) : undefined, select: selectProject, add: addProject, },