diff --git a/docs/plugins.md b/docs/plugins.md index 79220ef80a..847cbc3009 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -673,6 +673,37 @@ The overflow sheet's content portals to the app root, away from the composer. Pa `useComposerSheetBridge()` as the sheet's `contextBridge`, or `useComposer()` returns nothing and the sheet draws no plugin rows. +## Contribute New workspace items + +`addNewWorkspaceItem` mounts a plugin component on the New workspace screen. The public contract +is the [New workspace items reference](../public-docs/plugins/reference.md#new-workspace-items). + +The screen's choices that a plugin may change live in one plain store, +`createNewWorkspaceSetup` (`packages/app/src/screens/new-workspace/setup-model.ts`): the base +picker selection, the manual isolation, the branch name and the workspace metadata. The pickers and +the plugin commands write to it, so a command sees what an earlier command left. Project and host +stay in the screen's hooks (`useNewWorkspaceHostSelector` reconciles them against each other), and +they are read-only to plugins; a command that switched them would unmount the item that called it. + +The screen reports what it derives from those hooks (the lock, the project, whether the project +can make worktrees, whether the host supports metadata, and the pickers' own handlers) to the store +with `setup.report` on every render. Commands read it when they run, never from the render that +built them. `chooseIsolation` and `chooseBase` are the pickers' handlers, so a plugin command has the +effects of a pick. Add a capability by extending the store and its facts; do not read screen state +from a plugin component. + +`ensureWorkspace` sends the store's `branch` as `source.branchName` only for a branch-off worktree +(`checkoutRequest.action === "branch-off"`), and its `metadata` on `workspace.create` when it is +not empty. The server uses `branchName ?? worktreeSlug` as the new branch. + +Items render through `ComposerControl` with `surface="field"`: a `FieldTrigger` (a badge on +desktop, a full-width field when compact) and the same pending state, error toast and list as the +composer's controls. `ComposerControlLockProvider` carries the screen's lock to them. Plugin +`Combobox` is a `select` item with `remoteSearch`: the app `Combobox` does not filter locally, and +`PluginCombobox` (`plugins/react-native/composer-controls.tsx`) runs `search` through +`useFetchQuery` with `dataShape: "list"`, which keeps the previous results while a newer query runs. +`plugins/item-host.tsx` is the host shared by composer and New workspace items. + ## Contribute timeline items Timeline transformers and renderers are client contributions. The daemon's canonical rows and diff --git a/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts b/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts new file mode 100644 index 0000000000..3460464fb3 --- /dev/null +++ b/packages/app/e2e/browser/plugin-new-workspace-items.spec.ts @@ -0,0 +1,20 @@ +import { expect, test } from "../support/fixtures"; +import { withNewWorkspaceIssuesExample } from "../support/helpers/plugin-new-workspace-items"; + +for (const layout of [ + { name: "desktop", compact: false }, + { name: "compact", compact: true }, +]) { + test(`a plugin's New workspace item sets the branch and metadata on ${layout.name}`, async ({ + page, + }) => { + test.setTimeout(180_000); + await withNewWorkspaceIssuesExample(page, { compact: layout.compact }, async (screen) => { + await screen.chooseWorktree(); + await screen.chooseIssue("PAS-102 Crash on resume"); + const created = await screen.createWorkspace(); + expect(created.branch).toBe("pas-102-crash-on-resume"); + expect(created.metadata).toEqual({ "new-workspace-issues.issue": "PAS-102" }); + }); + }); +} diff --git a/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts b/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts new file mode 100644 index 0000000000..8655ded6e5 --- /dev/null +++ b/packages/app/e2e/support/helpers/plugin-new-workspace-items.ts @@ -0,0 +1,85 @@ +import { expect, test, type Page } from "@playwright/test"; +import { gotoAppShell } from "./app"; +import { + archiveWorkspaceFromDaemon, + assertNewWorkspaceSidebarAndHeader, + connectNewWorkspaceDaemonClient, + openNewWorkspaceComposer, + openProjectViaDaemon, + submitNewWorkspacePrompt, +} from "./new-workspace"; +import { copyPluginExample } from "./plugin-fixture"; +import { getServerId } from "./server-id"; +import { createTempGitRepo } from "./workspace"; +import { waitForSidebarHydration } from "./workspace-ui"; + +const PLUGIN_ID = "new-workspace-issues-example"; +const COMPACT = { width: 500, height: 800 }; + +/** Runs `run` on the New workspace screen with the new-workspace-issues example installed. */ +export async function withNewWorkspaceIssuesExample( + page: Page, + options: { compact: boolean }, + run: (screen: { + chooseWorktree(): Promise; + chooseIssue(label: string): Promise; + createWorkspace(): Promise<{ branch: string | null; metadata: unknown }>; + }) => Promise, +) { + const client = await connectNewWorkspaceDaemonClient(); + const previous = await client.getDaemonConfig(); + const example = await copyPluginExample("new-workspace-issues"); + const repo = await createTempGitRepo("new-workspace-issues-", { branches: ["main"] }); + let createdDirectory: string | null = null; + try { + await client.patchDaemonConfig({ pluginsEnabled: true }); + await client.installDirectoryPlugin(example.directory); + const project = await openProjectViaDaemon(client, repo.path); + await gotoAppShell(page); + await waitForSidebarHydration(page); + await openNewWorkspaceComposer(page, project); + // The sidebar rows used to reach the screen exist only at desktop width. + if (options.compact) await page.setViewportSize(COMPACT); + await run({ + chooseWorktree: () => + test.step("choose a new worktree", async () => { + await page.getByTestId("workspace-create-isolation-trigger").click(); + await page.getByTestId("workspace-create-isolation-worktree").click(); + }), + chooseIssue: (label) => + test.step(`choose the issue "${label}"`, async () => { + await page.getByRole("button", { name: "Issue", exact: true }).click(); + await page.getByText(label, { exact: true }).click(); + }), + createWorkspace: () => + test.step("create the workspace and read what it carries", async () => { + await submitNewWorkspacePrompt(page); + const created = await assertNewWorkspaceSidebarAndHeader(page, { + serverId: getServerId(), + client, + previousWorkspaceId: project.workspaceId, + projectDisplayName: project.projectDisplayName, + // The sidebar is closed at compact width. + assertSidebarRow: !options.compact, + }); + createdDirectory = created.workspaceDirectory; + const { entries } = await client.fetchWorkspaces(); + const workspace = entries.find((entry) => entry.id === created.workspaceId); + expect(workspace).toBeDefined(); + return { + branch: workspace?.gitRuntime?.currentBranch ?? null, + metadata: workspace?.metadata, + }; + }), + }); + } finally { + if (createdDirectory) { + await archiveWorkspaceFromDaemon(client, createdDirectory).catch(() => undefined); + } + await client.removePlugin(PLUGIN_ID).catch(() => undefined); + await client.patchDaemonConfig({ pluginsEnabled: previous.config.pluginsEnabled }); + await client.close(); + await example.cleanup(); + await repo.cleanup(); + } +} diff --git a/packages/app/src/components/ui/combobox.tsx b/packages/app/src/components/ui/combobox.tsx index adcd91afcb..18d575d3e0 100644 --- a/packages/app/src/components/ui/combobox.tsx +++ b/packages/app/src/components/ui/combobox.tsx @@ -89,6 +89,8 @@ export interface ComboboxProps { }) => ReactElement; onSearchQueryChange?: (query: string) => void; searchable?: boolean; + /** The caller answers the search query itself, so the options are shown as given. */ + remoteSearch?: boolean; placeholder?: string; searchPlaceholder?: string; emptyText?: string; @@ -1292,6 +1294,10 @@ function DesktopComboboxBody(props: DesktopBodyProps): ReactElement | null { ); } +function filtersLocally(input: { searchable: boolean; remoteSearch?: boolean }): boolean { + return input.searchable && !input.remoteSearch; +} + export function Combobox({ options, value, @@ -1299,6 +1305,7 @@ export function Combobox({ renderOption, onSearchQueryChange, searchable = true, + remoteSearch, placeholder, searchPlaceholder, emptyText, @@ -1458,7 +1465,7 @@ export function Combobox({ buildVisibleComboboxOptions({ options, searchQuery, - searchable, + searchable: filtersLocally({ searchable, remoteSearch }), allowCustomValue, customValuePrefix, customValueDescription, @@ -1470,6 +1477,7 @@ export function Combobox({ customValueKind, customValuePrefix, options, + remoteSearch, searchQuery, searchable, ], diff --git a/packages/app/src/composer/agent-controls/composer-control.tsx b/packages/app/src/composer/agent-controls/composer-control.tsx index 80536846f8..f98be7261f 100644 --- a/packages/app/src/composer/agent-controls/composer-control.tsx +++ b/packages/app/src/composer/agent-controls/composer-control.tsx @@ -1,5 +1,6 @@ import { createContext, + forwardRef, useCallback, useContext, useMemo, @@ -14,6 +15,9 @@ import { StyleSheet } from "react-native-unistyles"; import type { AgentControlIcon } from "@/agent-controls/icons"; import type { SheetHeader } from "@/components/adaptive-modal-sheet"; import { Combobox, type ComboboxOption } from "@/components/ui/combobox"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { FieldTrigger } from "@/screens/new-workspace/form/field-trigger"; +import { ICON_SIZE } from "@/styles/theme"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { AgentControlTrigger } from "@/composer/agent-controls/control"; import { useToast } from "@/contexts/toast-api-context"; @@ -39,8 +43,9 @@ export interface ComposerControlChoice { } /** - * One control in the composer's toolbar, drawn from data. Provider option controls, thinking and - * plugin controls all come through here, so each is an icon in the toolbar and a row in the sheet. + * One control, drawn from data. Provider option controls, thinking and plugin controls all come + * through here, so each is an icon in the composer toolbar, a row in its sheet, or, in a New + * workspace item, a field. The name stays from when the composer was the only place it drew. */ export type ComposerControlItem = | (ComposerControlBase & { @@ -52,11 +57,17 @@ export type ComposerControlItem = type: "select"; value: string | null; choices: readonly ComposerControlChoice[]; + /** Names a value the choices do not list, such as a pick that a search no longer returns. */ + valueLabel?: string; onChange: (value: string) => void | Promise; /** The toolbar trigger shows only the icon. */ iconOnly?: boolean; showCaret?: boolean; searchable?: boolean; + /** The list answers `onSearchQueryChange` itself, so it is not filtered again. */ + remoteSearch?: boolean; + onSearchQueryChange?: (query: string) => void; + emptyText?: string; desktopMinWidth?: number; renderOption?: (args: { option: ComboboxOption; @@ -70,12 +81,21 @@ export type ComposerControlItem = onPress: () => void | Promise; }); -export type ComposerControlSurface = "toolbar" | "sheet"; +/** `field`: a New workspace setup field, a badge on desktop and a full-width field when compact. */ +export type ComposerControlSurface = "toolbar" | "sheet" | "field"; const ComposerControlSurfaceContext = createContext("toolbar"); export const ComposerControlSurfaceProvider = ComposerControlSurfaceContext.Provider; -/** Where the control being drawn sits: the toolbar, or a row in the overflow sheet. */ +const ComposerControlLockContext = createContext(false); +/** Disables every control below it, for a surface that is not a composer and has its own lock. */ +export const ComposerControlLockProvider = ComposerControlLockContext.Provider; + +export function useComposerControlLocked(): boolean { + return useContext(ComposerControlLockContext); +} + +/** Where the control being drawn sits: the toolbar, a row in the overflow sheet, or a field. */ export function useComposerControlSurface(): ComposerControlSurface { return useContext(ComposerControlSurfaceContext); } @@ -133,7 +153,9 @@ function describeControl( }; } const choiceLabel = - item.choices.find((choice) => choice.value === item.value)?.label ?? item.title; + item.choices.find((choice) => choice.value === item.value)?.label ?? + item.valueLabel ?? + item.title; const iconOnly = item.iconOnly === true; return { options: item.choices.map((choice) => ({ @@ -150,22 +172,24 @@ function describeControl( }; } -export function ComposerControl({ - item, - surface, - disabled = false, - open, - onOpenChange, - onActionComplete, -}: ComposerControlProps) { - const { t } = useTranslation(); - const anchorRef = useRef(null); - const isSheet = surface === "sheet"; - const sheetHeader = useMemo(() => ({ title: item.title }), [item.title]); - const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]); +type SelectItem = Extract; - // A command that returns a promise keeps the control pending until it settles, and a failure - // reaches the user the way a built-in live control's does. +/** The list options only a select carries; a toggle's two-choice list has none of them. */ +function listProps(select: SelectItem | null) { + if (!select) return { searchable: false }; + return { + desktopMinWidth: select.desktopMinWidth, + searchable: select.searchable, + remoteSearch: select.remoteSearch, + onSearchQueryChange: select.onSearchQueryChange, + emptyText: select.emptyText, + renderOption: select.renderOption, + }; +} + +// A command that returns a promise keeps the control pending until it settles, and a failure +// reaches the user the way a built-in live control's does. +function usePendingRun() { const toast = useToast(); const [pending, setPending] = useState(false); const run = useCallback( @@ -183,6 +207,25 @@ export function ComposerControl({ }, [toast], ); + return { pending, run }; +} + +export function ComposerControl({ + item, + surface, + disabled = false, + open, + onOpenChange, + onActionComplete, +}: ComposerControlProps) { + const { t } = useTranslation(); + const anchorRef = useRef(null); + const isSheet = surface === "sheet"; + const isField = surface === "field"; + const sheetHeader = useMemo(() => ({ title: item.title }), [item.title]); + const view = useMemo(() => describeControl(item, surface, t), [item, surface, t]); + + const { pending, run } = usePendingRun(); const handlePress = useCallback(() => { if (item.type === "toggle") run(() => item.onChange(!item.value)); else if (item.type === "action") run(item.onPress); @@ -198,7 +241,17 @@ export function ComposerControl({ ); const select = item.type === "select" ? item : null; - const trigger = ( + const accessibilityLabel = item.accessibilityLabel ?? view.tooltip; + const trigger = isField ? ( + + ) : ( ); return ( <> - {isSheet ? ( + {isSheet || isField ? ( trigger ) : ( @@ -239,10 +292,8 @@ export function ComposerControl({ onOpenChange={onOpenChange} anchorRef={anchorRef} presentation={isSheet ? "push" : undefined} - desktopPlacement={isSheet ? undefined : "top-start"} - desktopMinWidth={select?.desktopMinWidth} - searchable={select ? select.searchable : false} - renderOption={select?.renderOption} + desktopPlacement={DESKTOP_PLACEMENT[surface]} + {...listProps(select)} header={isSheet ? sheetHeader : undefined} /> ) : null} @@ -250,6 +301,50 @@ export function ComposerControl({ ); } +/** Where a list opens on desktop; the sheet has its own presentation. */ +const DESKTOP_PLACEMENT = { + toolbar: "top-start", + sheet: undefined, + field: "bottom-start", +} as const; + +/** What a field shows: a badge has no label, so a toggle names itself beside its state. */ +function fieldValue(item: ComposerControlItem, view: ControlView, compact: boolean): string { + if (item.type === "action") return item.title; + if (item.type === "toggle" && !compact) return `${item.title}: ${view.value}`; + return view.value; +} + +const FieldControlTrigger = forwardRef< + View, + { + item: ComposerControlItem; + view: ControlView; + accessibilityLabel: string; + disabled: boolean; + onPress: () => void; + } +>(function FieldControlTrigger({ item, view, accessibilityLabel, disabled, onPress }, ref) { + const compact = useIsCompactFormFactor(); + const Icon = item.icon; + const iconColor = item.highlight ? styles[item.highlight].color : styles.fieldIcon.color; + const icon = useMemo(() => , [Icon, iconColor]); + return ( + + ); +}); + interface ComposerControlsProps { items: readonly ComposerControlItem[]; surface: ComposerControlSurface; @@ -296,6 +391,7 @@ function ComposerControlSlot({ } const styles = StyleSheet.create((theme) => ({ + fieldIcon: { color: theme.colors.foregroundMuted }, blue: { color: theme.colors.palette.blue[400] }, green: { color: theme.colors.palette.green[400] }, yellow: { color: theme.colors.palette.yellow[400] }, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 8d7c2a2950..d30c6b3007 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ar: TranslationResources = { + pluginCombobox: { + searching: "جارٍ البحث…", + empty: "لا توجد نتائج", + failed: "فشل البحث: {{message}}", + }, paneFind: { connectionFailure: "تعذر البحث في هذه المحادثة. تحقق من الاتصال بالمضيف وأعد المحاولة.", historyChangedFailure: "تغيّرت المحادثة أثناء البحث. أعد البحث.", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 1f61a83f12..0b69744fde 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1,4 +1,9 @@ export const en = { + pluginCombobox: { + searching: "Searching…", + empty: "No results", + failed: "Search failed: {{message}}", + }, paneFind: { connectionFailure: "Could not search this chat. Check the host connection and retry.", historyChangedFailure: "The chat changed while searching. Search again.", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 7e5a1d41e6..186f164317 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const es: TranslationResources = { + pluginCombobox: { + searching: "Buscando…", + empty: "Sin resultados", + failed: "La búsqueda falló: {{message}}", + }, paneFind: { connectionFailure: "No se pudo buscar en este chat. Comprueba la conexión con el host y vuelve a intentarlo.", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 551f4a4158..115ea515d9 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const fr: TranslationResources = { + pluginCombobox: { + searching: "Recherche…", + empty: "Aucun résultat", + failed: "Échec de la recherche : {{message}}", + }, paneFind: { connectionFailure: "Impossible de rechercher dans cette conversation. Vérifiez la connexion à l’hôte et réessayez.", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 4b1b8b6f64..4657a49548 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ja: TranslationResources = { + pluginCombobox: { + searching: "検索中…", + empty: "結果がありません", + failed: "検索に失敗しました: {{message}}", + }, paneFind: { connectionFailure: "このチャットを検索できませんでした。ホストへの接続を確認して再試行してください。", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index b9e33b4eba..6737f13c1e 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ko: TranslationResources = { + pluginCombobox: { + searching: "검색 중…", + empty: "결과 없음", + failed: "검색 실패: {{message}}", + }, paneFind: { connectionFailure: "이 채팅을 검색할 수 없습니다. 호스트 연결을 확인하고 다시 시도하세요.", historyChangedFailure: "검색 중에 채팅이 변경되었습니다. 다시 검색하세요.", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 2fa6c30c8e..6b751f0e32 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ptBR: TranslationResources = { + pluginCombobox: { + searching: "Pesquisando…", + empty: "Nenhum resultado", + failed: "Falha na pesquisa: {{message}}", + }, paneFind: { connectionFailure: "Não foi possível pesquisar nesta conversa. Verifique a conexão com o host e tente novamente.", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 9a6eb5b0e2..fb0fad1dd7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const ru: TranslationResources = { + pluginCombobox: { + searching: "Поиск…", + empty: "Нет результатов", + failed: "Не удалось выполнить поиск: {{message}}", + }, paneFind: { connectionFailure: "Не удалось выполнить поиск в чате. Проверьте подключение к хосту и повторите попытку.", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 553d8212b4..5f4d8638eb 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -2,6 +2,11 @@ import { en, type TranslationResources } from "./en"; import { pluginSettings } from "./plugin-settings"; export const zhCN: TranslationResources = { + pluginCombobox: { + searching: "搜索中…", + empty: "无结果", + failed: "搜索失败:{{message}}", + }, paneFind: { connectionFailure: "无法搜索此聊天。请检查主机连接后重试。", historyChangedFailure: "搜索期间聊天已更改。请重新搜索。", diff --git a/packages/app/src/plugins/buttons/model.test.ts b/packages/app/src/plugins/buttons/model.test.ts index 7f7a1da2b4..a17ce80cff 100644 --- a/packages/app/src/plugins/buttons/model.test.ts +++ b/packages/app/src/plugins/buttons/model.test.ts @@ -24,6 +24,7 @@ function installation(): InstalledPlugin { legacySidebarItems: [], filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/command-center/contributions.test.ts b/packages/app/src/plugins/command-center/contributions.test.ts index 9eccfab943..e4a388cbb9 100644 --- a/packages/app/src/plugins/command-center/contributions.test.ts +++ b/packages/app/src/plugins/command-center/contributions.test.ts @@ -84,6 +84,7 @@ function plugin(onAgentSelect: AgentCommandItem["onSelect"]): InstalledPlugin { legacySidebarItems: [], filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [ { id: "details", diff --git a/packages/app/src/plugins/composer-items/index.tsx b/packages/app/src/plugins/composer-items/index.tsx index 309dac4c36..b116da2a09 100644 --- a/packages/app/src/plugins/composer-items/index.tsx +++ b/packages/app/src/plugins/composer-items/index.tsx @@ -7,25 +7,12 @@ import { type ComposerControlSurface, } from "@/composer/agent-controls/composer-control"; import { useComposerAgent, useComposerInput } from "@/composer/session"; -import { useIsCompactFormFactor } from "@/constants/layout"; -import { useToast } from "@/contexts/toast-api-context"; -import { useHosts } from "@/runtime/host-runtime"; -import type { Theme } from "@/styles/theme"; -import { createPluginClientStateSource } from "../client-state/source"; -import { resolvePluginPlatform } from "../platform"; -import { PluginEnvironmentProvider, type PluginEnvironment } from "../popover"; +import { PluginItemList, pluginThemeMapping, usePluginItemHost } from "../item-host"; +import { PluginEnvironmentProvider } from "../popover"; import { useInstalledPlugins } from "../registry"; -import { SurfaceErrorBoundary } from "../surface-error-boundary"; -import { toPluginTheme } from "../theme"; import type { InstalledPlugin } from "../types"; import { toPluginComposer } from "./model"; -const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); - -function renderNothing() { - return null; -} - function useServerPlugins(serverId: string | null): InstalledPlugin[] { const installed = useInstalledPlugins(); return useMemo( @@ -76,52 +63,23 @@ function PluginComposerItemsHost({ }) { const agent = useComposerAgent(); const composerInput = useComposerInput(); - const toast = useToast(); - const compact = useIsCompactFormFactor(); - const hosts = useHosts(); - const hostLabel = - hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId; + const { environment, props } = usePluginItemHost(plugin, theme); const textSource = composerInput?.textSource; const prompt = useSyncExternalStore( textSource?.subscribe ?? subscribeNothing, textSource?.getSnapshot ?? emptyText, textSource?.getSnapshot ?? emptyText, ); - const environment = useMemo( - () => ({ - installation: plugin, - toast, - state: createPluginClientStateSource(plugin.serverId), - }), - [plugin, toast], - ); const composer = useMemo( () => agent && composerInput ? toPluginComposer({ plugin, agent, composerInput, prompt }) : null, [agent, composerInput, plugin, prompt], ); - const props = useMemo( - () => ({ - theme, - host: { id: plugin.serverId, label: hostLabel }, - layout: { compact, platform: resolvePluginPlatform() }, - }), - [compact, hostLabel, plugin.serverId, theme], - ); if (!composer) return null; return ( - {plugin.composerItems.map(({ id, Component }) => ( - - - - ))} + ); diff --git a/packages/app/src/plugins/composer-items/model.test.ts b/packages/app/src/plugins/composer-items/model.test.ts index e75585e36f..0e1fa9718b 100644 --- a/packages/app/src/plugins/composer-items/model.test.ts +++ b/packages/app/src/plugins/composer-items/model.test.ts @@ -27,6 +27,7 @@ function installed(): InstalledPlugin { legacySidebarItems: [], filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/evaluate.ts b/packages/app/src/plugins/evaluate.ts index c2a7e82838..ab7d3cac9f 100644 --- a/packages/app/src/plugins/evaluate.ts +++ b/packages/app/src/plugins/evaluate.ts @@ -132,6 +132,7 @@ export function runPluginClientBundle( legacySidebarItems: [], filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], @@ -148,6 +149,7 @@ export function runPluginClientBundle( }; const filePanelIds = new Set(); const composerItemIds = new Set(); + const newWorkspaceItemIds = new Set(); const workspacePanelIds = new Set(); const commandCenterItemIds = new Set(); const clientSlashCommandNames = new Set(); @@ -285,6 +287,20 @@ export function runPluginClientBundle( () => composerItemIds.delete(normalizedId), ); }, + addNewWorkspaceItem(contribution) { + const normalizedId = requireId(contribution.id, "New workspace item id"); + if (newWorkspaceItemIds.has(normalizedId)) + throw new Error(`Duplicate New workspace item: ${normalizedId}`); + if (!isComponentType(contribution.Component)) { + throw new Error(`New workspace item ${normalizedId} is not a component`); + } + newWorkspaceItemIds.add(normalizedId); + return register( + collector.newWorkspaceItems, + { id: normalizedId, Component: contribution.Component }, + () => newWorkspaceItemIds.delete(normalizedId), + ); + }, addSidebarHeaderItem(contribution: PluginSidebarItemContribution) { return addSidebarItem("header", contribution); }, @@ -602,6 +618,7 @@ export function runPluginClientBundle( legacySidebarItems: collector.legacySidebarItems, filePanels: collector.filePanels, composerItems: collector.composerItems, + newWorkspaceItems: collector.newWorkspaceItems, workspacePanels: collector.workspacePanels as EvaluatedPlugin["workspacePanels"], commandCenterItems: collector.commandCenterItems, clientSlashCommands: collector.clientSlashCommands, diff --git a/packages/app/src/plugins/installation-provider.test.tsx b/packages/app/src/plugins/installation-provider.test.tsx index 8744ed78bd..5c7828cdc5 100644 --- a/packages/app/src/plugins/installation-provider.test.tsx +++ b/packages/app/src/plugins/installation-provider.test.tsx @@ -37,6 +37,7 @@ function installation(invoked: string[]): InstalledPlugin { legacySidebarItems: [], filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/item-host.tsx b/packages/app/src/plugins/item-host.tsx new file mode 100644 index 0000000000..2df4198e7a --- /dev/null +++ b/packages/app/src/plugins/item-host.tsx @@ -0,0 +1,74 @@ +import { useMemo, type ComponentType } from "react"; +import type { PluginHostProps } from "@getpaseo/plugin/client"; +import { useIsCompactFormFactor } from "@/constants/layout"; +import { useToast } from "@/contexts/toast-api-context"; +import { useHosts } from "@/runtime/host-runtime"; +import type { Theme } from "@/styles/theme"; +import { createPluginClientStateSource } from "./client-state/source"; +import { resolvePluginPlatform } from "./platform"; +import type { PluginEnvironment } from "./popover"; +import { SurfaceErrorBoundary } from "./surface-error-boundary"; +import { toPluginTheme } from "./theme"; +import type { InstalledPlugin } from "./types"; + +/** `withUnistyles` mapping that hands a host the plugin theme. */ +export const pluginThemeMapping = (theme: Theme) => ({ theme: toPluginTheme(theme) }); + +function renderNothing() { + return null; +} + +/** What a plugin's items on a host surface (composer, New workspace) share: environment and props. */ +export function usePluginItemHost( + plugin: InstalledPlugin, + theme: PluginHostProps["theme"], +): { environment: PluginEnvironment; props: PluginHostProps } { + const toast = useToast(); + const compact = useIsCompactFormFactor(); + const hosts = useHosts(); + const hostLabel = + hosts.find((host) => host.serverId === plugin.serverId)?.label ?? plugin.serverId; + const environment = useMemo( + () => ({ + installation: plugin, + toast, + state: createPluginClientStateSource(plugin.serverId), + }), + [plugin, toast], + ); + const props = useMemo( + () => ({ + theme, + host: { id: plugin.serverId, label: hostLabel }, + layout: { compact, platform: resolvePluginPlatform() }, + }), + [compact, hostLabel, plugin.serverId, theme], + ); + return { environment, props }; +} + +/** Mounts each item behind its own boundary; a throwing item draws nothing. */ +export function PluginItemList({ + plugin, + items, + props, +}: { + plugin: InstalledPlugin; + items: readonly { id: string; Component: ComponentType }[]; + props: PluginHostProps; +}) { + return ( + <> + {items.map(({ id, Component }) => ( + + + + ))} + + ); +} diff --git a/packages/app/src/plugins/new-workspace-items/index.tsx b/packages/app/src/plugins/new-workspace-items/index.tsx new file mode 100644 index 0000000000..3522fee987 --- /dev/null +++ b/packages/app/src/plugins/new-workspace-items/index.tsx @@ -0,0 +1,101 @@ +import { useMemo } from "react"; +import type { PluginHostProps } from "@getpaseo/plugin/client"; +import { PluginNewWorkspaceProvider } from "@getpaseo/plugin/client/host"; +import { withUnistyles } from "react-native-unistyles"; +import { + ComposerControlLockProvider, + ComposerControlSurfaceProvider, +} from "@/composer/agent-controls/composer-control"; +import type { PickerItem } from "@/screens/new-workspace-picker-item"; +import { + useNewWorkspaceSetupState, + type NewWorkspaceSetup, +} from "@/screens/new-workspace/setup-model"; +import { PluginItemList, pluginThemeMapping, usePluginItemHost } from "../item-host"; +import { PluginEnvironmentProvider } from "../popover"; +import { useInstalledPlugins } from "../registry"; +import type { InstalledPlugin } from "../types"; +import { toPluginNewWorkspace } from "./model"; + +/** What the screen derives and the items read; the rest is in the setup model. */ +export interface NewWorkspaceItemsScreen { + project: { id: string; name: string } | null; + isolation: "local" | "worktree"; + baseItem: PickerItem | null; + locked: boolean; +} + +function useServerPlugins(serverId: string): InstalledPlugin[] { + const installed = useInstalledPlugins(); + return useMemo( + () => + installed.filter( + (plugin) => plugin.serverId === serverId && plugin.newWorkspaceItems.length > 0, + ), + [installed, serverId], + ); +} + +/** + * The selected host's plugin items on the New workspace screen, drawn as setup fields. They read + * the screen's choices and command them through the setup model. + */ +export function PluginNewWorkspaceItems({ + serverId, + screen, + setup, +}: { + serverId: string; + screen: NewWorkspaceItemsScreen; + setup: NewWorkspaceSetup; +}) { + const plugins = useServerPlugins(serverId); + return ( + + + {plugins.map((plugin) => ( + + ))} + + + ); +} + +function PluginNewWorkspaceItemsHost({ + plugin, + theme, + screen, + setup, +}: { + plugin: InstalledPlugin; + theme: PluginHostProps["theme"]; + screen: NewWorkspaceItemsScreen; + setup: NewWorkspaceSetup; +}) { + const { environment, props } = usePluginItemHost(plugin, theme); + const { branch, metadata } = useNewWorkspaceSetupState(setup); + const newWorkspace = useMemo( + () => + toPluginNewWorkspace({ + host: props.host, + view: { ...screen, branch, metadata }, + commands: setup.commands, + }), + [branch, metadata, props.host, screen, setup.commands], + ); + return ( + + + + + + ); +} + +const ThemedPluginNewWorkspaceItems = withUnistyles(PluginNewWorkspaceItemsHost); diff --git a/packages/app/src/plugins/new-workspace-items/model.ts b/packages/app/src/plugins/new-workspace-items/model.ts new file mode 100644 index 0000000000..ce8cba4c38 --- /dev/null +++ b/packages/app/src/plugins/new-workspace-items/model.ts @@ -0,0 +1,40 @@ +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { PluginHostProps, PluginNewWorkspace } from "@getpaseo/plugin/client"; +import { branchNameFromRef, type PickerItem } from "@/screens/new-workspace-picker-item"; +import type { SetupCommands } from "@/screens/new-workspace/setup-model"; +import { cloneAndFreeze } from "../freeze"; + +/** What the New workspace screen shows at render; the plugin reads it, never writes it. */ +export interface NewWorkspaceView { + project: { id: string; name: string } | null; + /** What the screen will create. */ + isolation: "local" | "worktree"; + baseItem: PickerItem | null; + branch: string | null; + metadata: Readonly>; + locked: boolean; +} + +function toBaseRef(item: PickerItem | null): PluginNewWorkspace["baseRef"] { + if (!item) return null; + return item.kind === "branch" + ? { type: "branch", name: branchNameFromRef(item.refName) } + : { type: "change-request", number: item.item.number }; +} + +export function toPluginNewWorkspace(input: { + host: PluginHostProps["host"]; + view: NewWorkspaceView; + commands: SetupCommands; +}): PluginNewWorkspace { + const { host, view, commands } = input; + return { + host: { id: host.id, label: host.label }, + project: cloneAndFreeze(view.project), + isolation: view.isolation, + baseRef: toBaseRef(view.baseItem), + branch: view.branch, + metadata: cloneAndFreeze(view.metadata), + ...commands, + }; +} diff --git a/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx b/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx new file mode 100644 index 0000000000..be894186c5 --- /dev/null +++ b/packages/app/src/plugins/react-native/composer-controls.browser.test.tsx @@ -0,0 +1,142 @@ +import React from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { ComboboxItem } from "@getpaseo/plugin/client/ui"; +import type { ToastApi } from "@/components/toast-host"; +import { ComposerControlSurfaceProvider } from "@/composer/agent-controls/composer-control"; +import { ToastApiProvider } from "@/contexts/toast-api-context"; +import { i18n } from "@/i18n/i18next"; +import { Combobox } from "./composer-controls"; + +beforeEach(async () => { + vi.stubGlobal("React", React); + await i18n.changeLanguage("en"); +}); +afterEach(cleanup); + +interface Issue extends ComboboxItem { + branch: string; +} + +const ONE: Issue = { id: "1", label: "Slow sidebar", branch: "one" }; +const TWO: Issue = { id: "2", label: "Crash on resume", branch: "two" }; + +interface Search { + query: string; + resolve(items: Issue[]): void; + reject(error: Error): void; +} + +/** A search the test settles by hand, so it decides which one finishes first. */ +function controlledSearch() { + const searches: Search[] = []; + const search = ({ query }: { query: string }) => + new Promise((resolve, reject) => { + searches.push({ query, resolve, reject }); + }); + const forQuery = (query: string) => { + const found = searches.find((candidate) => candidate.query === query); + if (!found) throw new Error(`No search for "${query}" yet`); + return found; + }; + return { search, forQuery, queries: () => searches.map((candidate) => candidate.query) }; +} + +const toast = { error: vi.fn() } as unknown as ToastApi; +const ignoreSelect = () => undefined; + +function Harness({ + search, + value, + onSelect = ignoreSelect, +}: { + search: (event: { query: string }) => Promise; + value: ComboboxItem | null; + onSelect?: (event: { item: Issue }) => void; +}) { + const [queryClient] = React.useState(() => new QueryClient()); + return ( + + + + + + + + ); +} + +function renderCombobox(input: { + search: (event: { query: string }) => Promise; + value?: ComboboxItem | null; + onSelect?: (event: { item: Issue }) => void; +}) { + render(); + fireEvent.click(screen.getByRole("button", { name: "Issue" })); +} + +describe("plugin Combobox", () => { + it("searches when the list opens, shows the results, and hands the picked item back", async () => { + const controlled = controlledSearch(); + const onSelect = vi.fn(); + renderCombobox({ search: controlled.search, onSelect }); + + await waitFor(() => expect(controlled.queries()).toEqual([""])); + expect(screen.getByText("Searching…")).toBeTruthy(); + controlled.forQuery("").resolve([ONE, TWO]); + + fireEvent.click(await screen.findByText("Crash on resume")); + expect(onSelect).toHaveBeenCalledWith({ item: TWO }); + }); + + it("shows the error and not stale results when the search fails", async () => { + const controlled = controlledSearch(); + renderCombobox({ search: controlled.search }); + + await waitFor(() => expect(controlled.queries()).toEqual([""])); + controlled.forQuery("").reject(new Error("tracker is down")); + + expect(await screen.findByText("Search failed: tracker is down")).toBeTruthy(); + }); + + it("keeps the previous results while a newer search runs and ignores a superseded one", async () => { + const controlled = controlledSearch(); + renderCombobox({ search: controlled.search }); + await waitFor(() => expect(controlled.queries()).toEqual([""])); + controlled.forQuery("").resolve([ONE, TWO]); + await screen.findByText("Slow sidebar"); + + const input = screen.getByRole("textbox"); + fireEvent.change(input, { target: { value: "c" } }); + await waitFor(() => expect(controlled.queries()).toEqual(["", "c"])); + expect(screen.getByText("Slow sidebar")).toBeTruthy(); + + fireEvent.change(input, { target: { value: "cr" } }); + await waitFor(() => expect(controlled.queries()).toEqual(["", "c", "cr"])); + controlled.forQuery("cr").resolve([TWO]); + await waitFor(() => expect(screen.queryByText("Slow sidebar")).toBeNull()); + controlled.forQuery("c").resolve([ONE]); + + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(screen.getByText("Crash on resume")).toBeTruthy(); + expect(screen.queryByText("Slow sidebar")).toBeNull(); + }); + + it("shows a chosen value that the search no longer returns", async () => { + const controlled = controlledSearch(); + renderCombobox({ search: controlled.search, value: { id: "9", label: "PAS-9 Old issue" } }); + + expect(screen.getByRole("button", { name: "Issue" }).textContent).toContain("PAS-9 Old issue"); + await waitFor(() => expect(controlled.queries()).toEqual([""])); + controlled.forQuery("").resolve([ONE]); + await screen.findByText("Slow sidebar"); + expect(screen.getByRole("button", { name: "Issue" }).textContent).toContain("PAS-9 Old issue"); + }); +}); diff --git a/packages/app/src/plugins/react-native/composer-controls.tsx b/packages/app/src/plugins/react-native/composer-controls.tsx index eb4d05a09b..4a6688444d 100644 --- a/packages/app/src/plugins/react-native/composer-controls.tsx +++ b/packages/app/src/plugins/react-native/composer-controls.tsx @@ -1,11 +1,21 @@ -import type { ActionProps, SelectProps, ToggleProps } from "@getpaseo/plugin/client/ui"; -import { useMemo, useState } from "react"; +import type { + ActionProps, + ComboboxItem, + ComboboxProps, + SelectProps, + ToggleProps, +} from "@getpaseo/plugin/client/ui"; +import { useEffect, useId, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; import { ComposerControl, + useComposerControlLocked, useComposerControlSurface, type ComposerControlItem, } from "@/composer/agent-controls/composer-control"; import { useComposerLocked } from "@/composer/session"; +import { useFetchQuery } from "@/data/query"; +import { toErrorMessage } from "@/utils/error-messages"; import { parsePluginIcon, pluginIconComponent } from "../icons"; /** Icons resolve at render, so an invalid one throws to the item's boundary. */ @@ -13,24 +23,35 @@ function usePluginControlIcon(icon: ToggleProps["icon"]) { return useMemo(() => pluginIconComponent(parsePluginIcon("Composer control", icon)), [icon]); } -/** Draws one plugin control where its composer item sits: inline, or as a row in the sheet. */ +/** Draws one plugin control where its item sits: inline, a row in the sheet, or a field. */ function PluginComposerControl({ item, disabled, + onOpenChange, }: { item: ComposerControlItem; disabled: boolean | undefined; + onOpenChange?: (open: boolean) => void; }) { const surface = useComposerControlSurface(); - const locked = useComposerLocked(); + const composerLocked = useComposerLocked(); + const fieldLocked = useComposerControlLocked(); + const locked = composerLocked || fieldLocked; const [open, setOpen] = useState(false); + const handleOpenChange = useMemo( + () => (next: boolean) => { + setOpen(next); + onOpenChange?.(next); + }, + [onOpenChange], + ); return ( ); } @@ -85,3 +106,82 @@ export function Action({ title, icon, disabled, onPress }: ActionProps) { ); return ; } + +const SEARCH_DEBOUNCE_MS = 200; +const NO_RESULTS: readonly never[] = []; + +/** The query settles after the user stops typing; an empty query applies at once. */ +function useDebounced(query: string): string { + const [settled, setSettled] = useState(query); + useEffect(() => { + if (query === "") return setSettled(""); + const timer = setTimeout(() => setSettled(query), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [query]); + return settled; +} + +/** + * Searches while the list is open. The search runs once per settled query, keeps the previous + * results on screen while the next one loads, and drops a search a newer one replaced. + */ +export function Combobox({ + title, + icon, + disabled, + value, + search, + onSelect, +}: ComboboxProps) { + const { t } = useTranslation(); + const Icon = usePluginControlIcon(icon); + const instanceId = useId(); + const [open, setOpen] = useState(false); + const [query, setQuery] = useState(""); + const settled = useDebounced(query); + const results = useFetchQuery({ + queryKey: ["plugin-combobox", instanceId, settled], + queryFn: () => search({ query: settled }), + dataShape: "list", + staleTimeMs: 0, + enabled: open, + gcTime: 0, + retry: false, + }); + const items: readonly Item[] = results.data ?? NO_RESULTS; + const item = useMemo(() => { + const choices = items.map(({ id, label, description }) => ({ value: id, label, description })); + return { + id: "plugin-combobox", + type: "select", + title, + icon: Icon, + value: value?.id ?? null, + valueLabel: value?.label, + choices, + onChange: (id) => { + const picked = items.find((candidate) => candidate.id === id); + return picked ? onSelect({ item: picked }) : undefined; + }, + searchable: true, + remoteSearch: true, + onSearchQueryChange: setQuery, + emptyText: comboboxEmptyText({ t, results, query, settled }), + showCaret: true, + }; + }, [Icon, items, onSelect, query, results, settled, t, title, value]); + return ; +} + +function comboboxEmptyText(input: { + t: (key: string, options?: Record) => string; + results: { isError: boolean; isFetching: boolean; error: unknown }; + query: string; + settled: string; +}): string { + const { t, results, query, settled } = input; + if (results.isError) + return t("pluginCombobox.failed", { message: toErrorMessage(results.error) }); + if (results.isFetching || query !== settled) return t("pluginCombobox.searching"); + return t("pluginCombobox.empty"); +} diff --git a/packages/app/src/plugins/react-native/ui.ts b/packages/app/src/plugins/react-native/ui.ts index 01d008574d..8b7d17216d 100644 --- a/packages/app/src/plugins/react-native/ui.ts +++ b/packages/app/src/plugins/react-native/ui.ts @@ -20,7 +20,7 @@ export { FilePanelLoading as Loading } from "@/file-panels/loading"; export { SettingsGroup } from "@/components/settings"; export { Icon } from "../icons"; -export { Action, Select, Toggle } from "./composer-controls"; +export { Action, Combobox, Select, Toggle } from "./composer-controls"; export { Modal } from "./modal"; export { ScrollView, FlatList } from "./scroll-view"; export { TextInput } from "./text-input"; diff --git a/packages/app/src/plugins/registry.ts b/packages/app/src/plugins/registry.ts index ce449d205b..f82ac14702 100644 --- a/packages/app/src/plugins/registry.ts +++ b/packages/app/src/plugins/registry.ts @@ -107,6 +107,7 @@ export class PluginRegistry { legacySidebarItems: [], filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/sidebar-groups.test.ts b/packages/app/src/plugins/sidebar-groups.test.ts index fc0dfd7854..3994c0f862 100644 --- a/packages/app/src/plugins/sidebar-groups.test.ts +++ b/packages/app/src/plugins/sidebar-groups.test.ts @@ -35,6 +35,7 @@ function installed( legacySidebarItems, filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/surface-contribution.test.ts b/packages/app/src/plugins/surface-contribution.test.ts index dd44e940d5..f3f9298380 100644 --- a/packages/app/src/plugins/surface-contribution.test.ts +++ b/packages/app/src/plugins/surface-contribution.test.ts @@ -37,6 +37,7 @@ function installation( })), filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/theme.test.ts b/packages/app/src/plugins/theme.test.ts index 83e7b2dc1f..07887bfc4d 100644 --- a/packages/app/src/plugins/theme.test.ts +++ b/packages/app/src/plugins/theme.test.ts @@ -62,6 +62,7 @@ function installed(serverId: string, themes: PluginThemeContribution[]): Install legacySidebarItems: [], filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/timeline/model.test.ts b/packages/app/src/plugins/timeline/model.test.ts index 33297a2d9a..97a27bad19 100644 --- a/packages/app/src/plugins/timeline/model.test.ts +++ b/packages/app/src/plugins/timeline/model.test.ts @@ -25,6 +25,7 @@ function plugin(input: { legacySidebarItems: [], filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [], commandCenterItems: [], clientSlashCommands: [], diff --git a/packages/app/src/plugins/types.ts b/packages/app/src/plugins/types.ts index 60b8aeb696..a34ab0f9e9 100644 --- a/packages/app/src/plugins/types.ts +++ b/packages/app/src/plugins/types.ts @@ -12,6 +12,7 @@ import type { PluginCommandCenterItemContribution as SDKPluginCommandCenterItemContribution, PluginClientSlashCommandContribution as SDKPluginClientSlashCommandContribution, PluginComposerItemContribution, + PluginNewWorkspaceItemContribution, PluginComposerPillContribution as SDKPluginComposerPillContribution, PluginSidebarContribution as SDKPluginSidebarContribution, PluginSidebarItemContribution as SDKPluginSidebarItemContribution, @@ -55,6 +56,7 @@ export interface EvaluatedPlugin { legacySidebarItems: PluginSidebarContribution[]; filePanels: PluginFilePanelContribution[]; composerItems: PluginComposerItemContribution[]; + newWorkspaceItems: PluginNewWorkspaceItemContribution[]; workspacePanels: EvaluatedPluginWorkspacePanelContribution[]; commandCenterItems: PluginCommandCenterItemContribution[]; clientSlashCommands: PluginClientSlashCommandContribution[]; @@ -85,6 +87,7 @@ export type { PluginCommandCenterItemContribution, PluginClientSlashCommandContribution, PluginComposerItemContribution, + PluginNewWorkspaceItemContribution, PluginComposerPillContribution, PluginSidebarContribution, PluginSidebarItemContribution, diff --git a/packages/app/src/plugins/workspace-panels/resolution.test.ts b/packages/app/src/plugins/workspace-panels/resolution.test.ts index 3dffea5558..b75880b543 100644 --- a/packages/app/src/plugins/workspace-panels/resolution.test.ts +++ b/packages/app/src/plugins/workspace-panels/resolution.test.ts @@ -22,6 +22,7 @@ function installed(): InstalledPlugin { legacySidebarItems: [], filePanels: [], composerItems: [], + newWorkspaceItems: [], workspacePanels: [ { id: "details", diff --git a/packages/app/src/screens/new-workspace-picker-item.ts b/packages/app/src/screens/new-workspace-picker-item.ts index d02c7a0bf2..be35e13309 100644 --- a/packages/app/src/screens/new-workspace-picker-item.ts +++ b/packages/app/src/screens/new-workspace-picker-item.ts @@ -132,7 +132,7 @@ export interface BaseRefCheckoutStatus { // Display only. The exact ref is what every request carries; this is just how a ref reads in // a row label, so "refs/remotes/origin/other-name" shows as "other-name". -function branchNameFromRef(refName: string): string { +export function branchNameFromRef(refName: string): string { if (refName.startsWith("refs/heads/")) return refName.slice("refs/heads/".length); if (refName.startsWith(REMOTE_TRACKING_PREFIX)) { const remainder = refName.slice(REMOTE_TRACKING_PREFIX.length); diff --git a/packages/app/src/screens/new-workspace-screen.tsx b/packages/app/src/screens/new-workspace-screen.tsx index fd03037fa1..f2f50ad4e4 100644 --- a/packages/app/src/screens/new-workspace-screen.tsx +++ b/packages/app/src/screens/new-workspace-screen.tsx @@ -5,7 +5,7 @@ import type { import type { AgentSnapshotPayload, CreationSnapshot } from "@getpaseo/protocol/messages"; import { encodeImages } from "@/utils/encode-images"; import { getHostRuntimeStore } from "@/runtime/host-runtime"; -import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { ReactNode } from "react"; import { useTranslation } from "react-i18next"; import { router } from "expo-router"; @@ -79,7 +79,7 @@ import type { MessagePayload } from "@/composer/types"; import type { UserComposerAttachment } from "@/attachments/types"; import type { AgentAttachment, ForgeSearchItem } from "@getpaseo/protocol/messages"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; -import type { AgentProvider } from "@getpaseo/protocol/agent-types"; +import type { AgentProvider, JsonValue } from "@getpaseo/protocol/agent-types"; import type { WorkspaceDraftTabSetup, WorkspaceTabTarget } from "@/workspace-tabs/model"; import { isEmptyWorkspaceSubmission, runCreateEmptyWorkspace } from "./new-workspace-empty"; import { @@ -98,10 +98,18 @@ import { } from "./new-workspace-picker-item"; import { clearPickerPrAttachmentForTargetChange, - initialPickerSelectionState, - reducePickerSelection, syncPickerPrAttachment, } from "./new-workspace-picker-state"; +import { + PluginNewWorkspaceItems, + type NewWorkspaceItemsScreen, +} from "@/plugins/new-workspace-items"; +import { + createNewWorkspaceSetup, + useNewWorkspaceSetupState, + type NewWorkspaceSetup, + type SetupFacts, +} from "./new-workspace/setup-model"; import { resolveNewWorkspaceAutomaticServerId, resolveNewWorkspaceInitialServerId, @@ -195,13 +203,14 @@ interface WorkspaceIsolationState { function useWorkspaceIsolation(input: { supportsMultiplicity: boolean; worktreeSupport: "supported" | "unsupported" | "unknown"; + manualIsolation: "local" | "worktree" | null; + setManualIsolation: (value: "local" | "worktree") => void; }): WorkspaceIsolationState { - const { supportsMultiplicity, worktreeSupport } = input; + const { supportsMultiplicity, worktreeSupport, manualIsolation, setManualIsolation } = input; // The last isolation choice is remembered alongside the other New Workspace // form preferences (provider, model, mode). A manual in-screen pick overrides // the remembered default until the screen remounts. const { preferences, updatePreferences } = useFormPreferences(); - const [manualIsolation, setManualIsolation] = useState<"local" | "worktree" | null>(null); const isolation = manualIsolation ?? preferences.isolation ?? "local"; const canCreateWorktree = supportsMultiplicity && worktreeSupport !== "unsupported"; const isWorktree = isolation === "worktree" && canCreateWorktree; @@ -211,7 +220,7 @@ function useWorkspaceIsolation(input: { setManualIsolation(value); void updatePreferences({ isolation: value }); }, - [updatePreferences], + [setManualIsolation, updatePreferences], ); return { @@ -288,6 +297,10 @@ async function createMultiplicityWorkspace(input: { project: HostProjectListItem; sourceDirectory: string; checkoutRequest: PickerCheckoutRequest | undefined; + /** Names the new branch of a worktree cut from a branch. */ + branchName: string | null; + /** Stored on the created workspace. */ + metadata: Readonly>; withInitialAgent: boolean; agent?: CreateWorkspaceRequestOptions["agent"]; onEvent?: (snapshot: CreationSnapshot) => void; @@ -318,6 +331,9 @@ async function createMultiplicityWorkspace(input: { projectId, worktreeSlug: input.worktreeSlug, ...input.checkoutRequest, + ...(input.branchName && input.checkoutRequest?.action === "branch-off" + ? { branchName: input.branchName } + : {}), } : { kind: "directory", @@ -325,6 +341,7 @@ async function createMultiplicityWorkspace(input: { projectId, }, ...(firstAgentContext ? { firstAgentContext } : {}), + ...(Object.keys(input.metadata).length > 0 ? { metadata: { ...input.metadata } } : {}), }); if (payload.error || !payload.workspace) { throw new Error(payload.error ?? input.createFailedMessage); @@ -1002,22 +1019,24 @@ export function NewWorkspaceScreen({ }), }); const composerState = chatDraft.composerState; - const [pickerSelection, dispatchPickerSelection] = useReducer( - reducePickerSelection, - initialPickerSelectionState, - ); - const selectedItem = pickerSelection.selectedItem; + const [setup] = useState(createNewWorkspaceSetup); + const setupState = useNewWorkspaceSetupState(setup); + const dispatchPickerSelection = setup.dispatchPicker; + const selectedItem = setupState.pickerSelection.selectedItem; const handleForgeChangeRequestDetected = useCallback(() => { dispatchPickerSelection({ type: "pr-detected" }); - }, []); + }, [dispatchPickerSelection]); - const handleForgeChangeRequestAutoAttach = useCallback((item: ForgeSearchItem) => { - dispatchPickerSelection({ - type: "pr-added", - item: { kind: "github-pr", item }, - }); - }, []); + const handleForgeChangeRequestAutoAttach = useCallback( + (item: ForgeSearchItem) => { + dispatchPickerSelection({ + type: "pr-added", + item: { kind: "github-pr", item }, + }); + }, + [dispatchPickerSelection], + ); const withConnectedClient = useCallback(() => { const connectedClient = getHostRuntimeStore().getClient(selectedServerId); @@ -1044,6 +1063,8 @@ export function NewWorkspaceScreen({ useWorkspaceIsolation({ supportsMultiplicity: supportsWorkspaceMultiplicity, worktreeSupport, + manualIsolation: setupState.manualIsolation, + setManualIsolation: setup.setManualIsolation, }); const branchSuggestionsQuery = useQuery({ @@ -1117,9 +1138,21 @@ export function NewWorkspaceScreen({ chatDraft.setAttachments(nextAttachments); setPickerOpen(false); }, - [chatDraft], + [chatDraft, dispatchPickerSelection], ); + const pluginScreen = usePluginSetup({ + setup, + serverId: selectedServerId, + project: selectedProject, + baseItem, + isolation: effectiveIsolation, + canCreateWorktree, + locked: isPending, + chooseIsolation: setIsolation, + chooseBase: selectPickerItem, + }); + const handleSelectOption = useCallback( (id: string) => { const item = itemById.get(id); @@ -1140,7 +1173,7 @@ export function NewWorkspaceScreen({ chatDraft.setAttachments(nextAttachments); dispatchPickerSelection({ type: "target-changed" }); }, - [chatDraft], + [chatDraft, dispatchPickerSelection], ); const handleSelectProjectOption = useCallback( @@ -1260,6 +1293,8 @@ export function NewWorkspaceScreen({ project: selectedProject, sourceDirectory: selectedSourceDirectory, checkoutRequest, + branchName: setup.getSnapshot().branch, + metadata: setup.getSnapshot().metadata, withInitialAgent: input.withInitialAgent, prompt: input.prompt, attachments: input.attachments, @@ -1282,6 +1317,7 @@ export function NewWorkspaceScreen({ selectedProject, selectedServerId, selectedSourceDirectory, + setup, supportsWorkspaceMultiplicity, t, withConnectedClient, @@ -1462,6 +1498,9 @@ export function NewWorkspaceScreen({ const formStack = useNewWorkspaceForm({ isCompact, isPending, + pluginItems: ( + + ), project: { anchorRef: projectPickerAnchorRef, open: openProjectPicker, @@ -1603,6 +1642,45 @@ export function NewWorkspaceScreen({ } /** Terminal and chat launches edit in separate sessions; the screen follows the mounted one. */ +/** Reports the screen's current facts to the setup model, which plugin commands read when they run. */ +function usePluginSetup(input: { + setup: NewWorkspaceSetup; + serverId: string | null; + project: Parameters[0] | null; + baseItem: PickerItem | null; + isolation: NewWorkspaceItemsScreen["isolation"]; + canCreateWorktree: boolean; + locked: boolean; + chooseIsolation: SetupFacts["chooseIsolation"]; + chooseBase: SetupFacts["chooseBase"]; +}): NewWorkspaceItemsScreen { + const { setup, serverId, project, baseItem, isolation, locked } = input; + const { canCreateWorktree, chooseIsolation, chooseBase } = input; + const supportsMetadata = useHostFeature(serverId, "entityMetadata"); + const projectId = project && serverId ? getHostProjectId(project, serverId) : null; + const projectName = project?.projectName ?? null; + const screen = useMemo( + () => ({ + project: projectId && projectName ? { id: projectId, name: projectName } : null, + isolation, + baseItem, + locked, + }), + [baseItem, isolation, locked, projectId, projectName], + ); + useEffect(() => { + setup.report({ + locked: screen.locked, + project: screen.project, + canCreateWorktree, + supportsMetadata, + chooseIsolation, + chooseBase, + }); + }, [canCreateWorktree, chooseBase, chooseIsolation, screen, setup, supportsMetadata]); + return screen; +} + function useNewWorkspaceComposerEditing() { const terminal = useComposerEditingSession("terminal"); const chat = useComposerEditingSession("chat"); diff --git a/packages/app/src/screens/new-workspace/form/index.tsx b/packages/app/src/screens/new-workspace/form/index.tsx index c1cf1e2482..3b2040d389 100644 --- a/packages/app/src/screens/new-workspace/form/index.tsx +++ b/packages/app/src/screens/new-workspace/form/index.tsx @@ -33,6 +33,8 @@ interface PickerControl { export interface NewWorkspaceFormProps { isCompact: boolean; isPending: boolean; + /** Plugin fields, drawn after the base ref. */ + pluginItems: ReactNode; project: PickerControl & { options: ComboboxOption[]; projectByOptionId: Map; @@ -165,7 +167,7 @@ function useRefRenderOption(itemById: Map, isPending: boolea */ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement { const { t } = useTranslation(); - const { isCompact, isPending, project, host, isolation, base, launch } = props; + const { isCompact, isPending, pluginItems, project, host, isolation, base, launch } = props; const variant: FieldTriggerVariant = isCompact ? "field" : "badge"; const projectPicker = useProjectPicker(project, isPending); const isolationPicker = useIsolationPicker(isolation, isPending); @@ -337,6 +339,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {hostField} {isolationField} {baseField} + {pluginItems} {launchField} {/* Isolation and Base come and go with the project; their rows stay reserved so the stack keeps one height and nothing above the composer jumps. */} @@ -353,6 +356,7 @@ export function useNewWorkspaceForm(props: NewWorkspaceFormProps): ReactElement {hostField} {isolationField ? {isolationField} : null} {baseField ? {baseField} : null} + {pluginItems} {launchField} {pickers} diff --git a/packages/app/src/screens/new-workspace/setup-model.test.ts b/packages/app/src/screens/new-workspace/setup-model.test.ts new file mode 100644 index 0000000000..65bd44a4cc --- /dev/null +++ b/packages/app/src/screens/new-workspace/setup-model.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, it } from "vitest"; +import type { PickerItem } from "../new-workspace-picker-item"; +import { createNewWorkspaceSetup, type SetupFacts } from "./setup-model"; + +function readyFacts(overrides: Partial = {}): SetupFacts { + return { + locked: false, + project: { id: "p1", name: "repo" }, + canCreateWorktree: true, + supportsMetadata: true, + chooseIsolation: () => {}, + chooseBase: () => {}, + ...overrides, + }; +} + +describe("New workspace setup commands", () => { + it("applies commands that run back to back without waiting for a render", async () => { + const setup = createNewWorkspaceSetup(); + setup.report(readyFacts()); + + void setup.commands.setBranch("pas-101"); + void setup.commands.setMetadata({ issue: "PAS-101", keep: true }); + await setup.commands.setMetadata({ keep: null }); + + expect(setup.getSnapshot().branch).toBe("pas-101"); + expect(setup.getSnapshot().metadata).toEqual({ issue: "PAS-101" }); + }); + + it("passes a base branch and an isolation choice to the picker's own handlers", async () => { + const setup = createNewWorkspaceSetup(); + const chosen: PickerItem[] = []; + const isolations: string[] = []; + setup.report( + readyFacts({ + chooseBase: (item) => chosen.push(item), + chooseIsolation: (isolation) => isolations.push(isolation), + }), + ); + + await setup.commands.setBaseRef({ type: "branch", name: " release " }); + await setup.commands.setIsolation("worktree"); + + expect(chosen).toMatchObject([{ kind: "branch", name: "release", refName: "release" }]); + expect(isolations).toEqual(["worktree"]); + }); + + it("rejects every command while the screen is locked, as the pickers do", async () => { + const setup = createNewWorkspaceSetup(); + setup.report(readyFacts({ locked: true })); + + const results = await Promise.allSettled([ + setup.commands.setBranch("x"), + setup.commands.setMetadata({ a: 1 }), + setup.commands.setIsolation("local"), + setup.commands.setBaseRef({ type: "branch", name: "main" }), + ]); + + expect(results.map((result) => result.status)).toEqual(Array(4).fill("rejected")); + expect(setup.getSnapshot().branch).toBeNull(); + expect(setup.getSnapshot().metadata).toEqual({}); + }); + + it("reads the facts when a command runs, so a project change after render is seen", async () => { + const setup = createNewWorkspaceSetup(); + setup.report(readyFacts()); + const pending = setup.commands.setBaseRef({ type: "branch", name: "main" }); + setup.report(readyFacts({ project: null })); + await pending; + await expect(setup.commands.setBaseRef({ type: "branch", name: "main" })).rejects.toThrow( + "Choose a project first.", + ); + }); + + it("rejects what the project or host cannot do", async () => { + const setup = createNewWorkspaceSetup(); + setup.report(readyFacts({ canCreateWorktree: false, supportsMetadata: false })); + + await expect(setup.commands.setIsolation("worktree")).rejects.toThrow( + "cannot create worktrees", + ); + await expect(setup.commands.setMetadata({ a: 1 })).rejects.toThrow("Update the host"); + await expect(setup.commands.setBranch(" ")).rejects.toThrow("needs a name"); + await setup.commands.setBranch(null); + expect(setup.getSnapshot().branch).toBeNull(); + }); +}); diff --git a/packages/app/src/screens/new-workspace/setup-model.ts b/packages/app/src/screens/new-workspace/setup-model.ts new file mode 100644 index 0000000000..4bc3c94c51 --- /dev/null +++ b/packages/app/src/screens/new-workspace/setup-model.ts @@ -0,0 +1,128 @@ +import { useSyncExternalStore } from "react"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import { applyMetadataPatch } from "@/composer/draft/metadata"; +import type { PickerItem } from "../new-workspace-picker-item"; +import { + initialPickerSelectionState, + reducePickerSelection, + type PickerSelectionEvent, + type PickerSelectionState, +} from "../new-workspace-picker-state"; + +type Isolation = "local" | "worktree"; + +/** What the user has chosen on the New workspace screen, apart from project and host. */ +interface SetupState { + pickerSelection: PickerSelectionState; + manualIsolation: Isolation | null; + /** The name for a new worktree branch; null lets Paseo pick one. */ + branch: string | null; + /** The created workspace's metadata. */ + metadata: Readonly>; +} + +/** + * What the screen derives from its hooks and reports on every render, so a command reads it at the + * moment it runs rather than from the render that built the command. + */ +export interface SetupFacts { + locked: boolean; + project: { id: string; name: string } | null; + canCreateWorktree: boolean; + supportsMetadata: boolean; + /** The picker's own handlers, so a command has the effects a pick has. */ + chooseIsolation(isolation: Isolation): void; + chooseBase(item: PickerItem): void; +} + +export interface SetupCommands { + setIsolation(isolation: Isolation): Promise; + setBaseRef(ref: { type: "branch"; name: string }): Promise; + setBranch(name: string | null): Promise; + setMetadata(patch: Record): Promise; +} + +export interface NewWorkspaceSetup { + subscribe(listener: () => void): () => void; + getSnapshot(): SetupState; + dispatchPicker(event: PickerSelectionEvent): void; + setManualIsolation(isolation: Isolation): void; + report(facts: SetupFacts): void; + commands: SetupCommands; +} + +const LOCKED_MESSAGE = "The New workspace screen is locked while the workspace is being created."; + +/** + * The New workspace screen's choices as one plain model. The pickers and the plugin commands + * write to it, so a command sees what an earlier command or pick left, and applies the same rules + * as the picker it stands in for. + */ +export function createNewWorkspaceSetup(): NewWorkspaceSetup { + let state: SetupState = { + pickerSelection: initialPickerSelectionState, + manualIsolation: null, + branch: null, + metadata: {}, + }; + let facts: SetupFacts | null = null; + const listeners = new Set<() => void>(); + const update = (patch: Partial) => { + state = { ...state, ...patch }; + for (const listener of listeners) listener(); + }; + const unlocked = (): SetupFacts => { + if (!facts) throw new Error("The New workspace screen is not ready."); + if (facts.locked) throw new Error(LOCKED_MESSAGE); + return facts; + }; + return { + subscribe(listener) { + listeners.add(listener); + return () => listeners.delete(listener); + }, + getSnapshot: () => state, + dispatchPicker: (event) => + update({ pickerSelection: reducePickerSelection(state.pickerSelection, event) }), + setManualIsolation: (manualIsolation) => update({ manualIsolation }), + report: (next) => { + facts = next; + }, + commands: { + async setIsolation(isolation) { + const current = unlocked(); + if (isolation === "worktree" && !current.canCreateWorktree) { + throw new Error("This project cannot create worktrees."); + } + current.chooseIsolation(isolation); + }, + async setBaseRef({ name }) { + const current = unlocked(); + const refName = name.trim(); + if (!current.project) throw new Error("Choose a project first."); + if (!refName) throw new Error("A base branch needs a name."); + current.chooseBase({ + kind: "branch", + name: refName, + refName, + accessibilityLabel: `${refName}, branch`, + }); + }, + async setBranch(name) { + unlocked(); + const branch = name === null ? null : name.trim(); + if (branch === "") throw new Error("A branch needs a name."); + update({ branch }); + }, + async setMetadata(patch) { + const current = unlocked(); + if (!current.supportsMetadata) throw new Error("Update the host to use metadata."); + update({ metadata: applyMetadataPatch(state.metadata, patch) }); + }, + }, + }; +} + +export function useNewWorkspaceSetupState(setup: NewWorkspaceSetup): SetupState { + return useSyncExternalStore(setup.subscribe, setup.getSnapshot, setup.getSnapshot); +} diff --git a/packages/plugin/src/boundaries.test.ts b/packages/plugin/src/boundaries.test.ts index 168acbaf27..b44e7f5309 100644 --- a/packages/plugin/src/boundaries.test.ts +++ b/packages/plugin/src/boundaries.test.ts @@ -250,6 +250,51 @@ describe("plugin composer SDK boundary", () => { }); }); +describe("plugin New workspace SDK boundary", () => { + it("lets a New workspace item read the setup and change it through typed commands and a Combobox", () => { + expect( + typeErrors(` + import { useNewWorkspace, type PluginClientContext } from "@getpaseo/plugin/client"; + import { Combobox } from "@getpaseo/plugin/client/ui"; + interface Issue { id: string; label: string; branch: string } + function Item() { + const newWorkspace = useNewWorkspace(); + const isolation: "local" | "worktree" = newWorkspace.isolation; + const branch: string | null = newWorkspace.branch; + const project: string | undefined = newWorkspace.project?.name; + const host: string = newWorkspace.host.label; + const base = newWorkspace.baseRef; + if (base?.type === "branch") { const name: string = base.name; } + // @ts-expect-error project is read-only + newWorkspace.project = null; + // @ts-expect-error host is read-only + newWorkspace.setHost("other"); + // @ts-expect-error metadata reads are not writable + newWorkspace.metadata.key = "x"; + newWorkspace.setIsolation("worktree").then(() => newWorkspace.setBaseRef({ type: "branch", name: "main" })); + newWorkspace.setBranch(null); + newWorkspace.setMetadata({ issue: "1", stale: null }); + // @ts-expect-error commands take JSON values + newWorkspace.setMetadata({ fn: () => {} }); + return ( + + title="Issue" + icon="CircleDot" + value={{ id: "1", label: "One" }} + search={async ({ query }: { query: string }) => [{ id: query, label: query, branch: "b" }]} + onSelect={({ item }) => newWorkspace.setBranch(item.branch)} + /> + ); + } + export default function contribute(client: PluginClientContext) { + client.addNewWorkspaceItem({ id: "item", Component: Item }); + return () => {}; + } + `), + ).toEqual([]); + }); +}); + describe("plugin agent tools SDK boundary", () => { it("lets a session hook attach typed tools without exposing their transport", () => { expect( diff --git a/packages/plugin/src/client/contracts.ts b/packages/plugin/src/client/contracts.ts index 9764ad204e..b85cbaddc0 100644 --- a/packages/plugin/src/client/contracts.ts +++ b/packages/plugin/src/client/contracts.ts @@ -5,6 +5,7 @@ import type { AgentTimelineItem, JsonValue } from "@getpaseo/protocol/agent-type import type { ZodType, input as ZodInput, output as ZodOutput } from "zod"; import type { PluginFile } from "./file.js"; import type { PluginComposerItemContribution } from "./composer.js"; +import type { PluginNewWorkspaceItemContribution } from "./new-workspace.js"; import type { PluginRpcContract } from "../rpc.js"; import type { PluginButtonRegistration, @@ -196,6 +197,7 @@ export interface PluginClientContext extends PluginCommandCapabilities, PluginCl ): PluginButtonRegistration; addAttachmentSource(contribution: PluginAttachmentSourceContribution): PluginCleanup; addComposerItem(contribution: PluginComposerItemContribution): PluginCleanup; + addNewWorkspaceItem(contribution: PluginNewWorkspaceItemContribution): PluginCleanup; addTheme(contribution: PluginThemeContribution): PluginCleanup; addFilePanel(contribution: PluginFilePanelContribution): PluginCleanup; addTimelineTransformer( diff --git a/packages/plugin/src/client/host.ts b/packages/plugin/src/client/host.ts index c09cb3b215..ea1f27c6d4 100644 --- a/packages/plugin/src/client/host.ts +++ b/packages/plugin/src/client/host.ts @@ -2,6 +2,7 @@ import type { PluginText } from "../text.js"; import { PluginAttachmentSearchPayloadSchema } from "../attachments.js"; export { PluginClientStateProvider, type PluginClientStateSource } from "./client-state.js"; export { PluginComposerProvider } from "./composer.js"; +export { PluginNewWorkspaceProvider } from "./new-workspace.js"; export { usePluginRuntimeContextBridge, type PluginRuntimeContextBridge, diff --git a/packages/plugin/src/client/index.ts b/packages/plugin/src/client/index.ts index e4de9dcaf4..49b7d3d485 100644 --- a/packages/plugin/src/client/index.ts +++ b/packages/plugin/src/client/index.ts @@ -78,6 +78,12 @@ export type { PluginComposerItemContribution, PluginComposerItemProps, } from "./composer.js"; +export { useNewWorkspace } from "./new-workspace.js"; +export type { + PluginNewWorkspace, + PluginNewWorkspaceItemContribution, + PluginNewWorkspaceItemProps, +} from "./new-workspace.js"; export { useRpc } from "./rpc-context.js"; import type { SettingsDefinition } from "../settings.js"; import type { SettingsState } from "./contracts.js"; diff --git a/packages/plugin/src/client/new-workspace.tsx b/packages/plugin/src/client/new-workspace.tsx new file mode 100644 index 0000000000..ca144fc4c5 --- /dev/null +++ b/packages/plugin/src/client/new-workspace.tsx @@ -0,0 +1,65 @@ +import { createContext, useContext, type ComponentType, type ReactNode } from "react"; +import type { JsonValue } from "@getpaseo/protocol/agent-types"; +import type { PluginHostProps } from "./contracts.js"; + +/** + * The New workspace setup the item sits in. `host` and `project` are the user's choices and are + * read-only. The commands change what the created workspace is built from, under the same rules as + * the screen's own pickers. + */ +export interface PluginNewWorkspace { + readonly host: { readonly id: string; readonly label: string }; + readonly project: { readonly id: string; readonly name: string } | null; + /** What the screen will create. A project that cannot make worktrees reads `"local"`. */ + readonly isolation: "local" | "worktree"; + /** What a new worktree starts from; `null` until the project's default is known. */ + readonly baseRef: + | { readonly type: "branch"; readonly name: string } + | { readonly type: "change-request"; readonly number: number } + | null; + /** + * The name of the branch a new worktree is created on; `null` lets Paseo pick one. It is kept + * while isolation is local or the base is a change request, and applies only to a worktree + * created from a branch. + */ + readonly branch: string | null; + /** The workspace's metadata, not the first agent's. */ + readonly metadata: Readonly>; + /** Rejects `"worktree"` for a project that cannot make worktrees. */ + setIsolation(isolation: "local" | "worktree"): Promise; + /** Starts the workspace from a branch. Rejects while no project is chosen. */ + setBaseRef(ref: { type: "branch"; name: string }): Promise; + setBranch(name: string | null): Promise; + /** Top-level keys replace; `null` removes the key. Rejects on a host without metadata support. */ + setMetadata(patch: Record): Promise; +} + +export interface PluginNewWorkspaceItemProps extends PluginHostProps {} + +export interface PluginNewWorkspaceItemContribution { + id: string; + Component: ComponentType; +} + +const PluginNewWorkspaceContext = createContext(null); + +export function PluginNewWorkspaceProvider({ + newWorkspace, + children, +}: { + newWorkspace: PluginNewWorkspace; + children: ReactNode; +}) { + return ( + + {children} + + ); +} + +/** The New workspace setup this item sits in. Call it from a component registered with `addNewWorkspaceItem`. */ +export function useNewWorkspace(): PluginNewWorkspace { + const newWorkspace = useContext(PluginNewWorkspaceContext); + if (!newWorkspace) throw new Error("useNewWorkspace must run inside a New workspace item"); + return newWorkspace; +} diff --git a/packages/plugin/src/client/ui.ts b/packages/plugin/src/client/ui.ts index 5130141332..30ad3e178a 100644 --- a/packages/plugin/src/client/ui.ts +++ b/packages/plugin/src/client/ui.ts @@ -210,10 +210,35 @@ export interface ActionPressEvent {} export interface ActionProps extends ComposerControlProps { onPress(event: ActionPressEvent): void | Promise; } +/** One result of a `Combobox` search. Extra fields ride along to `onSelect`. */ +export interface ComboboxItem { + id: string; + label: string; + description?: string; +} +export interface ComboboxSearchEvent { + /** Empty when the list opens. */ + query: string; +} +export interface ComboboxProps< + Item extends ComboboxItem = ComboboxItem, +> extends ComposerControlProps { + /** What the control shows. It need not be among the search results. */ + value: ComboboxItem | null; + /** + * Called when the list opens and again, debounced, as the user types. Paseo shows the loading + * and error states, keeps the previous results while a search runs, and ignores a search that a + * newer one replaced. + */ + search(event: ComboboxSearchEvent): Promise; + onSelect(event: { item: Item }): void | Promise; +} /** - * Composer controls. Render them from a composer item's `Component`. Paseo draws them inline when - * the composer has room and as rows in its overflow sheet when it does not. + * Paseo's controls. Render them from a composer item's or a New workspace item's `Component`. + * Paseo draws them where the item sits: inline when the composer has room and as rows in its + * overflow sheet when it does not, and as a field in the New workspace setup. */ export declare const Toggle: ComponentType; export declare function Select(props: SelectProps): ReactNode; export declare const Action: ComponentType; +export declare function Combobox(props: ComboboxProps): ReactNode; diff --git a/plugin-examples/new-workspace-issues/README.md b/plugin-examples/new-workspace-issues/README.md new file mode 100644 index 0000000000..3ad55f49ca --- /dev/null +++ b/plugin-examples/new-workspace-issues/README.md @@ -0,0 +1,17 @@ +# New workspace issues + +Install this directory as a plugin and open New workspace. An **Issue** field appears beside the +project, host, and branch pickers: a field on a phone, a badge on desktop. + +Choosing an issue: + +- Calls `newWorkspace.setBranch(issue.branch)`, so a worktree is created on that branch name. +- Calls `newWorkspace.setMetadata({ "new-workspace-issues.issue": issue.id })`, so the created + workspace carries the issue id. + +`Combobox` runs `search` when the list opens and again as the user types. Paseo shows the loading +and error states and drops results that a newer search replaced. The server handler in +[server/issues.ts](server/issues.ts) filters a fixed list; a real plugin would call its tracker. + +[client/issue-field.tsx](client/issue-field.tsx) is the field; [index.client.tsx](index.client.tsx) +registers it with `addNewWorkspaceItem`. diff --git a/plugin-examples/new-workspace-issues/client/issue-field.tsx b/plugin-examples/new-workspace-issues/client/issue-field.tsx new file mode 100644 index 0000000000..3786a1128f --- /dev/null +++ b/plugin-examples/new-workspace-issues/client/issue-field.tsx @@ -0,0 +1,31 @@ +import { useCallback, useMemo } from "react"; +import { useNewWorkspace, useRpc } from "@getpaseo/plugin/client"; +import { Combobox } from "@getpaseo/plugin/client/ui"; +import { searchIssuesRpc, type Issue } from "../shared/issues"; + +const ISSUE_KEY = "new-workspace-issues.issue"; + +/** Starts the workspace on the issue's branch and records which issue it is for. */ +export function IssueField() { + const newWorkspace = useNewWorkspace(); + const searchIssues = useRpc(searchIssuesRpc); + const issueId = newWorkspace.metadata[ISSUE_KEY]; + const value = useMemo( + () => (typeof issueId === "string" ? { id: issueId, label: issueId } : null), + [issueId], + ); + const search = useCallback( + async ({ query }: { query: string }) => (await searchIssues({ query })).issues, + [searchIssues], + ); + const select = useCallback( + async ({ item }: { item: Issue }) => { + await newWorkspace.setBranch(item.branch); + await newWorkspace.setMetadata({ [ISSUE_KEY]: item.id }); + }, + [newWorkspace], + ); + return ( + + ); +} diff --git a/plugin-examples/new-workspace-issues/index.client.tsx b/plugin-examples/new-workspace-issues/index.client.tsx new file mode 100644 index 0000000000..1d1132923e --- /dev/null +++ b/plugin-examples/new-workspace-issues/index.client.tsx @@ -0,0 +1,7 @@ +import type { PluginClientContext } from "@getpaseo/plugin/client"; +import { IssueField } from "./client/issue-field"; + +export default function contribute(client: PluginClientContext) { + client.addNewWorkspaceItem({ id: "issue", Component: IssueField }); + return () => {}; +} diff --git a/plugin-examples/new-workspace-issues/index.server.ts b/plugin-examples/new-workspace-issues/index.server.ts new file mode 100644 index 0000000000..544ab0081c --- /dev/null +++ b/plugin-examples/new-workspace-issues/index.server.ts @@ -0,0 +1,8 @@ +import type { PluginServerContext } from "@getpaseo/plugin/server"; +import { searchIssues } from "./server/issues"; +import { searchIssuesRpc } from "./shared/issues"; + +export default function contribute(server: PluginServerContext) { + server.handle(searchIssuesRpc, searchIssues); + return () => {}; +} diff --git a/plugin-examples/new-workspace-issues/paseo-plugin.json b/plugin-examples/new-workspace-issues/paseo-plugin.json new file mode 100644 index 0000000000..7a1bb2513d --- /dev/null +++ b/plugin-examples/new-workspace-issues/paseo-plugin.json @@ -0,0 +1,4 @@ +{ + "id": "new-workspace-issues-example", + "requirements": { "paseo": ">=0.8.0" } +} diff --git a/plugin-examples/new-workspace-issues/server/issues.ts b/plugin-examples/new-workspace-issues/server/issues.ts new file mode 100644 index 0000000000..a89ea0e3e3 --- /dev/null +++ b/plugin-examples/new-workspace-issues/server/issues.ts @@ -0,0 +1,26 @@ +import type { RpcInput } from "@getpaseo/plugin"; +import { searchIssuesRpc, type Issue } from "../shared/issues"; + +// A real plugin would ask its tracker. A fixed list keeps the example self-contained. +const ISSUES: Issue[] = [ + { + id: "PAS-101", + label: "PAS-101 Slow sidebar", + description: "Lists lag at 200 rows", + branch: "pas-101-slow-sidebar", + }, + { + id: "PAS-102", + label: "PAS-102 Crash on resume", + description: "Agent resume throws", + branch: "pas-102-crash-on-resume", + }, + { id: "PAS-103", label: "PAS-103 Dark theme contrast", branch: "pas-103-dark-theme-contrast" }, +]; + +export async function searchIssues({ query }: RpcInput) { + const needle = query.trim().toLowerCase(); + return { + issues: ISSUES.filter((issue) => issue.label.toLowerCase().includes(needle)), + }; +} diff --git a/plugin-examples/new-workspace-issues/shared/issues.ts b/plugin-examples/new-workspace-issues/shared/issues.ts new file mode 100644 index 0000000000..3f04c5afff --- /dev/null +++ b/plugin-examples/new-workspace-issues/shared/issues.ts @@ -0,0 +1,18 @@ +import { defineRpc } from "@getpaseo/plugin"; +import { z } from "zod"; + +export const IssueSchema = z.object({ + id: z.string(), + label: z.string(), + description: z.string().optional(), + /** The branch the issue's work goes on. */ + branch: z.string(), +}); + +export type Issue = z.infer; + +export const searchIssuesRpc = defineRpc({ + name: "issues.search", + input: z.object({ query: z.string() }), + output: z.object({ issues: z.array(IssueSchema) }), +}); diff --git a/public-docs/plugins/index.md b/public-docs/plugins/index.md index a9c7915a73..d4d12cc238 100644 --- a/public-docs/plugins/index.md +++ b/public-docs/plugins/index.md @@ -15,6 +15,7 @@ A plugin is a TypeScript project installed into one Paseo daemon. It can add [Command Center items](/docs/plugins/reference#command-center-items), [slash commands](/docs/plugins/reference#slash-commands), [composer items](/docs/plugins/reference#composer-items), +[New workspace items](/docs/plugins/reference#new-workspace-items), [composer pills](/docs/plugins/reference#composer-pills), [timeline items](/docs/plugins/reference#timeline-items), [themes](/docs/plugins/reference#contribute-a-theme), diff --git a/public-docs/plugins/index.zh-CN.md b/public-docs/plugins/index.zh-CN.md index 07a79d2922..d1367dac47 100644 --- a/public-docs/plugins/index.zh-CN.md +++ b/public-docs/plugins/index.zh-CN.md @@ -6,7 +6,7 @@ nav: 快速上手 # 插件快速上手 {#plugin-quickstart} -插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 项](/docs/plugins/reference#composer-items)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。 +插件是安装到某个 Paseo daemon 中的 TypeScript 项目。它可以添加[界面和侧边栏项](/docs/plugins/reference#screens-and-sidebar-items)、[工作区面板](/docs/plugins/reference#workspace-panels)、[文件面板](/docs/plugins/reference#file-panels)、[Command Center 项](/docs/plugins/reference#command-center-items)、[斜杠命令](/docs/plugins/reference#slash-commands)、[Composer 项](/docs/plugins/reference#composer-items)、[新建工作区项](/docs/plugins/reference#new-workspace-items)、[Composer 标签](/docs/plugins/reference#composer-pills)、[时间线条目](/docs/plugins/reference#timeline-items)、[主题](/docs/plugins/reference#contribute-a-theme)、[附件来源](/docs/plugins/reference#add-a-composer-attachment-source)和 [daemon 端 RPC](/docs/plugins/reference#add-plugin-specific-backend-behavior)。它还可以[把编程 Agent 接入为 Provider](/docs/plugins/providers)。客户端贡献会在连接到该 daemon 的每个 Paseo 客户端上运行,包括移动端。 浏览[已发布的插件](https://paseo.sh/plugins),然后在你的 daemon 上安装一个: diff --git a/public-docs/plugins/reference.md b/public-docs/plugins/reference.md index 453f6899d1..4d21997054 100644 --- a/public-docs/plugins/reference.md +++ b/public-docs/plugins/reference.md @@ -1981,6 +1981,77 @@ control. The [composer controls example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls) registers a `Select`, two `Toggle`s (one writes a provider option) and an `Action`. +## New workspace items + +`client.addNewWorkspaceItem({ id, Component })` puts a field on the New workspace screen, beside +the project, host and branch pickers. `useNewWorkspace()` reads and changes what the screen will +create, and `Combobox`, `Select`, `Toggle` and `Action` from `@getpaseo/plugin/client/ui` draw the +fields. On desktop an item is a badge in the setup row; on a phone it is a full-width field. + +```tsx +import { useNewWorkspace, useRpc, type PluginClientContext } from "@getpaseo/plugin/client"; +import { Combobox } from "@getpaseo/plugin/client/ui"; +import { searchIssuesRpc } from "../shared/issues"; + +function IssueField() { + const newWorkspace = useNewWorkspace(); + const searchIssues = useRpc(searchIssuesRpc); + const issueId = newWorkspace.metadata["issues.id"]; + return ( + (await searchIssues({ query })).issues} + onSelect={async ({ item }) => { + await newWorkspace.setBranch(item.branch); + await newWorkspace.setMetadata({ "issues.id": item.id }); + }} + /> + ); +} + +export default function contribute(client: PluginClientContext) { + client.addNewWorkspaceItem({ id: "issue", Component: IssueField }); + return () => {}; +} +``` + +`useNewWorkspace()` returns the setup the item sits in: + +| Field or command | Meaning | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `host`, `project` | The user's choices, `{ id, label }` and `{ id, name } \| null`. Read-only: an item cannot switch host or project. | +| `isolation`, `setIsolation` | `"local"` or `"worktree"`. A project that cannot make worktrees reads `"local"`, and `setIsolation("worktree")` rejects. | +| `baseRef`, `setBaseRef` | What a new worktree starts from: `{ type: "branch", name }`, `{ type: "change-request", number }`, or `null` until the default is known. `setBaseRef({ type: "branch", name })` rejects while no project is chosen. | +| `branch`, `setBranch` | The name of the branch a new worktree is created on. `null` lets Paseo pick one. It is kept while isolation is local or the base is a change request, and it applies only to a worktree created from a branch. | +| `metadata`, `setMetadata` | The created workspace's [metadata](/docs/plugins/hooks#metadata), not the first agent's. Top-level keys replace; `null` removes a key. Rejects on a host that does not support metadata with "Update the host to use metadata." | + +Commands follow the [composer item](#composer-items) rules: each one acts on the screen's state +when it runs, so `await newWorkspace.setBranch(name)` followed by `newWorkspace.setMetadata(patch)` +changes both, and its promise resolves once the change has taken effect. Payloads are JSON values. +`metadata` is a frozen copy. While the screen creates the workspace it is locked: your fields render +disabled and every command rejects with "The New workspace screen is locked while the workspace is +being created." A callback that returns a promise keeps the control disabled until it settles, and a +rejection reaches the user as an error toast. + +`Combobox` is a field with a searchable list whose results come from your code: + +| Prop | Meaning | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `value` | `{ id, label, description? } \| null`, what the field shows. It need not be among the search results, so it can show a choice made earlier. | +| `search` | `({ query }) => Promise`. Called with `""` when the list opens and again, debounced, as the user types. Each `Item` is `{ id, label, description? }` plus any fields you add. | +| `onSelect` | `({ item }) => void \| Promise`. `item` is the object `search` returned. | + +Paseo shows "Searching…" while a search runs, "Search failed" with the error message when it +rejects, and an empty state for no results. It keeps the previous results while a newer search runs +and ignores a search that a newer one replaced, so `search` needs no cancellation of its own. +`Combobox` takes the same `title`, `icon` and `disabled` as the other controls, and draws as a row +with a list in the composer's overflow sheet too. + +The [New workspace issues example](https://github.com/getpaseo/paseo/tree/main/plugin-examples/new-workspace-issues) +searches a fixed issue list from a daemon handler and sets the branch and metadata from the choice. + ## Composer pills `client.addComposerPill({ id, workspaceId, agentId, button })` uses the same [button descriptor](#button-descriptor) diff --git a/public-docs/plugins/reference.zh-CN.md b/public-docs/plugins/reference.zh-CN.md index 9ed5eb1b8a..a5b2b0a29e 100644 --- a/public-docs/plugins/reference.zh-CN.md +++ b/public-docs/plugins/reference.zh-CN.md @@ -1665,6 +1665,63 @@ export default function contribute(client: PluginClientContext) { [Composer 控件示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/composer-controls)注册了一个 `Select`、两个 `Toggle`(其中一个写入 Provider 选项)和一个 `Action`。 +## 新建工作区项 {#new-workspace-items} + +`client.addNewWorkspaceItem({ id, Component })` 在新建工作区界面上放置一个字段,位于项目、主机和分支选择器旁边。`useNewWorkspace()` 读取并修改界面将要创建的内容,`@getpaseo/plugin/client/ui` 中的 `Combobox`、`Select`、`Toggle` 和 `Action` 负责绘制字段。桌面端的项是设置行中的徽章;手机上是整行宽度的字段。 + +```tsx +import { useNewWorkspace, useRpc, type PluginClientContext } from "@getpaseo/plugin/client"; +import { Combobox } from "@getpaseo/plugin/client/ui"; +import { searchIssuesRpc } from "../shared/issues"; + +function IssueField() { + const newWorkspace = useNewWorkspace(); + const searchIssues = useRpc(searchIssuesRpc); + const issueId = newWorkspace.metadata["issues.id"]; + return ( + (await searchIssues({ query })).issues} + onSelect={async ({ item }) => { + await newWorkspace.setBranch(item.branch); + await newWorkspace.setMetadata({ "issues.id": item.id }); + }} + /> + ); +} + +export default function contribute(client: PluginClientContext) { + client.addNewWorkspaceItem({ id: "issue", Component: IssueField }); + return () => {}; +} +``` + +`useNewWorkspace()` 返回该项所在的设置: + +| 字段或命令 | 含义 | +| --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `host`、`project` | 用户的选择,分别是 `{ id, label }` 和 `{ id, name } \| null`。只读:项不能切换主机或项目。 | +| `isolation`、`setIsolation` | `"local"` 或 `"worktree"`。无法创建 worktree 的项目读取为 `"local"`,此时 `setIsolation("worktree")` 会被拒绝。 | +| `baseRef`、`setBaseRef` | 新 worktree 的起点:`{ type: "branch", name }`、`{ type: "change-request", number }`,默认值未知时为 `null`。未选择项目时,`setBaseRef({ type: "branch", name })` 会被拒绝。 | +| `branch`、`setBranch` | 新 worktree 所创建的分支名称。`null` 表示由 Paseo 选择名称。隔离为本地或起点为变更请求时会保留该值,它只对从分支创建的 worktree 生效。 | +| `metadata`、`setMetadata` | 所创建工作区的[元数据](/docs/plugins/hooks#metadata),而不是第一个 Agent 的元数据。顶层键替换,`null` 删除该键。主机不支持元数据时以 “Update the host to use metadata.” 拒绝。 | + +命令遵循 [Composer 项](#composer-items)的规则:每个命令都按界面运行时的状态执行,所以 `await newWorkspace.setBranch(name)` 之后再调用 `newWorkspace.setMetadata(patch)` 会同时生效,命令的 Promise 在更改生效后完成。载荷是 JSON 值。`metadata` 是冻结的副本。界面创建工作区期间会被锁定:你的字段显示为禁用,所有命令都会以 "The New workspace screen is locked while the workspace is being created." 拒绝。返回 Promise 的回调在完成之前会让控件保持禁用,被拒绝时会以错误提示向用户显示。 + +`Combobox` 是带可搜索列表的字段,结果由你的代码提供: + +| 属性 | 含义 | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | +| `value` | `{ id, label, description? } \| null`,字段显示的内容。它不必出现在搜索结果中,因此可以显示之前所做的选择。 | +| `search` | `({ query }) => Promise`。列表打开时以 `""` 调用,之后随用户输入防抖调用。每个 `Item` 是 `{ id, label, description? }` 加上你添加的字段。 | +| `onSelect` | `({ item }) => void \| Promise`。`item` 就是 `search` 返回的对象。 | + +搜索进行时 Paseo 显示 “Searching…”,搜索被拒绝时显示 “Search failed” 和错误信息,没有结果时显示空状态。新搜索进行期间会保留之前的结果,并忽略已被更新搜索取代的结果,所以 `search` 不需要自己处理取消。`Combobox` 接收与其他控件相同的 `title`、`icon` 和 `disabled`。 + +[新建工作区 Issue 示例](https://github.com/getpaseo/paseo/tree/main/plugin-examples/new-workspace-issues)通过守护进程处理器搜索一个固定的 Issue 列表,并根据所选项设置分支和元数据。 + ## Composer 胶囊 {#composer-pills} `client.addComposerPill({ id, workspaceId, agentId, button })` 使用同样的[按钮描述符](#button-descriptor),