From 6221b0c8b76c1ae522431dacd6beba5b9c28536e Mon Sep 17 00:00:00 2001 From: Monarch Maisuriya Date: Wed, 4 Nov 2026 14:32:25 +0530 Subject: [PATCH 01/29] Fork from earlier replies when a plugin card was updated later (#8746) The fork checkpoint check refused a fork when any projected row spanned the selected reply. A plugin card re-appended with the same id keeps its first position and stretches to its latest update, so it spanned every reply in between, although the fork never copies plugin cards. The check now looks only at the row kinds the fork copies, taken from the same constant as the copy. Closes #8745 Co-authored-by: Claude Opus 5.5 --- docs/timeline-sync.md | 7 +++-- .../src/server/agent/activity-curator.test.ts | 28 +++++++++++++++++++ .../src/server/agent/activity-curator.ts | 17 +++++++++-- 3 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index 3d6af249bf..e12b4a495a 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -95,9 +95,10 @@ Provider message IDs are not guaranteed for every displayed item. Paseo-generate Actions that address a point in chat history, such as Fork, use the daemon timeline `epoch` plus the projected item's `seqEnd`. The app carries that position on the rendered assistant item for both live and fetched history. When adjacent projected chunks merge, the merged item retains the newer chunk's position. The daemon validates the epoch and locates the projected item at the selected position. A fork -includes projected items through that checkpoint. If an item spans the checkpoint and changed -afterward, the daemon refuses that fork with an actionable error: discarded historical payloads -cannot be reconstructed from sequence metadata. Forking the current context remains available. +includes projected items through that checkpoint. If an item the fork copies spans the checkpoint +and changed afterward, the daemon refuses that fork with an actionable error: discarded historical +payloads cannot be reconstructed from sequence metadata. Plugin cards are not copied, so a card +updated after the checkpoint does not block it. Forking the current context remains available. ## Resume behavior diff --git a/packages/server/src/server/agent/activity-curator.test.ts b/packages/server/src/server/agent/activity-curator.test.ts index 4e9ce49528..6a1d85bcf0 100644 --- a/packages/server/src/server/agent/activity-curator.test.ts +++ b/packages/server/src/server/agent/activity-curator.test.ts @@ -454,6 +454,34 @@ second line'`, ).toThrow("Fork from a later completed response"); }); + it("forks from a checkpoint when a plugin card was updated after it", () => { + const card = (seq: number, turns: number) => + row(seq, { + type: "plugin", + id: "memory-receipt", + pluginId: "memorise", + kind: "memory-receipt", + version: 1, + data: { turns }, + }); + const result = buildAgentForkContextAttachment({ + boundaryMessageId: "assistant-1", + rows: [ + row(1, { type: "user_message", text: "First question", messageId: "user-1" }), + card(2, 1), + row(3, { type: "assistant_message", text: "First answer.", messageId: "assistant-1" }), + row(4, { type: "user_message", text: "Second question", messageId: "user-2" }), + row(5, { type: "assistant_message", text: "Second answer.", messageId: "assistant-2" }), + card(6, 2), + ], + }); + + expect(result.attachment.text).toContain("[User] First question"); + expect(result.attachment.text).toContain("[Assistant] First answer."); + expect(result.attachment.text).not.toContain("Second question"); + expect(result.attachment.text).not.toContain("memory-receipt"); + }); + it("selects a synthetic assistant error by its timeline cursor", () => { const result = buildAgentForkContextAttachment({ cursorBoundary: { diff --git a/packages/server/src/server/agent/activity-curator.ts b/packages/server/src/server/agent/activity-curator.ts index 2a7cbff5e6..a0c537254a 100644 --- a/packages/server/src/server/agent/activity-curator.ts +++ b/packages/server/src/server/agent/activity-curator.ts @@ -13,6 +13,12 @@ const MAX_TOOL_SUMMARY_CHARS = 200; const MAX_FORK_CONTEXT_CHARS = 100_000; const MAX_FORK_METADATA_CHARS = 1_000; const FORK_HISTORY_OMITTED = "[Earlier chat history omitted]\n"; +// A fork copies only these kinds, so only they can make a checkpoint stale. +const FORK_CONTEXT_KINDS: readonly AgentTimelineItem["type"][] = [ + "user_message", + "assistant_message", + "tool_call", +]; interface ActivityCuratorOptions { maxItems?: number; @@ -269,7 +275,14 @@ function selectForkContextRows(input: { ); } const boundarySeq = projectedRows[boundaryIndex].seqEnd; - if (projectedRows.some((row) => row.seqStart <= boundarySeq && row.seqEnd > boundarySeq)) { + if ( + projectedRows.some( + (row) => + FORK_CONTEXT_KINDS.includes(row.item.type) && + row.seqStart <= boundarySeq && + row.seqEnd > boundarySeq, + ) + ) { throw new Error( "This checkpoint changed after it was created. Fork from a later completed response instead.", ); @@ -349,7 +362,7 @@ export function buildAgentForkContextAttachment(input: { const entries = curateProjectedActivityEntries(selected.items, { maxItems: 0, labelAssistantMessages: true, - includeKinds: ["user_message", "assistant_message", "tool_call"], + includeKinds: FORK_CONTEXT_KINDS, includeExternalToolInput: false, }); const metadata = { agentTitle: input.agentTitle, cwd: input.cwd }; From fcbfb4f0d373f050ee12a3b68f6e82889be84cc5 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 4 Nov 2026 17:04:52 +0700 Subject: [PATCH 02/29] Size controls for touch on iPad and other touch screens (#8748) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(app): size controls for touch on any touch device, not just compact layouts One gate picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. The touch fact is read once, so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so the existing `{ xs, md }` consumers follow the gate without call-site changes. Touch values follow the iOS HIG 44pt hit region: controls, icon buttons, rows, bars and menu rows are 44 on touch; `sm` controls are 32. Glyphs move to a touch ladder (14/18/20/24). Pointer sizes are unchanged. Merges duplicated header control heights, density's private icon ladder and the compact-only icon button sizes into control-geometry. * feat(app): size the composer for touch through control geometry The composer toolbar, attach / dictation / realtime / send buttons, context meter and model triggers take TOOLBAR_CONTROL_SIZES (28 pointer, 44 touch) instead of private 28 constants. The density budget, button glyphs and the fullscreen glyph rail offset resolve the same gate, replacing the isWeb / isNative glyph branches. Track rows join the menu row height. Desktop geometry is unchanged. On touch the toolbar steps down density earlier: iPad portrait goes from condensed to tight. * feat(app): fold composer controls before the model label truncates The composer toolbar walks one width budget: full, condensed, tight, compact, then icon-only. Tight keeps the model at its family label; below that, thinking, mode and features fold into the model control's sheet (the phone toolbar), and only then does the model drop its label. A touch iPad in portrait with an attachment now reads "GPT-6-Astra Medium" instead of "GP…". The context meter glyph and combobox rows and icons take the density ladder. Desktop is pixel-identical in the content area. * refactor(app): keep the sizing gate free of the theme constants/layout sized its bars through control-geometry, which loads the theme and runs Platform.select at import. The gate (resolveControlSizing, bySizing, TOUCH_TARGET) moves to control-sizing.ts with no theme import; control-geometry re-exports it, so its callers are unchanged. * test(app): expect touch sizing in the compact Changes and plugin sidebar specs The compact Changes options button is a 44pt icon button with a 20pt glyph, and its framed actions trigger is 32 tall. In the compact sidebar the nav block is capped at half the height, so 44pt rows put the plugin header row below the cap; the spec scrolls it into view as a user would. * fix(app): close composer menus when the controls fold or unfold Folding swaps the toolbar controls for the model sheet. A menu opened on the separate controls stayed marked open across the fold, so widening the composer again reopened Thinking without a click. --- docs/design.md | 8 +- packages/app/e2e/browser/changes-pane.spec.ts | 10 +- .../browser/composer-control-density.spec.ts | 32 +++ .../e2e/browser/plugin-sidebar-items.spec.ts | 5 +- .../components/combined-model-selector.tsx | 3 +- .../src/components/context-window-meter.tsx | 7 +- .../src/components/settings/row-density.tsx | 4 +- .../src/components/sidebar-workspace-list.tsx | 13 +- .../sidebar/sidebar-workspace-menu.tsx | 13 +- packages/app/src/components/ui/combobox.tsx | 18 +- .../components/ui/control-geometry.test.ts | 97 +++++-- .../app/src/components/ui/control-geometry.ts | 108 +++++--- .../app/src/components/ui/control-sizing.ts | 68 +++++ .../src/components/ui/icon-button-chrome.ts | 44 +-- .../src/components/ui/menu/menu-geometry.ts | 5 +- .../src/composer/agent-controls/control.tsx | 5 +- .../app/src/composer/agent-controls/glyph.tsx | 6 +- .../app/src/composer/agent-controls/index.tsx | 59 ++-- .../agent-controls/layout-context.tsx | 12 +- .../composer/agent-controls/layout.test.ts | 126 ++++++++- .../app/src/composer/agent-controls/layout.ts | 253 +++++++++++++----- .../composer/agent-controls/mode-control.tsx | 11 +- .../composer/agent-controls/model-sheet.tsx | 36 ++- packages/app/src/composer/index.tsx | 28 +- packages/app/src/composer/input/input.tsx | 29 +- .../app/src/composer/input/presentation.tsx | 34 ++- packages/app/src/composer/tracks.tsx | 10 +- packages/app/src/constants/layout.ts | 11 +- packages/app/src/git/actions-split-button.tsx | 27 +- .../git/pull-request-panel/section-kit.tsx | 6 +- packages/app/src/hooks/use-fine-pointer.ts | 10 + packages/app/src/plugins/buttons/view.tsx | 4 +- packages/app/src/screens/settings-screen.tsx | 6 +- .../scripts/workspace-scripts-button.tsx | 7 +- .../workspace/workspace-desktop-tabs-row.tsx | 17 +- packages/app/src/styles/density.ts | 41 +-- .../src/workspace/open-in-editor/button.tsx | 17 +- 37 files changed, 832 insertions(+), 358 deletions(-) create mode 100644 packages/app/src/components/ui/control-sizing.ts diff --git a/docs/design.md b/docs/design.md index 8a2cae25ba..0437320c09 100644 --- a/docs/design.md +++ b/docs/design.md @@ -68,9 +68,11 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als `destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog. -Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px in the compact layout, so every phone control is a full touch target; the breakpoint, not the caller, makes that choice. `sm` is 28px with `fontSize.sm` labels and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. +Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. -Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 in the compact layout, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use. +One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size. + +Buttons are pills (`borderRadius.full`) with medium-weight labels; an icon-only button is a circle. `` follows the same two sizes: a 34×20 track by default and in `sm`, 44×26 where controls are touch-sized, and it reserves its size's control height so a row holding one lines up with a row holding a button. Buttons, text fields, dropdown triggers, and `` (`packages/app/src/components/ui/segmented-control.tsx`) share the size contract — the same size name means the same height and label size on every control kind, so controls in one row line up. Segments run tighter than a button, because the gap between segments already reads as padding. The selected segment is a `surface3` fill with `foreground` text, not an inverted one — inverting it inside thin chrome puts a white slab in the toolbar. A settings row sizes the controls in its trailing slot itself, default in a card and `sm` when nested (`packages/app/src/components/ui/control-size.tsx`), so don't pass a size to a button, dropdown or field placed in a row. Code that computes with a control's height outside a stylesheet uses `controlHeightForLayout`. Never shrink a control's font or padding locally to fit a context — if the context needs a smaller control, the size tier is missing or the wrong one is in use. A `` wrapping a `` is a sixth variant. It is wrong. ` + ) : null} + + ), + [t, isPending, error, isError, handleRetry], + ); + + if (!supported || !isWorktree || !workspaceId) return {label}; + + return ( + + + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + anchor: { flexShrink: 1, minWidth: 0 }, + footer: { padding: theme.spacing[3], gap: theme.spacing[2] }, + text: { color: theme.colors.foregroundMuted, fontSize: theme.fontSize.sm }, + error: { color: theme.colors.destructive, fontSize: theme.fontSize.sm }, +})); diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 098a49ae52..9d1ca6610d 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -930,6 +930,9 @@ export const ar: TranslationResources = { }, }, diff: { + baseBranch: { + title: "الفرع الأساسي", + }, openChangesTab: "فتح علامة تبويب التغييرات", openDiffTab: "فتح علامة تبويب الفرق", closeChangesTab: "إغلاق علامة تبويب التغييرات", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 16ba66d960..3f3091ae4a 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -938,6 +938,9 @@ export const en = { }, }, diff: { + baseBranch: { + title: "Change base", + }, openChangesTab: "Open Changes tab", openDiffTab: "Open Diff tab", closeChangesTab: "Close Changes tab", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 3d28cda81f..73cc70a8ca 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -963,6 +963,9 @@ export const es: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Rama base", + }, openChangesTab: "Abrir la pestaña Cambios", openDiffTab: "Abrir la pestaña Diferencia", closeChangesTab: "Cerrar la pestaña Cambios", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index da19c643ad..4257b799aa 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -956,6 +956,9 @@ export const fr: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Branche de base", + }, openChangesTab: "Ouvrir l’onglet Modifications", openDiffTab: "Ouvrir l’onglet Diff", closeChangesTab: "Fermer l’onglet Modifications", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 73e14a0b68..073f164e6b 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -943,6 +943,9 @@ export const ja: TranslationResources = { }, }, diff: { + baseBranch: { + title: "ベースブランチ", + }, openChangesTab: "変更タブを開く", openDiffTab: "差分タブを開く", closeChangesTab: "変更タブを閉じる", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 117d5d68bc..cee2bb19bd 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -937,6 +937,9 @@ export const ko: TranslationResources = { }, }, diff: { + baseBranch: { + title: "기준 브랜치", + }, openChangesTab: "변경사항 탭 열기", openDiffTab: "Diff 탭 열기", closeChangesTab: "변경사항 탭 닫기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index 4460039b7f..a88a749709 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -953,6 +953,9 @@ export const ptBR: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Branch base", + }, openChangesTab: "Abrir a aba Alterações", openDiffTab: "Abrir a aba Diff", closeChangesTab: "Fechar a aba Alterações", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 012e6eb47b..3d8bd13196 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -947,6 +947,9 @@ export const ru: TranslationResources = { }, }, diff: { + baseBranch: { + title: "Базовая ветка", + }, openChangesTab: "Открыть вкладку «Изменения»", openDiffTab: "Открыть вкладку «Дифф»", closeChangesTab: "Закрыть вкладку «Изменения»", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 1c6106553e..f0a0c9bc5c 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -922,6 +922,9 @@ export const zhCN: TranslationResources = { }, }, diff: { + baseBranch: { + title: "基准分支", + }, openChangesTab: "打开“更改”标签页", openDiffTab: "打开 Diff 标签页", closeChangesTab: "关闭“更改”标签页", diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index 68677c3f24..b29a34111c 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -3582,6 +3582,22 @@ export class DaemonClient { return { pinnedAt: payload.pinnedAt }; } + async setWorkspaceBaseBranch(options: { + workspaceId: string; + baseRef: string; + requestId?: string; + }): Promise<{ baseRef: string | null }> { + const { requestId, workspaceId, baseRef } = options; + const payload = + await this.sendNamespacedCorrelatedSessionRequest<"workspace.base_branch.set.response">({ + requestId, + message: { type: "workspace.base_branch.set.request", workspaceId, baseRef }, + }); + if (!payload.accepted) + throw new Error(payload.error ?? "Changing the workspace base branch failed"); + return { baseRef: payload.baseRef }; + } + async inspectWorkspaceRecovery( workspaceId: string, requestId?: string, @@ -5134,7 +5150,7 @@ export class DaemonClient { } async getBranchSuggestions( - options: { cwd: string; query?: string; limit?: number }, + options: { cwd: string; query?: string; limit?: number; refMode?: "exact" }, requestId?: string, ): Promise { return this.sendCorrelatedSessionRequest({ @@ -5144,6 +5160,7 @@ export class DaemonClient { cwd: options.cwd, query: options.query, limit: options.limit, + refMode: options.refMode, }, responseType: "branch_suggestions_response", }); diff --git a/packages/client/src/index.test.ts b/packages/client/src/index.test.ts index 62ee8dda6a..5fd4d92958 100644 --- a/packages/client/src/index.test.ts +++ b/packages/client/src/index.test.ts @@ -607,6 +607,29 @@ test("workspace handles keep identity and refresh snapshots through existing dri }), ); await expect(titlePromise).resolves.toEqual({ title: "SDK review" }); + const basePromise = workspace.setBaseBranch({ + baseRef: "refs/heads/release", + requestId: "workspace-base-request", + }); + expect(parseSentSessionMessage(ws.sent.at(-1))).toMatchObject({ + type: "workspace.base_branch.set.request", + workspaceId: "workspace_sdk", + baseRef: "refs/heads/release", + requestId: "workspace-base-request", + }); + ws.message( + sessionMessage({ + type: "workspace.base_branch.set.response", + payload: { + requestId: "workspace-base-request", + workspaceId: "workspace_sdk", + accepted: true, + baseRef: "refs/heads/release", + error: null, + }, + }), + ); + await expect(basePromise).resolves.toEqual({ baseRef: "refs/heads/release" }); unsubscribe(); ws.message( diff --git a/packages/client/src/index.ts b/packages/client/src/index.ts index 568c23896b..ead91e719c 100644 --- a/packages/client/src/index.ts +++ b/packages/client/src/index.ts @@ -189,6 +189,10 @@ export interface PaseoWorkspaceHandle { current(): PaseoWorkspace | null; refresh(options?: { requestId?: string }): Promise; setTitle(title: string | null, requestId?: string): Promise<{ title: string | null }>; + setBaseBranch(options: { + baseRef: string; + requestId?: string; + }): Promise<{ baseRef: string | null }>; archive(requestId?: string): Promise; /** * Subscribes to already-emitted daemon workspace_update events for this id. @@ -862,6 +866,8 @@ function createWorkspaceHandleFactory( current: () => current, refresh, setTitle: (title, requestId) => daemonClient.setWorkspaceTitle(id, title, requestId), + setBaseBranch: (options) => + daemonClient.setWorkspaceBaseBranch({ ...options, workspaceId: id }), archive: async (requestId) => { const result = await daemonClient.archiveWorkspace(id, requestId); if (current) { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index b5fa571914..64f37064e2 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -1040,6 +1040,13 @@ export const WorkspacePinSetRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceBaseBranchSetRequestSchema = z.object({ + type: z.literal("workspace.base_branch.set.request"), + workspaceId: z.string(), + baseRef: z.string(), + requestId: z.string(), +}); + export const WorkspaceLabelColorSchema = z.enum(WORKSPACE_LABEL_COLORS); export const WorkspaceLabelDefinitionSchema = z.object({ name: z.string(), @@ -2197,6 +2204,17 @@ export const WorkspacePinSetResponseSchema = z.object({ payload: WorkspacePinSetResponsePayloadSchema, }); +export const WorkspaceBaseBranchSetResponseSchema = z.object({ + type: z.literal("workspace.base_branch.set.response"), + payload: z.object({ + requestId: z.string(), + workspaceId: z.string(), + accepted: z.boolean(), + baseRef: z.string().nullable(), + error: z.string().nullable(), + }), +}); + export const WorkspaceRecoveryStateSchema = z.discriminatedUnion("kind", [ z.object({ kind: z.literal("recoverable"), @@ -2514,6 +2532,7 @@ export const BranchSuggestionsRequestSchema = z.object({ cwd: z.string(), query: z.string().optional(), limit: z.number().int().min(1).max(200).optional(), + refMode: z.literal("exact").optional(), requestId: z.string(), }); @@ -3328,6 +3347,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ ProjectRemoveRequestSchema, WorkspaceTitleSetRequestSchema, WorkspacePinSetRequestSchema, + WorkspaceBaseBranchSetRequestSchema, WorkspaceLabelListRequestSchema, WorkspaceLabelAssignmentSetRequestSchema, WorkspaceLabelUpdateRequestSchema, @@ -3712,6 +3732,8 @@ export const ServerInfoStatusPayloadSchema = z directorySync: z.boolean().optional(), // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. workspaceLabels: z.boolean().optional(), + // COMPAT(workspaceBaseBranch): added after v0.11.2, remove gate after 2027-10-14. + workspaceBaseBranch: z.boolean().optional(), // COMPAT(workspaceSetupRun): added in v0.8.0, remove gate after 2027-09-02. workspaceSetupRun: z.boolean().optional(), // COMPAT(workspaceTerminals): added in v0.8.0, remove gate after 2027-09-05. @@ -5405,6 +5427,7 @@ const CheckoutStatusCommonSchema = z.object({ cwd: z.string(), error: CheckoutErrorSchema.nullable(), requestId: z.string(), + baseRefExact: z.string().nullable().optional(), // The full ref currentBranch tracks, as git resolves `@{upstream}`: // "refs/remotes/origin/main", "refs/remotes/upstream/main" on a fork, or a // "refs/heads/..." ref for a branch tracking a local branch. Null when there is no @@ -7203,6 +7226,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ProjectRemoveResponseSchema, WorkspaceTitleSetResponseSchema, WorkspacePinSetResponseSchema, + WorkspaceBaseBranchSetResponseSchema, WorkspaceRecoveryInspectResponseSchema, WorkspaceRecoveryRestoreResponseSchema, WaitForFinishResponseMessageSchema, @@ -7564,6 +7588,7 @@ export type ProjectIconSetRequest = z.infer; export type ProjectRemoveRequest = z.infer; export type WorkspaceTitleSetRequest = z.infer; export type WorkspacePinSetRequest = z.infer; +export type WorkspaceBaseBranchSetRequest = z.infer; export type WorkspaceRecoveryInspectRequest = z.infer; export type WorkspaceRecoveryRestoreRequest = z.infer; export type SetAgentModeRequestMessage = z.infer; diff --git a/packages/protocol/src/messages.wire-compat.test.ts b/packages/protocol/src/messages.wire-compat.test.ts index abc89b06aa..a15c1ae1be 100644 --- a/packages/protocol/src/messages.wire-compat.test.ts +++ b/packages/protocol/src/messages.wire-compat.test.ts @@ -12,8 +12,65 @@ import { WorkspaceSetupSnapshotSchema, WorkspaceSetupProgressMessageSchema, AgentTimelineEntryPayloadSchema, + CheckoutStatusResponseSchema, + BranchSuggestionsRequestSchema, + WorkspaceBaseBranchSetRequestSchema, + WorkspaceBaseBranchSetResponseSchema, } from "./messages.js"; +test("base selection RPCs are correlated while older checkout and branch messages remain valid", () => { + const payload = { + cwd: "/repo", + requestId: "status", + error: null, + isGit: true, + isPaseoOwnedWorktree: false, + repoRoot: "/repo", + currentBranch: "feature", + isDirty: false, + baseRef: "release", + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + hasRemote: false, + remoteUrl: null, + }; + expect( + CheckoutStatusResponseSchema.safeParse({ type: "checkout_status_response", payload }).success, + ).toBe(true); + expect( + CheckoutStatusResponseSchema.parse({ + type: "checkout_status_response", + payload: { ...payload, baseRefExact: "refs/remotes/upstream/release" }, + }).payload.baseRefExact, + ).toBe("refs/remotes/upstream/release"); + expect( + BranchSuggestionsRequestSchema.safeParse({ + type: "branch_suggestions_request", + cwd: "/repo", + requestId: "branches", + }).success, + ).toBe(true); + const request = { + type: "workspace.base_branch.set.request", + workspaceId: "workspace", + baseRef: "refs/heads/release", + requestId: "base", + }; + expect(WorkspaceBaseBranchSetRequestSchema.parse(request)).toEqual(request); + const response = { + type: "workspace.base_branch.set.response", + payload: { + workspaceId: "workspace", + baseRef: "refs/heads/release", + requestId: "base", + accepted: true, + error: null, + }, + }; + expect(WorkspaceBaseBranchSetResponseSchema.parse(response)).toEqual(response); +}); + test("terminal listings accept older rows and retain new per-terminal directories", () => { const response = { type: "list_terminals_response", diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index 0719e3d984..8cc9d6d1a0 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -216,6 +216,7 @@ const INBOUND_PERMISSION = { "workspace.label.list.request": "workspace.read", "workspace.label.update.request": "workspace.manage", "workspace.pin.set.request": "workspace.manage", + "workspace.base_branch.set.request": "workspace.write", "workspace.recovery.inspect.request": ["workspace.read", "hub.execute"], "workspace.recovery.restore.request": ["workspace.manage", "hub.execute"], "workspace.script.list.request": "workspace.read", @@ -458,6 +459,7 @@ const OUTBOUND_PERMISSION = { "workspace.label.update": "workspace.read", "workspace.label.update.response": "workspace.manage", "workspace.pin.set.response": "workspace.manage", + "workspace.base_branch.set.response": "workspace.write", "workspace.recovery.inspect.response": ["workspace.read", "hub.execute"], "workspace.recovery.restore.response": ["workspace.manage", "hub.execute"], "workspace.script.list.response": "workspace.read", diff --git a/packages/server/src/server/checkout-diff-manager.ts b/packages/server/src/server/checkout-diff-manager.ts index 917813d587..8293ce965f 100644 --- a/packages/server/src/server/checkout-diff-manager.ts +++ b/packages/server/src/server/checkout-diff-manager.ts @@ -195,6 +195,7 @@ export class CheckoutDiffManager { remoteUrl: snapshot.git.remoteUrl, isPaseoOwnedWorktree: snapshot.git.isPaseoOwnedWorktree, baseRef: snapshot.git.baseRef, + baseRefExact: snapshot.git.baseRefExact, aheadBehind: snapshot.git.aheadBehind, aheadOfOrigin: snapshot.git.aheadOfOrigin, behindOfOrigin: snapshot.git.behindOfOrigin, diff --git a/packages/server/src/server/checkout/status-projection.ts b/packages/server/src/server/checkout/status-projection.ts index 7f7365efca..90c900435c 100644 --- a/packages/server/src/server/checkout/status-projection.ts +++ b/packages/server/src/server/checkout/status-projection.ts @@ -61,6 +61,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ currentBranch: snapshot.git.currentBranch ?? null, isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef, + baseRefExact: snapshot.git.baseRefExact, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, @@ -81,6 +82,7 @@ export function buildCheckoutStatusPayloadFromSnapshot({ currentBranch: snapshot.git.currentBranch ?? null, isDirty: snapshot.git.isDirty, baseRef: snapshot.git.baseRef ?? null, + baseRefExact: snapshot.git.baseRefExact, aheadBehind: snapshot.git.aheadBehind ?? null, upstreamRef: snapshot.git.upstreamRef ?? null, aheadOfOrigin: snapshot.git.aheadOfOrigin ?? null, diff --git a/packages/server/src/server/git-metadata-event-rules.test.ts b/packages/server/src/server/git-metadata-event-rules.test.ts index 8c7e491970..4456573624 100644 --- a/packages/server/src/server/git-metadata-event-rules.test.ts +++ b/packages/server/src/server/git-metadata-event-rules.test.ts @@ -21,6 +21,7 @@ describe("Git metadata event rules", () => { ["worktree", "HEAD", { kind: "owner", refreshBase: true }], ["worktree", "config.worktree", { kind: "owner", refreshBase: true }], ["worktree", "paseo/worktree.json", { kind: "owner", refreshBase: true }], + ["common", "paseo/worktree.json", { kind: "owner", refreshBase: true }], ["worktree", "COMMIT_EDITMSG", { kind: "ignore" }], ["worktree", "ORIG_HEAD", { kind: "ignore" }], ["common", "index", { kind: "owner", refreshBase: false }], diff --git a/packages/server/src/server/git-metadata-event-rules.ts b/packages/server/src/server/git-metadata-event-rules.ts index dab6bc4887..ff82747ba0 100644 --- a/packages/server/src/server/git-metadata-event-rules.ts +++ b/packages/server/src/server/git-metadata-event-rules.ts @@ -44,7 +44,7 @@ export const GIT_METADATA_EVENT_RULES = [ }, { id: "paseo-worktree-metadata", - scope: "worktree", + scope: "both", match: "exact", path: "paseo/worktree.json", route: "owner", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 9ce3fbbcb4..3dec72f45f 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -3,6 +3,12 @@ import type { AgentMessageDelivery } from "./agent/agent-messages/index.js"; import { AgentDirectory, type BackgroundVisibility } from "./agent/directory.js"; import { ConnectedHostSession } from "./connected-hosts/index.js"; import type { ConnectedHosts } from "./connected-hosts/index.js"; +import { + createWorkspaceBaseBranchService, + type WorkspaceBaseBranchService, +} from "./session/workspace-base-branch/service.js"; +import type { WorkspaceBaseBranchSetRequest } from "@getpaseo/protocol/messages"; + import { searchTimeline } from "./agent/chat-search/index.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import { BrowserAutomationHostCapabilitySchema } from "@getpaseo/protocol/browser-automation/capabilities"; @@ -483,6 +489,7 @@ export interface SessionOptions { creationService: Pick; projectRegistry: ProjectRegistry; workspaceRegistry: WorkspaceRegistry; + workspaceBaseBranch?: WorkspaceBaseBranchService; directorySync?: DirectorySyncService; workspaceLabelService?: WorkspaceLabelService; filesystem?: SessionFileSystem; @@ -779,6 +786,7 @@ export class Session { private readonly gitMutation: GitMutationService; private readonly workspaceProvisioning: WorkspaceProvisioningService; private readonly workspaceRecovery: WorkspaceRecoveryService; + private workspaceBaseBranch: WorkspaceBaseBranchService | undefined; private readonly daemonConfigStore: DaemonConfigStore; private readonly pushNotifications: PushNotifications; private readonly pluginRuntime: SessionOptions["pluginRuntime"]; @@ -962,12 +970,22 @@ export class Session { this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.workspaceGitService = workspaceGitService; this.workspaceRuntimeStore = workspaceRuntimeStore; + this.workspaceBaseBranch = + options.workspaceBaseBranch ?? + createWorkspaceBaseBranchService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); + this.gitMutation = createGitMutationService({ workspaceGitService: this.workspaceGitService, logger: this.sessionLogger, }); this.workspaceAutoName = workspaceAutoName; this.workspaceProvisioning = createWorkspaceProvisioningService({ + withCheckoutLock: this.workspaceBaseBranch.withCheckoutLock, lifecycle: this.pluginRuntime, serverId, workspaceRegistry: this.workspaceRegistry, @@ -3213,6 +3231,8 @@ export class Session { return this.handleWorkspaceTitleSetRequest(msg.workspaceId, msg.title, msg.requestId); case "workspace.pin.set.request": return this.handleWorkspacePinSetRequest(msg.workspaceId, msg.pinned, msg.requestId); + case "workspace.base_branch.set.request": + return this.handleWorkspaceBaseBranchSetRequest(msg); default: return undefined; } @@ -4076,6 +4096,41 @@ export class Session { } } + private async handleWorkspaceBaseBranchSetRequest( + msg: WorkspaceBaseBranchSetRequest, + ): Promise { + try { + this.workspaceBaseBranch ??= createWorkspaceBaseBranchService({ + paseoHome: this.paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); + const baseRef = await this.workspaceBaseBranch.setBaseBranch(msg); + this.emit({ + type: "workspace.base_branch.set.response", + payload: { + requestId: msg.requestId, + workspaceId: msg.workspaceId, + accepted: true, + baseRef, + error: null, + }, + }); + } catch (error) { + this.emit({ + type: "workspace.base_branch.set.response", + payload: { + requestId: msg.requestId, + workspaceId: msg.workspaceId, + accepted: false, + baseRef: null, + error: getErrorMessage(error), + }, + }); + } + } + private async handleWorkspacePinSetRequest( workspaceId: string, pinned: boolean, diff --git a/packages/server/src/server/session/checkout/checkout-session.ts b/packages/server/src/server/session/checkout/checkout-session.ts index ceb69e9623..4deb28a56b 100644 --- a/packages/server/src/server/session/checkout/checkout-session.ts +++ b/packages/server/src/server/session/checkout/checkout-session.ts @@ -379,13 +379,14 @@ export class CheckoutSession { } async handleBranchSuggestionsRequest(msg: BranchSuggestionsRequest): Promise { - const { cwd, query, limit, requestId } = msg; + const { cwd, query, limit, refMode, requestId } = msg; try { const resolvedCwd = expandTilde(cwd); const branchDetails = await this.workspaceGitService.suggestBranchesForCwd(resolvedCwd, { query, limit, + refMode, }); this.host.emit({ type: "branch_suggestions_response", diff --git a/packages/server/src/server/session/workspace-base-branch/service.ts b/packages/server/src/server/session/workspace-base-branch/service.ts new file mode 100644 index 0000000000..ffc8feffb0 --- /dev/null +++ b/packages/server/src/server/session/workspace-base-branch/service.ts @@ -0,0 +1,104 @@ +import { resolve } from "node:path"; +import { getCheckoutSnapshotFacts, setCheckoutBaseBranch } from "../../../utils/checkout-git.js"; +import { + readPaseoWorktreeMetadata, + setPaseoWorktreeBaseRef, +} from "../../../utils/worktree-metadata.js"; +import type { WorkspaceRegistry } from "../../workspace-registry.js"; +import type { WorkspaceGitService } from "../../workspace-git-service.js"; + +interface Dependencies { + paseoHome: string; + worktreesRoot?: string; + workspaceRegistry: WorkspaceRegistry; + workspaceGitService: WorkspaceGitService; +} + +export function createWorkspaceBaseBranchService(deps: Dependencies) { + const queues = new Map>(); + + async function withCheckoutLock(root: string, operation: () => Promise): Promise { + root = resolve(root); + const previous = queues.get(root); + let release!: () => void; + const queued = new Promise((done) => { + release = done; + }); + queues.set(root, queued); + await previous; + + try { + return await operation(); + } finally { + release(); + if (queues.get(root) === queued) queues.delete(root); + } + } + + async function setBaseBranch(input: { workspaceId: string; baseRef: string }): Promise { + const workspace = await deps.workspaceRegistry.get(input.workspaceId); + if (!workspace || workspace.archivedAt) throw new Error("Workspace is unavailable"); + const facts = await getCheckoutSnapshotFacts(workspace.cwd, { + paseoHome: deps.paseoHome, + worktreesRoot: deps.worktreesRoot, + }); + if (!facts.isGit) throw new Error("Workspace is not a Git checkout"); + if (!facts.paseoWorktree.isPaseoOwnedWorktree) + throw new Error("Workspace is not a Paseo worktree"); + const root = resolve(facts.worktreeRoot); + return withCheckoutLock(root, async () => { + const current = await deps.workspaceRegistry.get(input.workspaceId); + if (!current || current.archivedAt) throw new Error("Workspace is unavailable"); + const records = await deps.workspaceRegistry.listActive(); + const matching = records.filter( + (record) => + record.workspaceId === input.workspaceId || + resolve(record.worktreeRoot ?? record.cwd) === root, + ); + await deps.workspaceGitService.getSnapshot(root); + const metadata = await readPaseoWorktreeMetadata(root); + const baseRef = await setCheckoutBaseBranch({ + cwd: root, + baseRef: input.baseRef, + context: { paseoHome: deps.paseoHome, worktreesRoot: deps.worktreesRoot }, + }); + const updatedAt = new Date().toISOString(); + try { + for (const record of matching) { + await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({ + ...latest, + baseBranch: baseRef, + updatedAt, + })); + } + } catch (error) { + if (metadata) + await setPaseoWorktreeBaseRef({ + worktreeRoot: root, + baseRef: metadata.baseRef ?? metadata.baseRefName, + }); + for (const record of matching) { + await deps.workspaceRegistry.update(record.workspaceId, (latest) => ({ + ...latest, + baseBranch: record.baseBranch, + updatedAt: record.updatedAt, + })); + } + throw error; + } + for (const cwd of new Set(matching.map((record) => record.cwd))) { + await deps.workspaceGitService.getSnapshot(cwd, { + force: true, + afterInFlight: true, + includeForge: false, + reason: "base-branch-change", + }); + } + return baseRef; + }); + } + + return { setBaseBranch, withCheckoutLock }; +} + +export type WorkspaceBaseBranchService = ReturnType; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index d9f7ccea96..012ad0d490 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -5,6 +5,11 @@ import { mkdirSync, mkdtempSync, rmSync, statSync, symlinkSync } from "node:fs"; import { afterEach, beforeEach, expect, test } from "vitest"; import { getCheckoutStatus } from "../../../utils/checkout-git.js"; +import { + setPaseoWorktreeBaseRef, + writePaseoWorktreeMetadata, +} from "../../../utils/worktree-metadata.js"; +import { createWorkspaceBaseBranchService } from "../workspace-base-branch/service.js"; import { createTestLogger } from "../../../test-utils/test-logger.js"; import { createNoGitWorkspaceRuntimeSnapshot, @@ -125,6 +130,62 @@ afterEach(() => { rmSync(tmpDir, { recursive: true, force: true }); }); +test("a workspace created during a base change records the new base", async () => { + const cwd = path.join(tmpDir, "worktree"); + mkdirSync(path.join(cwd, ".git"), { recursive: true }); + await writePaseoWorktreeMetadata(cwd, { baseRefName: "main", baseRef: "refs/heads/main" }); + const git = createNoopWorkspaceGitService({ + getCheckout: async () => ({ + cwd, + isGit: true, + currentBranch: "feature", + remoteUrl: null, + worktreeRoot: cwd, + isPaseoOwnedWorktree: true, + mainRepoRoot: cwd, + }), + }); + const bases = createWorkspaceBaseBranchService({ + paseoHome: tmpDir, + workspaceRegistry, + workspaceGitService: git, + }); + let release!: () => void; + let entered!: () => void; + let attempted!: () => void; + const gate = new Promise((resolve) => { + release = resolve; + }); + const locked = new Promise((resolve) => { + entered = resolve; + }); + const waiting = new Promise((resolve) => { + attempted = resolve; + }); + const change = bases.withCheckoutLock(cwd, async () => { + entered(); + await gate; + await setPaseoWorktreeBaseRef({ worktreeRoot: cwd, baseRef: "refs/heads/release" }); + }); + await locked; + const creator = createWorkspaceProvisioningService({ + workspaceRegistry, + projectRegistry, + workspaceGitService: git, + isDirectory, + logger, + withCheckoutLock: (root, operation) => { + attempted(); + return bases.withCheckoutLock(root, operation); + }, + }); + const created = creator.createWorkspaceForDirectory(cwd); + await waiting; + release(); + await change; + expect((await created).baseBranch).toBe("refs/heads/release"); +}); + test("fresh git repo creates a workspace at the canonical worktree root", async () => { const repo = path.join(tmpDir, "repo"); gitRoots.add(repo); diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index c4554688e0..3f2a7b423c 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -20,6 +20,7 @@ import type { CreatePaseoWorktreeWorkflowResult } from "../../worktree-session.j import { deriveProjectKey } from "../../project-key.js"; import { areEquivalentPaths, createRealpathAwarePathMatcher } from "../../../utils/path.js"; import type { UntrustedWorkspaceSource } from "../../workspace-automation-gate.js"; +import { readPaseoWorktreeMetadata } from "../../../utils/worktree-metadata.js"; export interface ResolveOrCreateWorkspaceIdInput { createdWorktree: CreatePaseoWorktreeWorkflowResult | null; @@ -107,6 +108,7 @@ export function createWorkspaceProvisioningService(deps: { isDirectory: (path: string) => Promise; logger: Logger; lifecycle?: PluginLifecycle; + withCheckoutLock?: (root: string, operation: () => Promise) => Promise; }): WorkspaceProvisioningService { const { serverId, workspaceRegistry, projectRegistry, workspaceGitService, logger } = deps; @@ -253,21 +255,31 @@ export function createWorkspaceProvisioningService(deps: { ? await refreshProjectKind(await requireActiveProject(projectId), normalizedCwd, checkout) : // COMPAT(workspaceCreateMissingProjectId): added in v0.1.107, remove after 2027-01-15. await findOrCreateProjectForDirectory(normalizedCwd); - const timestamp = new Date().toISOString(); - const workspace = createPersistedWorkspaceRecord({ - workspaceId: context?.workspaceId ?? generateWorkspaceId(), - projectId: project.projectId, - ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), - title: title?.trim() || null, - createdAt: timestamp, - updatedAt: timestamp, - background: await resolveBackground(context), - }); - await workspaceRegistry.upsert(workspace, { - expectsInitialAgent: context?.expectsInitialAgent, - }); - emitWorkspaceCreated(workspace); - return workspace; + const create = async () => { + const baseMetadata = + checkout.isGit && checkout.isPaseoOwnedWorktree + ? await readPaseoWorktreeMetadata(checkout.worktreeRoot ?? normalizedCwd) + : null; + const timestamp = new Date().toISOString(); + const workspace = createPersistedWorkspaceRecord({ + workspaceId: context?.workspaceId ?? generateWorkspaceId(), + projectId: project.projectId, + ...initialWorkspacePlacement({ source: "checkout", cwd: normalizedCwd, checkout }), + baseBranch: baseMetadata?.baseRef ?? baseMetadata?.baseRefName ?? null, + title: title?.trim() || null, + createdAt: timestamp, + updatedAt: timestamp, + background: await resolveBackground(context), + }); + await workspaceRegistry.upsert(workspace, { + expectsInitialAgent: context?.expectsInitialAgent, + }); + emitWorkspaceCreated(workspace); + return workspace; + }; + return deps.withCheckoutLock && checkout.isGit && checkout.isPaseoOwnedWorktree + ? deps.withCheckoutLock(checkout.worktreeRoot ?? normalizedCwd, create) + : create(); } async function createWorkspaceForWorktree( diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index 61014327fd..7c301556c3 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -16,6 +16,10 @@ import { HOST_ROUTE_OPCODE } from "@getpaseo/protocol/binary-frames/index"; import { DaemonLinkEndpoint, VirtualHostSocket } from "./connected-hosts/index.js"; import { ConnectedHosts } from "./connected-hosts/index.js"; import { stat } from "node:fs/promises"; +import { + createWorkspaceBaseBranchService, + type WorkspaceBaseBranchService, +} from "./session/workspace-base-branch/service.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import { CreationService } from "./creation/index.js"; import { MessageReceipts } from "./message-receipts/index.js"; @@ -569,6 +573,7 @@ export class VoiceAssistantWebSocketServer { private readonly creationService: CreationService; private readonly projectRegistry: ProjectRegistry; private readonly workspaceRegistry: WorkspaceRegistry; + private readonly workspaceBaseBranch: WorkspaceBaseBranchService; private readonly workspaceLabelService: WorkspaceLabelService | null; private readonly scheduleService: ScheduleService; private readonly checkoutDiffManager: CheckoutDiffManager; @@ -756,12 +761,18 @@ export class VoiceAssistantWebSocketServer { this.checkoutDiffManager = requiredServices.checkoutDiffManager; this.github = github ?? createGitHubService(); this.workspaceGitService = workspaceGitService ?? createFallbackWorkspaceGitService(); + this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; + this.workspaceBaseBranch = createWorkspaceBaseBranchService({ + paseoHome, + worktreesRoot: this.worktreesRoot, + workspaceRegistry: this.workspaceRegistry, + workspaceGitService: this.workspaceGitService, + }); this.workspaceAutoName = workspaceAutoName; this.downloadTokenStore = downloadTokenStore; this.paseoHome = paseoHome; this.connectedHosts = new ConnectedHosts(paseoHome, { serverId, label: getHostName() }); this.passwordHash = auth?.password; - this.worktreesRoot = daemonRuntimeConfig?.worktreesRoot; this.daemonConfigStore = daemonConfigStore; this.mcpBaseUrl = mcpBaseUrl; this.assignOptionalServices({ @@ -1661,6 +1672,7 @@ export class VoiceAssistantWebSocketServer { creationService: this.creationService, projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, + workspaceBaseBranch: this.workspaceBaseBranch, workspaceLabelService: this.workspaceLabelService ?? undefined, directorySync: this.directorySync, scheduleService: this.scheduleService, @@ -2016,6 +2028,7 @@ export class VoiceAssistantWebSocketServer { backgroundWorkspaces: true, // COMPAT(backgroundAgents): added in v0.15.0; remove gate after 2027-05-03. backgroundAgents: true, + workspaceBaseBranch: true, // COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12. directorySync: true, // COMPAT(workspaceLabels): added in v0.5.0, remove after 2027-08-14. diff --git a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts index df229f9221..d10a098d79 100644 --- a/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts +++ b/packages/server/src/server/workspace-archive-record-scoped.e2e.test.ts @@ -8,6 +8,7 @@ import { afterEach, beforeEach, expect, test } from "vitest"; import { getFullAccessConfig } from "./daemon-e2e/agent-configs.js"; import { createWorktree } from "../utils/worktree.js"; +import { readPaseoWorktreeMetadata } from "../utils/worktree-metadata.js"; import { createDaemonTestContext, DaemonClient, @@ -277,6 +278,118 @@ test("renaming a workspace updates every subscribed client", async () => { } }); +test("a local checkout rejects a base change without creating worktree metadata", async () => { + const repo = createGitRepo(); + const id = await createLocalWorkspace(repo, "Local"); + await expect( + ctx.client.setWorkspaceBaseBranch({ workspaceId: id, baseRef: "refs/heads/main" }), + ).rejects.toThrow("not a Paseo worktree"); + expect(await readPaseoWorktreeMetadata(repo)).toBeNull(); +}); + +test("changing a worktree base refreshes its diff and persists across restart", async () => { + const repo = createGitRepo(); + execFileSync("git", ["checkout", "-b", "release"], { cwd: repo }); + writeFileSync(path.join(repo, "release.txt"), "release\n"); + execFileSync("git", ["add", "release.txt"], { cwd: repo }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "release"], { cwd: repo }); + const created = await ctx.client.createWorkspace({ + source: { kind: "worktree", cwd: repo, worktreeSlug: "base-refresh", baseBranch: "main" }, + }); + const workspace = created.workspace; + if (!workspace) throw new Error(created.error ?? "Failed to create workspace"); + const cwd = workspace.workspaceDirectory; + execFileSync("git", ["merge", "--ff-only", "release"], { cwd }); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path), + ).toContain("release.txt"); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/release", + }); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path), + ).not.toContain("release.txt"); + await expect( + ctx.client.setWorkspaceBaseBranch({ workspaceId: workspace.id, baseRef: "refs/heads/missing" }), + ).rejects.toThrow("Base branch not found"); + execFileSync("git", ["update-ref", "refs/remotes/origin/vanished", "release"], { cwd }); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/remotes/origin/vanished", + }); + execFileSync("git", ["update-ref", "-d", "refs/remotes/origin/vanished"], { cwd }); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/main", + }); + expect( + (await ctx.client.getCheckoutDiff(cwd, { mode: "base" })).files.map((file) => file.path), + ).toContain("release.txt"); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/release", + }); + const previous = ctx; + const paseoHomeRoot = path.dirname(previous.daemon.paseoHome); + await previous.client.close(); + await previous.daemon.daemon.stop(); + tempRoots.push(previous.daemon.staticDir); + ctx = await createDaemonTestContext({ paseoHomeRoot }); + expect((await ctx.client.getCheckoutStatus(cwd)).baseRefExact).toBe("refs/heads/release"); +}, 30_000); + +test("a managed workspace restores its changed base after archival removes the checkout", async () => { + const repo = createGitRepo(); + execFileSync("git", ["branch", "release"], { cwd: repo }); + const created = await ctx.client.createWorkspace({ + source: { kind: "worktree", cwd: repo, worktreeSlug: "changed-base", baseBranch: "main" }, + }); + const workspace = created.workspace; + if (!workspace) throw new Error(created.error ?? "Failed to create workspace"); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: workspace.id, + baseRef: "refs/heads/release", + }); + const joinedId = await createLocalWorkspace( + workspace.workspaceDirectory, + "Joined after changing base", + ); + expect((await ctx.client.archiveWorkspace(workspace.id)).error).toBe(null); + expect(existsSync(workspace.workspaceDirectory)).toBe(true); + expect((await ctx.client.archiveWorkspace(joinedId)).error).toBe(null); + expect(existsSync(workspace.workspaceDirectory)).toBe(false); + await ctx.client.restoreWorkspace(joinedId); + expect((await readPaseoWorktreeMetadata(workspace.workspaceDirectory))?.baseRef).toBe( + "refs/heads/release", + ); + expect((await ctx.client.getCheckoutStatus(workspace.workspaceDirectory)).baseRefExact).toBe( + "refs/heads/release", + ); +}); + +test("an archived workspace keeps its base when a later workspace reuses its path", async () => { + const repo = createGitRepo(); + execFileSync("git", ["branch", "release"], { cwd: repo }); + const source = { kind: "worktree", cwd: repo, worktreeSlug: "reused-path", baseBranch: "main" }; + const first = (await ctx.client.createWorkspace({ source })).workspace; + if (!first) throw new Error("Failed to create first workspace"); + expect((await ctx.client.archiveWorkspace(first.id)).error).toBe(null); + expect(existsSync(first.workspaceDirectory)).toBe(false); + const second = (await ctx.client.createWorkspace({ source })).workspace; + if (!second) throw new Error("Failed to create second workspace"); + expect(second.workspaceDirectory).toBe(first.workspaceDirectory); + await ctx.client.setWorkspaceBaseBranch({ + workspaceId: second.id, + baseRef: "refs/heads/release", + }); + expect((await ctx.client.archiveWorkspace(second.id)).error).toBe(null); + await ctx.client.restoreWorkspace(first.id); + expect((await readPaseoWorktreeMetadata(first.workspaceDirectory))?.baseRef).toBe( + "refs/heads/main", + ); +}); + test.each(["worktree", "workspace"] as const)( "%s archive immediately removes the deleted worktree from a warm CLI listing", async (command) => { diff --git a/packages/server/src/server/workspace-git-service.test.ts b/packages/server/src/server/workspace-git-service.test.ts index 490807f8d6..d87e896ead 100644 --- a/packages/server/src/server/workspace-git-service.test.ts +++ b/packages/server/src/server/workspace-git-service.test.ts @@ -766,6 +766,35 @@ describe("WorkspaceGitServiceImpl", () => { service.dispose(); }); + test("a refresh requested after a mutation waits for a new snapshot", async () => { + const gate = createDeferred(); + let reads = 0; + const service = createService({ + getCheckoutStatus: (cwd: string) => { + reads += 1; + return reads === 1 + ? gate.promise + : Promise.resolve(createCheckoutStatus(cwd, { currentBranch: "new-base" })); + }, + }); + const first = service.getSnapshot(REPO_CWD, { + force: true, + includeForge: false, + reason: "before-mutation", + }); + await vi.waitFor(() => expect(reads).toBe(1)); + const after = service.getSnapshot(REPO_CWD, { + force: true, + includeForge: false, + reason: "after-mutation", + afterInFlight: true, + }); + gate.resolve(createCheckoutStatus(REPO_CWD, { currentBranch: "old-base" })); + await first; + expect((await after).git.currentBranch).toBe("new-base"); + service.dispose(); + }); + test("bounds refresh generations and re-enqueues hot workspace successors fairly", async () => { const hotCwds = Array.from({ length: WORKSPACE_GIT_REFRESH_CONCURRENCY }, (_, index) => path.resolve(`/tmp/hot-repo-${index}`), diff --git a/packages/server/src/server/workspace-git-service.ts b/packages/server/src/server/workspace-git-service.ts index 668373e998..ea91186e9b 100644 --- a/packages/server/src/server/workspace-git-service.ts +++ b/packages/server/src/server/workspace-git-service.ts @@ -160,6 +160,7 @@ export interface WorkspaceGitRuntimeSnapshot { isPaseoOwnedWorktree: boolean; isDirty: boolean | null; baseRef: string | null; + baseRefExact?: string | null; aheadBehind: { ahead: number; behind: number } | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -296,6 +297,7 @@ export type WorkspaceGitReadOptions = export interface WorkspaceGitBranchSuggestionsOptions { query?: string; limit?: number; + refMode?: "exact"; } export interface WorkspaceGitStashListOptions { @@ -905,7 +907,13 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const normalizedCwd = resolve(cwd); const query = options?.query ?? ""; const limit = options?.limit; - const key = JSON.stringify(["branch-suggestions", normalizedCwd, query, limit ?? null]); + const key = JSON.stringify([ + "branch-suggestions", + normalizedCwd, + query, + limit ?? null, + options?.refMode, + ]); return this.readAuxiliaryCache(this.branchSuggestionsCache, key, readOptions, () => this.deps.listBranchSuggestions(normalizedCwd, options), ); @@ -3573,6 +3581,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { const cwd = target.cwd; const previousForgePrStatusPollKey = this.getForgePrStatusPollKey(target); + const previousBaseRef = target.latestFacts?.isGit ? target.latestFacts.resolvedBaseRef : null; const baseContext: CheckoutContext = { paseoHome: this.paseoHome, worktreesRoot: this.worktreesRoot, @@ -3580,6 +3589,10 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { runGitCommand: runRefreshGitCommand, }; const facts = await this.loadCheckoutFacts(target, baseContext); + if (facts.isGit && facts.resolvedBaseRef !== previousBaseRef) { + this.invalidateCheckoutDiffCache(cwd, "base"); + this.invalidateCheckoutDiffCache(facts.worktreeRoot, "base"); + } const context: CheckoutContext = { ...baseContext, facts }; const checkoutStatus = await this.deps.getCheckoutStatus(cwd, context); if (!checkoutStatus.isGit) { @@ -3612,6 +3625,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService { ? checkoutStatus.isDirty : (target.latestGit?.isDirty ?? checkoutStatus.isDirty), baseRef: checkoutStatus.baseRef, + baseRefExact: checkoutStatus.baseRefExact, aheadBehind: checkoutStatus.aheadBehind, upstreamRef: checkoutStatus.upstreamRef, aheadOfOrigin: checkoutStatus.aheadOfOrigin, diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 181ec96eae..3170281d60 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -108,7 +108,7 @@ export function initialWorkspacePlacement( /** * Applies live placement facts without rewriting the workspace's durable name - * or its creation-time base branch. + * or its selected base branch. * * A null checkout is an absence, not an observation: nothing was read at the * workspace directory, so the recorded placement stands. Only a directory that diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 474fc24829..351ba043e4 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -75,9 +75,9 @@ const PersistedWorkspaceRecordSchema = z.object({ // archive and recovery do not need the directory to still exist in order to // recover placement. worktreeRoot: z.string().nullable().default(null), - // Comparison base preserved across worktree deletion and restore. New branch-off + // Current base preserved across worktree deletion and restore. New branch-off // workspaces store the resolved ref (e.g. refs/remotes/upstream/main); legacy and - // PR checkout records may contain a bare branch name. Ordinary checkouts use null. + // PR checkout records may contain a bare branch name. Null means no recorded base. baseBranch: z .string() .nullable() diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index bd52b0e49f..075f7ebda1 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -32,6 +32,7 @@ import { getCheckoutStatus, checkoutResolvedBranch, listBranchSuggestions, + setCheckoutBaseBranch, mergeToBase, mergeFromBase, MergeConflictError, @@ -263,6 +264,56 @@ describe("checkout git utilities", () => { rmSync(tempDir, { recursive: true, force: true }); }); + it("rejects changing the base of a local checkout without creating metadata", async () => { + await expect(setCheckoutBaseBranch({ cwd: repoDir, baseRef: "main" })).rejects.toThrow(); + expect(existsSync(await getPaseoWorktreeMetadataPath(repoDir))).toBe(false); + }); + + it("changing a managed worktree base preserves its runtime and PR identity", async () => { + const worktree = await createLegacyWorktreeForTest({ + cwd: repoDir, + branchName: "feature/base", + baseBranch: "main", + worktreeSlug: "base", + paseoHome, + runSetup: false, + }); + const original = await readPaseoWorktreeMetadata(worktree.worktreePath); + execFileSync("git", ["branch", "release"], { cwd: repoDir }); + await setCheckoutBaseBranch({ + cwd: worktree.worktreePath, + baseRef: "release", + context: { paseoHome }, + }); + expect(await readPaseoWorktreeMetadata(worktree.worktreePath)).toEqual({ + ...original, + baseRefName: "release", + baseRef: "refs/heads/release", + }); + expect(await getCheckoutSnapshotFacts(worktree.worktreePath, { paseoHome })).toMatchObject({ + storedBaseRef: "refs/heads/release", + }); + }); + + it("lists exact local and all remote refs separately for the base picker", async () => { + execFileSync("git", ["branch", "release"], { cwd: repoDir }); + execFileSync("git", ["update-ref", "refs/remotes/origin/release", "HEAD"], { cwd: repoDir }); + execFileSync("git", ["update-ref", "refs/remotes/upstream/release", "HEAD"], { cwd: repoDir }); + const suggestions = await listBranchSuggestions(repoDir, { + refMode: "exact", + query: "release", + }); + expect(suggestions.map((entry) => entry.name).sort()).toEqual([ + "refs/heads/release", + "refs/remotes/origin/release", + "refs/remotes/upstream/release", + ]); + expect(suggestions.find((entry) => entry.name === "refs/heads/release")).toMatchObject({ + hasLocal: true, + hasRemote: false, + }); + }); + it("throws NotGitRepoError for non-git directories", async () => { const nonGitDir = join(tempDir, "not-git"); mkdirSync(nonGitDir, { recursive: true }); @@ -1920,6 +1971,13 @@ const x = 1; expect(status.currentBranch).toBe("feature/plain"); }); + it("rejects merging a branch into its own same-named remote base", async () => { + execFileSync("git", ["update-ref", "refs/remotes/origin/main", "HEAD"], { cwd: repoDir }); + await expect(mergeToBase(repoDir, { baseRef: "refs/remotes/origin/main" })).rejects.toThrow( + "Cannot merge a branch into itself", + ); + }); + it("merges the current branch into base from a worktree checkout", async () => { const worktree = await createLegacyWorktreeForTest({ branchName: "main", diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index a0cbaa7afc..d793e2c683 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -51,6 +51,7 @@ import { type PaseoWorktreeMetadata, readPaseoWorktreeMetadata, rebindPaseoWorktreeChangeRequestHint, + setPaseoWorktreeBaseRef, } from "./worktree-metadata.js"; const READ_ONLY_GIT_ENV = { GIT_OPTIONAL_LOCKS: "0", @@ -323,15 +324,30 @@ function sortBranchSuggestions( export async function listBranchSuggestions( cwd: string, - options?: { query?: string; limit?: number }, + options: { query?: string; limit?: number; refMode?: "exact" } = {}, ): Promise { await requireGitRepo(cwd); - const requestedLimit = options?.limit ?? 50; + const requestedLimit = options.limit ?? 50; const limit = Math.max(1, Math.min(200, requestedLimit)); - const rawQuery = options?.query?.trim().toLowerCase() ?? ""; + const rawQuery = options.query?.trim().toLowerCase() ?? ""; const query = normalizeBranchSuggestionName(rawQuery) ?? rawQuery; + if (options.refMode === "exact") { + const refs = await listGitRefs(cwd, "refs"); + const branches = refs.filter((ref) => { + const isBranch = ref.name.startsWith("refs/heads/") || ref.name.startsWith("refs/remotes/"); + return isBranch && !ref.name.endsWith("/HEAD") && ref.name.toLowerCase().includes(rawQuery); + }); + branches.sort((a, b) => b.committerDate - a.committerDate || a.name.localeCompare(b.name)); + return branches.slice(0, limit).map((ref) => ({ + name: ref.name, + committerDate: ref.committerDate, + hasLocal: ref.name.startsWith("refs/heads/"), + hasRemote: ref.name.startsWith("refs/remotes/"), + })); + } + const [localRefs, remoteRefs] = await Promise.all([ listGitRefs(cwd, "refs/heads"), listGitRefs(cwd, "refs/remotes/origin"), @@ -889,6 +905,7 @@ export interface CheckoutStatusGitNonPaseo { currentBranch: string | null; isDirty: boolean; baseRef: string | null; + baseRefExact?: string | null; aheadBehind: AheadBehind | null; // Remote-tracking ref for currentBranch, e.g. "refs/remotes/origin/main". Null when the // branch has no upstream or git could not resolve it. aheadOfOrigin/behindOfOrigin are @@ -908,6 +925,7 @@ export interface CheckoutStatusGitPaseo { currentBranch: string | null; isDirty: boolean; baseRef: string; + baseRefExact?: string | null; aheadBehind: AheadBehind | null; upstreamRef: string | null; aheadOfOrigin: number | null; @@ -1308,12 +1326,36 @@ async function getStoredBaseRefForCwd( if (context?.facts?.isGit) { return context.facts.storedBaseRef; } - const paseoWorktree = await getPaseoWorktreeForCwd(cwd, { context }); - if (!paseoWorktree.isPaseoOwnedWorktree) { - return null; - } + const ownership = await getPaseoWorktreeForCwd(cwd, { context }); + return ownership.isPaseoOwnedWorktree ? readPaseoWorktreeBaseRef(ownership.worktreeRoot) : null; +} - return readPaseoWorktreeBaseRef(paseoWorktree.worktreeRoot); +export async function setCheckoutBaseBranch(input: { + cwd: string; + baseRef: string; + context?: CheckoutContext; +}): Promise { + await requireGitRepo(input.cwd); + const ownership = await getPaseoWorktreeForCwd(input.cwd, { context: input.context }); + if (!ownership.isPaseoOwnedWorktree) throw new Error("Workspace is not a Paseo worktree"); + const requested = input.baseRef.trim(); + const qualified = requested.startsWith("refs/"); + const candidates = qualified + ? [requested] + : [`refs/heads/${requested}`, `refs/remotes/${requested}`, `refs/remotes/origin/${requested}`]; + for (const candidate of candidates) { + if (!isQualifiedBranchRef(candidate) || candidate.endsWith("/HEAD")) continue; + const valid = await runGitCommand(["check-ref-format", candidate], { + cwd: input.cwd, + acceptExitCodes: [0, 1], + }); + if (valid.exitCode !== 0 || !(await doesGitRefExist(input.cwd, candidate))) continue; + const worktreeRoot = await getWorktreeRoot(input.cwd); + if (!worktreeRoot) throw new NotGitRepoError(input.cwd); + await setPaseoWorktreeBaseRef({ worktreeRoot, baseRef: candidate }); + return candidate; + } + throw new Error(`Base branch not found: ${requested}`); } async function getResolvedBaseRefForCwd( @@ -1786,7 +1828,11 @@ async function getAheadBehind( context?: CheckoutContext, ): Promise { const normalizedBaseRef = branchNameFromRef(baseRef); - if (!normalizedBaseRef || !currentBranch || normalizedBaseRef === currentBranch) { + if ( + !normalizedBaseRef || + !currentBranch || + (!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch) + ) { return null; } const comparisonBaseRef = @@ -2128,7 +2174,8 @@ export async function getCheckoutSnapshotFacts( if ( resolvedBaseRef && inspected.currentBranch && - branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch + (isQualifiedBranchRef(resolvedBaseRef) || + branchNameFromRef(resolvedBaseRef) !== inspected.currentBranch) ) { comparisonBaseRef = await resolveBestComparisonBaseRef(cwd, resolvedBaseRef, context).catch( () => null, @@ -2373,6 +2420,7 @@ export async function getCheckoutStatus( currentBranch, isDirty, baseRef: displayBaseRef ?? baseRef, + baseRefExact: baseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2391,6 +2439,7 @@ export async function getCheckoutStatus( currentBranch, isDirty, baseRef: displayBaseRef, + baseRefExact: baseRef, aheadBehind, upstreamRef, aheadOfOrigin, @@ -2605,7 +2654,10 @@ async function tryResolveCheckoutCommitsBaseRef( return null; } const normalizedBaseRef = branchNameFromRef(baseRef); - if (!normalizedBaseRef || normalizedBaseRef === currentBranch) { + if ( + !normalizedBaseRef || + (!isQualifiedBranchRef(baseRef) && normalizedBaseRef === currentBranch) + ) { return null; } try { @@ -3890,7 +3942,7 @@ export async function mergeToBase( const normalizedBaseRef = resolveMergeTargetBranch(baseRef); const currentWorktreeRoot = (await getWorktreeRoot(cwd, context)) ?? cwd; if (normalizedBaseRef === currentBranch) { - return currentWorktreeRoot; + throw new Error("Cannot merge a branch into itself"); } const baseWorktree = await getWorktreePathForBranch(cwd, normalizedBaseRef); diff --git a/packages/server/src/utils/worktree-metadata.ts b/packages/server/src/utils/worktree-metadata.ts index fcb0454672..fc4488d742 100644 --- a/packages/server/src/utils/worktree-metadata.ts +++ b/packages/server/src/utils/worktree-metadata.ts @@ -14,7 +14,7 @@ const ChangeRequestLookupTargetSchema = z.object({ localBranchName: z.string().min(1).optional(), }); -// baseRefName is the display name; baseRef is the exact ref the worktree was cut from +// baseRefName is the display name; baseRef is the checkout's current exact base // ("refs/remotes/upstream/main"). baseRef is optional because worktrees written before it // existed only have the name — there are no migrations, so readers fall back. const PaseoWorktreeMetadataV1Schema = z.object({ @@ -249,6 +249,21 @@ export async function writePaseoWorktreeMetadata( }); } +export async function setPaseoWorktreeBaseRef(input: { + worktreeRoot: string; + baseRef: string; +}): Promise { + return mutateMetadata(input.worktreeRoot, async (root) => { + const current = await readMetadataFile(root); + if (!current) throw new Error("Cannot change base: missing worktree metadata"); + await writePaseoWorktreeMetadataFile(root, { + ...current, + baseRefName: branchNameFromRef(input.baseRef), + baseRef: input.baseRef, + }); + }); +} + export async function writePaseoWorktreeRuntimeMetadata( worktreeRoot: string, options: { worktreePort: number }, From a68dc55490812605a174d7f01b45a62e0a0a74f5 Mon Sep 17 00:00:00 2001 From: Okan YILDIRIM <63511519+ragokan@users.noreply.github.com> Date: Fri, 6 Nov 2026 03:03:57 +0000 Subject: [PATCH 26/29] fix(app): refresh visible usage summaries periodically (#7806) --- .../e2e/browser/usage-sidebar-item.spec.ts | 108 +++++++++++++++++- packages/app/src/usage/queries.ts | 21 ++-- packages/app/src/usage/sidebar-item.tsx | 9 +- 3 files changed, 129 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/browser/usage-sidebar-item.spec.ts b/packages/app/e2e/browser/usage-sidebar-item.spec.ts index 9151403904..8d206f35e8 100644 --- a/packages/app/e2e/browser/usage-sidebar-item.spec.ts +++ b/packages/app/e2e/browser/usage-sidebar-item.spec.ts @@ -8,7 +8,7 @@ import { openProjectViaDaemon, } from "../support/helpers/new-workspace"; import { getServerId } from "../support/helpers/server-id"; -import { openSettingsHostSection } from "../support/helpers/settings"; +import { openCompactSettings, openSettingsHostSection } from "../support/helpers/settings"; import { expectFooterItemSetting, expectFooterSeparator, @@ -42,6 +42,7 @@ import { import { seedSidebarFooterPreferences } from "../support/helpers/sidebar-nav-settings"; import { waitForSettledPosition } from "../support/helpers/sheet-layout"; import { createTempGitRepo } from "../support/helpers/workspace"; +import { buildOpenProjectRoute } from "@/utils/host-routes"; const WIDE = { width: 1440, height: 900 }; const COMPACT = { width: 390, height: 844 }; @@ -70,7 +71,112 @@ async function footerClip(page: Page) { return (await page.locator('[data-testid="sidebar-footer"]:visible').boundingBox())!; } +async function setPageVisibility(page: Page, visibility: DocumentVisibilityState): Promise { + await page.evaluate((state) => { + Object.defineProperty(document, "visibilityState", { configurable: true, get: () => state }); + Object.defineProperty(document, "hidden", { + configurable: true, + get: () => state === "hidden", + }); + document.dispatchEvent(new Event("visibilitychange")); + }, visibility); +} + test.describe("Usage item", () => { + test("visible summary refreshes every five minutes without opening Usage", async ({ page }) => { + let usedPct = 31; + const usage = await installUsageReportsFixture(page, { + lists: [ + () => [ + { + ...claudeAndCodexReports()[0]!, + report: { + status: "available", + windows: [ + { id: "five_hour", label: "Session", shortLabel: "5h", summary: true, usedPct }, + ], + }, + }, + ], + ], + }); + await page.clock.install(); + await page.setViewportSize(WIDE); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await expectPinnedUsage(page, ["31% 5h"]); + const startingUrl = page.url(); + await qaScreenshot(page, "usage-auto-refresh-initial", { kind: "footer" }); + const requestsBeforeRefresh = usage.listRequests(); + + usedPct = 42; + await page.clock.fastForward(5 * 60_000); + await qaScreenshot(page, "usage-auto-refresh-after-interval", { kind: "footer" }); + await expectPinnedUsage(page, ["42% 5h"]); + await expect(usageModal(page)).toHaveCount(0); + await expect(page).toHaveURL(startingUrl); + expect(usage.listRequests()).toEqual([ + ...requestsBeforeRefresh, + { forceRefresh: false, reportIds: undefined, agentId: undefined }, + ]); + + await setPageVisibility(page, "hidden"); + const requestsBeforeBackgrounding = usage.listRequests(); + usedPct = 47; + await page.clock.fastForward(5 * 60_000); + expect(usage.listRequests()).toEqual(requestsBeforeBackgrounding); + await expectPinnedUsage(page, ["42% 5h"]); + await setPageVisibility(page, "visible"); + await page.clock.fastForward(5 * 60_000); + await expectPinnedUsage(page, ["47% 5h"]); + expect(usage.listRequests()).toEqual([ + ...requestsBeforeBackgrounding, + { forceRefresh: false, reportIds: undefined, agentId: undefined }, + ]); + + await page.setViewportSize(COMPACT); + await expect(usageItem(page)).toHaveCount(0); + const requestsBeforeHiding = usage.listRequests(); + await page.clock.fastForward(5 * 60_000); + expect(usage.listRequests()).toEqual(requestsBeforeHiding); + + await openCompactSidebar(page); + await expectPinnedUsage(page, ["47% 5h"]); + await expect(page.getByTestId("agent-list-backdrop")).toBeVisible(); + usedPct = 53; + await page.clock.fastForward(5 * 60_000); + await expectPinnedUsage(page, ["53% 5h"]); + await expect(usageModal(page)).toHaveCount(0); + + await page.setViewportSize(WIDE); + await openSidebarNavSettings(page); + await setFooterItemVisible(page, "usage", false); + await leaveSettings(page); + await expectNoUsageItem(page); + const requestsBeforeDisabling = usage.listRequests(); + await page.clock.fastForward(5 * 60_000); + expect(usage.listRequests()).toEqual(requestsBeforeDisabling); + }); + + test("Usage settings do not poll reports while the sidebar is inactive", async ({ page }) => { + const usage = await installUsageReportsFixture(page, { + lists: [() => claudeAndCodexReports()], + }); + await page.clock.install(); + await page.setViewportSize(COMPACT); + await seedSidebarFooterPreferences(page, [{ key: "usage", visible: true }]); + await gotoAppShell(page); + await openCompactSettings(page, buildOpenProjectRoute()); + await openSettingsHostSection(page, getServerId(), "usage"); + await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible(); + await expect(usageItem(page)).toHaveCount(0); + const requestsBeforeWaiting = usage.listRequests(); + + await page.clock.fastForward(5 * 60_000); + expect(usage.listRequests()).toEqual(requestsBeforeWaiting); + await expect(page.getByTestId("usage-card").getByText("31% used")).toBeVisible(); + }); + test("both compact footer entry points open Usage as a sheet", async ({ page }) => { await page.setViewportSize(COMPACT); await installUsageReportsFixture(page, { lists: [() => claudeAndCodexReports()] }); diff --git a/packages/app/src/usage/queries.ts b/packages/app/src/usage/queries.ts index 524972a010..bac8bd7ffa 100644 --- a/packages/app/src/usage/queries.ts +++ b/packages/app/src/usage/queries.ts @@ -33,9 +33,10 @@ import { } from "./model"; import type { UsageReportEntry, UsageView } from "./types"; -// The daemon caches each report for five minutes, so re-reading it is cheap. Only -// an explicit refresh passes `forceRefresh` and reaches the source's API. +// The daemon caches each report for five minutes, so re-reading it is cheap. +// An explicit refresh passes `forceRefresh` to bypass that cache. const REPORTS_STALE_TIME_MS = 60_000; +const REPORTS_REFETCH_INTERVAL_MS = 5 * 60_000; /** Every report list of a host: its own and each agent's. */ function hostUsageQueryKey(serverId: string) { @@ -164,11 +165,16 @@ export function useHostUsage(serverId: string): { view: UsageView; refresh: () = const NO_REPORTS: UsageReportEntry[] = []; -/** - * The reports of the sidebar's usage host, which is connected and reports usage; none until they - * load, or without a host. - */ -export function useUsageHostReports(serverId: string | null): UsageReportEntry[] { +interface UsageHostReportsInput { + serverId: string | null; + shouldPoll?: boolean; +} + +/** A host's reports, with periodic refresh enabled only by the visible sidebar summary. */ +export function useUsageHostReports({ + serverId, + shouldPoll = false, +}: UsageHostReportsInput): UsageReportEntry[] { const setting = useUsageSetting(serverId); const queryClient = useQueryClient(); const query = useFetchQuery({ @@ -177,6 +183,7 @@ export function useUsageHostReports(serverId: string | null): UsageReportEntry[] queryFn: serverId ? () => listReports(queryClient, serverId) : skipToken, dataShape: "list", staleTimeMs: REPORTS_STALE_TIME_MS, + refetchInterval: shouldPoll ? REPORTS_REFETCH_INTERVAL_MS : false, }); return setting.enabled ? (query.data ?? NO_REPORTS) : NO_REPORTS; } diff --git a/packages/app/src/usage/sidebar-item.tsx b/packages/app/src/usage/sidebar-item.tsx index 437c0a929c..bd4dfeb65e 100644 --- a/packages/app/src/usage/sidebar-item.tsx +++ b/packages/app/src/usage/sidebar-item.tsx @@ -16,6 +16,8 @@ import { type PressableStateCallbackType, } from "react-native"; import { StyleSheet } from "react-native-unistyles"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useAppVisible } from "@/hooks/use-app-visible"; import { builtinSidebarNavLabelKey } from "@/sidebar-nav/model"; import { useUsageDisplay } from "./display"; import { useUsageInSidebar } from "./in-sidebar"; @@ -36,9 +38,14 @@ import { UsageModal } from "./usage-modal"; /** Each summary window with data on the usage host, under its source; empty while none has. */ export function useUsageSummary(): readonly PinnedUsageSource[] { const { t } = useTranslation(); + const active = useRetainedPanelActive(); + const isAppVisible = useAppVisible(); const { inSidebar } = useUsageInSidebar(); const serverId = useUsageHostId(); - const reports = useUsageHostReports(inSidebar ? serverId : null); + const reports = useUsageHostReports({ + serverId: inSidebar ? serverId : null, + shouldPoll: active && inSidebar && isAppVisible, + }); const { preferences } = useUsageDisplay(reports); return useMemo(() => resolvePinnedUsage(reports, preferences, t), [preferences, reports, t]); } From 8b9c42c26683e69db724318e8272f2d9e90f6fc1 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 10:28:38 +0700 Subject: [PATCH 27/29] Keep every sidebar footer action reachable in a narrow sidebar (#8885) * fix(app): move sidebar footer actions that don't fit behind a caret Touch sizing makes each footer icon a 44pt target, so five of them plus the row's padding need 236pt. A docked sidebar can be 200pt and a fold column about as narrow, so Settings was cut off at the sidebar's edge. The row now measures itself and keeps as many full-size buttons as fit; the rest move behind a caret that opens a menu-engine popover. Help and Usage leave first, Settings and Add project last, and Hosts and Help open as pages of the overflow menu rather than second popovers. * fix(app): keep the sidebar footer empty until it has measured Laying the footer out before its first onLayout put all five buttons in the row, so a narrow sidebar painted Settings clipped for a few frames before the caret replaced it. The unmeasured row now places nothing and holds its height, so the first painted row is already the measured one. * fix(app): keep the footer overflow menu on a narrow, short screen The overflow menu asked for 240pt, and edge clamping can move a surface but not shrink it, so on a 220pt screen its right edge and submenu arrows hung off the display. Anchored surfaces now cap their width to the display inside the same edge padding that clamping uses. The Hosts page lists every host, so the overflow menu is scrollable: on a short screen the last hosts and Add host were below the fold with no way to reach them. --- docs/menus.md | 5 +- .../browser/sidebar-footer-overflow.spec.ts | 127 +++++++++ packages/app/e2e/support/helpers/hosts.ts | 36 ++- .../helpers/sidebar-footer-overflow.ts | 127 +++++++++ packages/app/src/components/left-sidebar.tsx | 103 ++++++-- .../components/sidebar-footer-layout.test.ts | 89 +++++++ .../src/components/sidebar-footer-layout.ts | 62 +++++ .../sidebar/sidebar-footer-menu-trigger.tsx | 69 +++++ .../sidebar/sidebar-footer-overflow-menu.tsx | 245 ++++++++++++++++++ .../components/sidebar/sidebar-help-menu.tsx | 172 ++++++------ packages/app/src/components/ui/anchor.test.ts | 25 +- packages/app/src/components/ui/anchor.ts | 21 ++ .../src/components/ui/menu/menu-overlay.tsx | 7 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + 22 files changed, 956 insertions(+), 141 deletions(-) create mode 100644 packages/app/e2e/browser/sidebar-footer-overflow.spec.ts create mode 100644 packages/app/e2e/support/helpers/sidebar-footer-overflow.ts create mode 100644 packages/app/src/components/sidebar-footer-layout.test.ts create mode 100644 packages/app/src/components/sidebar-footer-layout.ts create mode 100644 packages/app/src/components/sidebar/sidebar-footer-menu-trigger.tsx create mode 100644 packages/app/src/components/sidebar/sidebar-footer-overflow-menu.tsx diff --git a/docs/menus.md b/docs/menus.md index 590def2b33..3e29e13ac3 100644 --- a/docs/menus.md +++ b/docs/menus.md @@ -226,7 +226,8 @@ its own. inactive. An async action can navigate before its menu closes; a hidden panel must not leave a portal backdrop blocking the destination. On web the chat suspends one commit after it goes inactive, and that inactive commit is where the surface unmounts. -- Anchoring, flipping, and edge clamping live in `components/ui/anchor.ts`, shared with the hover - card, and are unit-tested. Fix positioning bugs there, not at a call site. +- Anchoring, flipping, edge clamping, and capping a surface's width to the display live in + `components/ui/anchor.ts`, shared with the hover card, and are unit-tested. Clamping moves a + surface but cannot shrink it, so the width cap is what keeps a wide menu on a narrow screen. Fix positioning bugs there, not at a call site. - Everything else about floating surfaces on Android — Portal/Modal escape, lifecycle gates, status-bar offset, the open flash — is in [floating-panels.md](floating-panels.md). diff --git a/packages/app/e2e/browser/sidebar-footer-overflow.spec.ts b/packages/app/e2e/browser/sidebar-footer-overflow.spec.ts new file mode 100644 index 0000000000..da16dbb648 --- /dev/null +++ b/packages/app/e2e/browser/sidebar-footer-overflow.spec.ts @@ -0,0 +1,127 @@ +import { expect, test } from "../support/fixtures"; +import { addProjectFlow } from "../support/helpers/add-project-flow"; +import { gotoAppShell } from "../support/helpers/app"; +import { + chooseFooterOverflowAction, + expectFooterLine, + expectFooterMenusInsideViewport, + expectFooterNeverClipped, + expectFooterOverflowActions, + footerOverflowMenu, + openFooterOverflow, + seedDockedSidebarWidth, + watchFooterClipping, +} from "../support/helpers/sidebar-footer-overflow"; +import { addOfflineHostsAndReload } from "../support/helpers/hosts"; +import { openCompactSidebar } from "../support/helpers/usage-sidebar-item"; + +const TOUCH_CONTROL = 44; +const MIN_SIDEBAR_WIDTH = 200; +const ALL_INLINE = [ + "sidebar-add-project", + "sidebar-usage-icon", + "sidebar-hosts-trigger", + "sidebar-help", + "sidebar-settings", +]; +// Five 44pt buttons and the row's padding need 236pt; 200pt holds three and the caret. +const NARROW_TOUCH_LINE = [ + "sidebar-add-project", + "sidebar-hosts-trigger", + "sidebar-footer-overflow", + "sidebar-settings", +]; + +test.describe("Sidebar footer on a touch screen", () => { + test.use({ viewport: { width: 1280, height: 800 }, hasTouch: true }); + + test("a narrow docked sidebar moves the actions it can't fit behind a caret", async ({ + page, + }) => { + await seedDockedSidebarWidth(page, MIN_SIDEBAR_WIDTH); + await watchFooterClipping(page); + await gotoAppShell(page); + await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL }); + await expectFooterNeverClipped(page); + + await test.step("Help opens as a page of the overflow menu", async () => { + await openFooterOverflow(page); + await expectFooterOverflowActions(page, ["Usage", "Help and support"]); + await chooseFooterOverflowAction(page, "Help and support"); + await expect(page.getByRole("menuitem", { name: "What's new" })).toBeVisible(); + await expect(page.getByTestId("sidebar-help-menu")).toHaveCount(0); + await page.keyboard.press("Escape"); + await expect(footerOverflowMenu(page)).toBeHidden(); + }); + + await test.step("Usage opens from the overflow menu", async () => { + await openFooterOverflow(page); + await chooseFooterOverflowAction(page, "Usage"); + await expect(footerOverflowMenu(page)).toBeHidden(); + await expect(page.getByTestId("usage-modal")).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("usage-modal")).toBeHidden(); + }); + + await test.step("Add project and Settings shortcuts still work", async () => { + await page.keyboard.press("Control+o"); + await expect(addProjectFlow(page)).toBeVisible(); + await page.keyboard.press("Escape"); + await expect(addProjectFlow(page)).toBeHidden(); + await page.keyboard.press("Control+,"); + await expect(page).toHaveURL(/\/settings\/general$/); + }); + }); + + test("the default sidebar width keeps all five actions inline", async ({ page }) => { + await gotoAppShell(page); + await expectFooterLine(page, { testIDs: ALL_INLINE, controlSize: TOUCH_CONTROL }); + }); +}); + +test.describe("Sidebar footer in a compact overlay narrower than the footer", () => { + test.use({ viewport: { width: 220, height: 800 }, hasTouch: true }); + + test("the overflow menu reaches the hidden actions", async ({ page }) => { + await watchFooterClipping(page); + await gotoAppShell(page); + await openCompactSidebar(page); + await expectFooterLine(page, { testIDs: NARROW_TOUCH_LINE, controlSize: TOUCH_CONTROL }); + await expectFooterNeverClipped(page); + await openFooterOverflow(page); + await expectFooterOverflowActions(page, ["Usage", "Help and support"]); + await expectFooterMenusInsideViewport(page); + }); +}); + +test.describe("Sidebar footer on a short, narrow phone", () => { + // 164pt for buttons holds Settings, Add project and the caret, so Hosts moves behind it. + test.use({ viewport: { width: 180, height: 360 }, hasTouch: true }); + + test("the overflow Hosts page scrolls to its last host and Add host", async ({ page }) => { + await gotoAppShell(page); + await addOfflineHostsAndReload( + page, + Array.from({ length: 10 }, (_, index) => ({ + serverId: `overflow-host-${index}`, + label: `Overflow host ${index}`, + })), + ); + await openCompactSidebar(page); + await expectFooterLine(page, { + testIDs: ["sidebar-add-project", "sidebar-footer-overflow", "sidebar-settings"], + controlSize: TOUCH_CONTROL, + }); + await openFooterOverflow(page); + await expectFooterOverflowActions(page, ["Usage", "Hosts", "Help and support"]); + await expectFooterMenusInsideViewport(page); + await chooseFooterOverflowAction(page, "Hosts"); + await expectFooterMenusInsideViewport(page); + const addHost = page.getByRole("menuitem", { name: "Add host" }); + await addHost.scrollIntoViewIfNeeded(); + await expect(page.getByRole("menuitem", { name: "Overflow host 9" })).toBeVisible(); + await expect(addHost).toBeInViewport(); + await addHost.click(); + await expect(page.getByText("Add connection", { exact: true })).toBeVisible(); + }); +}); diff --git a/packages/app/e2e/support/helpers/hosts.ts b/packages/app/e2e/support/helpers/hosts.ts index 69e0c4b9f5..0e0a9f56fe 100644 --- a/packages/app/e2e/support/helpers/hosts.ts +++ b/packages/app/e2e/support/helpers/hosts.ts @@ -22,15 +22,27 @@ export async function addOfflineHostAndReload( page: Page, input: { serverId: string; label: string; primaryLabel?: string }, ): Promise { - const offlineHost = buildSeededHost({ - serverId: input.serverId, - label: input.label, - endpoint: "127.0.0.1:59999", - nowIso: new Date().toISOString(), - }); + await addOfflineHostsAndReload(page, [input], { primaryLabel: input.primaryLabel }); +} + +/** Adds several unreachable hosts with one reload. See `addOfflineHostAndReload`. */ +export async function addOfflineHostsAndReload( + page: Page, + hosts: Array<{ serverId: string; label: string }>, + options: { primaryLabel?: string } = {}, +): Promise { + const nowIso = new Date().toISOString(); + const offlineHosts = hosts.map((host) => + buildSeededHost({ + serverId: host.serverId, + label: host.label, + endpoint: "127.0.0.1:59999", + nowIso, + }), + ); await page.evaluate( - ({ host, keys, primaryLabel }) => { + ({ hosts: added, keys, primaryLabel }) => { const nonce = localStorage.getItem(keys.nonce); if (!nonce) { throw new Error("Expected the e2e seed nonce before overriding the host registry."); @@ -40,20 +52,22 @@ export async function addOfflineHostAndReload( if (primaryLabel && registry[0]) { registry[0].label = primaryLabel; } - if (!registry.some((entry) => entry.serverId === host.serverId)) { - registry.push(host); + for (const host of added) { + if (!registry.some((entry) => entry.serverId === host.serverId)) { + registry.push(host); + } } localStorage.setItem(keys.registry, JSON.stringify(registry)); localStorage.setItem(keys.disableSeedOnce, nonce); }, { - host: offlineHost, + hosts: offlineHosts, keys: { registry: REGISTRY_KEY, nonce: SEED_NONCE_KEY, disableSeedOnce: DISABLE_DEFAULT_SEED_ONCE_KEY, }, - primaryLabel: input.primaryLabel, + primaryLabel: options.primaryLabel, }, ); diff --git a/packages/app/e2e/support/helpers/sidebar-footer-overflow.ts b/packages/app/e2e/support/helpers/sidebar-footer-overflow.ts new file mode 100644 index 0000000000..85983578df --- /dev/null +++ b/packages/app/e2e/support/helpers/sidebar-footer-overflow.ts @@ -0,0 +1,127 @@ +import { expect, type Locator, type Page } from "@playwright/test"; + +/** Every footer control that can sit in the icon line, caret included. */ +const FOOTER_LINE_TEST_IDS = [ + "sidebar-add-project", + "sidebar-usage-icon", + "sidebar-hosts-trigger", + "sidebar-help", + "sidebar-footer-overflow", + "sidebar-settings", +]; + +// Browser layout boxes include floating-point rounding, even for whole-pixel styles. +const GEOMETRY_TOLERANCE = 0.01; + +/** Open the app with the docked sidebar at `width`, as if the user had dragged it there. */ +export async function seedDockedSidebarWidth(page: Page, width: number): Promise { + await page.addInitScript((sidebarWidth) => { + localStorage.setItem("panel-state", JSON.stringify({ version: 17, state: { sidebarWidth } })); + }, width); +} + +/** + * From the first frame, record any frame in which a footer button reaches past its row, so a row + * laid out before it measured shows up even if it lasted one frame. + */ +export async function watchFooterClipping(page: Page): Promise { + await page.addInitScript((testIDs) => { + const clippedFrames: string[] = []; + Object.assign(window, { __footerClippedFrames: clippedFrames }); + const sample = () => { + for (const row of document.querySelectorAll('[data-testid="sidebar-footer-bottom-line"]')) { + const rowRect = row.getBoundingClientRect(); + if (rowRect.width === 0) continue; + for (const testID of testIDs) { + const button = row.querySelector(`[data-testid="${testID}"]`); + if (button && button.getBoundingClientRect().right > rowRect.right + 0.5) { + clippedFrames.push(testID); + } + } + } + requestAnimationFrame(sample); + }; + requestAnimationFrame(sample); + }, FOOTER_LINE_TEST_IDS); +} + +export async function expectFooterNeverClipped(page: Page): Promise { + const clipped = await page.evaluate( + () => (window as unknown as { __footerClippedFrames: string[] }).__footerClippedFrames, + ); + expect(clipped).toEqual([]); +} + +function footerLine(page: Page): Locator { + return page.locator('[data-testid="sidebar-footer-bottom-line"]:visible'); +} + +export function footerOverflowMenu(page: Page): Locator { + return page.getByTestId("sidebar-footer-overflow-menu"); +} + +/** + * The icon line shows exactly `testIDs`, in order, each `controlSize` square and wholly inside the + * line's box, so no button is cut off by the sidebar's edge. + */ +export async function expectFooterLine( + page: Page, + input: { testIDs: string[]; controlSize: number }, +): Promise { + const line = footerLine(page); + await expect(line).toHaveCount(1); + await expect + .poll(async () => + line + .locator(FOOTER_LINE_TEST_IDS.map((testID) => `[data-testid="${testID}"]`).join(",")) + .evaluateAll((elements) => elements.map((element) => element.getAttribute("data-testid"))), + ) + .toEqual(input.testIDs); + const { row, buttons } = await line.evaluate((element, testIDs) => { + const rect = (node: Element) => { + const { x, width, height } = node.getBoundingClientRect(); + return { x, width, height }; + }; + return { + row: rect(element), + buttons: testIDs.map((testID) => rect(element.querySelector(`[data-testid="${testID}"]`)!)), + }; + }, input.testIDs); + for (const button of buttons) { + expect(Math.abs(button.width - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE); + expect(Math.abs(button.height - input.controlSize)).toBeLessThan(GEOMETRY_TOLERANCE); + expect(button.x).toBeGreaterThanOrEqual(row.x - GEOMETRY_TOLERANCE); + expect(button.x + button.width).toBeLessThanOrEqual(row.x + row.width + GEOMETRY_TOLERANCE); + } +} + +export async function openFooterOverflow(page: Page): Promise { + await footerLine(page).getByTestId("sidebar-footer-overflow").click(); + await expect(footerOverflowMenu(page)).toBeVisible(); +} + +/** The overflow menu lists exactly these actions, in row order. */ +export async function expectFooterOverflowActions(page: Page, names: string[]): Promise { + await expect(footerOverflowMenu(page).getByRole("menuitem")).toHaveText(names); +} + +export async function chooseFooterOverflowAction(page: Page, name: string): Promise { + await footerOverflowMenu(page).getByRole("menuitem", { name }).click(); +} + +/** Every row of the open overflow menu, and of the page it opened, is inside the viewport's width. */ +export async function expectFooterMenusInsideViewport(page: Page): Promise { + const viewport = page.viewportSize()!; + const rows = page.getByRole("menuitem"); + await expect(rows.first()).toBeVisible(); + const boxes = await rows.evaluateAll((elements) => + elements.map((element) => { + const { x, width } = element.getBoundingClientRect(); + return { x, width }; + }), + ); + for (const box of boxes) { + expect(box.x).toBeGreaterThanOrEqual(0); + expect(box.x + box.width).toBeLessThanOrEqual(viewport.width); + } +} diff --git a/packages/app/src/components/left-sidebar.tsx b/packages/app/src/components/left-sidebar.tsx index b4b54db3db..1b2df08302 100644 --- a/packages/app/src/components/left-sidebar.tsx +++ b/packages/app/src/components/left-sidebar.tsx @@ -17,6 +17,7 @@ import { Text, useWindowDimensions, View, + type LayoutChangeEvent, } from "react-native"; import { Gesture, ScrollView as GHScrollView } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue } from "react-native-reanimated"; @@ -40,6 +41,17 @@ import { SidebarSeparator } from "@/components/sidebar/sidebar-separator"; import { SidebarNavRows } from "@/components/sidebar/sidebar-nav-rows"; import { SidebarFooterIconHighlight } from "@/components/sidebar/sidebar-footer-icon-highlight"; import { SidebarHelpMenu } from "@/components/sidebar/sidebar-help-menu"; +import { SidebarFooterOverflowMenu } from "@/components/sidebar/sidebar-footer-overflow-menu"; +import { + resolveSidebarFooterLayout, + SIDEBAR_FOOTER_ACTIONS, + type SidebarFooterAction, +} from "@/components/sidebar-footer-layout"; +import { + controlSizingForLayout, + mapLayout, + TOOLBAR_CONTROL_SIZES, +} from "@/components/ui/control-geometry"; import { SidebarResizeHandle } from "@/components/sidebar-resize-handle"; import { DensityIcon } from "@/components/ui/density-icon"; import { SidebarSectionHeader } from "@/components/sidebar/sidebar-section-header"; @@ -55,6 +67,7 @@ import { type SidebarWorkspaceEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { density } from "@/styles/density"; +import { SPACING } from "@/styles/theme"; import { useSidebarModel } from "@/components/sidebar/sidebar-model"; import type { PinnedSidebarGroups } from "@/hooks/use-sidebar-pins"; import { RetainedPanelActivity } from "@/components/retained-panel"; @@ -79,6 +92,9 @@ import { SidebarWorkspaceList } from "./sidebar-workspace-list"; type SidebarTheme = ReturnType["theme"]; +/** The footer icon line's inset; the overflow layout counts it as space the buttons can't use. */ +const FOOTER_ROW_PADDING = SPACING[2]; + const DEV_BUILD_LABEL = process.env.EXPO_PUBLIC_PASEO_DEV_BUILD_LABEL?.trim() || null; interface SidebarSharedProps { @@ -410,35 +426,72 @@ function SidebarFooter({ }) { const newAgentKeys = useShortcutKeys("new-agent"); const settingsKeys = useShortcutKeys("toggle-settings"); + const isCompactLayout = useIsCompactFormFactor(); + const [rowWidth, setRowWidth] = useState(0); + const handleRowLayout = useCallback((event: LayoutChangeEvent) => { + setRowWidth(event.nativeEvent.layout.width); + }, []); + const { inline, overflow } = useMemo( + () => + resolveSidebarFooterLayout({ + rowWidth, + controlSize: TOOLBAR_CONTROL_SIZES[controlSizingForLayout(isCompactLayout)], + rowPadding: FOOTER_ROW_PADDING, + actions: SIDEBAR_FOOTER_ACTIONS, + }), + [rowWidth, isCompactLayout], + ); + const isInline = (action: SidebarFooterAction) => inline.includes(action); - // One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. + // One line of icons: Add project, Usage, Hosts, then Help and Settings at the end. Actions that + // don't fit move behind a caret before Settings. return ( - - - - + + {isInline("add-project") ? ( + + ) : null} + {isInline("usage") ? : null} + {isInline("hosts") ? ( + + ) : null} - - + {isInline("help") ? : null} + {overflow.length > 0 ? ( + + ) : null} + {isInline("settings") ? ( + + ) : null} @@ -937,10 +990,12 @@ const styles = StyleSheet.create((theme) => ({ borderTopColor: theme.colors.border, }, // Buttons sit edge to edge; their own inset spaces the glyphs. + // As tall as its buttons from the first frame, which places none until the row has measured. sidebarFooter: { flexDirection: "row", alignItems: "center", - paddingHorizontal: theme.spacing[2], + minHeight: mapLayout(density.iconButton, (height) => height + theme.spacing[1.5] * 2), + paddingHorizontal: FOOTER_ROW_PADDING, paddingVertical: theme.spacing[1.5], }, // Pushes Help and Settings to the end of the icon line. diff --git a/packages/app/src/components/sidebar-footer-layout.test.ts b/packages/app/src/components/sidebar-footer-layout.test.ts new file mode 100644 index 0000000000..b4ec0584f7 --- /dev/null +++ b/packages/app/src/components/sidebar-footer-layout.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + resolveSidebarFooterLayout, + SIDEBAR_FOOTER_ACTIONS, +} from "@/components/sidebar-footer-layout"; +import { DEFAULT_SIDEBAR_WIDTH, MIN_SIDEBAR_WIDTH } from "@/stores/panel-store"; + +const TOUCH = 44; +const POINTER = 28; +const ROW_PADDING = 8; + +function layout(rowWidth: number, controlSize: number) { + return resolveSidebarFooterLayout({ + rowWidth, + controlSize, + rowPadding: ROW_PADDING, + actions: SIDEBAR_FOOTER_ACTIONS, + }); +} + +describe("sidebar footer layout", () => { + it("keeps every action inline at the default width with either sizing", () => { + for (const controlSize of [TOUCH, POINTER]) { + expect(layout(DEFAULT_SIDEBAR_WIDTH, controlSize)).toEqual({ + inline: ["add-project", "usage", "hosts", "help", "settings"], + overflow: [], + }); + } + }); + + it("keeps every action inline at the minimum width with pointer sizing", () => { + expect(layout(MIN_SIDEBAR_WIDTH, POINTER).overflow).toEqual([]); + }); + + // The iPhone Duo report: the folded sidebar column is about the minimum width, and touch + // sizing makes five 44pt buttons plus padding 236pt wide. + it("moves Help and Usage behind the caret at the minimum width with touch sizing", () => { + expect(layout(MIN_SIDEBAR_WIDTH, TOUCH)).toEqual({ + inline: ["add-project", "hosts", "settings"], + overflow: ["usage", "help"], + }); + }); + + it("keeps Settings and Add project inline longest", () => { + // Three slots: two actions and the caret. + expect(layout(3 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({ + inline: ["add-project", "settings"], + overflow: ["usage", "hosts", "help"], + }); + // Two slots: Settings and the caret. + expect(layout(2 * TOUCH + 2 * ROW_PADDING, TOUCH)).toEqual({ + inline: ["settings"], + overflow: ["add-project", "usage", "hosts", "help"], + }); + }); + + it("puts everything behind the caret when only the caret fits", () => { + expect(layout(TOUCH + 2 * ROW_PADDING - 10, TOUCH)).toEqual({ + inline: [], + overflow: ["add-project", "usage", "hosts", "help", "settings"], + }); + }); + + it("fits all five actions exactly at their combined width", () => { + const exact = 5 * TOUCH + 2 * ROW_PADDING; + expect(layout(exact, TOUCH).overflow).toEqual([]); + // Browser layout rounds a little below the exact width. + expect(layout(exact - 0.4, TOUCH).overflow).toEqual([]); + expect(layout(exact - 1, TOUCH).overflow).toEqual(["usage", "help"]); + }); + + it("only places the actions it is given", () => { + expect( + resolveSidebarFooterLayout({ + rowWidth: 3 * TOUCH + 2 * ROW_PADDING, + controlSize: TOUCH, + rowPadding: ROW_PADDING, + actions: ["add-project", "hosts", "settings"], + }), + ).toEqual({ inline: ["add-project", "hosts", "settings"], overflow: [] }); + }); + + // Laying out against a guessed width would paint a clipped row for a frame on narrow sidebars. + it("places nothing before the row has been measured", () => { + for (const controlSize of [TOUCH, POINTER]) { + expect(layout(0, controlSize)).toEqual({ inline: [], overflow: [] }); + } + }); +}); diff --git a/packages/app/src/components/sidebar-footer-layout.ts b/packages/app/src/components/sidebar-footer-layout.ts new file mode 100644 index 0000000000..5513d59d09 --- /dev/null +++ b/packages/app/src/components/sidebar-footer-layout.ts @@ -0,0 +1,62 @@ +/** The sidebar footer's icon line, in row order. */ +export const SIDEBAR_FOOTER_ACTIONS = [ + "add-project", + "usage", + "hosts", + "help", + "settings", +] as const; + +export type SidebarFooterAction = (typeof SIDEBAR_FOOTER_ACTIONS)[number]; + +/** The order actions keep their place in a row too narrow for all of them. */ +const KEEP_ORDER: readonly SidebarFooterAction[] = [ + "settings", + "add-project", + "hosts", + "usage", + "help", +]; + +/** Measured widths land a hair under the exact sum of their parts. */ +const MEASUREMENT_TOLERANCE = 0.5; + +export interface SidebarFooterLayout { + /** Actions drawn in the row, in row order. */ + inline: SidebarFooterAction[]; + /** Actions behind the overflow caret, in row order. Empty when everything fits. */ + overflow: SidebarFooterAction[]; +} + +/** + * Splits the footer's actions between the row and the overflow menu. Buttons keep their full size; + * when they don't all fit, the caret takes one slot and the actions that lose their place move + * behind it, Help first and Settings last. Nothing is placed until the row has a measured width. + */ +export function resolveSidebarFooterLayout(input: { + rowWidth: number; + controlSize: number; + rowPadding: number; + actions: readonly SidebarFooterAction[]; +}): SidebarFooterLayout { + const { rowWidth, controlSize, rowPadding, actions } = input; + // An unmeasured row stays empty: any guess would paint a clipped row for a frame on a narrow + // sidebar before the real width arrives. + if (rowWidth <= 0) { + return { inline: [], overflow: [] }; + } + const slots = Math.floor( + (rowWidth - rowPadding * 2 + MEASUREMENT_TOLERANCE) / Math.max(controlSize, 1), + ); + if (slots >= actions.length) { + return { inline: [...actions], overflow: [] }; + } + const inlineCount = Math.max(slots - 1, 0); + const kept = new Set( + KEEP_ORDER.filter((action) => actions.includes(action)).slice(0, inlineCount), + ); + return { + inline: actions.filter((action) => kept.has(action)), + overflow: actions.filter((action) => !kept.has(action)), + }; +} diff --git a/packages/app/src/components/sidebar/sidebar-footer-menu-trigger.tsx b/packages/app/src/components/sidebar/sidebar-footer-menu-trigger.tsx new file mode 100644 index 0000000000..fd165efa79 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-footer-menu-trigger.tsx @@ -0,0 +1,69 @@ +import type { ReactElement } from "react"; +import { Text, View } from "react-native"; +import type { LucideIcon } from "lucide-react-native"; +import { StyleSheet } from "react-native-unistyles"; +import { DropdownMenuTrigger } from "@/components/ui/dropdown-menu"; +import { DensityIcon } from "@/components/ui/density-icon"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { density } from "@/styles/density"; +import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight"; + +/** + * A footer icon that opens a menu. Sized and drawn like the footer's other icon buttons; the + * tooltip stays out of the way while the menu is open. + */ +export function SidebarFooterMenuTrigger({ + icon, + label, + menuOpen, + testID, +}: { + icon: LucideIcon; + label: string; + menuOpen: boolean; + testID: string; +}): ReactElement { + return ( + + + + + {({ hovered, pressed, open }) => ( + <> + + + + )} + + + + + {label} + + + ); +} + +const styles = StyleSheet.create((theme) => ({ + trigger: { + width: density.iconButton, + height: density.iconButton, + alignItems: "center", + justifyContent: "center", + paddingVertical: theme.spacing[1], + paddingHorizontal: theme.spacing[1], + }, + tooltipText: { + fontSize: theme.fontSize.base, + color: theme.colors.popoverForeground, + }, +})); diff --git a/packages/app/src/components/sidebar/sidebar-footer-overflow-menu.tsx b/packages/app/src/components/sidebar/sidebar-footer-overflow-menu.tsx new file mode 100644 index 0000000000..aef13f7e28 --- /dev/null +++ b/packages/app/src/components/sidebar/sidebar-footer-overflow-menu.tsx @@ -0,0 +1,245 @@ +import { useCallback, useMemo, useState, type ReactElement } from "react"; +import { useTranslation } from "react-i18next"; +import { + ChevronUp, + CircleGauge, + CircleHelp, + FolderPlus, + Plus, + Server, + Settings, +} from "lucide-react-native"; +import { withUnistyles } from "react-native-unistyles"; +import { HostStatusDotSlot } from "@/components/hosts/host-picker"; +import type { SidebarFooterAction } from "@/components/sidebar-footer-layout"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuSubTrigger, + type MenuPageDefinition, +} from "@/components/ui/dropdown-menu"; +import { Shortcut } from "@/components/ui/shortcut"; +import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon"; +import { useShortcutKeys } from "@/hooks/use-shortcut-keys"; +import { useHosts } from "@/runtime/host-runtime"; +import { ICON_SIZE, type Theme } from "@/styles/theme"; +import { orderHostsLocalFirst } from "@/types/host-connection"; +import { useOpenSidebarUsage } from "@/usage"; +import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger"; +import { SidebarHelpMenuItems } from "./sidebar-help-menu"; + +const mutedIconMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted }); +const ThemedFolderPlus = withUnistyles(FolderPlus); +const ThemedCircleGauge = withUnistyles(CircleGauge); +const ThemedServer = withUnistyles(Server); +const ThemedCircleHelp = withUnistyles(CircleHelp); +const ThemedSettings = withUnistyles(Settings); +const ThemedPlus = withUnistyles(Plus); +const addProjectLeading = ; +const usageLeading = ; +const hostsLeading = ; +const helpLeading = ; +const settingsLeading = ; +const addHostLeading = ; + +const HOSTS_PAGE_ID = "hosts"; +const HELP_PAGE_ID = "help"; + +export interface SidebarFooterOverflowMenuProps { + /** The footer actions that did not fit in the row, in row order. */ + actions: readonly SidebarFooterAction[]; + labels: { addProject: string; hosts: string; settings: string; usage: string }; + onAddProject: () => void; + onSettings: () => void; + onAddHost: () => void; + onOpenHostSettings: (serverId: string) => void; +} + +/** + * The caret at the end of a footer row too narrow for every action. It lists the actions that + * lost their place, with the same test IDs they carry in the row; Hosts and Help, menus of their + * own in the row, open as pages of this menu. + */ +export function SidebarFooterOverflowMenu({ + actions, + labels, + onAddProject, + onSettings, + onAddHost, + onOpenHostSettings, +}: SidebarFooterOverflowMenuProps): ReactElement { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const openUsage = useOpenSidebarUsage(); + const newAgentKeys = useShortcutKeys("new-agent"); + const settingsKeys = useShortcutKeys("toggle-settings"); + const helpLabel = t("sidebar.help.trigger"); + const addProjectTrailing = useMemo( + () => (newAgentKeys ? : null), + [newAgentKeys], + ); + const settingsTrailing = useMemo( + () => (settingsKeys ? : null), + [settingsKeys], + ); + + const pages = useMemo((): MenuPageDefinition[] => { + const result: MenuPageDefinition[] = []; + if (actions.includes("hosts")) { + result.push({ + id: HOSTS_PAGE_ID, + title: labels.hosts, + content: , + }); + } + if (actions.includes("help")) { + result.push({ id: HELP_PAGE_ID, title: helpLabel, content: }); + } + return result; + }, [actions, helpLabel, labels.hosts, onAddHost, onOpenHostSettings]); + + function renderAction(action: SidebarFooterAction): ReactElement { + switch (action) { + case "add-project": + return ( + + {labels.addProject} + + ); + case "usage": + return ( + + {labels.usage} + + ); + case "hosts": + return ( + + {labels.hosts} + + ); + case "help": + return ( + + {helpLabel} + + ); + case "settings": + return ( + + {labels.settings} + + ); + } + } + + return ( + + + + {actions.map(renderAction)} + + + ); +} + +/** The host picker's choices as a menu page: each host opens its settings, then Add host. */ +function HostsPage({ + onAddHost, + onOpenHostSettings, +}: { + onAddHost: () => void; + onOpenHostSettings: (serverId: string) => void; +}): ReactElement { + const { t } = useTranslation(); + const hosts = useHosts(); + const localServerId = useLocalDaemonServerId(); + const orderedHosts = useMemo( + () => orderHostsLocalFirst(hosts, localServerId), + [hosts, localServerId], + ); + + return ( + <> + {orderedHosts.map((host) => ( + + ))} + {orderedHosts.length > 0 ? : null} + + {t("settings.addHost")} + + + ); +} + +function HostRow({ + serverId, + label, + onOpenHostSettings, +}: { + serverId: string; + label: string; + onOpenHostSettings: (serverId: string) => void; +}): ReactElement { + const handleSelect = useCallback( + () => onOpenHostSettings(serverId), + [onOpenHostSettings, serverId], + ); + const leading = useMemo(() => , [serverId]); + return ( + + {label} + + ); +} diff --git a/packages/app/src/components/sidebar/sidebar-help-menu.tsx b/packages/app/src/components/sidebar/sidebar-help-menu.tsx index 3ba3011517..d6ba4e98e1 100644 --- a/packages/app/src/components/sidebar/sidebar-help-menu.tsx +++ b/packages/app/src/components/sidebar/sidebar-help-menu.tsx @@ -1,5 +1,5 @@ import { useCallback, useState } from "react"; -import { Text, View } from "react-native"; +import { View } from "react-native"; import { Activity, CircleHelp, Gift, Keyboard } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { StyleSheet, withUnistyles } from "react-native-unistyles"; @@ -12,17 +12,13 @@ import { DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, - DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import { DensityIcon } from "@/components/ui/density-icon"; -import { SidebarFooterIconHighlight } from "./sidebar-footer-icon-highlight"; -import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { SidebarFooterMenuTrigger } from "./sidebar-footer-menu-trigger"; import { useAppDiagnosticStore } from "@/diagnostics/store"; import { useHardwareKeyboardPresent } from "@/keyboard"; import { useHostRuntimeIsConnected, useHosts } from "@/runtime/host-runtime"; import { useKeyboardShortcutsStore } from "@/stores/keyboard-shortcuts-store"; import { useSessionStore } from "@/stores/session-store"; -import { density } from "@/styles/density"; import { ICON_SIZE, type Theme } from "@/styles/theme"; import type { HostProfile } from "@/types/host-connection"; import { formatVersionWithPrefix } from "@/desktop/updates/desktop-updates"; @@ -77,12 +73,32 @@ function HostVersionHint({ host }: { host: HostProfile }) { ); } +/** The Help menu's trigger, and the menu it opens. */ export function SidebarHelpMenu() { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + + return ( + + + + + + + ); +} + +/** The Help menu's rows, shared by the Help menu and the footer's overflow page for it. */ +export function SidebarHelpMenuItems() { const { t } = useTranslation(); const shortcutsAvailable = useHardwareKeyboardPresent(); const openAppDiagnostic = useAppDiagnosticStore((state) => state.open); const setShortcutsDialogOpen = useKeyboardShortcutsStore((state) => state.setShortcutsDialogOpen); - const [open, setOpen] = useState(false); const version = formatVersionWithPrefix(resolveAppVersion()); const hosts = useHosts(); @@ -99,105 +115,65 @@ export function SidebarHelpMenu() { }, []); return ( - - - - - - {({ hovered, pressed, open: menuOpen }) => ( - <> - - - - )} - - - - - {t("sidebar.help.trigger")} - - - - {t("sidebar.help.sectionHelp")} - {shortcutsAvailable ? ( - - {t("sidebar.help.shortcuts")} - - ) : null} + <> + {t("sidebar.help.sectionHelp")} + {shortcutsAvailable ? ( - {t("sidebar.help.whatsNew")} + {t("sidebar.help.shortcuts")} - + {t("sidebar.help.whatsNew")} + + + {t("sidebar.help.diagnostics")} + + + {t("sidebar.help.reportIssue")} + + {t("sidebar.help.discord")} + + + {t("sidebar.help.github")} + + + + - {t("sidebar.help.diagnostics")} - - - {t("sidebar.help.reportIssue")} - - {t("sidebar.help.discord")} - - - {t("sidebar.help.github")} - - - - - {t("sidebar.help.appName")} - - {hosts.map((host) => ( - - ))} - - - + {t("sidebar.help.appName")} + + {hosts.map((host) => ( + + ))} + + ); } const styles = StyleSheet.create((theme) => ({ - trigger: { - width: density.iconButton, - height: density.iconButton, - alignItems: "center", - justifyContent: "center", - paddingVertical: theme.spacing[1], - paddingHorizontal: theme.spacing[1], - }, - tooltipText: { - fontSize: theme.fontSize.base, - color: theme.colors.popoverForeground, - }, versionList: { gap: theme.spacing[1], paddingVertical: theme.spacing[2], diff --git a/packages/app/src/components/ui/anchor.test.ts b/packages/app/src/components/ui/anchor.test.ts index cb18a86271..2e1cd6f68f 100644 --- a/packages/app/src/components/ui/anchor.test.ts +++ b/packages/app/src/components/ui/anchor.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { computePosition, getTransformOrigin, type Rect } from "./anchor"; +import { computePosition, fitSurfaceWidth, getTransformOrigin, type Rect } from "./anchor"; const DISPLAY: Rect = { x: 0, y: 0, width: 1000, height: 800 }; const TRIGGER: Rect = { x: 100, y: 100, width: 40, height: 20 }; @@ -97,3 +97,26 @@ describe("getTransformOrigin", () => { expect(getTransformOrigin(placement, alignment)).toEqual(expected); }); }); + +describe("fitSurfaceWidth", () => { + it("leaves a surface that fits the display alone, capped at the display", () => { + expect(fitSurfaceWidth({ width: 240, minWidth: 180 }, 1000)).toEqual({ + width: 240, + minWidth: 180, + maxWidth: 984, + }); + }); + + it("shrinks a surface wider than a narrow display to the space inside its edge padding", () => { + expect(fitSurfaceWidth({ width: 240, minWidth: 180, maxWidth: 320 }, 220)).toEqual({ + width: 204, + minWidth: 180, + maxWidth: 204, + }); + expect(fitSurfaceWidth({ minWidth: 180 }, 150)).toEqual({ + width: undefined, + minWidth: 134, + maxWidth: 134, + }); + }); +}); diff --git a/packages/app/src/components/ui/anchor.ts b/packages/app/src/components/ui/anchor.ts index 51454a9ee5..96a7518cba 100644 --- a/packages/app/src/components/ui/anchor.ts +++ b/packages/app/src/components/ui/anchor.ts @@ -120,6 +120,27 @@ function clampToDisplayArea(input: { }; } +export interface SurfaceWidth { + width?: number; + minWidth?: number; + maxWidth?: number; +} + +/** + * Caps a surface's requested width to the display. Position clamping can move a surface but not + * shrink it, so a menu wider than a narrow screen would otherwise hang off the far edge. + */ +export function fitSurfaceWidth(requested: SurfaceWidth, displayWidth: number): SurfaceWidth { + const available = Math.max(displayWidth - EDGE_PADDING * 2, 0); + const cap = (value: number | undefined) => + typeof value === "number" ? Math.min(value, available) : undefined; + return { + width: cap(requested.width), + minWidth: cap(requested.minWidth), + maxWidth: cap(requested.maxWidth) ?? available, + }; +} + export function computePosition({ triggerRect, contentSize, diff --git a/packages/app/src/components/ui/menu/menu-overlay.tsx b/packages/app/src/components/ui/menu/menu-overlay.tsx index 5bfcd9b5bf..c48fda7156 100644 --- a/packages/app/src/components/ui/menu/menu-overlay.tsx +++ b/packages/app/src/components/ui/menu/menu-overlay.tsx @@ -29,6 +29,7 @@ import type { KeyboardFocusScope } from "@/keyboard/actions"; import { getOverlayRoot, OverlayLayerProvider, useOverlayLayer } from "@/lib/overlay-root"; import { computePosition, + fitSurfaceWidth, getTransformOrigin, measureElement, type Alignment, @@ -283,11 +284,7 @@ export function AnchoredSurface({ const { width: screenWidth } = Dimensions.get("window"); const resolvedWidthStyle: ViewStyle = fullWidth ? { width: screenWidth - horizontalPadding * 2 } - : { - ...(typeof width === "number" ? { width } : null), - ...(typeof minWidth === "number" ? { minWidth } : null), - ...(typeof maxWidth === "number" ? { maxWidth } : null), - }; + : fitSurfaceWidth({ width, minWidth, maxWidth }, screenWidth); return [ resolvedWidthStyle, { diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 9d1ca6610d..f86d81b6e9 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -1236,6 +1236,7 @@ export const ar: TranslationResources = { hosts: "المضيفون", settings: "إعدادات", closeSidebar: "إغلاق الشريط الجانبي", + moreActions: "المزيد من الإجراءات", }, footer: { usage: "الاستخدام", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 3f3091ae4a..103a981abc 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -1244,6 +1244,7 @@ export const en = { hosts: "Hosts", settings: "Settings", closeSidebar: "Close sidebar", + moreActions: "More actions", }, footer: { usage: "Usage", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 73cc70a8ca..48737ee20e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -1272,6 +1272,7 @@ export const es: TranslationResources = { hosts: "Hosts", settings: "Ajustes", closeSidebar: "Cerrar barra lateral", + moreActions: "Más acciones", }, footer: { usage: "Uso", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 4257b799aa..0d2e4aabce 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -1266,6 +1266,7 @@ export const fr: TranslationResources = { hosts: "Hôtes", settings: "Paramètres", closeSidebar: "Fermer la barre latérale", + moreActions: "Plus d’actions", }, footer: { usage: "Utilisation", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 073f164e6b..ca93aa3106 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -1250,6 +1250,7 @@ export const ja: TranslationResources = { hosts: "ホスト", settings: "設定", closeSidebar: "サイドバーを閉じる", + moreActions: "その他のアクション", }, footer: { usage: "使用状況", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index cee2bb19bd..51ea225983 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -1243,6 +1243,7 @@ export const ko: TranslationResources = { hosts: "호스트", settings: "설정", closeSidebar: "사이드바 닫기", + moreActions: "추가 작업", }, footer: { usage: "사용량", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index a88a749709..5c2695f3bf 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -1262,6 +1262,7 @@ export const ptBR: TranslationResources = { hosts: "Hosts", settings: "Configurações", closeSidebar: "Fechar barra lateral", + moreActions: "Mais ações", }, footer: { usage: "Uso", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 3d8bd13196..ea8fb1eb25 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -1254,6 +1254,7 @@ export const ru: TranslationResources = { hosts: "Хосты", settings: "Настройки", closeSidebar: "Закрыть боковую панель", + moreActions: "Дополнительные действия", }, footer: { usage: "Использование", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f0a0c9bc5c..54454d9bff 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -1228,6 +1228,7 @@ export const zhCN: TranslationResources = { hosts: "Hosts", settings: "设置", closeSidebar: "关闭侧边栏", + moreActions: "更多操作", }, footer: { usage: "使用情况", From 6355b339c8f7ea0f2b4d0f55d315d6a03d7ef434 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 10:28:42 +0700 Subject: [PATCH 28/29] fix(app): paint a smaller send and stop disc inside the touch hit frame (#8891) Under touch sizing the composer's send and stop buttons filled their whole 44pt hit frame with color, so they outweighed every unpainted control in the toolbar. The trigger keeps the 44pt frame and now paints a 36pt disc (TOOLBAR_DISC_SIZE) centered inside it. Pointer sizing paints 28 inside 28, so desktop is unchanged. The voice button's recording fill follows the same rule. --- docs/design.md | 2 +- .../components/ui/control-geometry.test.ts | 7 ++ .../app/src/components/ui/control-geometry.ts | 8 +++ packages/app/src/composer/index.tsx | 12 +++- packages/app/src/composer/input/input.tsx | 65 ++++++++++++------- 5 files changed, 67 insertions(+), 27 deletions(-) diff --git a/docs/design.md b/docs/design.md index b48ed81891..e840e81bfa 100644 --- a/docs/design.md +++ b/docs/design.md @@ -68,7 +68,7 @@ the sidebar. Apply it as `backgroundColor`; setting `opacity` on the control als `destructive` is filled with `destructive`. It only appears inside a confirm. The button on the page is `outline`; the destructive button is the confirm button inside the dialog. -Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. +Controls come in two sizes, defined once in `control-geometry.ts`. The default is the right size almost everywhere — pages, dialogs, forms, settings rows, the setup flow — so don't pass a size. It is 32px tall where a pointer drives it and 44px where controls are touch-sized, so every touch control meets the iOS HIG's 44pt hit region. The hit region is not the painted area: a filled toolbar control such as the composer's send or stop keeps the 44pt frame and paints a 36pt disc inside it (`TOOLBAR_DISC_SIZE`), so it does not outweigh its unpainted neighbours. `sm` is 28px pointer and 32px touch, with `fontSize.sm` labels, and is for tight chrome only: toolbars, nested settings rows, dense inline row actions. A `size="sm"` on an ordinary page or dialog is wrong, and so is choosing a size from `useIsCompactFormFactor()`. One gate, `resolveControlSizing`, picks touch or pointer sizing: touch when the layout is compact or the device has a touch screen. An iPad with the sidebar docked is wide and touch-sized; a desktop is pointer-sized; a phone is both compact and touch. The touch-screen fact is read once at startup (`hasTouchScreen` in `hooks/use-fine-pointer.ts`), so attaching a mouse or keyboard to a tablet never shrinks its controls. Stylesheets take sizes from `bySizing({ touch, pointer })`, which resolves to a breakpoint value, so a caller never sees which sizing it got. Rows, icon buttons and icon slots take theirs from `styles/density.ts`, which uses the same gate. An `{ xs, md }` literal for a hit target or glyph is wrong: on an iPad it yields the pointer size. diff --git a/packages/app/src/components/ui/control-geometry.test.ts b/packages/app/src/components/ui/control-geometry.test.ts index 90b18d6029..9b0ef21308 100644 --- a/packages/app/src/components/ui/control-geometry.test.ts +++ b/packages/app/src/components/ui/control-geometry.test.ts @@ -14,6 +14,8 @@ import { getControlInteractionPhase, TOOLBAR_CONTROL_HEIGHT, TOOLBAR_CONTROL_SIZES, + TOOLBAR_DISC_SIZE, + TOOLBAR_DISC_SIZES, TOUCH_TARGET, } from "@/components/ui/control-geometry"; import type { Theme } from "@/styles/theme"; @@ -90,6 +92,11 @@ describe("control sizing gate", () => { // Composer toolbar controls: a full target on touch, unchanged 28 on pointer. expect(TOOLBAR_CONTROL_SIZES).toEqual({ touch: TOUCH_TARGET, pointer: 28 }); expect(TOOLBAR_CONTROL_HEIGHT).toEqual({ xs: TOUCH_TARGET, md: 28 }); + // A filled toolbar control paints a 36pt disc inside its 44pt touch frame, and fills its + // 28pt pointer frame exactly, so desktop is unchanged. Its glyph is the `md` glyph. + expect(TOOLBAR_DISC_SIZES).toEqual({ touch: 36, pointer: TOOLBAR_CONTROL_SIZES.pointer }); + expect(TOOLBAR_DISC_SIZE).toEqual({ xs: 36, md: 28 }); + expect(iconSizeFor("md")).toEqual({ touch: 20, pointer: 16 }); for (const size of ["sm", "md"] as const) { expect(controlHeightForLayout(size, true)).toBeGreaterThan( controlHeightForLayout(size, false), diff --git a/packages/app/src/components/ui/control-geometry.ts b/packages/app/src/components/ui/control-geometry.ts index 407c042cbe..ae552a4c78 100644 --- a/packages/app/src/components/ui/control-geometry.ts +++ b/packages/app/src/components/ui/control-geometry.ts @@ -93,6 +93,14 @@ export const ICON_BUTTON_SIZES = { export const TOOLBAR_CONTROL_SIZES = { touch: TOUCH_TARGET, pointer: 28 } satisfies SizingValue; export const TOOLBAR_CONTROL_HEIGHT = bySizing(TOOLBAR_CONTROL_SIZES); +/** + * The painted disc of a filled toolbar control, such as the composer's send and stop. The + * control keeps the full toolbar hit frame and paints this smaller disc centered inside it, so + * touch-sized it does not outweigh its unpainted neighbours. Pointer-sized it fills the frame. + */ +export const TOOLBAR_DISC_SIZES = { touch: 36, pointer: 28 } satisfies SizingValue; +export const TOOLBAR_DISC_SIZE = bySizing(TOOLBAR_DISC_SIZES); + /** * Glyph sizes. Pointer glyphs sit beside 13-14pt text; touch glyphs sit beside iOS's 17pt body * text, so each step is larger. diff --git a/packages/app/src/composer/index.tsx b/packages/app/src/composer/index.tsx index ad6961fe16..5401f77d73 100644 --- a/packages/app/src/composer/index.tsx +++ b/packages/app/src/composer/index.tsx @@ -35,6 +35,7 @@ import { controlSizingForLayout, iconSizeFor, TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_DISC_SIZE, } from "@/components/ui/control-geometry"; import { usePanelStore } from "@/stores/panel-store"; import { usePointerCapabilities } from "@/hooks/pointer-capabilities"; @@ -1117,7 +1118,7 @@ function ComposerCancelButton({ accessibilityRole="button" style={cancelButtonStyle} > - {icon} + {icon} @@ -2595,14 +2596,21 @@ const styles = StyleSheet.create((theme: Theme) => ({ width: "100%", gap: theme.spacing[3], }, + // The trigger is the hit frame and stays unpainted; the disc inside it carries the fill. cancelButton: { width: TOOLBAR_CONTROL_HEIGHT, height: TOOLBAR_CONTROL_HEIGHT, + alignItems: "center", + justifyContent: "center", + marginLeft: theme.spacing[1], + }, + cancelButtonDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, backgroundColor: theme.colors.palette.red[600], alignItems: "center", justifyContent: "center", - marginLeft: theme.spacing[1], }, rightControls: { flexDirection: "row", diff --git a/packages/app/src/composer/input/input.tsx b/packages/app/src/composer/input/input.tsx index a71469c626..be1f2c3ed0 100644 --- a/packages/app/src/composer/input/input.tsx +++ b/packages/app/src/composer/input/input.tsx @@ -33,6 +33,7 @@ import { controlSizingForLayout, iconSizeFor, TOOLBAR_CONTROL_HEIGHT, + TOOLBAR_DISC_SIZE, } from "@/components/ui/control-geometry"; import { ArrowUp, Mic, MicOff, CornerDownLeft, Plus, Square } from "lucide-react-native"; import * as Haptics from "expo-haptics"; @@ -349,7 +350,11 @@ function VoiceButtonIcon({ buttonIconSize: number; }) { if (isDictating) { - return ; + return ( + + + + ); } const colorMapping = hovered ? iconForegroundMapping : iconForegroundMutedMapping; if (isMutedRealtime) { @@ -837,12 +842,14 @@ function SendButtonTooltip({ testID={submitButtonTestID} style={sendButtonCombinedStyle} > - + + + @@ -1925,7 +1932,6 @@ export const MessageInput = forwardRef( styles.voiceButton, Boolean(hovered) && !isDictating && styles.iconButtonHovered, !isDictationStartEnabled && styles.buttonDisabled, - isDictating && styles.voiceButtonRecording, ], [isDictating, isDictationStartEnabled], ); @@ -1974,12 +1980,8 @@ export const MessageInput = forwardRef( allowEmpty: allowEmptySubmit, }); const sendButtonCombinedStyle = useMemo( - () => [ - styles.sendButton, - submitLabel ? styles.sendButtonLabeled : undefined, - isPresentedSendDisabled && styles.buttonDisabled, - ], - [isPresentedSendDisabled, submitLabel], + () => [styles.sendButton, isPresentedSendDisabled && styles.buttonDisabled], + [isPresentedSendDisabled], ); const overlayContainerStyle = useMemo( () => [styles.overlayContainer, { opacity: surfacePresentation.overlay.opacity }], @@ -2416,23 +2418,38 @@ const styles = StyleSheet.create((theme: Theme) => ({ alignItems: "center", justifyContent: "center", }, - voiceButtonRecording: { - backgroundColor: theme.colors.destructive, - }, - sendButton: { - width: TOOLBAR_CONTROL_HEIGHT, - height: TOOLBAR_CONTROL_HEIGHT, + voiceButtonRecordingDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, borderRadius: theme.borderRadius.full, - backgroundColor: theme.colors.accent, + backgroundColor: theme.colors.destructive, + alignItems: "center", + justifyContent: "center", + }, + // The trigger is the hit frame and stays unpainted; the disc inside it carries the fill. + sendButton: { + minWidth: TOOLBAR_CONTROL_HEIGHT, + height: TOOLBAR_CONTROL_HEIGHT, alignItems: "center", justifyContent: "center", marginLeft: theme.spacing[1], }, - sendButtonLabeled: { - width: "auto", - minWidth: TOOLBAR_CONTROL_HEIGHT, + sendButtonDisc: { + width: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, + borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", + }, + sendButtonDiscLabeled: { + minWidth: TOOLBAR_DISC_SIZE, + height: TOOLBAR_DISC_SIZE, paddingHorizontal: theme.spacing[3], borderRadius: theme.borderRadius.full, + backgroundColor: theme.colors.accent, + alignItems: "center", + justifyContent: "center", }, sendButtonLabel: { fontSize: theme.fontSize.base, From 5abfdf7107b5dabb885964de97dde8b55050613d Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Fri, 6 Nov 2026 10:55:56 +0700 Subject: [PATCH 29/29] Record workspace creation parentage (#8890) * feat(workspaces): record workspace creation parentage Retain the caller workspace as informational ancestry for future sidebar nesting, independently of workspace placement and lifecycle. * fix(app): preserve workspace parentage in replica cache --- docs/agent-lifecycle.md | 6 ++ .../src/runtime/replica-cache/index.test.ts | 20 +++++ .../app/src/runtime/replica-cache/index.ts | 2 + packages/app/src/stores/session-store.test.ts | 27 +++++++ packages/app/src/stores/session-store.ts | 2 + packages/protocol/src/messages.ts | 2 + .../protocol/src/messages.workspaces.test.ts | 20 +++++ .../src/server/agent/mcp-parity.e2e.test.ts | 35 ++++++++- .../src/server/agent/tools/paseo-tools.ts | 2 + packages/server/src/server/session.ts | 2 + .../workspace-provisioning-service.test.ts | 76 +++++++++++++++++++ .../workspace-provisioning-service.ts | 20 +++-- .../server/src/server/workspace-registry.ts | 2 + 13 files changed, 207 insertions(+), 9 deletions(-) diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 1d0b29b018..45c54ca18b 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -136,6 +136,12 @@ the marker. Closing a tab sets that client's label to `false`. Any `true` client open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a normal root agent immediately, and closing its still-open tab archives it. +Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`. +This is informational ancestry for future workspace grouping. It does not change placement, +visibility, status, or archive behavior. Archiving a parent workspace preserves its children's +records and parentage. Unscoped creation and existing workspaces have no parent; ancestry is +never inferred from directories, projects, or Git branches. + Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation reuses that workspace unless a new workspace is explicitly requested. Setup belongs to the workspace, including combined workspace-plus-agent creation and the supported agent `worktree` request. Run setup through the workspace workflow before launching configured terminals or scripts. Setup output is read in the Setup panel from per-command snapshots; never produce setup chat timeline rows or concatenate command logs. Legacy agent `git`/`worktreeName` inputs and hidden MCP worktree placement are rejected with workspace-creation guidance. Existing wire schemas retain an empty combined-log string for parsing compatibility. diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 0ea262f7cb..5c90ffa5e1 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -321,6 +321,26 @@ describe("ReplicaCache", () => { expect(restored?.range).toBeNull(); }); + it("preserves workspace parentage and its checkpoint across cache reopen", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + const baseline = directory({ workspaces: { generation: "g", afterSeq: 8 } }); + const workspace = normalizeWorkspaceDescriptor({ + ...workspacePayload(), + parentWorkspaceId: "parent", + }); + baseline.workspaces.set(workspace.id, workspace); + commitDirectory(writer, SERVER_ID, baseline); + await writer.flush(); + const reader = createCache(storage); + const restored = await reader.readDirectory(SERVER_ID); + expect(restored.checkpoint).toEqual(baseline.checkpoint); + expect(restored.workspaces.get(workspace.id)?.parentWorkspaceId).toBe("parent"); + expect((await reader.readWorkspace(SERVER_ID, workspace.id))?.workspace.parentWorkspaceId).toBe( + "parent", + ); + }); + it("preserves workspace sorting timestamps across cache reopen", async () => { const storage = new MemoryStorage(); const writer = createCache(storage); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index 1032a663bf..91a41e4186 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -299,6 +299,7 @@ const WorkspaceGitRuntimeSchema = z .optional(); const StoredWorkspaceSchema = z.strictObject({ + parentWorkspaceId: z.string().optional(), // Require both keys: a cursor without these values would preserve the wrong sort on reconnect. createdAt: IsoDateSchema.nullable(), lastUserMessageAt: IsoDateSchema.nullable(), @@ -707,6 +708,7 @@ function restoreWorkspace(stored: StoredWorkspace): WorkspaceDescriptor { function serializeWorkspace(workspace: WorkspaceDescriptor): StoredWorkspace { return { + parentWorkspaceId: workspace.parentWorkspaceId, createdAt: workspace.createdAt?.toISOString() ?? null, lastUserMessageAt: workspace.lastUserMessageAt?.toISOString() ?? null, id: workspace.id, diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index fb38605220..1b9052c4c1 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -557,6 +557,33 @@ describe("normalizeWorkspaceDescriptor", () => { expect(blank.workspaceDirectory).toBe(""); }); + it("preserves informational workspace parentage", () => { + const payload = { + id: "child", + parentWorkspaceId: "parent", + projectId: "1", + projectDisplayName: "Project 1", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "checkout", + name: "main", + archivingAt: null, + status: "done", + statusEnteredAt: null, + activityAt: null, + diffStat: null, + scripts: [], + } as WorkspaceDescriptorPayload; + + const workspace = normalizeWorkspaceDescriptor(payload); + + expect(workspace.parentWorkspaceId).toBe("parent"); + expect( + normalizeWorkspaceDescriptor({ ...payload, parentWorkspaceId: undefined }).parentWorkspaceId, + ).toBeUndefined(); + }); + it("defaults missing scripts to an empty array", () => { const payload = { id: "1", diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index b1312860a9..0f8a9f2476 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -105,6 +105,7 @@ export interface Agent { } export interface WorkspaceDescriptor { + parentWorkspaceId?: string; createdAt: Date | null; lastUserMessageAt: Date | null; id: string; @@ -162,6 +163,7 @@ export function normalizeWorkspaceDescriptor( projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, background: payload.background ?? false, + parentWorkspaceId: payload.parentWorkspaceId, name: payload.name, title: payload.title ?? null, pinnedAt: payload.pinnedAt ?? null, diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 64f37064e2..5436e1737d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -4189,6 +4189,8 @@ export const WorkspaceGitHubRuntimePayloadSchema = z export const WorkspaceDescriptorPayloadSchema = z .object({ + // Informational creation ancestry; independent of placement and lifecycle. + parentWorkspaceId: z.string().optional(), id: z.string(), projectId: z.string(), projectDisplayName: z.string(), diff --git a/packages/protocol/src/messages.workspaces.test.ts b/packages/protocol/src/messages.workspaces.test.ts index 3cb9b0d5bd..8a5bd13224 100644 --- a/packages/protocol/src/messages.workspaces.test.ts +++ b/packages/protocol/src/messages.workspaces.test.ts @@ -634,6 +634,26 @@ describe("workspace message schemas", () => { ]); }); + test("preserves informational workspace parentage in directory updates", () => { + const workspace = { + id: "child", + parentWorkspaceId: "parent", + projectId: "project", + projectDisplayName: "repo", + projectRootPath: "/repo", + projectKind: "git", + workspaceKind: "worktree", + name: "child", + status: "done", + activityAt: null, + }; + expect(WorkspaceDescriptorPayloadSchema.parse(workspace).parentWorkspaceId).toBe("parent"); + expect( + WorkspaceDescriptorPayloadSchema.parse({ ...workspace, parentWorkspaceId: undefined }) + .parentWorkspaceId, + ).toBeUndefined(); + }); + test("parses legacy workspace descriptors without workspaceDirectory", () => { const parsed = SessionOutboundMessageSchema.parse({ type: "workspace_update", diff --git a/packages/server/src/server/agent/mcp-parity.e2e.test.ts b/packages/server/src/server/agent/mcp-parity.e2e.test.ts index 80d6da20a7..d4b1b76af1 100644 --- a/packages/server/src/server/agent/mcp-parity.e2e.test.ts +++ b/packages/server/src/server/agent/mcp-parity.e2e.test.ts @@ -1171,7 +1171,7 @@ describe("Suite E: Workspace Tools", () => { .callTool({ name: "archive_workspace", args: { workspaceId } }) .catch(() => undefined); } - }); + }, 30_000); test("agent-scoped workspace creation inherits visibility and permits an explicit false override", async () => { const parentWorkspace = await callToolStructured(topLevelClient, "create_workspace", { @@ -1197,12 +1197,43 @@ describe("Suite E: Workspace Tools", () => { }); ownedWorkspaces.push(str(inherited.workspaceId)); expect(inherited.background).toBe(true); + expect(inherited.parentWorkspaceId).toBe(parentWorkspaceId); const visible = await callToolStructured(scoped, "create_workspace", { isolation: "local", background: false, }); ownedWorkspaces.push(str(visible.workspaceId)); expect(visible.background).toBe(false); + expect(visible.parentWorkspaceId).toBe(parentWorkspaceId); + const worktree = await callToolStructured(scoped, "create_workspace", { + isolation: "worktree", + branchName: "parentage-child", + baseBranch: "main", + background: false, + }); + ownedWorkspaces.push(str(worktree.workspaceId)); + expect(worktree.parentWorkspaceId).toBe(parentWorkspaceId); + const client = new DaemonClient({ url: `ws://127.0.0.1:${daemonHandle.port}/ws` }); + try { + await client.connect(); + const created = await client.createWorkspace({ + source: { kind: "directory", path: worktreeRepoCwd }, + callerAgentId: str(parent.agentId), + background: false, + }); + const workspaceId = str(created.workspace?.id); + ownedWorkspaces.push(workspaceId); + expect(created.workspace?.parentWorkspaceId).toBe(parentWorkspaceId); + const listed = await client.fetchWorkspaces(); + expect(listed.entries).toContainEqual( + expect.objectContaining({ + id: workspaceId, + parentWorkspaceId: parentWorkspaceId, + }), + ); + } finally { + await client.close(); + } } finally { await scoped?.close(); for (const workspaceId of ownedWorkspaces.toReversed()) @@ -1210,7 +1241,7 @@ describe("Suite E: Workspace Tools", () => { .callTool({ name: "archive_workspace", args: { workspaceId } }) .catch(() => undefined); } - }); + }, 30_000); test("a background agent's children and workspaces are background unless it overrides", async () => { const visibleAgentId = await createTopLevelAgent(); diff --git a/packages/server/src/server/agent/tools/paseo-tools.ts b/packages/server/src/server/agent/tools/paseo-tools.ts index 7b33d2e250..42b6ddec4c 100644 --- a/packages/server/src/server/agent/tools/paseo-tools.ts +++ b/packages/server/src/server/agent/tools/paseo-tools.ts @@ -188,6 +188,7 @@ interface ProviderSummary { } const WorkspaceAutomationSummarySchema = z.object({ + parentWorkspaceId: z.string().optional(), workspaceId: z.string(), projectId: z.string(), cwd: z.string(), @@ -206,6 +207,7 @@ function toWorkspaceAutomationSummary(workspace: PersistedWorkspaceRecord) { kind: workspace.kind, title: workspace.title, background: workspace.background, + parentWorkspaceId: workspace.parentWorkspaceId, }; } diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 3dec72f45f..339a7c17fb 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -5900,6 +5900,7 @@ export class Session { pinnedAt: workspace.pinnedAt, ...(workspace.labels && workspace.labels.length > 0 ? { labels: workspace.labels } : {}), background: workspace.background, + parentWorkspaceId: workspace.parentWorkspaceId, archivingAt: null, status: "done", statusEnteredAt: null, @@ -5988,6 +5989,7 @@ export class Session { projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, background: result.workspace.background, + parentWorkspaceId: result.workspace.parentWorkspaceId, name: resolveWorkspaceName({ title: result.workspace.title, derivedDisplayName: result.worktree.branchName || result.workspace.displayName, diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts index 012ad0d490..706f199114 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.test.ts @@ -205,6 +205,82 @@ test("fresh non-git directory creates a directory workspace at the exact path", expect(workspace.cwd).toBe(dir); }); +test("workspace parentage survives reload and parent archive without changing placement", async () => { + const cwd = path.join(tmpDir, "plain"); + const parent = await provisioning.createWorkspaceForDirectory(cwd); + const child = await provisioning.createWorkspaceForDirectory(cwd, null, undefined, { + callerWorkspaceId: parent.workspaceId, + background: false, + }); + const grandchild = await provisioning.createWorkspaceForDirectory( + path.join(tmpDir, "other"), + null, + undefined, + { + callerWorkspaceId: child.workspaceId, + }, + ); + expect(parent.parentWorkspaceId).toBeUndefined(); + expect(child).toMatchObject({ parentWorkspaceId: parent.workspaceId, cwd }); + expect(grandchild).toMatchObject({ parentWorkspaceId: child.workspaceId }); + await workspaceRegistry.archive(parent.workspaceId, ARCHIVED_AT); + const reloaded = new FileBackedWorkspaceRegistry( + path.join(tmpDir, "projects", "workspaces.json"), + logger, + ); + await reloaded.initialize(); + expect(await reloaded.get(child.workspaceId)).toMatchObject({ + parentWorkspaceId: parent.workspaceId, + archivedAt: null, + }); + expect(await reloaded.get(grandchild.workspaceId)).toMatchObject({ + parentWorkspaceId: child.workspaceId, + archivedAt: null, + }); +}); + +test("worktree workspace creation records its caller independently of its source project", async () => { + const sourceCwd = path.join(tmpDir, "repo"); + gitRoots.add(sourceCwd); + const parent = await provisioning.createWorkspaceForDirectory(path.join(tmpDir, "other")); + const child = await provisioning.createWorkspaceForWorktree({ + sourceCwd, + repoRoot: sourceCwd, + cwd: path.join(tmpDir, "worktree"), + worktreeRoot: path.join(tmpDir, "worktree"), + branch: "child", + baseBranch: "main", + title: null, + callerWorkspaceId: parent.workspaceId, + }); + expect(child.parentWorkspaceId).toBe(parent.workspaceId); + expect(child.projectId).not.toBe(parent.projectId); + expect(await workspaceRegistry.get(child.workspaceId)).toMatchObject({ + parentWorkspaceId: parent.workspaceId, + }); +}); + +test("workspace creation rejects an unknown caller even with explicit visibility", async () => { + await expect( + provisioning.createWorkspaceForDirectory(path.join(tmpDir, "plain"), null, undefined, { + callerWorkspaceId: "missing", + background: false, + }), + ).rejects.toThrow("Caller workspace missing not found"); +}); + +test("workspace creation cannot overwrite its caller with a self relationship", async () => { + const cwd = path.join(tmpDir, "plain"); + const parent = await provisioning.createWorkspaceForDirectory(cwd); + await expect( + provisioning.createWorkspaceForDirectory(cwd, null, undefined, { + callerWorkspaceId: parent.workspaceId, + workspaceId: parent.workspaceId, + }), + ).rejects.toThrow("A workspace cannot be its own parent"); + expect(await workspaceRegistry.get(parent.workspaceId)).toEqual(parent); +}); + test("background workspace creation preserves hooks, inheritance and durable visibility", async () => { const dir = path.join(tmpDir, "plain"); const events: string[] = []; diff --git a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts index 3f2a7b423c..5744c2ddeb 100644 --- a/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts +++ b/packages/server/src/server/session/workspace-provisioning/workspace-provisioning-service.ts @@ -227,15 +227,21 @@ export function createWorkspaceProvisioningService(deps: { return project; } - async function resolveBackground(input?: { + async function resolveCreationMetadata(input?: { + workspaceId?: string; background?: boolean; callerWorkspaceId?: string; - }): Promise { - if (input?.background !== undefined) return input.background; - if (!input?.callerWorkspaceId) return false; + }): Promise<{ background: boolean; parentWorkspaceId?: string }> { + if (!input?.callerWorkspaceId) return { background: input?.background ?? false }; + if (input.workspaceId === input.callerWorkspaceId) { + throw new Error("A workspace cannot be its own parent"); + } const callerWorkspace = await workspaceRegistry.get(input.callerWorkspaceId); if (!callerWorkspace) throw new Error(`Caller workspace ${input.callerWorkspaceId} not found`); - return callerWorkspace.background; + return { + background: input.background ?? callerWorkspace.background, + parentWorkspaceId: callerWorkspace.workspaceId, + }; } async function createWorkspaceForDirectory( @@ -269,7 +275,7 @@ export function createWorkspaceProvisioningService(deps: { title: title?.trim() || null, createdAt: timestamp, updatedAt: timestamp, - background: await resolveBackground(context), + ...(await resolveCreationMetadata(context)), }); await workspaceRegistry.upsert(workspace, { expectsInitialAgent: context?.expectsInitialAgent, @@ -310,7 +316,7 @@ export function createWorkspaceProvisioningService(deps: { createdAt: timestamp, updatedAt: timestamp, ...(input.untrustedSource ? { untrustedSource: input.untrustedSource } : {}), - background: await resolveBackground(input), + ...(await resolveCreationMetadata(input)), }); await workspaceRegistry.upsert(workspace, { expectsInitialAgent: input.expectsInitialAgent, diff --git a/packages/server/src/server/workspace-registry.ts b/packages/server/src/server/workspace-registry.ts index 351ba043e4..b337543c7e 100644 --- a/packages/server/src/server/workspace-registry.ts +++ b/packages/server/src/server/workspace-registry.ts @@ -49,6 +49,7 @@ const PersistedProjectRecordSchema = z.object({ }); const PersistedWorkspaceRecordSchema = z.object({ + parentWorkspaceId: z.string().optional(), workspaceId: z.string(), projectId: z.string(), cwd: z.string(), @@ -679,6 +680,7 @@ export function resolveProjectDisplayName(record: PersistedProjectRecord): strin } export function createPersistedWorkspaceRecord(input: { + parentWorkspaceId?: string; workspaceId: string; projectId: string; cwd: string;