feat(app): group prompt projects by server (#33941)

This commit is contained in:
Brendan Allan 2026-06-26 01:47:45 +08:00 committed by GitHub
parent a267222080
commit 44806777ca
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 192 additions and 51 deletions

View file

@ -30,7 +30,7 @@ import {
AgentPart, AgentPart,
FileAttachmentPart, FileAttachmentPart,
} from "@/context/prompt" } from "@/context/prompt"
import { useLayout } from "@/context/layout" import { getProjectAvatarVariant, useLayout } from "@/context/layout"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useComments } from "@/context/comments" 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 { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
import { Icon, type IconProps } from "@opencode-ai/ui/icon" import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-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 { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { IconButton } from "@opencode-ai/ui/icon-button" import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select" import { Select } from "@opencode-ai/ui/select"
@ -71,7 +72,7 @@ import { createPromptInputTransientState } from "./prompt-input/transient-state"
import { showToast } from "@/utils/toast" import { showToast } from "@/utils/toast"
import { ImagePreview } from "@opencode-ai/ui/image-preview" import { ImagePreview } from "@opencode-ai/ui/image-preview"
import { pathKey } from "@/utils/path-key" import { pathKey } from "@/utils/path-key"
import { displayName } from "@/pages/layout/helpers" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers"
export type PromptInputState = ReturnType<typeof usePrompt> export type PromptInputState = ReturnType<typeof usePrompt>
@ -100,10 +101,18 @@ export type PromptInputControls = {
loading: boolean loading: boolean
} }
projects: { 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 directory: string
select: (worktree: string) => void server?: string
add: (title: string) => void select: (worktree: string, server?: string) => void
add: (title: string, server?: string) => void
} }
session: { session: {
id?: string id?: string
@ -1397,7 +1406,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
if (!directory) return if (!directory) return
const key = pathKey(directory) const key = pathKey(directory)
return projects().find( 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)) const selectedProject = createMemo(() => projectForDirectory(props.controls.projects.directory))
@ -1407,47 +1418,114 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
return projects().filter((project) => displayName(project).toLowerCase().includes(search)) return projects().filter((project) => displayName(project).toLowerCase().includes(search))
}) })
const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0) const showAgentControl = createMemo(() => props.controls.agents.visible && props.controls.agents.options.length > 0)
const selectProject = (worktree: string) => { const selectProject = (worktree: string, server?: string) => {
setPicker({ setPicker({
projectOpen: false, projectOpen: false,
projectSearch: "", projectSearch: "",
}) })
if (pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "")) { if (
pathKey(worktree) === pathKey(selectedProject()?.worktree ?? "") &&
server === selectedProject()?.server?.key
) {
restoreFocus() restoreFocus()
return return
} }
props.controls.projects.select(worktree) props.controls.projects.select(worktree, server)
restoreFocus() restoreFocus()
} }
const addProject = () => { const addProject = (server?: string) => {
props.controls.projects.add(language.t("command.project.open")) props.controls.projects.add(language.t("command.project.open"), server)
} }
const projectPickerState = createMemo<ComposerPickerState>(() => ({ const projectItems = createMemo<ComposerPickerItemState[]>(() =>
open: picker.projectOpen, projectResults().map((project) => ({
leading: (
<ProjectAvatar
fallback={displayName(project)}
src={getProjectAvatarSource(project.id, project.icon)}
variant={getProjectAvatarVariant(project.icon?.color)}
/>
),
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: { trigger: {
action: "prompt-project", action: "prompt-project",
icon: "folder", get icon() {
label: selectedProject() ? displayName(selectedProject()!) : language.t("session.new.project.new"), return selectedProject() ? undefined : "folder"
},
get leading() {
const project = selectedProject()
if (!project) return
return (
<ProjectAvatar
fallback={displayName(project)}
src={getProjectAvatarSource(project.id, project.icon)}
variant={getProjectAvatarVariant(project.icon?.color)}
/>
)
},
get label() {
const project = selectedProject()
return project ? displayName(project) : language.t("session.new.project.new")
},
class: "max-w-[203px]", class: "max-w-[203px]",
style: control(), get style() {
return control()
},
onPress: () => setPicker("projectOpen", true), onPress: () => setPicker("projectOpen", true),
}, },
search: picker.projectSearch, get search() {
return picker.projectSearch
},
searchPlaceholder: language.t("session.new.project.search"), searchPlaceholder: language.t("session.new.project.search"),
clearLabel: language.t("common.clear"), clearLabel: language.t("common.clear"),
items: projectResults().map((project) => ({ get items() {
icon: "folder", return projectItems()
label: displayName(project), },
selected: selectedProject()?.worktree === project.worktree, get groups() {
onSelect: () => selectProject(project.worktree), return projectGroups()
})), },
action: { action: {
icon: "plus", icon: "plus",
label: language.t("session.new.project.add"), get label() {
return addProjectLabel()
},
onSelect: () => { onSelect: () => {
setPicker("projectOpen", false) setPicker("projectOpen", false)
void addProject() addProject(projectServers()[0]?.key)
}, },
}, },
onOpenChange: (open) => { onOpenChange: (open) => {
@ -1457,7 +1535,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onSearchInput: (value) => setPicker("projectSearch", value), onSearchInput: (value) => setPicker("projectSearch", value),
onSearchClear: () => setPicker("projectSearch", ""), onSearchClear: () => setPicker("projectSearch", ""),
searchRef: (el) => (projectSearchRef = el), searchRef: (el) => (projectSearchRef = el),
})) }
const agentControlState = createMemo<ComposerAgentControlState>(() => ({ const agentControlState = createMemo<ComposerAgentControlState>(() => ({
title: language.t("command.agent.cycle"), title: language.t("command.agent.cycle"),
keybind: command.keybind("agent.cycle"), keybind: command.keybind("agent.cycle"),
@ -1666,7 +1744,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
</DockShellForm> </DockShellForm>
<Show when={newSession() && selectedProject()}> <Show when={newSession() && selectedProject()}>
<div class="flex h-7 min-w-0 items-center gap-0 px-2"> <div class="flex h-7 min-w-0 items-center gap-0 px-2">
<ComposerPicker state={projectPickerState()} /> <ComposerPicker state={projectPickerState} />
</div> </div>
</Show> </Show>
</div> </div>
@ -2011,7 +2089,8 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
} }
type ComposerPickerItemState = { type ComposerPickerItemState = {
icon: IconProps["name"] icon?: IconProps["name"]
leading?: JSX.Element
label: string label: string
selected?: boolean selected?: boolean
onSelect: () => void onSelect: () => void
@ -2020,6 +2099,7 @@ type ComposerPickerItemState = {
type ComposerPickerTriggerState = { type ComposerPickerTriggerState = {
action: string action: string
icon?: IconProps["name"] icon?: IconProps["name"]
leading?: JSX.Element
label: string label: string
class?: string class?: string
style: JSX.CSSProperties | undefined style: JSX.CSSProperties | undefined
@ -2033,6 +2113,7 @@ type ComposerPickerState = {
searchPlaceholder: string searchPlaceholder: string
clearLabel: string clearLabel: string
items: ComposerPickerItemState[] items: ComposerPickerItemState[]
groups?: { label: string; server: string; items: ComposerPickerItemState[]; action: ComposerPickerItemState }[]
action: ComposerPickerItemState action: ComposerPickerItemState
listClass?: string listClass?: string
searchRef: (el: HTMLInputElement) => void searchRef: (el: HTMLInputElement) => void
@ -2070,12 +2151,19 @@ function ComposerPickerTrigger(props: ComponentProps<"button"> & { state: Compos
{...rest} {...rest}
data-action={local.state.action} data-action={local.state.action}
type="button" 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} style={local.state.style}
onClick={() => local.state.onPress()} onClick={() => local.state.onPress()}
> >
<Show when={local.state.icon}> <Show
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />} when={local.state.leading}
fallback={
<Show when={local.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-muted" />}
</Show>
}
>
{(leading) => leading()}
</Show> </Show>
<span class="min-w-0 truncate leading-5">{local.state.label}</span> <span class="min-w-0 truncate leading-5">{local.state.label}</span>
<Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" /> <Icon name="chevron-down" size="small" class="shrink-0 text-v2-icon-icon-muted" />
@ -2087,10 +2175,19 @@ function ComposerPickerMenuItem(props: { state: ComposerPickerItemState }) {
return ( return (
<button <button
type="button" type="button"
class="flex h-7 w-full items-center gap-2 rounded px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" class="flex h-7 w-full items-center gap-2 rounded-sm px-3 text-left text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none"
onClick={props.state.onSelect} onClick={props.state.onSelect}
> >
<Icon name={props.state.icon} size="small" class="shrink-0 text-v2-icon-icon-base" /> <Show
when={props.state.leading}
fallback={
<Show when={props.state.icon}>
{(icon) => <Icon name={icon()} size="small" class="shrink-0 text-v2-icon-icon-base" />}
</Show>
}
>
{(leading) => leading()}
</Show>
<span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span> <span class="min-w-0 flex-1 truncate leading-5">{props.state.label}</span>
<Show when={props.state.selected}> <Show when={props.state.selected}>
<Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" /> <Icon name="check-small" size="small" class="shrink-0 text-v2-icon-icon-base" />
@ -2135,12 +2232,31 @@ function ComposerPicker(props: { state: ComposerPickerState }) {
</button> </button>
</Show> </Show>
</div> </div>
<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For> <Show
</div> when={props.state.groups}
<div class="h-px bg-v2-border-border-muted" /> fallback={<For each={props.state.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>}
<div class="flex flex-col p-0.5"> >
<ComposerPickerMenuItem state={props.state.action} /> {(groups) => (
<For each={groups()}>
{(group) => (
<div>
<div class="flex h-7 select-none items-center pl-1.5 pr-3 text-[11px] font-[530] leading-none tracking-[0.05px] text-v2-text-text-faint">
{group.label}
</div>
<For each={group.items}>{(item) => <ComposerPickerMenuItem state={item} />}</For>
<ComposerPickerMenuItem state={group.action} />
</div>
)}
</For>
)}
</Show>
</div> </div>
<Show when={!props.state.groups}>
<div class="h-px bg-v2-border-border-muted" />
<div class="flex flex-col p-0.5">
<ComposerPickerMenuItem state={props.state.action} />
</div>
</Show>
</KobaltePopover.Content> </KobaltePopover.Content>
</KobaltePopover.Portal> </KobaltePopover.Portal>
</KobaltePopover> </KobaltePopover>

View file

@ -8,7 +8,7 @@ import { useGlobal } from "@/context/global"
import { useLayout } from "@/context/layout" import { useLayout } from "@/context/layout"
import { useLocal } from "@/context/local" import { useLocal } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync" 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 { useSDK } from "@/context/sdk"
import { useSettings } from "@/context/settings" import { useSettings } from "@/context/settings"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
@ -49,38 +49,61 @@ export function createSessionComposerControls(input: {
const conn = projectServer() const conn = projectServer()
if (conn) return global.ensureServerCtx(conn) if (conn) return global.ensureServerCtx(conn)
}) })
const projects = createMemo(() => const projects = createMemo(() => {
search.draftId ? (projectServerCtx()?.projects.list() ?? []) : layout.projects.list(), 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 agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory)))
const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null))
const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory)))
const selectProject = (worktree: string) => { const selectProject = (worktree: string, serverKey?: string) => {
const conn = projectServer() const conn = serverKey
const target = projectServerCtx() ? server.list.find((conn) => ServerConnection.key(conn) === serverKey)
: projectServer()
if (search.draftId) { if (search.draftId) {
if (!conn || !target) return if (!conn) return
const target = global.ensureServerCtx(conn)
target.projects.open(worktree) target.projects.open(worktree)
target.projects.touch(worktree) target.projects.touch(worktree)
tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree }) tabs.updateDraft(search.draftId, { server: ServerConnection.key(conn), directory: worktree })
return return
} }
layout.projects.open(worktree) if (!serverKey) {
server.projects.touch(worktree) 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`) navigate(`/${base64Encode(worktree)}/session`)
} }
const addProject = (title: string) => { const addProject = (title: string, serverKey?: string) => {
const conn = projectServer() const conn = serverKey
? server.list.find((conn) => ServerConnection.key(conn) === serverKey)
: projectServer()
if (!conn) return if (!conn) return
pickDirectory({ pickDirectory({
server: conn, server: conn,
title, title,
onSelect: (result) => { onSelect: (result) => {
const directory = Array.isArray(result) ? result[0] : 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: { projects: {
available: projects(), available: projects(),
directory: sdk().directory, directory: sdk().directory,
server:
server.list.length > 1 && projectServer() ? ServerConnection.key(projectServer()!) : undefined,
select: selectProject, select: selectProject,
add: addProject, add: addProject,
}, },