From fe45cc62a277f8318a55e9d4c813a2213ac6098d Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sat, 31 Oct 2026 00:16:57 +0000 Subject: [PATCH 001/472] feat(web): add workspace panes and transitions --- apps/desktop/src/electron/ElectronMenu.ts | 5 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/preview.ts | 12 + apps/desktop/src/preload.ts | 14 + apps/desktop/src/preview/Manager.ts | 78 + .../src/window/DesktopApplicationMenu.ts | 6 +- apps/desktop/src/window/DesktopWindow.test.ts | 38 +- apps/desktop/src/window/DesktopWindow.ts | 21 +- apps/server/src/keybindings.test.ts | 36 +- apps/server/src/keybindings.ts | 17 +- apps/server/src/serverSettings.test.ts | 89 +- apps/server/src/serverSettings.ts | 35 +- apps/web/src/appInit.tsx | 2 + apps/web/src/browser/BrowserSurfaceSlot.tsx | 175 +- apps/web/src/browser/HostedBrowserWebview.tsx | 22 +- apps/web/src/browser/ServerBrowserSurface.tsx | 118 +- apps/web/src/browser/browserSurfaceStore.ts | 69 + .../web/src/browser/browserSurfaceTracking.ts | 105 + .../src/browser/hostedBrowserWebviewStyle.ts | 7 +- apps/web/src/commandPaletteBus.ts | 5 +- apps/web/src/commandPaletteStore.ts | 29 +- apps/web/src/components/AppSidebarLayout.tsx | 45 +- apps/web/src/components/BranchToolbar.tsx | 49 +- apps/web/src/components/ChatView.logic.ts | 43 - apps/web/src/components/ChatView.tsx | 575 ++--- ...ommandPalette.composition.browser.test.tsx | 5 +- apps/web/src/components/CommandPalette.tsx | 114 +- .../components/ComposerPromptEditorTiptap.tsx | 11 +- apps/web/src/components/ConfirmDialogHost.tsx | 24 +- .../src/components/ConnectionStatusDot.tsx | 75 - apps/web/src/components/DiffPanel.tsx | 172 +- apps/web/src/components/DiffPanelShell.tsx | 12 +- .../src/components/HistorySwipeNavigation.tsx | 4 +- apps/web/src/components/NoProjectsHero.tsx | 129 +- apps/web/src/components/PaneNotch.tsx | 148 +- .../web/src/components/PanelTabBreadcrumb.tsx | 53 + .../components/ReopenClosedViewShortcut.tsx | 24 +- .../RightPanelSheet.browser.test.tsx | 16 - apps/web/src/components/RightPanelSheet.tsx | 34 - apps/web/src/components/RightPanelTabs.tsx | 1075 +++++---- apps/web/src/components/Sidebar.motion.ts | 22 +- apps/web/src/components/Sidebar.tsx | 176 +- apps/web/src/components/SurfaceLauncher.tsx | 341 +++ .../ThreadNotificationCoordinator.tsx | 15 +- apps/web/src/components/ThreadRouteView.tsx | 52 +- apps/web/src/components/ThreadSurfaceTab.tsx | 1155 ++++++++++ .../ThreadTerminalDrawer.browser.test.tsx | 67 +- .../src/components/ThreadTerminalDrawer.tsx | 486 +++- ...rminals.tsx => ThreadTerminalSurfaces.tsx} | 399 ++-- .../src/components/WorkspacePageHeader.tsx | 11 +- .../chat/AssistantCitationSource.tsx | 7 +- apps/web/src/components/chat/ChatComposer.tsx | 21 +- .../chat/ChatHeader.browser.test.tsx | 2 + apps/web/src/components/chat/ChatHeader.tsx | 381 ++- .../src/components/chat/ChatPaneHeader.tsx | 142 ++ .../src/components/chat/ChatRightPanel.tsx | 59 - .../components/chat/ChatTerminalDrawers.tsx | 107 - .../components/chat/ComposerApprovalPanel.tsx | 38 +- .../src/components/chat/ComposerBanner.tsx | 3 +- .../chat/ComposerPendingUserInputPanel.tsx | 6 +- .../components/chat/ComposerRequestCard.tsx | 10 +- .../src/components/chat/ComposerStashMenu.tsx | 6 +- .../src/components/chat/DraftHeroHeadline.tsx | 375 +-- .../src/components/chat/MessagesTimeline.tsx | 29 +- .../web/src/components/chat/NoProjectChip.tsx | 42 + .../components/chat/NoProjectsComposer.tsx | 240 ++ apps/web/src/components/chat/OpenInPicker.tsx | 31 +- .../components/chat/OpenInPickerShortcut.ts | 4 + .../src/components/chat/PaneHeaderActions.tsx | 8 +- .../components/chat/PanelLayoutControls.tsx | 141 +- .../chat/RightPanelSurfaceContent.tsx | 2 + .../chat/ThreadContextDragGhost.tsx | 23 +- .../chat/ThreadRelationshipsControl.tsx | 395 +++- .../ThreadVisitWatermark.browser.test.tsx | 53 +- .../components/chat/ThreadVisitWatermark.tsx | 38 +- .../src/components/chat/TimelineMinimap.tsx | 2 +- .../components/chat/TimelineWorkEntryRow.tsx | 30 +- .../src/components/chat/threadContextDrag.ts | 23 +- .../chat/timelineScrollAnchoring.ts | 10 + .../src/components/chat/timelineViewport.ts | 2 + .../src/components/chat/useChatShortcuts.ts | 29 +- .../components/chat/useChatTerminalActions.ts | 145 +- .../src/components/chat/useComposerFocus.ts | 6 +- .../chat/useComposerMultilinePrompt.ts | 73 +- .../src/components/chat/useComposerStash.ts | 3 + .../chat/useFocusComposerOnThreadOpen.ts | 3 + .../chat/usePasteToFocusComposer.ts | 6 +- .../chat/useRestingComposerControlsLayout.ts | 11 +- .../components/chat/useRightPanelActions.ts | 213 +- .../chat/useThreadFindNavigation.ts | 8 +- .../chat/useThreadFindPositionReader.ts | 5 +- .../chat/useThreadLifecycle.browser.test.tsx | 38 +- .../components/chat/useThreadLifecycle.tsx | 6 +- .../src/components/chat/useTimelineFollow.ts | 11 +- .../web/src/components/connectionPhaseTone.ts | 23 + .../design-system/DesignSystemPage.tsx | 12 +- .../components/device/DeviceControlsRail.tsx | 13 +- .../components/device/DeviceHostUpdates.tsx | 16 +- .../device/DevicePanel.browser.test.tsx | 8 +- .../web/src/components/device/DevicePanel.tsx | 51 +- .../src/components/device/DeviceWorkspace.tsx | 2 +- .../src/components/device/phoneTrackpad.ts | 5 + .../components/diffs/AnnotatableCodeView.tsx | 13 +- .../src/components/diffs/DiffViewControls.tsx | 22 +- .../components/diffs/StyledDiffCodeView.tsx | 43 +- .../src/components/files/FileBreadcrumbs.tsx | 144 +- .../src/components/files/FileBrowserPanel.tsx | 232 +- .../src/components/files/FileExplorerPane.tsx | 8 +- .../src/components/files/FilePreviewPanel.tsx | 418 ++-- .../files/ReadOnlySourcePreview.tsx | 2 +- .../components/preview/PreviewEmptyState.tsx | 11 +- .../components/preview/PreviewPanelShell.tsx | 57 +- .../preview/PreviewView.browser.test.tsx | 28 + .../src/components/preview/PreviewView.tsx | 72 +- .../components/preview/addBrowserSurface.ts | 69 +- .../preview/agentOpenedPreviews.test.ts | 26 + .../components/preview/agentOpenedPreviews.ts | 51 +- .../components/preview/usePreviewSession.ts | 15 +- .../pullRequest/PullRequestActionsMenu.tsx | 10 + .../PullRequestCompactFilterMenu.tsx | 26 +- .../pullRequest/PullRequestDetailPanel.tsx | 9 + .../pullRequest/PullRequestListFilters.tsx | 46 +- .../pullRequest/PullRequestsColumn.tsx | 129 +- .../pullRequest/PullRequestsPage.tsx | 168 +- .../pullRequest/ThreadPullRequestsPanel.tsx | 11 +- .../pullRequest/usePullRequestListView.ts | 77 + .../settings/AddEnvironmentDialog.tsx | 2 +- ...nstanceDialog.environment.browser.test.tsx | 2 +- .../components/settings/CustomModelEditor.tsx | 6 +- .../settings/DiagnosticsSettings.tsx | 4 +- .../KeybindingsSettings.browser.test.tsx | 4 +- .../settings/KeybindingsSettings.logic.ts | 35 +- .../settings/KeybindingsSettings.tsx | 4 +- .../src/components/settings/LayoutEditor.tsx | 1103 +++++++++ .../components/settings/LayoutSettings.tsx | 359 +++ .../settings/LinkedEnvironmentsSettings.tsx | 18 +- .../settings/PanelAnimationsPreview.tsx | 232 +- .../settings/ProjectActionsList.tsx | 2 +- .../settings/ProjectSettingsPanel.tsx | 15 +- .../settings/ProviderInstanceCard.tsx | 5 +- .../settings/ProviderSettingsForm.tsx | 5 +- .../settings/ProviderSettingsPanel.tsx | 15 +- .../settings/SettingsScopeNotice.tsx | 5 +- .../settings/SettingsSidebarNav.tsx | 2 + .../components/settings/SettingsTabNotch.tsx | 115 + .../settings/SourceControlTokenSettings.tsx | 13 +- .../settings/TelemetryExportSettings.tsx | 20 +- .../components/settings/ThemeEditorPanel.tsx | 8 + .../src/components/settings/ThemeSettings.tsx | 10 +- .../components/settings/ThemeWireframe.tsx | 226 +- .../components/settings/settingsLayout.tsx | 20 +- .../components/settings/settingsScopeAxis.ts | 1 + .../settings/settingsScopeNavigation.test.ts | 25 + .../settings/settingsScopeNavigation.ts | 28 +- .../settings/settingsSearch.test.ts | 16 + .../src/components/settings/settingsSearch.ts | 50 + .../src/components/sidebar/SidebarChrome.tsx | 4 +- .../sidebar/SidebarProviderUpdatePill.tsx | 15 +- .../components/sidebar/SidebarThreadRow.tsx | 13 +- .../src/components/sidebar/mainAppLocation.ts | 30 +- apps/web/src/components/ui/alert.tsx | 10 +- apps/web/src/components/ui/autocomplete.tsx | 8 +- apps/web/src/components/ui/badge.tsx | 2 - apps/web/src/components/ui/button.tsx | 2 - apps/web/src/components/ui/combobox.tsx | 8 +- apps/web/src/components/ui/dialog.tsx | 10 +- apps/web/src/components/ui/input-group.tsx | 27 +- apps/web/src/components/ui/menu.tsx | 18 +- apps/web/src/components/ui/number-field.tsx | 26 +- apps/web/src/components/ui/part-presence.ts | 36 + apps/web/src/components/ui/sheet.tsx | 5 +- apps/web/src/components/ui/sidebar.tsx | 18 +- apps/web/src/components/ui/toast.tsx | 2 +- apps/web/src/components/ui/toggle-group.tsx | 26 +- apps/web/src/components/ui/toggle.tsx | 2 - apps/web/src/components/usage/UsagePage.tsx | 65 +- apps/web/src/composerDraftStore.test.ts | 42 + apps/web/src/composerDraftStore.ts | 42 +- apps/web/src/composerPlaceholder.ts | 3 + apps/web/src/contextMenuFallback.ts | 67 + apps/web/src/draftSendOnOpenStore.ts | 16 + apps/web/src/hooks/useHandleNewThread.test.ts | 1 + apps/web/src/hooks/useHandleNewThread.ts | 34 +- apps/web/src/hooks/usePaneTabStripFit.ts | 134 ++ apps/web/src/hooks/useRemoveClonedProject.ts | 2 + apps/web/src/hooks/useRevealActiveTab.ts | 37 + apps/web/src/hooks/useScratchProject.ts | 50 +- apps/web/src/hooks/useSettings.test.ts | 93 +- apps/web/src/hooks/useSettings.ts | 71 +- apps/web/src/hooks/useThreadActionMenu.ts | 33 +- .../useThreadActions.permissions.test.ts | 3 + apps/web/src/hooks/useThreadActions.ts | 26 +- .../src/hooks/useThreadActions.undo.test.ts | 6 +- apps/web/src/index.css | 387 +++- apps/web/src/keybindings.test.ts | 10 +- apps/web/src/keybindings.ts | 37 + apps/web/src/layout/ChatPaneStandIn.tsx | 92 + apps/web/src/layout/ColumnResizeHandle.tsx | 490 ++++ apps/web/src/layout/DropZones.tsx | 247 ++ apps/web/src/layout/LayoutHint.tsx | 230 ++ .../web/src/layout/LayoutShortcutsOverlay.tsx | 650 ++++++ apps/web/src/layout/NewTabLauncher.tsx | 464 ++++ apps/web/src/layout/Overview.tsx | 1333 +++++++++++ apps/web/src/layout/PageSurface.tsx | 46 + apps/web/src/layout/PaneSkeleton.tsx | 89 + apps/web/src/layout/PaneTabs.tsx | 571 +++++ apps/web/src/layout/PaneThumbnail.tsx | 651 ++++++ apps/web/src/layout/SurfaceHost.tsx | 2038 +++++++++++++++++ apps/web/src/layout/WorkspacePlane.tsx | 82 + apps/web/src/layout/chatRenderGate.ts | 112 + apps/web/src/layout/layoutActions.ts | 155 ++ apps/web/src/layout/layoutBehavior.ts | 46 + apps/web/src/layout/layoutCommands.ts | 452 ++++ apps/web/src/layout/layoutDrag.ts | 415 ++++ apps/web/src/layout/layoutModel.ts | 1763 ++++++++++++++ apps/web/src/layout/layoutOverlayStore.ts | 20 + apps/web/src/layout/layoutPaletteItems.tsx | 231 ++ apps/web/src/layout/layoutPaneContext.ts | 124 + apps/web/src/layout/layoutSettingsLeave.ts | 17 + apps/web/src/layout/layoutStore.ts | 560 +++++ apps/web/src/layout/layoutTemplate.ts | 385 ++++ apps/web/src/layout/openRules.ts | 259 +++ apps/web/src/layout/pageNavigation.tsx | 87 + apps/web/src/layout/settledByUser.ts | 22 + apps/web/src/layout/surfaceSync.ts | 667 ++++++ apps/web/src/layout/surfaceTabLabels.ts | 56 + apps/web/src/layout/threadSurfaceBridge.ts | 77 + .../useDraftSurfaceCleanupOnLayoutClose.ts | 28 + apps/web/src/layout/useIndexShowsLayout.ts | 17 + apps/web/src/layout/useLayoutRouteSync.ts | 267 +++ apps/web/src/layout/useLayoutSettingsSync.ts | 10 + apps/web/src/layout/useSyncLayoutBehavior.ts | 31 + apps/web/src/layout/useThreeFingerSwipe.ts | 40 + apps/web/src/lib/keybindingCapture.ts | 8 + apps/web/src/lib/modalDialog.ts | 16 + apps/web/src/lib/openLayers.ts | 2 +- apps/web/src/lib/runtime.test.ts | 3 +- apps/web/src/lib/terminalFocus.test.ts | 21 +- apps/web/src/lib/terminalFocus.ts | 9 +- .../src/lib/workspaceFocus.browser.test.ts | 202 ++ apps/web/src/lib/workspaceFocus.ts | 167 +- apps/web/src/motion/animation.ts | 192 ++ apps/web/src/motion/gesture.ts | 158 ++ apps/web/src/motion/paneMotion.ts | 557 +++++ apps/web/src/motion/registry.ts | 93 + apps/web/src/motion/spring.ts | 101 + apps/web/src/motion/stripCamera.ts | 99 + apps/web/src/motion/workspaceSwitch.ts | 536 +++++ apps/web/src/pierre-tree-theme.ts | 5 + apps/web/src/rightPanelStore.ts | 239 +- apps/web/src/routeTree.gen.ts | 21 + apps/web/src/routes/__root.tsx | 5 +- apps/web/src/routes/_chat.index.tsx | 107 +- apps/web/src/routes/_chat.pull-requests.tsx | 77 +- apps/web/src/routes/_chat.settings.layout.tsx | 11 + .../src/routes/_chat.settings.providers.tsx | 9 +- .../routes/_chat.settings.scheduled-tasks.tsx | 3 +- apps/web/src/routes/_chat.settings.tsx | 35 +- apps/web/src/routes/_chat.tsx | 22 +- .../chatRouteCommands.browser.test.ts | 148 +- apps/web/src/shortcuts/chatRouteCommands.ts | 93 +- .../src/shortcuts/commandPaletteCommands.ts | 6 +- apps/web/src/speech/useDictationShortcut.ts | 5 +- apps/web/src/state/device.browser.test.tsx | 3 +- .../state/entities.relationshipShells.test.ts | 13 +- apps/web/src/state/entities.ts | 11 +- apps/web/src/state/session.ts | 17 + apps/web/src/state/threads.ts | 7 +- apps/web/src/terminal/ghostty/surface.ts | 11 + apps/web/src/threadlessSurfaces.ts | 70 + docs/user/appearance.md | 11 +- docs/user/keybindings.md | 9 +- docs/user/layout.md | 44 + .../client-runtime/src/state/threadVisits.ts | 12 +- packages/contracts/src/ipc.ts | 38 + packages/contracts/src/keybindings.ts | 47 +- packages/contracts/src/server.test.ts | 25 +- packages/contracts/src/server.ts | 9 +- packages/contracts/src/settings.test.ts | 65 + packages/contracts/src/settings.ts | 113 +- packages/shared/src/keybindings.ts | 80 +- packages/shared/src/preview.ts | 14 + patches/@legendapp__list@3.7.2.patch | 114 + patches/@pierre%2Ftrees@1.0.0-beta.6.patch | 32 + pnpm-lock.yaml | 15 +- pnpm-workspace.yaml | 6 +- vite.config.ts | 143 +- 287 files changed, 27467 insertions(+), 3808 deletions(-) create mode 100644 apps/web/src/browser/browserSurfaceTracking.ts delete mode 100644 apps/web/src/components/ConnectionStatusDot.tsx create mode 100644 apps/web/src/components/PanelTabBreadcrumb.tsx delete mode 100644 apps/web/src/components/RightPanelSheet.browser.test.tsx delete mode 100644 apps/web/src/components/RightPanelSheet.tsx create mode 100644 apps/web/src/components/SurfaceLauncher.tsx create mode 100644 apps/web/src/components/ThreadSurfaceTab.tsx rename apps/web/src/components/{PersistentThreadTerminals.tsx => ThreadTerminalSurfaces.tsx} (51%) create mode 100644 apps/web/src/components/chat/ChatPaneHeader.tsx delete mode 100644 apps/web/src/components/chat/ChatRightPanel.tsx delete mode 100644 apps/web/src/components/chat/ChatTerminalDrawers.tsx create mode 100644 apps/web/src/components/chat/NoProjectChip.tsx create mode 100644 apps/web/src/components/chat/NoProjectsComposer.tsx create mode 100644 apps/web/src/components/connectionPhaseTone.ts create mode 100644 apps/web/src/components/settings/LayoutEditor.tsx create mode 100644 apps/web/src/components/settings/LayoutSettings.tsx create mode 100644 apps/web/src/components/settings/SettingsTabNotch.tsx create mode 100644 apps/web/src/components/ui/part-presence.ts create mode 100644 apps/web/src/draftSendOnOpenStore.ts create mode 100644 apps/web/src/hooks/usePaneTabStripFit.ts create mode 100644 apps/web/src/hooks/useRevealActiveTab.ts create mode 100644 apps/web/src/layout/ChatPaneStandIn.tsx create mode 100644 apps/web/src/layout/ColumnResizeHandle.tsx create mode 100644 apps/web/src/layout/DropZones.tsx create mode 100644 apps/web/src/layout/LayoutHint.tsx create mode 100644 apps/web/src/layout/LayoutShortcutsOverlay.tsx create mode 100644 apps/web/src/layout/NewTabLauncher.tsx create mode 100644 apps/web/src/layout/Overview.tsx create mode 100644 apps/web/src/layout/PageSurface.tsx create mode 100644 apps/web/src/layout/PaneSkeleton.tsx create mode 100644 apps/web/src/layout/PaneTabs.tsx create mode 100644 apps/web/src/layout/PaneThumbnail.tsx create mode 100644 apps/web/src/layout/SurfaceHost.tsx create mode 100644 apps/web/src/layout/WorkspacePlane.tsx create mode 100644 apps/web/src/layout/chatRenderGate.ts create mode 100644 apps/web/src/layout/layoutActions.ts create mode 100644 apps/web/src/layout/layoutBehavior.ts create mode 100644 apps/web/src/layout/layoutCommands.ts create mode 100644 apps/web/src/layout/layoutDrag.ts create mode 100644 apps/web/src/layout/layoutModel.ts create mode 100644 apps/web/src/layout/layoutOverlayStore.ts create mode 100644 apps/web/src/layout/layoutPaletteItems.tsx create mode 100644 apps/web/src/layout/layoutPaneContext.ts create mode 100644 apps/web/src/layout/layoutSettingsLeave.ts create mode 100644 apps/web/src/layout/layoutStore.ts create mode 100644 apps/web/src/layout/layoutTemplate.ts create mode 100644 apps/web/src/layout/openRules.ts create mode 100644 apps/web/src/layout/pageNavigation.tsx create mode 100644 apps/web/src/layout/settledByUser.ts create mode 100644 apps/web/src/layout/surfaceSync.ts create mode 100644 apps/web/src/layout/surfaceTabLabels.ts create mode 100644 apps/web/src/layout/threadSurfaceBridge.ts create mode 100644 apps/web/src/layout/useDraftSurfaceCleanupOnLayoutClose.ts create mode 100644 apps/web/src/layout/useIndexShowsLayout.ts create mode 100644 apps/web/src/layout/useLayoutRouteSync.ts create mode 100644 apps/web/src/layout/useLayoutSettingsSync.ts create mode 100644 apps/web/src/layout/useSyncLayoutBehavior.ts create mode 100644 apps/web/src/layout/useThreeFingerSwipe.ts create mode 100644 apps/web/src/lib/keybindingCapture.ts create mode 100644 apps/web/src/lib/modalDialog.ts create mode 100644 apps/web/src/motion/animation.ts create mode 100644 apps/web/src/motion/gesture.ts create mode 100644 apps/web/src/motion/paneMotion.ts create mode 100644 apps/web/src/motion/registry.ts create mode 100644 apps/web/src/motion/spring.ts create mode 100644 apps/web/src/motion/stripCamera.ts create mode 100644 apps/web/src/motion/workspaceSwitch.ts create mode 100644 apps/web/src/routes/_chat.settings.layout.tsx create mode 100644 apps/web/src/threadlessSurfaces.ts create mode 100644 docs/user/layout.md create mode 100644 patches/@pierre%2Ftrees@1.0.0-beta.6.patch diff --git a/apps/desktop/src/electron/ElectronMenu.ts b/apps/desktop/src/electron/ElectronMenu.ts index 540326346edf8..a9a13204c7801 100644 --- a/apps/desktop/src/electron/ElectronMenu.ts +++ b/apps/desktop/src/electron/ElectronMenu.ts @@ -84,6 +84,9 @@ function normalizeContextMenuItems(source: readonly ContextMenuItem[]): ContextM disabled: sourceItem.disabled === true, ...(sourceItem.separatorBefore === true ? { separatorBefore: true } : {}), ...(typeof sourceItem.checked === "boolean" ? { checked: sourceItem.checked } : {}), + ...(typeof sourceItem.accelerator === "string" + ? { accelerator: sourceItem.accelerator } + : {}), }; if (sourceItem.children) { @@ -171,6 +174,8 @@ export const make = Effect.gen(function* () { label: item.label, enabled: !item.disabled, ...(typeof item.checked === "boolean" ? { type: "checkbox", checked: item.checked } : {}), + // Shown beside the label for learning; the app's own handler runs the key. + ...(item.accelerator ? { accelerator: item.accelerator, registerAccelerator: false } : {}), }; if (item.children && item.children.length > 0) { itemOption.submenu = buildTemplate(item.children, complete); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index f80b66b1bcbc7..96f2ba891491d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -126,6 +126,8 @@ export const PREVIEW_RECORDING_STOP_CHANNEL = "desktop:preview-recording-stop"; export const PREVIEW_RECORDING_SAVE_CHANNEL = "desktop:preview-recording-save"; export const PREVIEW_STATE_CHANGE_CHANNEL = "desktop:preview-state-change"; export const PREVIEW_POINTER_EVENT_CHANNEL = "desktop:preview-pointer-event"; +export const PREVIEW_SET_FORWARDED_KEY_CHORDS_CHANNEL = "desktop:preview-set-forwarded-key-chords"; +export const PREVIEW_FORWARDED_KEY_CHORD_CHANNEL = "desktop:preview-forwarded-key-chord"; export const PREVIEW_OPEN_LINK_CHANNEL = "desktop:preview-open-link"; export const PREVIEW_FIND_RESULT_CHANNEL = "desktop:preview-find-result"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 757e1e463f538..51fc579d2c8ff 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -1,6 +1,7 @@ import { DesktopPreviewAnnotationSendEnabledInputSchema, DesktopPreviewAnnotationThemeInputSchema, + DesktopPreviewForwardedShortcutsInputSchema, DesktopPreviewAnnotationVoiceStateSchema, DesktopPreviewPickElementInputSchema, DesktopPreviewArtifactInputSchema, @@ -402,6 +403,16 @@ const setAnnotationTheme = DesktopIpc.makeIpcMethod({ }), }); +const setForwardedKeyChords = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_FORWARDED_KEY_CHORDS_CHANNEL, + payload: DesktopPreviewForwardedShortcutsInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setForwardedKeyChords")(function* ({ shortcuts }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setForwardedKeyChords(shortcuts); + }), +}); + const pickElement = DesktopIpc.makeIpcMethod({ channel: IpcChannels.PREVIEW_PICK_ELEMENT_CHANNEL, payload: DesktopPreviewPickElementInputSchema, @@ -504,6 +515,7 @@ export const methods = [ clearCache, getPreviewConfig, setAnnotationTheme, + setForwardedKeyChords, setAnnotationSendEnabled, pickElement, cancelPickElement, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 3005c0dff4b34..4f1623226def6 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -438,6 +438,20 @@ contextBridge.exposeInMainWorld("desktopBridge", { ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_CACHE_CHANNEL, { environmentId, profileId }), getPreviewConfig: (environmentId, profileId) => ipcRenderer.invoke(IpcChannels.PREVIEW_GET_CONFIG_CHANNEL, { environmentId, profileId }), + setForwardedKeyChords: (shortcuts) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_FORWARDED_KEY_CHORDS_CHANNEL, { shortcuts }), + onForwardedKeyChord: (listener) => { + const wrappedListener = (_event: Electron.IpcRendererEvent, input: unknown) => { + if (typeof input !== "object" || input === null) return; + listener(input as Parameters[0]); + }; + ipcRenderer.on(IpcChannels.PREVIEW_FORWARDED_KEY_CHORD_CHANNEL, wrappedListener); + return () => + ipcRenderer.removeListener( + IpcChannels.PREVIEW_FORWARDED_KEY_CHORD_CHANNEL, + wrappedListener, + ); + }, setAnnotationTheme: (theme) => ipcRenderer.invoke(IpcChannels.PREVIEW_SET_ANNOTATION_THEME_CHANNEL, { theme }), setAnnotationSendEnabled: (tabId, enabled) => diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 32ac876996c81..3ac6645e3d4ed 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -7,6 +7,7 @@ import { DESKTOP_PREVIEW_RECORDING_CAPTURE_TRIGGER, } from "@t3tools/contracts"; import type { + DesktopForwardedShortcut, DesktopPreviewAnnotationTheme, DesktopPreviewAnnotationVoiceConfig, DesktopPreviewAnnotationVoiceState, @@ -67,6 +68,7 @@ import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts" import { MENU_ACTION_CHANNEL, PREVIEW_ANNOTATION_VOICE_EVENT_CHANNEL, + PREVIEW_FORWARDED_KEY_CHORD_CHANNEL, PREVIEW_PICTURE_IN_PICTURE_FRAME_CHANNEL, } from "../ipc/channels.ts"; import * as DesktopBrowserHost from "./DesktopBrowserHost.ts"; @@ -506,6 +508,49 @@ export const previewWindowOpenAction = (details: { : "navigate"; }; +const FORWARDED_SHORTCUT_CODE_KEYS: Readonly> = { + BracketLeft: "[", + BracketRight: "]", + Backslash: "\\", + Comma: ",", + Period: ".", + Slash: "/", + Semicolon: ";", + Quote: "'", + Backquote: "`", + Minus: "-", + Equal: "=", +}; + +/** + * The keybinding key token for a key press: letters and digits by physical + * position (Option on macOS changes the typed character, e.g. to "®"), + * named keys lowercased ("arrowleft", "home"). + */ +const forwardedShortcutKey = (input: Electron.Input): string => { + const letter = input.code.match(/^Key([A-Z])$/)?.[1]; + if (letter) return letter.toLowerCase(); + const digit = input.code.match(/^Digit([0-9])$/)?.[1]; + if (digit) return digit; + return FORWARDED_SHORTCUT_CODE_KEYS[input.code] ?? input.key.toLowerCase(); +}; + +const isForwardedShortcut = ( + input: Electron.Input, + shortcuts: ReadonlyArray, +): boolean => { + if (input.type !== "keyDown" || shortcuts.length === 0) return false; + const key = forwardedShortcutKey(input); + return shortcuts.some( + (shortcut) => + shortcut.key === key && + shortcut.metaKey === input.meta && + shortcut.ctrlKey === input.control && + shortcut.altKey === input.alt && + shortcut.shiftKey === input.shift, + ); +}; + const isPreviewRefreshShortcut = (input: Electron.Input, platform: NodeJS.Platform): boolean => input.type === "keyDown" && input.key.toLowerCase() === "r" && @@ -589,6 +634,9 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function new Set(), ); const pickSessionsRef = yield* Ref.make>(new Map()); + // Read synchronously in before-input-event, which must decide on the spot + // whether the page sees a key, so a plain variable rather than a Ref. + let forwardedKeyChords: ReadonlyArray = []; const controlSessionsRef = yield* SynchronizedRef.make< ReadonlyMap >(new Map()); @@ -1613,6 +1661,27 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function } return; } + // App chords (the layout map) go to the app window, not the page. + if (isForwardedShortcut(input, forwardedKeyChords)) { + event.preventDefault(); + const forwardedKey = { + key: input.key, + code: input.code, + metaKey: input.meta, + ctrlKey: input.control, + altKey: input.alt, + shiftKey: input.shift, + }; + runFork( + Ref.get(mainWindowRef).pipe( + Effect.map((window) => { + if (Option.isNone(window) || window.value.isDestroyed()) return; + window.value.webContents.send(PREVIEW_FORWARDED_KEY_CHORD_CHANNEL, forwardedKey); + }), + ), + ); + return; + } if (isPreviewRefreshShortcut(input, hostPlatform)) { event.preventDefault(); runFork( @@ -3730,6 +3799,10 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function setShown, setCaptureDrawn, setColorScheme, + setForwardedKeyChords: (shortcuts: ReadonlyArray) => + Effect.sync(() => { + forwardedKeyChords = shortcuts; + }), setMainWindow, setForwardedShortcuts: (shortcuts: ReadonlyArray) => Effect.sync(() => { @@ -3965,6 +4038,10 @@ export class PreviewManager extends Context.Service< tabId: string, enabled: boolean, ) => Effect.Effect; + /** Chords a focused preview webview hands back to the app window. */ + readonly setForwardedKeyChords: ( + shortcuts: ReadonlyArray, + ) => Effect.Effect; readonly pickElement: ( tabId: string, voice?: DesktopPreviewAnnotationVoiceConfig, @@ -4138,6 +4215,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { }, ), setAnnotationTheme: operations.setAnnotationTheme, + setForwardedKeyChords: operations.setForwardedKeyChords, setAnnotationSendEnabled: operations.setAnnotationSendEnabled, pickElement: operations.pickElement, updateAnnotationVoice: operations.updateAnnotationVoice, diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts index b4025ebfde8ef..3b001e9543dd5 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.ts @@ -210,7 +210,8 @@ export const make = Effect.gen(function* () { { role: "services" }, { type: "separator" }, { role: "hide", label: `Hide ${environment.displayName}` }, - { role: "hideOthers" }, + // alt+cmd+H focuses the pane on the left; Hide Others keeps its item. + { role: "hideOthers", accelerator: "" }, { role: "unhide" }, { type: "separator" }, { role: "quit", label: `Quit ${environment.displayName}` }, @@ -287,9 +288,10 @@ export const make = Effect.gen(function* () { accelerator: "Shift+CmdOrCtrl+R", click: mainContentsClick("forceReload"), }, + // alt+cmd+I and J belong to the layout keys on macOS. { label: "Toggle Developer Tools", - accelerator: environment.platform === "darwin" ? "Alt+Command+I" : "Ctrl+Shift+I", + accelerator: environment.platform === "darwin" ? "Alt+Shift+Command+I" : "Ctrl+Shift+I", click: mainContentsClick("toggleDevTools"), }, { type: "separator" }, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index e58c5c01f29f1..0c30a51758c9d 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -1,7 +1,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, describe, it } from "@effect/vitest"; import * as Deferred from "effect/Deferred"; -import { DesktopSnapShotId } from "@t3tools/contracts"; +import { DEFAULT_CLIENT_SETTINGS, DesktopSnapShotId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Fiber from "effect/Fiber"; @@ -46,7 +46,7 @@ import * as ElectronMenu from "../electron/ElectronMenu.ts"; import * as ElectronShell from "../electron/ElectronShell.ts"; import * as ElectronTheme from "../electron/ElectronTheme.ts"; import * as ElectronWindow from "../electron/ElectronWindow.ts"; -import { SNAP_SHOT_EVENT_CHANNEL } from "../ipc/channels.ts"; +import { MENU_ACTION_CHANNEL, SNAP_SHOT_EVENT_CHANNEL } from "../ipc/channels.ts"; import * as DesktopServerExposure from "../backend/DesktopServerExposure.ts"; import * as DesktopWindow from "./DesktopWindow.ts"; import { @@ -163,9 +163,7 @@ function makeFakeBrowserWindow() { }; } -const layerDesktopClientSettings = Layer.mock(DesktopClientSettings.DesktopClientSettings)({ - get: Effect.succeedNone, -}); +const layerDesktopClientSettings = DesktopClientSettings.layerTest(); const layerElectronApp = Layer.mock(ElectronApp.ElectronApp)({ quit: Effect.void, @@ -380,6 +378,7 @@ function layerTest(input: { ), ), Layer.merge(DesktopState.layer), + Layer.merge(layerDesktopClientSettings), ); } @@ -1340,18 +1339,35 @@ describe("DesktopWindow", () => { if (!swipe) return; const history = fakeWindow.navigationHistory; - swipe({}, "left"); + yield* Effect.promise(() => Promise.resolve(swipe({}, "left"))); assert.equal(history.goBack.mock.calls.length, 1); assert.equal(history.goForward.mock.calls.length, 0); - swipe({}, "right"); + yield* Effect.promise(() => Promise.resolve(swipe({}, "right"))); assert.equal(history.goForward.mock.calls.length, 1); - swipe({}, "up"); - swipe({}, "down"); + yield* Effect.promise(() => Promise.resolve(swipe({}, "up"))); + yield* Effect.promise(() => Promise.resolve(swipe({}, "down"))); history.canGoBack.mockReturnValue(false); history.canGoForward.mockReturnValue(false); - swipe({}, "left"); - swipe({}, "right"); + yield* Effect.promise(() => Promise.resolve(swipe({}, "left"))); + yield* Effect.promise(() => Promise.resolve(swipe({}, "right"))); + assert.equal(history.goBack.mock.calls.length, 1); + assert.equal(history.goForward.mock.calls.length, 1); + assert.equal( + fakeWindow.send.mock.calls.filter(([channel]) => channel === MENU_ACTION_CHANNEL).length, + 0, + ); + const settings = yield* DesktopClientSettings.DesktopClientSettings; + yield* settings.set({ ...DEFAULT_CLIENT_SETTINGS, layoutThreeFingerSwipe: true }); + history.canGoBack.mockReturnValue(true); + history.canGoForward.mockReturnValue(true); + for (const direction of ["left", "right", "up", "down"]) { + yield* Effect.promise(() => Promise.resolve(swipe({}, direction))); + assert.deepEqual(fakeWindow.send.mock.lastCall, [ + MENU_ACTION_CHANNEL, + `layout-swipe-${direction}`, + ]); + } assert.equal(history.goBack.mock.calls.length, 1); assert.equal(history.goForward.mock.calls.length, 1); }).pipe(Effect.provide(layer)); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index 3919259a607ad..6345d97c268a6 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -834,11 +834,22 @@ export const make = Effect.gen(function* () { if (environment.platform === "darwin") { // Mouse drivers can deliver Back/Forward as macOS swipe gestures instead of buttons. - window.on("swipe", (_event, direction) => { - const history = window.webContents.navigationHistory; - if (direction === "left" && history.canGoBack()) history.goBack(); - else if (direction === "right" && history.canGoForward()) history.goForward(); - }); + window.on("swipe", (_event, direction) => + runPromise( + Effect.gen(function* () { + const settings = yield* clientSettings.get.pipe( + Effect.orElseSucceed(() => Option.none()), + ); + if (Option.isSome(settings) && settings.value.layoutThreeFingerSwipe) { + window.webContents.send(MENU_ACTION_CHANNEL, `layout-swipe-${direction}`); + return; + } + const history = window.webContents.navigationHistory; + if (direction === "left" && history.canGoBack()) history.goBack(); + else if (direction === "right" && history.canGoForward()) history.goForward(); + }), + ), + ); window.on("enter-full-screen", () => { window.webContents.send(WINDOW_FULLSCREEN_STATE_CHANNEL, true); }); diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index 7bff1a6823df1..812cc979b394d 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -340,7 +340,9 @@ it.layer(NodeServices.layer)("keybindings", (it) => { }); const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); - assert.isFalse(persisted.some((entry) => entry.command === "terminal.toggle")); + assert.isFalse( + persisted.some((entry) => entry.command === "terminal.toggle" && entry.key === "mod+j"), + ); assert.isTrue(persisted.some((entry) => entry.command === "script.custom-action.run")); assert.isTrue( @@ -355,6 +357,38 @@ it.layer(NodeServices.layer)("keybindings", (it) => { ); }); + it.effect("drops a retired default so its replacement takes the key", () => + Effect.gen(function* () { + const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; + yield* writeKeybindingsConfig(keybindingsConfigPath, [ + { key: "mod+d", command: "diff.toggle", when: "!terminalFocus" }, + { key: "mod+shift+d", command: "diff.toggle" }, + { key: "mod+w", command: "rightPanel.close", when: "(!terminalFocus)" }, + ]); + + yield* Effect.gen(function* () { + const keybindings = yield* Keybindings.Keybindings; + yield* keybindings.syncDefaultKeybindingsOnStartup; + }); + + const persisted = yield* readKeybindingsConfig(keybindingsConfigPath); + assert.isFalse( + persisted.some((entry) => entry.command === "diff.toggle" && entry.key === "mod+d"), + ); + assert.isTrue( + persisted.some((entry) => entry.command === "diff.toggle" && entry.key === "mod+shift+d"), + ); + assert.isTrue( + persisted.some((entry) => entry.command === "layout.splitRight" && entry.key === "mod+d"), + ); + assert.deepInclude(persisted, { + key: "mod+w", + command: "rightPanel.close", + when: "(!terminalFocus)", + }); + }).pipe(Effect.provide(layerKeybindings())), + ); + it.effect("appends additional custom keybindings for the same command", () => Effect.gen(function* () { const { keybindingsConfigPath } = yield* ServerConfig.ServerConfig; diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 49a7d85d4fa36..ce2bab8983b50 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -36,6 +36,7 @@ import { writeFileStringAtomically } from "@t3tools/shared/atomicWrite"; import { fromJsonStringPretty, fromLenientJson } from "@t3tools/shared/schemaJson"; import { DEFAULT_KEYBINDINGS, + RETIRED_DEFAULT_KEYBINDINGS, mergeWithDefaultKeybindings, compileResolvedKeybindingRule, compileResolvedKeybindingsConfig, @@ -499,7 +500,18 @@ const make = Effect.gen(function* () { yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); return; } - const customConfig = runtimeConfig.keybindings; + const persistedConfig = runtimeConfig.keybindings; + // A retired default left in the file would shadow its replacement. + const customConfig = persistedConfig.filter( + (entry) => + !RETIRED_DEFAULT_KEYBINDINGS.some( + (retired) => + entry.command === retired.command && + entry.key === retired.key && + entry.when === retired.when, + ), + ); + const retiredRemoved = customConfig.length !== persistedConfig.length; const existingCommands = new Set(customConfig.map((entry) => entry.command)); const missingDefaults: KeybindingRule[] = []; const shortcutConflictWarnings: Array<{ @@ -569,7 +581,8 @@ const make = Effect.gen(function* () { commands: skippedDefaults.map((rule) => rule.command), }); } - if (defaultsToAppend.length > 0) { + // Dropping a retired default rewrites the file even with nothing to append. + if (defaultsToAppend.length > 0 || retiredRemoved) { yield* writeConfigAtomically([...defaultsToAppend, ...customConfig]); } const settledLateDefaults = pendingLateDefaults.filter( diff --git a/apps/server/src/serverSettings.test.ts b/apps/server/src/serverSettings.test.ts index 873649cb168f5..24bcbbe0d8d19 100644 --- a/apps/server/src/serverSettings.test.ts +++ b/apps/server/src/serverSettings.test.ts @@ -5,6 +5,7 @@ import { DEFAULT_SERVER_SETTINGS, globalIdleCompaction, type ManagedPolicyDocument, + DEFAULT_LAYOUT_SETTINGS, ModelSelection, ProjectId, ProjectScript, @@ -13,6 +14,7 @@ import { ProviderInstanceId, resolveProviderInstanceEnabled, SecretProviderId, + ServerConfig as ServerConfigContract, ServerSettings, ServerSettingsPatch, UsageLimitSourceId, @@ -43,7 +45,10 @@ import { resolveProviderInstanceTerminalEnvironment } from "./terminal/Manager.t const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const decodeServerSettingsJson = Schema.decodeUnknownEffect(Schema.fromJsonString(ServerSettings)); -const decodeServerSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); +const decodeClientConfigSettings = Schema.decodeEffect( + Schema.toType(ServerConfigContract.fields.settings), +); +const decodeSettingsPatch = Schema.decodeUnknownEffect(ServerSettingsPatch); const layerServerSettings = (policy?: ManagedPolicyDocument) => ServerSettingsModule.layer.pipe( @@ -219,6 +224,86 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" }), ).pipe(Effect.provide(layerServerSettings())), ); + it.effect("retains layout only for client migration across unrelated saves and restarts", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + const legacyLayout = { ...DEFAULT_LAYOUT_SETTINGS, terminalPlacement: "new-column" as const }; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + layout: legacyLayout, + responseStreamingMode: "turn", + projectSettingsOverrides: { + legacy: { layout: { template: "retired-shape" }, defaultAutoPull: true }, + }, + }), + ); + + const settings = yield* service.getSettings; + const clientSettings = yield* decodeClientConfigSettings( + ServerSettingsModule.redactServerSettingsForClient(settings), + ); + assert.deepEqual(clientSettings.layout, legacyLayout); + const projectSettingsOverrides: unknown = settings.projectSettingsOverrides; + assert.deepEqual(projectSettingsOverrides, { + [ProjectId.make("legacy")]: { + layout: { template: "retired-shape" }, + defaultAutoPull: true, + }, + }); + yield* service.updateSettings( + yield* decodeSettingsPatch({ + responseStreamingMode: "paragraph", + layout: { ...legacyLayout, newTabPosition: "end" }, + }), + ); + const persisted = JSON.parse(yield* fs.readFileString(config.settingsPath)); + assert.deepEqual(persisted.layout, legacyLayout); + assert.notProperty(persisted, "legacyLayout"); + assert.deepEqual(persisted.projectSettingsOverrides, { + legacy: { layout: { template: "retired-shape" }, defaultAutoPull: true }, + }); + + const restarted = yield* Effect.gen(function* () { + const service = yield* ServerSettingsModule.ServerSettingsService; + return yield* service.getSettings; + }).pipe( + Effect.provide( + Layer.fresh(ServerSettingsModule.layer).pipe(Layer.provide(ManagedPolicy.layerTest())), + ), + ); + assert.deepEqual( + (yield* decodeClientConfigSettings( + ServerSettingsModule.redactServerSettingsForClient(restarted), + )).layout, + legacyLayout, + ); + assert.equal(restarted.responseStreamingMode, "paragraph"); + }).pipe(Effect.provide(layerServerSettingsWithSecrets())), + ); + + it.effect("ignores malformed retired layout without resetting other server settings", () => + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fs = yield* FileSystem.FileSystem; + const service = yield* ServerSettingsModule.ServerSettingsService; + yield* fs.writeFileString( + config.settingsPath, + JSON.stringify({ + layout: { template: "retired-shape" }, + responseStreamingMode: "turn", + enableAgentBrowserAccess: false, + }), + ); + const settings = yield* service.getSettings; + assert.notProperty(settings, "layout"); + assert.equal(settings.responseStreamingMode, "turn"); + assert.isFalse(settings.enableAgentBrowserAccess); + }).pipe(Effect.provide(layerServerSettings())), + ); + it.effect("migrates saved token delivery to paragraph buffering without resetting settings", () => Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -326,7 +411,7 @@ it.layer(Layer.mergeAll(NodeServices.layer, NodeCrypto.layer))("server settings" Effect.gen(function* () { const service = yield* ServerSettingsModule.ServerSettingsService; const write = (entry: unknown) => - decodeServerSettingsPatch({ secretProviders: { personal: entry } }).pipe( + decodeSettingsPatch({ secretProviders: { personal: entry } }).pipe( Effect.flatMap(service.updateSettings), Effect.flip, ); diff --git a/apps/server/src/serverSettings.ts b/apps/server/src/serverSettings.ts index 4dc30d29a6cff..9871c392d6298 100644 --- a/apps/server/src/serverSettings.ts +++ b/apps/server/src/serverSettings.ts @@ -1,5 +1,7 @@ import { DEFAULT_SERVER_SETTINGS, + ForwardCompatibleOptional, + LayoutSettings, ModelSelection, ProjectId, ProjectScript, @@ -76,7 +78,11 @@ export { } from "@t3tools/shared/serverSettings"; const encodeServerSettings = Schema.encodeEffect(ServerSettings); -const encodeServerSettingsJson = Schema.encodeUnknownEffect(fromJsonStringPretty(ServerSettings)); +const encodeServerSettingsJson = Schema.encodeUnknownEffect( + fromJsonStringPretty( + Schema.Struct({ ...ServerSettings.fields, layout: ForwardCompatibleOptional(LayoutSettings) }), + ), +); const decodeServerSettings = Schema.decodeUnknownEffect(ServerSettings); const textEncoder = new TextEncoder(); @@ -260,6 +266,7 @@ export class ServerSettingsService extends Context.Service< /** The administrator's policy as clients see it; absent when unmanaged. */ readonly managedPolicy?: ManagedPolicySummary | undefined; + /** Read current server settings. */ readonly getSettings: Effect.Effect; readonly updateSettings: ( @@ -380,8 +387,10 @@ const PersistedServerSettings = Schema.Struct({ // Parse first and decode second, so a managed policy can fill its defaults in between. const decodeSettingsFileJsonExit = Schema.decodeUnknownExit(fromLenientJson(Schema.Unknown)); const decodePersistedServerSettingsExit = Schema.decodeUnknownExit(PersistedServerSettings); +/** Retired keys feed source-control migration and the read-only client layout migration. */ const LegacyProviderSettingsJson = fromLenientJson( Schema.Struct({ + layout: ForwardCompatibleOptional(LayoutSettings), providers: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), bitbucket: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), github: Schema.optionalKey(Schema.Record(Schema.String, Schema.Unknown)), @@ -686,6 +695,7 @@ const make = Effect.gen(function* () { ); const writeSemaphore = yield* Semaphore.make(1); const cacheKey = "settings" as const; + const legacyLayoutRef = yield* Ref.make(undefined); const changesPubSub = yield* PubSub.unbounded(); const startedRef = yield* Ref.make(false); const startedDeferred = yield* Deferred.make(); @@ -726,14 +736,19 @@ const make = Effect.gen(function* () { const writeSettingsAtomically = Effect.fnUntraced( function* (settings: ServerSettings) { + // Preserve the read-only layout on disk so each client can migrate independently. + const legacyLayout = yield* Ref.get(legacyLayoutRef); const stripped = stripDefaultServerSettings(settings, managed.defaults) ?? {}; - const sparseSettingsJson = yield* encodeServerSettingsJson( + const sparseSettings = managed.keepStored === undefined ? stripped : managed.keepStored(stripped, settings, yield* readStoredSettings, (key) => ATOMIC_SETTINGS_KEYS.has(key), - ), - ); + ); + const sparseSettingsJson = yield* encodeServerSettingsJson({ + ...(typeof sparseSettings === "object" && sparseSettings !== null ? sparseSettings : {}), + ...(legacyLayout === undefined ? {} : { layout: legacyLayout }), + }); return yield* writeFileStringAtomically({ filePath: settingsPath, @@ -785,6 +800,7 @@ const make = Effect.gen(function* () { const loadSettingsFromDisk = Effect.gen(function* () { let settings = managed.defaults; + yield* Ref.set(legacyLayoutRef, undefined); let legacyProviders: Readonly> | undefined; let legacySourceControl: Parameters[1] = {}; let parseFailure = Option.none(); @@ -797,6 +813,7 @@ const make = Effect.gen(function* () { : decodePersistedServerSettingsExit(managed.withDefaults(parsed.value)); const legacySettings = decodeLegacyProviderSettingsJsonExit(raw); if (legacySettings._tag === "Success") { + yield* Ref.set(legacyLayoutRef, legacySettings.value.layout); legacyProviders = legacySettings.value.providers; legacySourceControl = { bitbucket: legacySettings.value.bitbucket, @@ -1366,7 +1383,15 @@ const make = Effect.gen(function* () { managedPolicy, start, ready: Deferred.await(startedDeferred), - getSettings: getMaterializedSettings, + getSettings: getMaterializedSettings.pipe( + Effect.flatMap((settings) => + Ref.get(legacyLayoutRef).pipe( + Effect.map((legacyLayout) => + legacyLayout === undefined ? settings : { ...settings, layout: legacyLayout }, + ), + ), + ), + ), updateSettings: (patch) => updateAndPersistSettings(patch, (current) => ensureSecretProviderWritesAllowed(patch, settingsPath).pipe( diff --git a/apps/web/src/appInit.tsx b/apps/web/src/appInit.tsx index 10fa831606f70..2407411de6900 100644 --- a/apps/web/src/appInit.tsx +++ b/apps/web/src/appInit.tsx @@ -42,6 +42,7 @@ import { startHostEnvironmentLookup } from "./state/primaryEnvironment"; import { getClientSettings, getClientSettingsHydratedSnapshot, + startLegacyLayoutSettingsMigration, startModelPreferencesMigration, subscribeClientSettings, subscribeClientSettingsHydration, @@ -278,6 +279,7 @@ export function startAppServices(): AppRouter { startBrowserChromeThemeSync(router); startDefaultThemeAdoption(); startModelPreferencesMigration(); + startLegacyLayoutSettingsMigration(); startAgentOpenedPreviews(); if (isElectron) startElectronBrowserHost(); startPreviewTitleRecording(); diff --git a/apps/web/src/browser/BrowserSurfaceSlot.tsx b/apps/web/src/browser/BrowserSurfaceSlot.tsx index e00b002ca0036..531736992dd7f 100644 --- a/apps/web/src/browser/BrowserSurfaceSlot.tsx +++ b/apps/web/src/browser/BrowserSurfaceSlot.tsx @@ -1,7 +1,94 @@ import { useLayoutEffect, useRef } from "react"; -import { acquireBrowserSurface } from "./browserSurfaceStore"; import { observeResize } from "../lib/observeResize"; +import { acquireBrowserSurface, type BrowserSurfacePlacement } from "./browserSurfaceStore"; +import { + type BrowserSurfaceSlotPhase, + kickBrowserSurfaceTracking, + trackBrowserSurfaceSlot, +} from "./browserSurfaceTracking"; + +/** An ancestor that clips the slot: its pane, the strip, the overview's frame. */ +interface ClipAncestor { + readonly element: Element; + /** Corner radii in its own pixels: top-left, top-right, bottom-right, bottom-left. */ + readonly radii: readonly [number, number, number, number]; +} + +function clipAncestorsOf(element: HTMLElement): ClipAncestor[] { + const ancestors: ClipAncestor[] = []; + for ( + let node = element.parentElement; + node && node !== document.body; + node = node.parentElement + ) { + const style = getComputedStyle(node); + if (style.overflowX === "visible" && style.overflowY === "visible") continue; + ancestors.push({ + element: node, + radii: [ + parseFloat(style.borderTopLeftRadius) || 0, + parseFloat(style.borderTopRightRadius) || 0, + parseFloat(style.borderBottomRightRadius) || 0, + parseFloat(style.borderBottomLeftRadius) || 0, + ], + }); + } + return ancestors; +} + +const near = (value: number, edges: readonly number[]) => + edges.some((edge) => Math.abs(edge - value) < 1); + +/** + * The part of the slot its ancestors leave visible, as a `clip-path` in the + * slot's own pixels. A corner that meets a rounded pane's corner, or where + * the strip's edge cuts that pane (index.css, [data-pane-cut]), rounds as + * the pane does. + */ +function resolveClipPath(rect: DOMRect, scale: number, ancestors: readonly ClipAncestor[]) { + const boxes = ancestors.map((ancestor) => ancestor.element.getBoundingClientRect()); + let left = rect.left; + let top = rect.top; + let right = rect.right; + let bottom = rect.bottom; + for (const box of boxes) { + left = Math.max(left, box.left); + top = Math.max(top, box.top); + right = Math.min(right, box.right); + bottom = Math.min(bottom, box.bottom); + } + if (right - left < 1 || bottom - top < 1) return "inset(50%)"; + const corners = [ + [left, top], + [right, top], + [right, bottom], + [left, bottom], + ] as const; + const radii = corners.map(([x, y], corner) => { + let radius = 0; + ancestors.forEach((ancestor, index) => { + const own = ancestor.radii[corner]!; + if (own <= 0) return; + const box = boxes[index]!; + const outer = boxes.slice(index + 1); + const xs = [box.left, box.right, ...outer.flatMap((edge) => [edge.left, edge.right])]; + const ys = [box.top, box.bottom, ...outer.flatMap((edge) => [edge.top, edge.bottom])]; + if (!near(x, xs) || !near(y, ys)) return; + const ownScale = box.width / Math.max(1, (ancestor.element as HTMLElement).offsetWidth || 1); + radius = Math.max(radius, own * ownScale); + }); + return radius / scale; + }); + const insets = [top - rect.top, rect.right - right, rect.bottom - bottom, left - rect.left].map( + (inset) => Math.max(0, inset / scale), + ); + if (insets.every((inset) => inset < 0.5) && radii.every((radius) => radius === 0)) return ""; + const round = radii.some((radius) => radius > 0) + ? ` round ${radii.map((radius) => `${radius}px`).join(" ")}` + : ""; + return `inset(${insets.map((inset) => `${inset}px`).join(" ")}${round})`; +} export function BrowserSurfaceSlot(props: { readonly tabId: string; @@ -22,56 +109,78 @@ export function BrowserSurfaceSlot(props: { fitSourceContent = false, } = props; const presentationRef = useRef({ visible, cornerRadius, zIndex }); - const updateRef = useRef<(() => void) | null>(null); + const updateRef = useRef<((phase: BrowserSurfaceSlotPhase) => boolean) | null>(null); // The lease lives as long as this element for one tab, so the ref callback owns it. const attachSurface = (element: HTMLDivElement) => { let lease = acquireBrowserSurface(tabId, fitSourceContent); - const update = () => { + let ancestors = clipAncestorsOf(element); + let presented = ""; + let placed: BrowserSurfacePlacement | null = null; + // The webview keeps the slot's own size and follows it on screen with a + // transform, so a pane sliding or zooming never resizes the page. + const update = (phase: BrowserSurfaceSlotPhase) => { const rect = element.getBoundingClientRect(); + const width = Math.max(1, element.offsetWidth); + const height = Math.max(1, element.offsetHeight); const presentation = presentationRef.current; - const presented = lease.present( - { + const shown = presentation.visible && element.offsetWidth > 0 && element.offsetHeight > 0; + if (phase !== "frame") ancestors = clipAncestorsOf(element); + const key = `${width}:${height}:${shown}:${presentation.cornerRadius}:${presentation.zIndex}`; + // Position alone reaches the store once it settles; size and visibility at once. + if (key !== presented || phase !== "frame") { + const box = { x: Math.round(rect.x), y: Math.round(rect.y), - width: Math.max(1, Math.round(rect.width)), - height: Math.max(1, Math.round(rect.height)), - }, - presentation.visible && rect.width > 0 && rect.height > 0, - presentation.cornerRadius, - presentation.zIndex, - ); - if (presentation.visible && !presented) { - lease.release(); - lease = acquireBrowserSurface(tabId, fitSourceContent); - lease.present( - { - x: Math.round(rect.x), - y: Math.round(rect.y), - width: Math.max(1, Math.round(rect.width)), - height: Math.max(1, Math.round(rect.height)), - }, - rect.width > 0 && rect.height > 0, - presentation.cornerRadius, - presentation.zIndex, - ); + width, + height, + }; + if (!lease.present(box, shown, presentation.cornerRadius, presentation.zIndex)) { + // Another slot took the tab (the mini player); a visible one takes it back, + // but not every frame, so two never trade it back and forth. + if (!shown || phase === "frame") return false; + lease.release(); + lease = acquireBrowserSurface(tabId, fitSourceContent); + lease.present(box, shown, presentation.cornerRadius, presentation.zIndex); + } + presented = key; } + const scale = rect.width / width; + const next: BrowserSurfacePlacement = { + x: rect.x, + y: rect.y, + scale, + clipPath: resolveClipPath(rect, scale, ancestors), + // Under the overview or off the focused workspace mid-switch the pane is inert. + interactive: element.closest("[inert]") === null, + }; + const moved = + placed === null || + Math.abs(placed.x - next.x) > 0.01 || + Math.abs(placed.y - next.y) > 0.01 || + Math.abs(placed.scale - next.scale) > 0.0001 || + placed.clipPath !== next.clipPath || + placed.interactive !== next.interactive; + if (moved || phase !== "frame") lease.place(next); + placed = next; + return moved; }; updateRef.current = update; - update(); + update("start"); + const stopTracking = trackBrowserSurfaceSlot(element, update); const observed: Element[] = [element]; // Inline shells animate without resizing the slot, so observe the shell too. const panel = element.closest('[data-preview-panel-mode="inline"]'); if (panel) observed.push(panel); // Sidebar width changes move the slot without changing its size or column offset. observed.push(...document.querySelectorAll('[data-slot="sidebar-gap"]')); - const stopObserving = observeResize(observed, update); - window.addEventListener("resize", update); - window.addEventListener("scroll", update, true); + const stopObserving = observeResize(observed, () => { + update("start"); + kickBrowserSurfaceTracking(); + }); return () => { stopObserving(); - window.removeEventListener("resize", update); - window.removeEventListener("scroll", update, true); + stopTracking(); if (updateRef.current === update) updateRef.current = null; lease.release(); }; @@ -80,7 +189,7 @@ export function BrowserSurfaceSlot(props: { // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes visibility, radius and stacking to the desktop browser surface after layout useLayoutEffect(() => { presentationRef.current = { visible, cornerRadius, zIndex }; - updateRef.current?.(); + updateRef.current?.("start"); }, [ cornerRadius, // oxlint-disable-next-line react/exhaustive-effect-dependencies -- A layoutVersion bump means the slot moved without resizing, so it re-presents at the new position. diff --git a/apps/web/src/browser/HostedBrowserWebview.tsx b/apps/web/src/browser/HostedBrowserWebview.tsx index 30140af3ae2d0..715ba5d2a6c70 100644 --- a/apps/web/src/browser/HostedBrowserWebview.tsx +++ b/apps/web/src/browser/HostedBrowserWebview.tsx @@ -4,7 +4,7 @@ import type { ScopedThreadRef, } from "@t3tools/contracts"; import { useShallow } from "zustand/react/shallow"; -import { useEffect, useEffectEvent, useRef, useState } from "react"; +import { useEffect, useEffectEvent, useLayoutEffect, useRef, useState } from "react"; import { previewBridge } from "~/components/preview/previewBridge"; import { @@ -15,7 +15,11 @@ import { usePreviewBridge } from "~/components/preview/usePreviewBridge"; import { useClientSettingsHydrated } from "~/hooks/useSettings"; import { cn, isMacPlatform } from "~/lib/utils"; -import { resolveBrowserSurfacePanelRect, useBrowserSurfaceStore } from "./browserSurfaceStore"; +import { + resolveBrowserSurfacePanelRect, + syncBrowserSurfaceWrapper, + useBrowserSurfaceStore, +} from "./browserSurfaceStore"; import { useActiveBrowserRecordingTabIds } from "./browserRecording"; import { browserViewportSettingKey, @@ -188,7 +192,10 @@ export function HostedBrowserWebview(props: { // Guest clicks never reach this document, so replay a pointerdown to dismiss host popups. // The guest preload reports each press, since the guest does not always take focus. const dismissHostPopups = () => { - webview.dispatchEvent( + const slot = document.querySelector( + `[data-browser-surface-slot="${CSS.escape(runtimeTabId)}"]`, + ); + (slot?.closest("[data-layout-tab-id]") ? slot : webview).dispatchEvent( new PointerEvent("pointerdown", { bubbles: true, pointerType: "mouse" }), ); }; @@ -293,6 +300,15 @@ export function HostedBrowserWebview(props: { return () => window.cancelAnimationFrame(frameId); }, [syncContentPresentation]); + // The slot moves the wrapper on screen directly (BrowserSurfaceSlot); a + // render that shows or hides it takes or drops that placement. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Publish the committed wrapper placement to the external browser surface registry. + useLayoutEffect(() => { + syncBrowserSurfaceWrapper(runtimeTabId, wrapperRef.current, active); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Remove this wrapper from the browser surface registry when its runtime tab unmounts. + useEffect(() => () => syncBrowserSurfaceWrapper(runtimeTabId, null, false), [runtimeTabId]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the webview wrapper element to its origin for a new tab or viewport size useEffect(() => { const wrapper = wrapperRef.current; diff --git a/apps/web/src/browser/ServerBrowserSurface.tsx b/apps/web/src/browser/ServerBrowserSurface.tsx index 67b467dd5f9ae..f3544ac2beb9b 100644 --- a/apps/web/src/browser/ServerBrowserSurface.tsx +++ b/apps/web/src/browser/ServerBrowserSurface.tsx @@ -39,6 +39,7 @@ import { Button } from "~/components/ui/button"; import { Input } from "~/components/ui/input"; import { toastManager } from "~/components/ui/toast"; import { cn } from "~/lib/utils"; +import { setWheelScroller } from "~/motion/gesture"; import { refreshPreviewStreamAccess, usePreviewStreamAccess } from "~/state/previewStream"; import { observeResize } from "~/lib/observeResize"; @@ -52,6 +53,8 @@ export interface ServerBrowserHandle { } const RESIZE_THROTTLE_MS = 150; +// How long a page this viewer resized may take to send a frame at its new size. +const RESIZE_HOLD_MS = 1_000; // Reuse nearby probes when a slow link would return the tap's answer after it ends. const PROBE_REUSE_PX = 24; const PROBE_REUSE_MS = 10_000; @@ -176,6 +179,7 @@ export function ServerBrowserSurface(props: { const clientRef = useRef(null); const viewportRef = useRef(null); const sizeRef = useRef<{ width: number; height: number } | null>(null); + const placementRef = useRef(null); const hasFrameRef = useRef(false); const controlRef = useRef(null); const [control, setControl] = useState(null); @@ -354,7 +358,17 @@ export function ServerBrowserSurface(props: { if (capTimer !== null) clearTimeout(capTimer); capTimer = setTimeout(growCap, previous === null ? 0 : RESIZE_THROTTLE_MS * 2); }; - const stopObserving = observeResize(element, () => { + const stopObserving = observeResize(element, (entries) => { + const entry = entries.at(-1); + // A page that follows this viewer's size is about to be resized. + const resizing = followSize && controlRef.current?.controller === "you"; + if (entry) { + placementRef.current?.resized( + entry.contentRect.width, + entry.contentRect.height, + resizing ? RESIZE_THROTTLE_MS + RESIZE_HOLD_MS : 0, + ); + } if (timer !== null) return; timer = setTimeout(measure, sizeRef.current === null ? 0 : RESIZE_THROTTLE_MS); }); @@ -370,7 +384,10 @@ export function ServerBrowserSurface(props: { const canvas = canvasRef.current; if (!visible || accessDenied || hostSetup || !access || !cap || !canvas) return; let refreshTimer: ReturnType | null = null; + const placement = createFramePlacement(canvas); + placementRef.current = placement; const painter = createPreviewFramePainter(canvas, () => { + placement.painted(sizeRef.current); if (hasFrameRef.current) return; hasFrameRef.current = true; firstFrame(); @@ -449,6 +466,8 @@ export function ServerBrowserSurface(props: { clientRef.current = client; return () => { painter.stop(); + placement.stop(); + if (placementRef.current === placement) placementRef.current = null; if (refreshTimer !== null) clearTimeout(refreshTimer); client.stop(); if (clientRef.current === client) clientRef.current = null; @@ -488,7 +507,13 @@ export function ServerBrowserSurface(props: { queueWheel(point, event.deltaX * unit, event.deltaY * unit, previewStreamModifiers(event)); }; canvas.addEventListener("wheel", onWheel, { passive: false }); - return () => canvas.removeEventListener("wheel", onWheel); + // The page takes the wheel while you control it, so a swipe over it + // scrolls the page instead of switching workspaces. + setWheelScroller(canvas, () => controlRef.current?.controller === "you"); + return () => { + canvas.removeEventListener("wheel", onWheel); + setWheelScroller(canvas, null); + }; }, [pagePoint, queueWheel]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- cancels the pending input animation frame when the component unmounts @@ -959,3 +984,92 @@ function placeAgentCursor( top: (box.height - height) / 2 + (pointer.y * height) / viewport.height, }; } + +type Size = { width: number; height: number }; + +/** Hold a matching frame at native size during viewport resizing to avoid rescaling into bars; fit + * other sizes once the bounded hold expires. */ +interface FramePlacement { + /** A frame was painted; `size` is the surface size last sent to the page. */ + readonly painted: (size: Size | null) => void; + /** The surface resized; `holdMs` is how long a resize of the page may take. */ + readonly resized: (width: number, height: number, holdMs: number) => void; + readonly stop: () => void; +} + +function createFramePlacement(canvas: HTMLCanvasElement): FramePlacement { + let drawn: Size | null = null; + // The surface's size as last seen; the frame shown may predate this placement. + let box: Size | null = canvas.parentElement + ? { width: canvas.parentElement.clientWidth, height: canvas.parentElement.clientHeight } + : null; + let holdUntil = 0; + let fallback: ReturnType | null = null; + // The painted frame's corner color, read at most once per frame. + let corner: string | null = null; + const setRoom = (color: string) => { + if (canvas.parentElement) canvas.parentElement.style.backgroundColor = color; + }; + const place = (next: Size | null) => { + if (next?.width !== drawn?.width || next?.height !== drawn?.height) { + canvas.style.width = next ? `${next.width}px` : ""; + canvas.style.height = next ? `${next.height}px` : ""; + } + drawn = next; + // Room the frame leaves uncovered takes its far corner's color. + const uncovered = + next !== null && + box !== null && + (box.width > next.width + 0.5 || box.height > next.height + 0.5); + setRoom(uncovered ? (corner ??= frameCornerColor(canvas)) : ""); + }; + const fits = (size: Size | null) => + size !== null && + canvas.width > 1 && + Math.abs(canvas.width / canvas.height - size.width / size.height) < 0.01; + const settle = () => { + holdUntil = 0; + if (fallback !== null) clearTimeout(fallback); + fallback = null; + }; + return { + painted: (size) => { + corner = null; + if (fits(size)) { + settle(); + place(size); + } else if (!drawn || performance.now() >= holdUntil) { + place(null); + } + }, + resized: (width, height, holdMs) => { + // A frame drawn before this placement was made, at the old size. + if (!drawn && fits(box)) place(box); + box = { width, height }; + if (!drawn) return; + if (holdMs <= 0) { + // Nothing will redraw the page at this size: fit the frame shown. + settle(); + place(null); + return; + } + holdUntil = performance.now() + holdMs; + if (fallback !== null) clearTimeout(fallback); + // The page never came back at the new size: fit what it last sent. + fallback = setTimeout(() => { + fallback = null; + if (drawn && !fits(box)) place(null); + }, holdMs); + place(drawn); + }, + stop: settle, + }; +} + +/** The color at the frame's bottom-right corner, where a page's own background usually shows. */ +function frameCornerColor(canvas: HTMLCanvasElement): string { + const pixel = canvas + .getContext("2d") + ?.getImageData(canvas.width - 1, canvas.height - 1, 1, 1).data; + return pixel ? `rgb(${pixel[0]} ${pixel[1]} ${pixel[2]})` : ""; +} diff --git a/apps/web/src/browser/browserSurfaceStore.ts b/apps/web/src/browser/browserSurfaceStore.ts index a49154ed8def4..9b47536244f9f 100644 --- a/apps/web/src/browser/browserSurfaceStore.ts +++ b/apps/web/src/browser/browserSurfaceStore.ts @@ -53,6 +53,8 @@ export interface BrowserSurfaceLease { cornerRadius?: number, zIndex?: number, ) => boolean; + /** Moves the webview to where the slot shows now; false once another slot owns the tab. */ + readonly place: (placement: BrowserSurfacePlacement) => boolean; readonly release: () => void; } @@ -222,10 +224,77 @@ export function acquireBrowserSurface( useBrowserSurfaceStore.getState().present(tabId, owner, rect, visible, cornerRadius, zIndex); return true; }, + place: (placement) => { + if (released) return false; + if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner !== owner) return false; + place(tabId, placement); + return true; + }, release: () => { if (released) return; released = true; + if (useBrowserSurfaceStore.getState().byTabId[tabId]?.owner === owner) place(tabId, null); useBrowserSurfaceStore.getState().release(tabId, owner); }, }; } + +/** Placement changes every animation frame, so apply it directly to the webview wrapper instead of + * rerendering React. */ +export interface BrowserSurfacePlacement { + readonly x: number; + readonly y: number; + readonly scale: number; + /** A CSS `clip-path` in the wrapper's own pixels; empty when nothing is cut. */ + readonly clipPath: string; + readonly interactive: boolean; +} + +const placements = new Map(); +const wrappers = new Map(); +/** What each wrapper was last given; the style reads back normalized. */ +const written = new WeakMap(); + +function writePlacement(element: HTMLElement, placement: BrowserSurfacePlacement | null): void { + const last = written.get(element) ?? null; + if (last === placement) return; + written.set(element, placement); + const transform = placement + ? `translate(${placement.x}px, ${placement.y}px) scale(${placement.scale})` + : ""; + const lastTransform = last ? `translate(${last.x}px, ${last.y}px) scale(${last.scale})` : ""; + if (transform !== lastTransform) element.style.transform = transform; + const clipPath = placement?.clipPath ?? ""; + if (clipPath !== (last?.clipPath ?? "")) element.style.clipPath = clipPath; + if (placement && placement.interactive !== last?.interactive) { + element.style.pointerEvents = placement.interactive ? "auto" : "none"; + } +} + +/** + * The webview's wrapper for a tab, kept by `HostedBrowserWebview`, which + * calls this after every render: an active wrapper takes the tab's latest + * placement, an inactive one drops it. + */ +export function syncBrowserSurfaceWrapper( + tabId: string, + element: HTMLElement | null, + active: boolean, +): void { + if (!element) { + wrappers.delete(tabId); + return; + } + wrappers.set(tabId, element); + // React may have restyled it (pointer events): write it all again. + written.delete(element); + writePlacement(element, active ? (placements.get(tabId) ?? null) : null); +} + +function place(tabId: string, placement: BrowserSurfacePlacement | null): void { + if (placement) placements.set(tabId, placement); + else placements.delete(tabId); + const element = wrappers.get(tabId); + const current = useBrowserSurfaceStore.getState().byTabId[tabId]; + if (element) writePlacement(element, current?.visible ? placement : null); +} diff --git a/apps/web/src/browser/browserSurfaceTracking.ts b/apps/web/src/browser/browserSurfaceTracking.ts new file mode 100644 index 0000000000000..173541d099771 --- /dev/null +++ b/apps/web/src/browser/browserSurfaceTracking.ts @@ -0,0 +1,105 @@ +/** Compositor transforms emit no layout events. Remeasure browser slots while motion runs, then stop + * after several resting frames. */ + +/** Where a frame belongs in a burst of movement. */ +export type BrowserSurfaceSlotPhase = "start" | "frame" | "settle"; + +/** Re-measures one slot; returns whether its placement changed. */ +type SlotUpdate = (phase: BrowserSurfaceSlotPhase) => boolean; + +/** Frames without movement or a running animation before a burst settles. */ +const REST_FRAMES = 6; +/** A transition of one of these may move or resize a slot. */ +const MOVING_PROPERTY = + /transform|translate|scale|width|height|left|right|top|bottom|inset|margin|flex/; + +const slots = new Map(); +let frame: number | null = null; +let quietFrames = 0; +let starting = false; + +function underAnimation(): boolean { + for (const animation of document.getAnimations()) { + if (animation.playState !== "running") continue; + const target = (animation.effect as KeyframeEffect | null)?.target; + if (!(target instanceof Element)) continue; + for (const element of slots.keys()) { + if (target.contains(element)) return true; + } + } + return false; +} + +function tick() { + frame = null; + const phase = starting ? "start" : "frame"; + starting = false; + let moved = false; + for (const update of slots.values()) { + if (update(phase)) moved = true; + } + quietFrames = moved || underAnimation() ? 0 : quietFrames + 1; + if (quietFrames < REST_FRAMES) { + frame = window.requestAnimationFrame(tick); + return; + } + for (const update of slots.values()) update("settle"); +} + +/** + * Something may have moved the slots: follow them each frame until they + * rest. `now` also places them at once, from a layout effect, so the frame + * a layout change paints already shows its webviews where they belong. + */ +export function kickBrowserSurfaceTracking(options?: { readonly now?: boolean }): void { + if (slots.size === 0) return; + quietFrames = 0; + if (options?.now) { + for (const update of slots.values()) update("start"); + } + if (frame === null) { + starting = true; + frame = window.requestAnimationFrame(tick); + } +} + +const onMove = () => kickBrowserSurfaceTracking(); +const onPointerMove = (event: PointerEvent) => { + // A drag (a column's edge, the overview's camera) moves panes; hovering does not. + if (event.buttons !== 0) kickBrowserSurfaceTracking(); +}; +const onTransitionRun = (event: TransitionEvent) => { + if (MOVING_PROPERTY.test(event.propertyName)) kickBrowserSurfaceTracking(); +}; +const listenerOptions = { capture: true, passive: true } as const; + +function listen(add: boolean) { + if (add) { + window.addEventListener("scroll", onMove, listenerOptions); + window.addEventListener("wheel", onMove, listenerOptions); + window.addEventListener("resize", onMove, listenerOptions); + window.addEventListener("pointermove", onPointerMove, listenerOptions); + window.addEventListener("transitionrun", onTransitionRun, listenerOptions); + return; + } + window.removeEventListener("scroll", onMove, listenerOptions); + window.removeEventListener("wheel", onMove, listenerOptions); + window.removeEventListener("resize", onMove, listenerOptions); + window.removeEventListener("pointermove", onPointerMove, listenerOptions); + window.removeEventListener("transitionrun", onTransitionRun, listenerOptions); +} + +/** Follow a slot while it may move; returns the way to stop. */ +export function trackBrowserSurfaceSlot(element: HTMLElement, update: SlotUpdate): () => void { + if (slots.size === 0) listen(true); + slots.set(element, update); + kickBrowserSurfaceTracking(); + return () => { + if (slots.get(element) !== update) return; + slots.delete(element); + if (slots.size > 0) return; + listen(false); + if (frame !== null) window.cancelAnimationFrame(frame); + frame = null; + }; +} diff --git a/apps/web/src/browser/hostedBrowserWebviewStyle.ts b/apps/web/src/browser/hostedBrowserWebviewStyle.ts index f37c222b08e9c..a1742dcb6b69d 100644 --- a/apps/web/src/browser/hostedBrowserWebviewStyle.ts +++ b/apps/web/src/browser/hostedBrowserWebviewStyle.ts @@ -8,6 +8,8 @@ export interface HostedBrowserWebviewSize { export interface HostedBrowserWebviewWrapperStyle { readonly left: number; readonly top: number; + /** An active wrapper sits at the origin; its slot's placement moves it (browserSurfaceStore). */ + readonly transformOrigin?: string; readonly width: number; readonly height: number; readonly zIndex: number; @@ -38,8 +40,9 @@ export function resolveHostedBrowserWebviewWrapperStyle(input: { } = input; if (active && rect) { return { - left: rect.x, - top: rect.y, + left: 0, + top: 0, + transformOrigin: "0 0", width: rect.width, height: rect.height, zIndex, diff --git a/apps/web/src/commandPaletteBus.ts b/apps/web/src/commandPaletteBus.ts index 7e0e672110e6c..98d92a1f81f6b 100644 --- a/apps/web/src/commandPaletteBus.ts +++ b/apps/web/src/commandPaletteBus.ts @@ -38,7 +38,8 @@ export function openCommandPalette(detail: CommandPaletteOpenDetail = {}): void } } -/** True while the palette popup is on screen, including its closing animation. */ +/** True while the palette popup is open. */ export function isCommandPaletteOpen(): boolean { - return openLayerMatches("[data-command-palette]"); + // A palette playing its close animation is closed: shortcuts work again. + return openLayerMatches("[data-command-palette]:not([data-closed])"); } diff --git a/apps/web/src/commandPaletteStore.ts b/apps/web/src/commandPaletteStore.ts index 3fdd07383fd24..8fa9391008b52 100644 --- a/apps/web/src/commandPaletteStore.ts +++ b/apps/web/src/commandPaletteStore.ts @@ -1,4 +1,5 @@ import { useEffect } from "react"; +import { flushSync } from "react-dom"; import { create } from "zustand"; import { @@ -19,6 +20,11 @@ export const useCommandPaletteStore = create(() => INITIA let returnFocus: { element: HTMLElement | null; href: string; focusRequest: number } | null = null; +export const commandPaletteFocus = { + openerRef: { current: null as HTMLElement | null }, + dismissedRef: { current: false }, +}; + export function dispatchCommandPalette(action: CommandPaletteUiAction): void { const state = useCommandPaletteStore.getState(); const next = reduceCommandPaletteUiState(state, action); @@ -30,7 +36,19 @@ export function dispatchCommandPalette(action: CommandPaletteUiAction): void { focusRequest: getWorkspaceFocusRequestVersion(), }; } - useCommandPaletteStore.setState(next, true); + commandPaletteFocus.dismissedRef.current = false; + const active = typeof document === "undefined" ? null : document.activeElement; + if ( + typeof HTMLElement !== "undefined" && + active instanceof HTMLElement && + !active.closest("[data-command-palette]") + ) { + commandPaletteFocus.openerRef.current = active === document.body ? null : active; + } + // Commit and focus the input before the next trusted key event. + flushSync(() => { + useCommandPaletteStore.setState(next, true); + }); } /** @@ -48,6 +66,15 @@ export function getCommandPaletteReturnFocus(): HTMLElement | boolean { return element?.isConnected && !element.matches(":disabled") ? element : true; } +export function leaveCommandPaletteOverlay(): void { + if (useCommandPaletteStore.getState().parentSearch) { + dispatchCommandPalette({ _tag: "BackToCommand" }); + } else { + dispatchCommandPalette({ _tag: "SetOpen", open: false }); + commandPaletteFocus.dismissedRef.current = true; + } +} + // The palette is only mounted on app-shell routes (not /pair, sign-in or // landings), so its shortcuts must stay inert elsewhere. let mountedPalettes = 0; diff --git a/apps/web/src/components/AppSidebarLayout.tsx b/apps/web/src/components/AppSidebarLayout.tsx index af780b43e8a65..ed5e7535bffe0 100644 --- a/apps/web/src/components/AppSidebarLayout.tsx +++ b/apps/web/src/components/AppSidebarLayout.tsx @@ -52,9 +52,14 @@ import { import { BackgroundThreadAnnouncer } from "./BackgroundThreadAnnouncer"; import { HistorySwipeNavigation } from "./HistorySwipeNavigation"; import ThreadSidebar from "./Sidebar"; -import { isAppModalPage } from "./sidebar/mainAppLocation"; +import { getMainAppHref, isAppModalPage } from "./sidebar/mainAppLocation"; +import { selectFocusedTab } from "../layout/layoutModel"; +import { useLayoutStore } from "../layout/layoutStore"; +import { isLayoutDragActive } from "../layout/layoutDrag"; +import { isModalDialogOpen } from "../lib/modalDialog"; import { SidebarChromeHeader } from "./sidebar/SidebarChrome"; import type { SidebarRouteReveal } from "./sidebar/routeReveal"; +import { WorkspacePlane } from "../layout/WorkspacePlane"; import { ChromeChannelArt, useSidebarStageBackdropVariant } from "./SidebarStageBackdrop"; import { clampThreadSidebarWidth, @@ -222,10 +227,26 @@ function registerPullRequestsEscapeCommand(router: AnyRouter): () => void { { observe: (event, consumed) => { if (consumed || event.repeat || event.isComposing || event.key !== "Escape") return; - if (router.state.location.pathname !== "/pull-requests") return; + if ( + router.state.location.pathname !== "/pull-requests" || + isCommandPaletteOpen() || + isModalDialogOpen() || + isLayoutDragActive() + ) + return; + const inPane = router.state.location.search.pane === 1; + if ( + inPane && + selectFocusedTab(useLayoutStore.getState().layout)?.content.kind !== "pull-requests" + ) + return; event.preventDefault(); const activeElement = document.activeElement; if (activeElement instanceof HTMLElement) activeElement.blur(); + if (!inPane) { + void router.navigate({ href: getMainAppHref() ?? "/" }); + return; + } if (router.state.location.state.__TSR_index !== 0) { window.history.back(); return; @@ -320,7 +341,7 @@ function registerAppShellCommands(router: AnyRouter, shellElement: Element): () registerPullRequestsEscapeCommand(router), registerMenuAction("open-settings", () => { if (!/^\/settings(\/|$)/.test(router.state.location.pathname)) - void router.navigate({ to: "/settings" }); + void router.navigate({ to: "/settings", search: { pane: undefined } }); }), ]; return () => { @@ -354,13 +375,19 @@ function SidebarControl({ showSidebar }: { readonly showSidebar: boolean }) { ) { return; } + const composer = + event.target instanceof HTMLElement + ? event.target.closest('[data-composer-rich-text="true"]') + : null; + const selection = window.getSelection(); if ( - isRichTextBoldShortcut(event, window.getSelection()) && - event.target instanceof HTMLElement && - event.target.closest('[data-composer-rich-text="true"]') + isRichTextBoldShortcut(event, selection) && + composer && + selection && + composer.contains(selection.anchorNode) ) { - // The rich-text composer claims Mod+B only to bold selected text; - // the toggle stays available everywhere else. + // The rich-text composer claims Mod+B to bold text selected in it; + // with just a caret, and everywhere else, it toggles the sidebar. return; } const command = resolveShortcutCommand(event, keybindings, { @@ -626,7 +653,7 @@ export function AppSidebarLayout({ ) : null} - {children} + {children} {isElectron ? ( // The chrome strip above the inset pane moves the window like the diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 5dde9fb10e89c..4267133d3dd02 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -3,6 +3,7 @@ import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-action import { readLocalApi } from "../localApi"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; import { ChevronDownIcon, @@ -27,7 +28,7 @@ import { flushSync } from "react-dom"; import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; -import { useProject, useThreadShellsForProjectRefs } from "../state/entities"; +import { useProject, useServerConfigs, useThreadShellsForProjectRefs } from "../state/entities"; import { ADD_TO_MACHINE_LABEL_CLASS, type EnvMode, @@ -83,6 +84,7 @@ import { resolveRestingComposerControlsMinimumWidth, resolveRestingComposerControlsNaturalWidth, } from "./composerFooterLayout"; +import { NoProjectChip } from "./chat/NoProjectChip"; import { useComposerMenuProps } from "./chat/composerEventScope"; import { cn } from "~/lib/utils"; import { observeResize } from "~/lib/observeResize"; @@ -719,6 +721,8 @@ interface RunContextSelectorProps { onUsePreviousWorktree: () => void; addTargets: readonly AddProjectTarget[]; onAddToMachine: (environmentId: EnvironmentId) => void; + /** A scratch thread with no project: the row names the folder as "No project". */ + noProject?: boolean; } const RunContextSelector = memo(function RunContextSelector({ @@ -742,23 +746,27 @@ const RunContextSelector = memo(function RunContextSelector({ onUsePreviousWorktree, addTargets, onAddToMachine, + noProject = false, }: RunContextSelectorProps) { const composerFloatingLayerProps = useComposerMenuProps(); const activeEnvironment = availableEnvironments?.find((env) => env.environmentId === environmentId) ?? null; - const WorkspaceIcon = - effectiveEnvMode === "worktree" + const WorkspaceIcon = noProject + ? FolderIcon + : effectiveEnvMode === "worktree" ? FolderGit2Icon : activeWorktreePath ? FolderGitIcon : FolderIcon; - const workspaceLabel = forceNewWorktree - ? resolveEnvModeLabel("worktree") - : envModeLocked - ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode) - : effectiveEnvMode === "worktree" - ? resolveEnvModeLabel("worktree") - : resolveCurrentWorkspaceLabel(activeWorktreePath); + const workspaceLabel = noProject + ? "No project" + : forceNewWorktree + ? resolveEnvModeLabel("worktree") + : envModeLocked + ? resolveLockedWorkspaceLabel(activeWorktreePath, effectiveEnvMode) + : effectiveEnvMode === "worktree" + ? resolveEnvModeLabel("worktree") + : resolveCurrentWorkspaceLabel(activeWorktreePath); const isPanel = displayMode === "panel"; const isLocked = !isPanel && (envLocked || envModeLocked); const workspacePath = @@ -777,7 +785,7 @@ const RunContextSelector = memo(function RunContextSelector({ {isPanel - ? forceNewWorktree + ? forceNewWorktree && !noProject ? "Each model starts in its own worktree." : (workspacePath ?? workspaceLabel) : workspaceLabel} @@ -835,7 +843,7 @@ const RunContextSelector = memo(function RunContextSelector({ className={cn( isPanel ? THREAD_DETAILS_PANEL_LOCKED_ROW_CLASS - : "inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-1.75 text-dim-foreground text-caption sm:h-6", + : "inline-flex h-7 min-w-0 max-w-[48%] flex-initial items-center justify-start gap-1 rounded-md border border-transparent px-1.75 text-meta text-dim-foreground sm:h-6", )} onContextMenu={handleContextMenu} aria-label={ @@ -1083,9 +1091,12 @@ function useStripCompaction(): readonly [ const attach = (current: HTMLDivElement | null) => { if (!current) return; + let measured = false; const labelAnimations = new Map(); const measure = () => { + // Hidden tabs retain stage layout but must keep their last label mode. + if (!current.checkVisibility()) return; // The committed attribute, not render state, so a stale closure cannot misread it. const compact = readStripCompaction(current); const stripWidth = current.clientWidth; @@ -1144,6 +1155,8 @@ function useStripCompaction(): readonly [ availableWidth: available, labelsSavedWidth, }); + const firstMeasure = !measured; + measured = true; if (nextCompaction === compact) return; const previousRects = new Map( Array.from(current.querySelectorAll(COMPOSER_CONTEXT_LABEL_SELECTOR)).map( @@ -1151,7 +1164,7 @@ function useStripCompaction(): readonly [ ), ); flushSync(() => setCompaction(nextCompaction)); - animateLabelWidths(previousRects, labelAnimations); + if (!firstMeasure) animateLabelWidths(previousRects, labelAnimations); }; let stopResize = () => {}; @@ -1226,6 +1239,7 @@ export const BranchToolbar = memo(function BranchToolbar({ // The composer retains resolved thread details while bootstrap cleanup can remove its shell. const activeProjectRef = scopeProjectRef(environmentId, projectId); const activeProject = useProject(activeProjectRef); + const scratchWorkspaceRoot = useServerConfigs().get(environmentId)?.scratchWorkspaceRoot; const activeWorktreePath = forceNewWorktree ? null : worktreePath; const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode; const envModeLocked = envLocked || (isServerThread && activeWorktreePath !== null); @@ -1284,6 +1298,7 @@ export const BranchToolbar = memo(function BranchToolbar({ return (
{panelSection !== "branch" ? ( + // Without Git there is no worktree to make, so the row only names the folder. + {isScratchProject(activeProject, scratchWorkspaceRoot) ? ( + + ) : null} {!showGitControls ? workspaceMenu : null} {showGitControls ? machineMenu : null} {showGitControls ? ( diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 927a253ead2b8..0c83e0a080860 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -35,7 +35,6 @@ import { type ProviderInstanceId, type ProviderThreadId, type ServerProvider, - type ScopedProjectRef, type ScopedThreadRef, type ThreadContextRecord, type EnvironmentContextRecord, @@ -108,7 +107,6 @@ type ThreadProjection = EnvironmentThread["projection"]; type ThreadStructureRow = ThreadStructure["visibleTurnItems"][number]; export const LAST_INVOKED_SCRIPT_BY_PROJECT_KEY = "t3code:last-invoked-script-by-project"; -export const MAX_HIDDEN_MOUNTED_TERMINAL_THREADS = 10; export const ENVIRONMENT_RECONNECT_WARNING_GRACE_MS = 2_000; @@ -395,16 +393,6 @@ export function resolveThreadSwitchTimeline(input: return input.nextEntries; } -export function startNewThreadForProject( - projectRef: ScopedProjectRef | null, - handleNewThread: (projectRef: ScopedProjectRef) => Promise, -): boolean { - if (projectRef === null) return false; - void handleNewThread(projectRef); - - return true; -} - export function resolveThreadMetadataUpdateForNextTurn(input: { currentModelSelection: ModelSelection; nextModelSelection?: ModelSelection; @@ -557,37 +545,6 @@ export function getAntigravitySendBlockReason( return null; } -export function reconcileMountedTerminalThreadIds(input: { - currentThreadIds: ReadonlyArray; - openThreadIds: ReadonlyArray; - activeThreadId: string | null; - activeThreadTerminalOpen: boolean; - maxHiddenThreadCount?: number; -}): string[] { - const openThreadIdSet = new Set(input.openThreadIds); - const hiddenThreadIds = input.currentThreadIds.filter( - (threadId) => threadId !== input.activeThreadId && openThreadIdSet.has(threadId), - ); - const maxHiddenThreadCount = Math.max( - 0, - input.maxHiddenThreadCount ?? MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - ); - const nextThreadIds = - hiddenThreadIds.length > maxHiddenThreadCount - ? hiddenThreadIds.slice(-maxHiddenThreadCount) - : hiddenThreadIds; - - if ( - input.activeThreadId && - input.activeThreadTerminalOpen && - !nextThreadIds.includes(input.activeThreadId) - ) { - nextThreadIds.push(input.activeThreadId); - } - - return nextThreadIds; -} - // Null identifies the project checkout when a launch or session is known. export function resolveTerminalWorktreePath(input: { launchContext: { readonly worktreePath: string | null } | null; diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 33a090d5b379d..eda1d72f22772 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -45,7 +45,6 @@ import { recallCheckoutIsRepo, rememberCheckoutIsRepo } from "./ChatView.logic"; import { useLogicalProjectEnvironments } from "../hooks/useLogicalProjectEnvironments"; import { useStableCallback } from "../hooks/useStableCallback"; import { useProjectScriptEditing } from "./chat/useProjectScriptEditing"; -import { RightPanelSurfaceContent } from "./chat/RightPanelSurfaceContent"; import { usePendingThreadRequests } from "./chat/usePendingThreadRequests"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; @@ -125,6 +124,7 @@ import { resolveThreadReferenceCopyTarget } from "@t3tools/client-runtime/thread import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { useAtomValue } from "@effect/atom-react"; import { + type CSSProperties, type MouseEvent as ReactMouseEvent, type SetStateAction, use, @@ -180,14 +180,12 @@ import { useRightPanelStore, } from "../rightPanelStore"; import { isPreviewSupportedInRuntime, useThreadPreviewState } from "../previewStateStore"; -import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; import { ThreadPreviewMiniPlayer, useThreadPreviewCanvasSource, } from "./preview/ThreadPreviewMiniPlayer"; import { createChatCanvasStore, resolveChatCanvasModel } from "./chat/canvasStore"; import { usePreviewSession } from "./preview/usePreviewSession"; -import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; import { previewMiniPlayerSourceKey, @@ -204,7 +202,6 @@ import { isEditableFocused } from "../lib/editableFocus"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { shortcutLabelForCommand } from "../keybindings"; import { cn } from "~/lib/utils"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { projectScriptIdFromCommand } from "~/projectScripts"; import { newMessageId } from "~/lib/utils"; @@ -248,6 +245,7 @@ import { useComposerDraftStore, DraftId, } from "../composerDraftStore"; +import { useDraftSendOnOpenStore } from "../draftSendOnOpenStore"; import { type TerminalContextDraft } from "../lib/terminalContext"; import { collectInlineContextIds } from "../lib/composerContextReferences"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; @@ -311,20 +309,19 @@ import { ProviderSubagentBar } from "./chat/ProviderSubagentBar"; import { getTriggerDisplayModelName } from "./chat/providerIconUtils"; import { pendingApprovalSlotLabel } from "./chat/MessagesTimeline.logic"; import { resolveScrollToEndClearance } from "./composerFooterLayout"; -import { ChatHeader } from "./chat/ChatHeader"; +import { ChatHeader, type HiddenTerminalChip } from "./chat/ChatHeader"; import { useSideChat } from "./chat/useSideChat"; import { useRemoteOpenState } from "~/remoteOpen"; import { shouldShowOpenInPicker } from "./chat/OpenInPicker.logic"; +import { + ChatPaneHeader, + ChatPaneHeaderControls, + useHeaderControlsInset, +} from "./chat/ChatPaneHeader"; import { PaneHeaderActions } from "./chat/PaneHeaderActions"; import GitActionsControl from "./GitActionsControl"; import { useOpenFavoriteEditorShortcut } from "./chat/OpenInPickerShortcut"; -import { - PanelLayoutControls, - type PanelLayoutControlsProps, - RightPanelMaximizeControl, - ThreadPanelHeaderControl, - WorkspaceTitlebarControls, -} from "./chat/PanelLayoutControls"; +import { PanelLayoutControls, type PanelLayoutControlsProps } from "./chat/PanelLayoutControls"; import type { ExpandedImagePreview } from "./chat/ExpandedImagePreview"; import { ThreadDetailsPanel } from "./chat/ThreadDetailsPanel"; import { NoActiveThreadState } from "./NoActiveThreadState"; @@ -348,8 +345,7 @@ import { QueuedRunsControl, type QueuedRunsControlHandle } from "./chat/QueuedRu import { useThreadLinkedPullRequest } from "./pullRequest/useThreadLinkedPullRequest"; import { useProactivePanels } from "../hooks/useProactivePanels"; import { useIncrementalTimelineEntries } from "../hooks/useIncrementalTimelineEntries"; -import { useRightPanelSurfaceClose } from "../hooks/useRightPanelSurfaceClose"; -import { type ComposerBannerStackItem } from "./chat/ComposerBannerStack"; +import type { ComposerBannerStackItem } from "./chat/ComposerBannerStack"; import { modelFallbackBannerItem } from "./chat/ComposerUsageLimits"; import { ComposerSurface } from "./chat/ComposerSurface"; import { resolveThreadSyncPhase } from "../threadSync"; @@ -375,13 +371,15 @@ import { resolveComposerProviderSelection, resolveThreadMetadataUpdateForNextTurn, resolveSendEnvMode, - startNewThreadForProject, codexArtifactTemplatePromptToAppend, shouldRefocusComposerOnWindowFocus, hasCompactableConversation, } from "./ChatView.logic"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { useComposerHandleContext } from "../composerHandleContext"; +import { useLayoutPane, useLayoutPaneFocusedRef } from "../layout/layoutPaneContext"; +import { publishThreadChatBridge } from "../layout/threadSurfaceBridge"; +import { runInNewLayoutTab } from "../layout/layoutStore"; import { clampFileAttachmentUploadBytes } from "@t3tools/client-runtime/state/attachments"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { assetEnvironment } from "../state/assets"; @@ -391,8 +389,6 @@ import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { observeResize } from "~/lib/observeResize"; import { ThreadVisitWatermark } from "./chat/ThreadVisitWatermark"; -import { ChatTerminalDrawers } from "./chat/ChatTerminalDrawers"; -import { ChatRightPanel } from "./chat/ChatRightPanel"; import { useDraftEnvironmentRouting } from "./chat/useDraftEnvironmentRouting"; import { branchMismatchBannerItem, extensionStatusBannerItem } from "./chat/ThreadActivityBanners"; import { ThreadNow } from "./chat/ThreadNow"; @@ -425,8 +421,6 @@ const EMPTY_AUTO_UPDATE_ENVIRONMENTS: ReadonlyArray = [ const EMPTY_COMPOSER_BANNER_ITEMS: ReadonlyArray = []; const EMPTY_PROVIDER_SLASH_COMMANDS: ServerProvider["slashCommands"] = []; -const EMPTY_PENDING_FILE_SURFACE_IDS: ReadonlySet = new Set(); - type ChatViewProps = | { environmentId: EnvironmentId; @@ -538,7 +532,11 @@ export default function ChatView(props: ChatViewProps) { }); const promptRef = useRef(""); const localComposerRef = useRef(null); - const composerRef = useComposerHandleContext() ?? localComposerRef; + const layoutPane = useLayoutPane(); + const layoutPaneFocusedRef = useLayoutPaneFocusedRef(); + const ownsThread = layoutPane.focused || layoutPane.related; + const paletteComposerRef = useComposerHandleContext(); + const composerRef = (ownsThread ? paletteComposerRef : null) ?? localComposerRef; const queuedRunEdit = useQueuedRunEdit({ environmentId, routeThreadRef, @@ -604,23 +602,36 @@ export default function ChatView(props: ChatViewProps) { const clientSettingsHydrated = useClientSettingsHydrated(); const timestampFormat = settings.timestampFormat; const navigate = useNavigate(); - // Selects primitives: an object here is new on every router store update, which - // re-renders ChatView on unrelated navigation state changes. - const citationHref = useLocation({ select: (location) => location.href }); - const citationKey = useLocation({ - select: (location) => location.state.assistantCitationActivation ?? location.state.__TSR_key, + const citationLocation = useLocation({ + // Other panes navigating must not rerender this thread's timeline. + select: (location) => { + const citation = assistantCitationFromLocation(location.href); + return citation && citation.environmentId === environmentId && (citation.threadId === threadId || (!embedded && layoutPane.focused)) + ? { + href: location.href, + key: + location.state.assistantCitationActivation ?? + location.state.__TSR_key ?? + location.href, + } + : null; + }, + structuralSharing: true, }); - const citationLocation = { href: citationHref, key: citationKey }; - const locationCitation = assistantCitationFromLocation(citationLocation.href); - const citationActivation = citationLocation.key ?? citationLocation.href; + const locationCitation = citationLocation + ? assistantCitationFromLocation(citationLocation.href) + : null; + const citationActivation = citationLocation?.key ?? ""; const citationRequest = ( - locationCitation && - locationCitation.environmentId === environmentId && - locationCitation.threadId === threadId + locationCitation && locationCitation.threadId === threadId ? { citation: locationCitation, key: citationActivation } : null ) as AssistantCitationRequest | null; - useSideChatCitationReveal(embedded ? null : routeThreadRef, locationCitation, citationActivation); + useSideChatCitationReveal( + embedded || !layoutPane.focused ? null : routeThreadRef, + locationCitation, + citationActivation, + ); const citationHistory = useCitedHistory(citationRequest); const { resolvedTheme } = useTheme(); const composerRuntimeMode = useComposerDraftStore( @@ -660,6 +671,7 @@ export default function ChatView(props: ChatViewProps) { const composerImagesRef = useRef([]); const composerFilesRef = useRef([]); const composerTerminalContextsRef = useRef([]); + const [headerControlsInset, measureHeaderControls] = useHeaderControlsInset(); const branchToolbarRef = useRef(null); const [composerStashHost, setComposerStashHost] = useState(null); const [composerTrayControlsHost, setComposerTrayControlsHost] = useState( @@ -750,6 +762,9 @@ export default function ChatView(props: ChatViewProps) { return () => window.removeEventListener("dragend", clearWorkspaceFileDrag); }, [isWorkspaceFileDragActive]); const prefersPlanSidebarSheet = useMediaQuery(RIGHT_PANEL_INLINE_LAYOUT_MEDIA_QUERY); + // Layout opens are tabs at every viewport size. Only explicit shortcuts + // create splits; the legacy drawer/sheet is for views outside the layout. + const companionPanes = layoutPane.tabId !== null; const isMobileViewport = useMediaQuery("max-sm"); // Focusing the composer without a tap raises the on-screen keyboard on touch devices. const isCoarsePointer = useMediaQuery({ pointer: "coarse" }); @@ -1102,15 +1117,14 @@ export default function ChatView(props: ChatViewProps) { const rightPanelState = useRightPanelStore((state) => selectThreadRightPanelState(state.byThreadKey, activeThreadRef), ); - const shouldUsePlanSidebarSheet = prefersPlanSidebarSheet && rightPanelState.maximized !== true; + const shouldUsePlanSidebarSheet = + !companionPanes && + (prefersPlanSidebarSheet || layoutPane.narrow) && + rightPanelState.maximized !== true; const activeRightPanelSurface = useRightPanelStore((state) => selectActiveRightPanelSurface(state.byThreadKey, activeThreadRef), ); const activePreviewState = useThreadPreviewState(activeThreadRef); - const activePreviewServerEpoch = activePreviewState.serverEpoch; - const resolvePreviewRuntimeTabId = activeThreadRef - ? (tabId: string) => previewRuntimeTabId(activeThreadRef, activePreviewServerEpoch, tabId) - : undefined; const activePreviewMiniPlayer = usePreviewMiniPlayerStore((state) => selectThreadPreviewMiniPlayer(state.byThreadKey, activeThreadRef), ); @@ -1136,25 +1150,23 @@ export default function ChatView(props: ChatViewProps) { panelAnimationDurationMs, ); const rightPanelPresent = rightPanelPresence.present; + const rightPanelControlsInPanel = shouldUsePlanSidebarSheet && rightPanelPresent && rightPanelOpen; - const rightPanelControlsAtRoot = rightPanelPresent && !shouldUsePlanSidebarSheet; + const rightPanelControlsAtRoot = + rightPanelPresent && !shouldUsePlanSidebarSheet && !companionPanes; const renderedRightPanelSurface = rightPanelPresence.value?.activeSurface ?? null; - const renderedRightPanelSurfaces = rightPanelPresence.value?.surfaces ?? []; - const previewMiniPlayerVisible = shouldRenderPreviewMiniPlayer( - activePreviewMiniPlayer?.source ?? null, - renderedRightPanelSurface, - ); - const canMaximizeRightPanel = rightPanelOpen; - // An embedded side chat has no panel; a stale maximized flag would hide its own column. - const rightPanelMaximized = - !embedded && canMaximizeRightPanel && rightPanelState.maximized === true; - const toggleRightPanelMaximizedStable = useStableCallback(() => { - if (canMaximizeRightPanel && activeThreadRef) { - useRightPanelStore.getState().setMaximized(activeThreadRef, !rightPanelMaximized); - } - }); - const inlineRightPanelOwnsTitleBar = rightPanelOpen && !shouldUsePlanSidebarSheet; + // In a layout pane every shown surface is a tab of its own, not only the active one. + const previewMiniPlayerVisible = companionPanes + ? (rightPanelOpen ? rightPanelState.surfaces : [null]).every((surface) => + shouldRenderPreviewMiniPlayer(activePreviewMiniPlayer?.source ?? null, surface), + ) + : shouldRenderPreviewMiniPlayer( + activePreviewMiniPlayer?.source ?? null, + renderedRightPanelSurface, + ); + const inlineRightPanelOwnsTitleBar = + rightPanelOpen && !shouldUsePlanSidebarSheet && !companionPanes; const [threadPanelPopoverHandle] = useState(PopoverCreateHandle); const [chatCanvasStore] = useState(createChatCanvasStore); @@ -1178,8 +1190,10 @@ export default function ChatView(props: ChatViewProps) { !isServerThread && draftFallback ? draftFallback.selection : activeProjectSettings.settings.defaultModelSelection; + // The notch's "+": a new thread in this project, in a new tab of this pane. const handleNewThreadInActiveProject = () => { - startNewThreadForProject(activeProjectRef, handleNewThread); + if (activeProjectRef === null) return; + void runInNewLayoutTab(() => handleNewThread(activeProjectRef)); }; const handleNewThreadInActiveProjectStable = useStableCallback(handleNewThreadInActiveProject); const projectGroupingSettings = selectProjectGroupingSettings(settings); @@ -1201,29 +1215,6 @@ export default function ChatView(props: ChatViewProps) { const activeProjectKey = activeProject ? `${activeProject.environmentId}:${activeProject.workspaceRoot}` : null; - const [pendingFileSurfaceIdsByProject, setPendingFileSurfaceIdsByProject] = useState< - ReadonlyMap> - >(() => new Map()); - const pendingFileSurfaceIds = activeProjectKey - ? (pendingFileSurfaceIdsByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS) - : EMPTY_PENDING_FILE_SURFACE_IDS; - const handleFilePendingChange = (relativePath: string, pending: boolean) => { - if (!activeProjectKey) return; - setPendingFileSurfaceIdsByProject((currentByProject) => { - const current = currentByProject.get(activeProjectKey) ?? EMPTY_PENDING_FILE_SURFACE_IDS; - const surfaceId = `file:${relativePath}`; - if (current.has(surfaceId) === pending) return currentByProject; - const next = new Set(current); - if (pending) next.add(surfaceId); - else next.delete(surfaceId); - const nextByProject = new Map(currentByProject); - if (next.size === 0) nextByProject.delete(activeProjectKey); - else nextByProject.set(activeProjectKey, next); - return nextByProject; - }); - }; - const handleFilePendingChangeStable = useStableCallback(handleFilePendingChange); - const configuredPreviewUrls = getConfiguredPreviewUrls(activeProjectScripts); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- drops file surfaces from the right panel store once bootstrap shows the thread has no workspace useEffect(() => { @@ -1786,6 +1777,7 @@ export default function ChatView(props: ChatViewProps) { const activeWorkspaceRoot = activeThreadWorktreePath ?? activeProjectCwd ?? undefined; const activeTerminalLaunchContext = terminalUiLaunchContext?.threadId === activeThreadId ? terminalUiLaunchContext : null; + // A draft without a project (the scratch folder) never shows git controls. const liveIsGitRepo = gitStatusQuery.data?.isRepo; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records live git repo status in the session cache read while the status query is unavailable useEffect(() => { @@ -1793,7 +1785,14 @@ export default function ChatView(props: ChatViewProps) { rememberCheckoutIsRepo(environmentId, gitStatusCwd, liveIsGitRepo); } }, [environmentId, gitStatusCwd, liveIsGitRepo]); - const isGitRepo = liveIsGitRepo ?? recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? true; + const gitProjectIsScratch = + activeProject !== null && isScratchProject(activeProject, serverConfig?.scratchWorkspaceRoot); + const isGitRepo = + gitProjectIsScratch && !isServerThread + ? false + : (liveIsGitRepo ?? + recallCheckoutIsRepo(environmentId, gitStatusCwd) ?? + !gitProjectIsScratch); // Provider-native subagents cannot take messages; approvals and questions go to the top-level parent. const showProviderSubagentBar = isProviderSubagent; const composerMounted = !showProviderSubagentBar; @@ -1829,32 +1828,6 @@ export default function ChatView(props: ChatViewProps) { isGitRepo, showEnvironmentIndicator: showComposerEnvironmentIndicator, }); - const terminalShortcutLabelOptions = { - context: { - terminalFocus: true, - terminalOpen: Boolean(terminalUiState.terminalOpen), - }, - }; - const splitTerminalShortcutLabel = shortcutLabelForCommand( - keybindings, - "terminal.split", - terminalShortcutLabelOptions, - ); - const splitTerminalVerticalShortcutLabel = shortcutLabelForCommand( - keybindings, - "terminal.splitVertical", - terminalShortcutLabelOptions, - ); - const newTerminalShortcutLabel = shortcutLabelForCommand( - keybindings, - "terminal.new", - terminalShortcutLabelOptions, - ); - const closeTerminalShortcutLabel = shortcutLabelForCommand( - keybindings, - "terminal.close", - terminalShortcutLabelOptions, - ); const { needsLoadBalancing, @@ -1885,7 +1858,7 @@ export default function ChatView(props: ChatViewProps) { }); const { focusComposer: focusComposerStable, scheduleComposerFocus: scheduleComposerFocusStable } = - useComposerFocus(composerRef); + useComposerFocus(composerRef, layoutPaneFocusedRef); const canInterruptRunningThread = canOperateThread && deriveCanInterruptRunningThread(activeThread !== undefined, activeRuntime); const onInterrupt = async () => { @@ -1952,17 +1925,13 @@ export default function ChatView(props: ChatViewProps) { const queuedRunsControlRef = useRef(null); const { - activatePanelTerminalStable, + startTerminal, addTerminalContextToDraftStable, - addTerminalSurfaceStable, - closePanelTerminalStable, lastInvokedScriptByProjectId, runProjectScriptStable, runShellCommandStable, - setTerminalOpenStable, - splitPanelTerminalStable, - splitPanelTerminalVerticalStable, toggleTerminalVisibilityStable, + setTerminalOpenStable, shortcuts: terminalShortcuts, } = useChatTerminalActions({ environmentId, @@ -1972,7 +1941,6 @@ export default function ChatView(props: ChatViewProps) { activeThreadId, activeThreadRef, activeProject, - activeRightPanelSurface, runningTerminalIds, activeServerOrderedTerminalIds, activeTerminalLabelsById, @@ -1982,6 +1950,24 @@ export default function ChatView(props: ChatViewProps) { setTerminalUiLaunchContext, setThreadError, }); + // A hidden terminal pane stays in the chat's notch until cmd+J brings it back. + const terminalToggleShortcutLabel = shortcutLabelForCommand(keybindings, "terminal.toggle"); + const hasHiddenTerminal = + companionPanes && !terminalUiState.terminalOpen && terminalUiState.terminalIds.length > 0; + const hiddenTerminalChip = ( + hasHiddenTerminal + ? { + shortcutLabel: terminalToggleShortcutLabel, + running: activeThreadKnownSessions.some( + (session) => + terminalUiState.terminalIds.includes(session.target.terminalId) && + session.state.status === "running", + ), + onShow: toggleTerminalVisibilityStable, + } + : null + ) as HiddenTerminalChip | null; + const { saveProjectScript: saveProjectScriptStable, updateProjectScript: updateProjectScriptStable, @@ -1997,6 +1983,8 @@ export default function ChatView(props: ChatViewProps) { AuthPreviewOperateScope, ); const shownPreviewMiniPlayer = + layoutPane.visible && + !backgroundThread && activeThreadRef && activePreviewMiniPlayer && previewMiniPlayerVisible && @@ -2082,16 +2070,6 @@ export default function ChatView(props: ChatViewProps) { visiblePullRequestCount, onDiffPanelOpen, }); - const { - closeSurface: closeRightPanelSurface, - closeOtherSurfaces: closeOtherRightPanelSurfacesStable, - closeSurfacesToRight: closeRightPanelSurfacesToRightStable, - closeAllSurfaces: closeAllRightPanelSurfacesStable, - } = useRightPanelSurfaceClose({ - threadRef: activeThreadRef, - terminalLabelsById: activeTerminalLabelsById, - }); - const closeRightPanelSurfaceStable = closeRightPanelSurface; const persistThreadSettingsForNextTurn = async (input: { threadId: ThreadId; createdAt: string; @@ -2187,13 +2165,28 @@ export default function ChatView(props: ChatViewProps) { setTimelineAnchor, }); + // Moving between panes keeps the workspace control the user chose in the pane it enters. + const threadEntryPaneFocusedRef = useRef(layoutPane.focused); + const keepsEnteredPaneFocus = useStableCallback(() => { + if (threadEntryPaneFocusedRef.current || layoutPane.tabId === null) return false; + const activeArea = document.activeElement?.closest("[data-workspace-area]"); + return ( + activeArea?.closest("[data-layout-tab-id]")?.dataset.layoutTabId === + layoutPane.tabId + ); + }); useFocusComposerOnThreadOpen( activeThreadRef, workspaceLayoutElement, - !embedded, + !embedded && layoutPane.focused && !backgroundThread, terminalUiState.terminalOpen, isCoarsePointer ? ignoreComposerFocus : focusComposerStable, + keepsEnteredPaneFocus, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- records pane focus after thread entry reads it, so the next entry knows it moved between panes + useEffect(() => { + threadEntryPaneFocusedRef.current = layoutPane.focused; + }, [layoutPane.focused]); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- window focus listener that refocuses the composer after the window regains focus on a fine pointer useEffect(() => { @@ -2208,6 +2201,7 @@ export default function ChatView(props: ChatViewProps) { } let frame: number | null = null; const onWindowFocus = () => { + if (!layoutPaneFocusedRef.current) return; if (frame !== null) window.cancelAnimationFrame(frame); // Wait an extra frame after window focus so the composer treats focus as an expansion request. frame = window.requestAnimationFrame(() => { @@ -2235,6 +2229,7 @@ export default function ChatView(props: ChatViewProps) { isCoarsePointer, isMobileViewport, terminalUiState.terminalOpen, + layoutPaneFocusedRef, ]); // Promotion drops draftId while a send may still be dispatching, so key by thread. @@ -2367,11 +2362,15 @@ export default function ChatView(props: ChatViewProps) { useLayoutEffect(() => { if (!composerOverlayElement) return; - const updateHeight = () => { - publishComposerOverlayHeightStable(composerOverlayElement.getBoundingClientRect().height); + const updateHeight = (entries: readonly ResizeObserverEntry[]) => { + const overlayEntry = entries.find((entry) => entry.target === composerOverlayElement); + publishComposerOverlayHeightStable( + overlayEntry?.borderBoxSize[0]?.blockSize ?? + composerOverlayElement.getBoundingClientRect().height, + ); }; - updateHeight(); + updateHeight([]); // The hero overlay fills the pane, so the composer's own growth only shows on its stack. const stack = isDraftHeroState ? composerOverlayElement.querySelector('[data-chat-composer-stack="true"]') @@ -2412,25 +2411,15 @@ export default function ChatView(props: ChatViewProps) { }) : null; const { - activateRightPanelSurfaceStable, - addDeviceSurfaceStable, - addDiffSurfaceStable, - addFilesSurfaceStable, - addPullRequestSurfaceStable, - addPullRequestsSurfaceStable, - closePreviewPanelStable, - copyRightPanelFilePathStable, - createBrowserSurface, - createBrowserSurfaceStable, - moveRightPanelSurfaceStable, + besideAvailable, + openBesideStable, onOpenToolDiffStable, onOpenTurnDiffStable, openChangesFromThreadPanelStable, - openFileSurfaceStable, - toggleRightPanelStable, toggleThreadPanelStable, shortcuts: rightPanelShortcuts, } = useRightPanelActions({ + startTerminal, activeThreadRef, activeProject, activeRightPanelSurface, @@ -2446,15 +2435,10 @@ export default function ChatView(props: ChatViewProps) { canOperatePreview, diffOpen, previewPanelOpen, - rightPanelOpen, supportsPullRequests, pullRequestsSurfaceAvailable, - gitCwd, - activeProjectCwd, onDiffPanelOpen, - closeRightPanelSurface, }); - const pullRequestSurfaceAvailable = supportsPullRequests && pullRequestPanelTarget !== null; const { acknowledgeActiveThreadWoke, activeBranchMismatchKey, @@ -2650,7 +2634,7 @@ export default function ChatView(props: ChatViewProps) { : null; // The working bar names the request while its row stays actionable. const firstPendingApproval = pendingApprovals[0] ?? null; - const timelinePendingRequest = ((): TimelinePendingRequest | null => { + const timelinePendingRequest = (() => { if (firstPendingApproval) { return { kind: "approval", @@ -2669,7 +2653,7 @@ export default function ChatView(props: ChatViewProps) { activeThreadShell?.hasPendingUserInput !== false, } : null; - })(); + })() as TimelinePendingRequest | null; const limitRecovery = activeThreadShell?.limitRecovery ?? null; const limitedRunId = serverRuntime?.status === "failed" && serverRuntime.lastErrorClass === "usage_limit" @@ -2720,7 +2704,7 @@ export default function ChatView(props: ChatViewProps) { const onToggleTimelineUsageLimitSnoozeStable = useStableCallback( onToggleTimelineUsageLimitSnooze, ); - const timelineUsageLimit = ((): TimelineUsageLimit | null => { + const timelineUsageLimit = (() => { if (limitedRunId === null || limitThreadId === null) return null; return { runId: limitedRunId, @@ -2728,7 +2712,7 @@ export default function ChatView(props: ChatViewProps) { snoozed: limitSnoozed, onToggleSnooze: limitResetAt === null ? null : onToggleTimelineUsageLimitSnoozeStable, }; - })(); + })() as TimelineUsageLimit | null; const focusComposerAtEnd = () => composerRef.current?.focusAtEnd(); const focusComposerAtEndStable = useStableCallback(focusComposerAtEnd); const composerBannerItems = (() => { @@ -2856,7 +2840,6 @@ export default function ChatView(props: ChatViewProps) { isWeb: !isElectron, isDesktop: isElectron, }); - const getShortcutContextStable = useStableCallback(getShortcutContext); const timelineSkills = activeProviderStatus ? resolveProviderSkillsForCwd(activeProviderStatus, gitCwd) : EMPTY_PROVIDER_SKILLS; @@ -2929,15 +2912,6 @@ export default function ChatView(props: ChatViewProps) { }, }); - // A focused desktop browser page forwards these chords as menu actions. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to desktop bridge menu actions that toggle the right panel - useEffect(() => { - if (embedded) return; - return window.desktopBridge?.onMenuAction((action) => { - if (action === "rightPanel.toggle") toggleRightPanelStable(); - }); - }, [embedded, toggleRightPanelStable]); - usePasteToFocusComposer(embedded ? null : activeThreadId, composerRef); // A delegated task is a remote thread's subagent. const isSubagentThread = @@ -2959,11 +2933,6 @@ export default function ChatView(props: ChatViewProps) { if (activeThread) setThreadError(activeThread.id, message); }, }); - // A maximized panel covers the terminal drawer the command runs in. - const runSideChatShellCommandStable = useStableCallback((command: string) => { - if (activeThreadRef) useRightPanelStore.getState().setMaximized(activeThreadRef, false); - runShellCommandStable(command); - }); // Main-thread surfaces show this reason; `/side` sent anyway gets it as a toast. const sideChatDisabledReason = activeThread?.lineage.sideChat === true @@ -2983,7 +2952,6 @@ export default function ChatView(props: ChatViewProps) { ) ? "Available once the thread's worktree is set up." : null; - const openSideChatStable = useStableCallback(() => void sideChat.open()); const sendSideChatStable = useStableCallback((prompt: string) => sideChat.open(prompt)); const { onConfirmRevertStable, onForkFromRunStable, onRevertTimelineTurnStable } = useTimelineRewind({ @@ -3159,7 +3127,7 @@ export default function ChatView(props: ChatViewProps) { environmentId, activeProviderStatus, providerStatuses, - usageLimitSources, + usageLimitSources: serverConfig?.usageLimitSources ?? EMPTY_USAGE_LIMIT_SOURCES, canSwitchProvider: lockedProvider === null || supportsProviderSwitchingViaHandoff, providerStatus: visibleProviderStatus, providerStatusDismissalKey, @@ -3264,15 +3232,8 @@ export default function ChatView(props: ChatViewProps) { /> ) : null; - const resendAfterWorkLocally = () => { - setWorkLocallyResendDraftId(null); - void onComposerSendStable(); - }; - const resendAfterWorkLocallyStable = useStableCallback(resendAfterWorkLocally); - const workLocallyResendReady = - workLocallyResendDraftId !== null && - workLocallyResendDraftId === draftId && - isLocalDraftThread && + // Transient send blockers must also hold surface annotations until the chat can send. + const chatSendReady = !isSendBusy && !isRevertingCheckpoint && !threadDetailLoading && @@ -3281,6 +3242,37 @@ export default function ChatView(props: ChatViewProps) { !activeEnvironmentUnavailable && !activePendingUserInput && !feedbackUploading; + // The thread's surface tabs reach this chat through its bridge: diffs and + // files refresh on its workspace changes, browser annotations send from its + // composer, terminal selections go into it. + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the mounted chat bridge to the external surface registry and releases it on cleanup + useEffect(() => { + if (!activeThreadRef) return; + return publishThreadChatBridge(activeThreadRef, { + workspaceMutationId, + sendReady: chatSendReady, + sendAnnotation: (annotation, image) => { + sendPreviewAnnotationStable(annotation, image); + }, + addTerminalContext: addTerminalContextToDraftStable, + terminalLaunchContext: activeTerminalLaunchContext ?? null, + }); + }, [ + activeTerminalLaunchContext, + activeThreadRef, + addTerminalContextToDraftStable, + chatSendReady, + workspaceMutationId, + sendPreviewAnnotationStable, + ]); + // `chatSendReady` (above) holds every state that makes `onSend` bail and wait. + const draftSendReady = isLocalDraftThread && chatSendReady; + const resendAfterWorkLocallyStable = useStableCallback(() => { + setWorkLocallyResendDraftId(null); + void onComposerSendStable(); + }); + const workLocallyResendReady = + workLocallyResendDraftId !== null && workLocallyResendDraftId === draftId && draftSendReady; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- switches the draft to local mode, then resends the waiting message once sending is idle useEffect(() => { if ( @@ -3305,6 +3297,30 @@ export default function ChatView(props: ChatViewProps) { setDraftThreadContext, workLocallyResendReady, ]); + // Send on the no-projects home lands here with the message carried in; + // send it once, as soon as the same readiness holds. + const sendOnOpenDraftId = useDraftSendOnOpenStore((store) => store.draftId); + const sendOnOpenReady = + sendOnOpenDraftId !== null && sendOnOpenDraftId === draftId && draftSendReady; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- delivers the pending cross-pane send request to the mounted composer once its provider is ready + useEffect(() => { + if ( + !sendOnOpenReady || + sendInFlightRef.current || + feedbackUploadsInFlightRef.current.has(routeThreadKey) + ) { + return; + } + useDraftSendOnOpenStore.setState({ draftId: null }); + // Out of the commit: sending flushes the composer synchronously. + queueMicrotask(() => void onComposerSendStable()); + }, [ + routeThreadKey, + sendOnOpenReady, + sendInFlightRef, + feedbackUploadsInFlightRef, + onComposerSendStable, + ]); const shownThreadSyncPhase = activeEnvironmentUnavailable ? null : delayedThreadSyncPhase; const hasThreadNow = @@ -3395,82 +3411,17 @@ export default function ChatView(props: ChatViewProps) { (folders: Parameters[0]) => composerRef.current?.addDroppedFolders(folders), ); - // Above the early return, where React Compiler can cache it for the side chat pane. - const sideChatParentPanel = { - openToolDiff: onOpenToolDiffStable, - runShellCommand: runSideChatShellCommandStable, - }; if (!activeThread) { return embedded ? null : ; } - const rightPanelContent = activeThreadRef ? ( - 1 - ? addPullRequestsSurfaceStable - : undefined, - }} - files={{ - projectId: activeProject?.id, - projectName: activeProject?.title ?? null, - workspaceRoot: activeWorkspaceRoot, - availableEditors, - pendingSurfaceIds: pendingFileSurfaceIds, - onOpenFile: openFileSurfaceStable, - onPendingChange: handleFilePendingChangeStable, - }} - /> - ) : null; const panelToggleControlProps = { - terminalAvailable: activeProject !== null, - terminalOpen: terminalUiState.terminalOpen, - terminalShortcutLabel: shortcutLabelForCommand(keybindings, "terminal.toggle"), threadPanelOpen, threadPanelPresentation, threadPanelPopoverHandle, threadPanelShortcutLabel: shortcutLabelForCommand(keybindings, "threadPanel.toggle"), - rightPanelAvailable: activeProject !== null, - rightPanelOpen, - rightPanelShortcutLabel: shortcutLabelForCommand(keybindings, "rightPanel.toggle"), - onToggleTerminal: toggleTerminalVisibilityStable, onToggleThreadPanel: toggleThreadPanelStable, - onToggleRightPanel: toggleRightPanelStable, } satisfies PanelLayoutControlsProps; const panelToggleControls = ( ); - const threadPanelHeaderControl = ; + const threadPanelHeaderControl = ( + + + + ); const panelLayoutControls = ( - + {panelToggleControls} - + ); const workspaceFileDropHandlers = makeWorkspaceFileDropHandlers({ setDragActive: setIsWorkspaceFileDragActive, @@ -3511,36 +3466,26 @@ export default function ChatView(props: ChatViewProps) { )} {rightPanelControlsAtRoot && !embedded ? panelLayoutControls : null}
{embedded ? null : ( -
{isElectron && rightPanelControlsAtRoot ? ( ) : null} {!rightPanelControlsAtRoot && !rightPanelControlsInPanel ? panelLayoutControls : null} @@ -3555,13 +3500,20 @@ export default function ChatView(props: ChatViewProps) { parentThreadLink={headerParentThreadLink} onOpenThread={onOpenRelatedThreadStable} rightPanelOpen={inlineRightPanelOwnsTitleBar} + trailingInset={ + !rightPanelControlsAtRoot && !rightPanelControlsInPanel ? headerControlsInset : null + } onNewThreadInProject={handleNewThreadInActiveProjectStable} + besideAvailable={besideAvailable} + onOpenBeside={openBesideStable} + newTabShortcutLabel={shortcutLabelForCommand(keybindings, "tab.new")} + hiddenTerminal={hiddenTerminalChip} {...(activeDraftLogicalProjectKey ? { onOpenProjectSettings: handleOpenDraftProjectSettingsStable } : {})} {...(threadFindEnabled ? { onFindInThread: openThreadFindStable } : {})} /> -
+ )}
@@ -3586,7 +3538,9 @@ export default function ChatView(props: ChatViewProps) {
- {/* Input bar — docked at the bottom; an empty draft centers its headline above it. */} + {/* Input bar — docked at the bottom on the pane's surface, which hides the + timeline's end inset under it; an empty draft centers its headline above it. */}
{isDraftHeroState ? ( -
+ // A short split pane has no room above the composer. +
- - {embedded ? null : ( - - )}
- {rightPanelPresent && activeThreadRef && !embedded ? ( - - - {panelToggleControls} -
- ) : null - } - surfaces={renderedRightPanelSurfaces} - environmentId={activeThreadRef.environmentId} - activeSurfaceId={renderedRightPanelSurface?.id ?? null} - pendingSurfaceIds={pendingFileSurfaceIds} - previewSessions={activePreviewState.sessions} - desktopByTabId={activePreviewState.desktopByTabId} - previewRuntimeTabId={resolvePreviewRuntimeTabId} - terminalLabelsById={activeTerminalLabelsById} - onActivate={activateRightPanelSurfaceStable} - onCloseSurface={closeRightPanelSurfaceStable} - onRenameDevice={(surfaceId, title) => { - if (activeThreadRef) - useRightPanelStore.getState().renameDevice(activeThreadRef, surfaceId, title); - }} - onCloseOtherSurfaces={closeOtherRightPanelSurfacesStable} - onCloseSurfacesToRight={closeRightPanelSurfacesToRightStable} - onCloseAllSurfaces={closeAllRightPanelSurfacesStable} - onMoveSurface={moveRightPanelSurfaceStable} - onCopyFilePath={copyRightPanelFilePathStable} - onAddBrowser={() => createBrowserSurface()} - onAddBrowserInProfile={createBrowserSurfaceStable} - onAddTerminal={addTerminalSurfaceStable} - onAddDiff={addDiffSurfaceStable} - onAddFiles={addFilesSurfaceStable} - onAddPullRequest={addPullRequestSurfaceStable} - onAddPullRequests={addPullRequestsSurfaceStable} - onAddDevice={addDeviceSurfaceStable} - onAddSideChat={sideChatsOffered ? openSideChatStable : undefined} - browserAvailable={canOperatePreview && browserAvailable} - terminalAvailable={activeProject !== null && canOperateTerminal} - diffAvailable={isServerThread && isGitRepo} - filesAvailable={activeProject !== null} - pullRequestAvailable={pullRequestSurfaceAvailable} - pullRequestsAvailable={pullRequestsSurfaceAvailable} - deviceAvailable={activeThreadRef !== null} - sideChatAvailable={sideChat.available} - sideChatDisabledReason={sideChatDisabledReason} - > - {rightPanelContent} - - ) : null} - ({ useUpdateEnvironmentSettings: () => () => undefined, })); vi.mock("../hooks/useScratchProject", () => ({ - useScratchProject: () => ({ scratchEnvironmentId: () => null }), + useScratchProject: () => ({ + scratchEnvironmentId: () => null, + scratchUnavailableReason: () => null, + }), })); vi.mock("../hooks/useNewProject", () => ({ useNewProject: () => async () => undefined })); vi.mock("../hooks/useHandleNewThread", () => ({ diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 1c989af4961cc..c8befa85b14b3 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -170,6 +170,7 @@ import { resolvePastedPath } from "@t3tools/shared/path"; import { dispatchCommandPalette, getCommandPaletteReturnFocus, + commandPaletteFocus, useCommandPaletteMount, useCommandPaletteStore, } from "../commandPaletteStore"; @@ -232,6 +233,7 @@ import { } from "./CommandPalette.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteContent, type PendingInputSelection } from "./CommandPaletteContent"; +import { buildLayoutPaletteItems, buildOpenBesidePaletteItem } from "../layout/layoutPaletteItems"; import { selectCommandPaletteClientSettings, useEnvironmentLookups, @@ -273,6 +275,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { suggestAppAccessForRemote } from "./sourceControlAppAccessToast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; +import { returnKeyboardToFocusedPane } from "../layout/layoutPaneContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; @@ -326,6 +329,14 @@ export function CommandPalette({ children }: { children: ReactNode }) { const openChildSearch = (mode: "files" | "content", context: CommandPaletteReturnContext) => dispatchCommandPalette({ _tag: "OpenChildSearch", mode, context }); const composerHandleRef = useRef(null); + const dismiss = () => { + setOpen(false); + commandPaletteFocus.dismissedRef.current = true; + }; + const leaveOverlay = () => { + if (state.parentSearch) dispatchCommandPalette({ _tag: "BackToCommand" }); + else dismiss(); + }; return ( @@ -335,10 +346,11 @@ export function CommandPalette({ children }: { children: ReactNode }) { onOpenChange={(open, eventDetails) => { if (!open && eventDetails.reason === "escape-key" && state.mode !== "command") { eventDetails.cancel(); - dispatchCommandPalette({ _tag: "BackToCommand" }); + leaveOverlay(); return; } - setOpen(open); + if (open) setOpen(true); + else dismiss(); }} >
@@ -348,6 +360,7 @@ export function CommandPalette({ children }: { children: ReactNode }) { mode={state.mode} session={state.session} openIntent={state.openIntent} + dismiss={dismiss} setOpen={setOpen} openChildSearch={openChildSearch} initialContext={state.restoredSearch} @@ -362,6 +375,7 @@ function CommandPaletteDialog(props: { readonly mode: SearchOverlayMode; readonly session: number; readonly openIntent: CommandPaletteOpenIntent | null; + readonly dismiss: () => void; readonly setOpen: (open: boolean) => void; readonly openChildSearch: ( mode: "files" | "content", @@ -392,15 +406,26 @@ function CommandPaletteDialog(props: { if (restoreWorkspaceFocusFromPalette()) return false; const returnFocus = getCommandPaletteReturnFocus(); if (returnFocus === false) return false; + const opener = commandPaletteFocus.openerRef.current; + // Dismissed, it gives the keyboard back to an opener outside the panes (the sidebar). + if ( + commandPaletteFocus.dismissedRef.current && + opener?.isConnected && + !opener.closest("[data-layout-strip]") + ) { + return opener; + } + // In the layout the keyboard goes back to the focused pane, which an + // action may have changed (a surface opened beside the chat). + if (returnKeyboardToFocusedPane()) return false; const composer = composerHandleRef?.current; - // Behind the Settings modal the composer is inert and cannot take focus. - if (!composer || document.querySelector("[data-app-modal]")) return returnFocus; - composer.focusAtEnd(); - return false; - }} - onBackdropPointerDown={() => { - props.setOpen(false); + if (composer && !document.querySelector("[data-app-modal]")) { + composer.focusAtEnd(); + return false; + } + return returnFocus; }} + onBackdropPointerDown={props.dismiss} > {props.mode === "files" ? ( @@ -476,7 +501,12 @@ function OpenCommandPaletteDialog(props: { const updateProject = useOrchestrationCommand(projectEnvironment.update, { label: "project unarchive", }); - const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); + const { + scratchEnvironmentId, + scratchWorkspaceRootFor, + scratchUnavailableReason, + startScratchThread, + } = useScratchProject(); const startTriage = useStartTriage(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { reportFailure: false, @@ -741,6 +771,9 @@ function OpenCommandPaletteDialog(props: { const canTriage = useAtomValue(serverEnvironment.prepareTriage.permissionAtom(triageEnvironmentId)) && supportsTriage(triageEnvironmentId); + const scratchOffReason = scratchUnavailableReason( + currentProjectEnvironmentId ?? primaryEnvironmentId, + ); const currentProject = resolveCommandPaletteProject( projectByKey, currentProjectEnvironmentId, @@ -912,7 +945,30 @@ function OpenCommandPaletteDialog(props: { ); }, }); - if (scratchTargetEnvironmentId === null) return projectItems; + const noProjectItem = + scratchTargetEnvironmentId !== null + ? { + kind: "action" as const, + value: NEW_THREAD_NO_PROJECT_VALUE, + searchTerms: ["no project", "without project", "none"], + title: "No project", + icon: , + shortcutCommand: "chat.newWithoutProject" as const, + run: () => startScratchThread(scratchTargetEnvironmentId), + } + : scratchOffReason !== null + ? { + kind: "action" as const, + value: NEW_THREAD_NO_PROJECT_VALUE, + searchTerms: ["no project", "without project", "none"], + title: "No project", + description: scratchOffReason, + disabled: true, + icon: , + run: async () => {}, + } + : null; + if (noProjectItem === null) return projectItems; const noProjectIndex = newThreadNoProjectIndex( projectPickerEntries.map(({ group, targetProject, isPreferred }) => ({ @@ -926,15 +982,7 @@ function OpenCommandPaletteDialog(props: { ); return [ ...projectItems.slice(0, noProjectIndex), - { - kind: "action" as const, - value: NEW_THREAD_NO_PROJECT_VALUE, - searchTerms: ["no project", "without project", "none"], - title: "No project", - icon: , - shortcutCommand: "chat.newWithoutProject" as const, - run: () => startScratchThread(scratchTargetEnvironmentId), - }, + noProjectItem, ...projectItems.slice(noProjectIndex), ]; })(), @@ -1063,7 +1111,7 @@ function OpenCommandPaletteDialog(props: { const openSourceControlSettings = () => { setOpen(false); - void navigate({ to: "/settings/source-control" }); + void navigate({ to: "/settings/source-control", search: { pane: undefined } }); }; const buildAddProjectSourceGroups = ( @@ -1238,7 +1286,7 @@ function OpenCommandPaletteDialog(props: { const openConnectionSettings = () => { setOpen(false); - void navigate({ to: "/settings/connections" }); + void navigate({ to: "/settings/connections", search: { pane: undefined } }); }; const addProjectEntry = resolveAddProjectEntry( addProjectEnvironmentOptions, @@ -1359,6 +1407,17 @@ function OpenCommandPaletteDialog(props: { shortcutCommand: "chat.newWithoutProject", run: () => startScratchThread(scratchTargetEnvironmentId), }); + } else if (scratchOffReason !== null) { + actionItems.push({ + kind: "action", + value: "action:new-thread-without-project", + searchTerms: ["new thread", "no project", "without project", "none", "chat"], + title: "New thread without a project", + description: scratchOffReason, + disabled: true, + icon: , + run: async () => {}, + }); } if (triageEnvironmentId !== null && canTriage) { @@ -1547,6 +1606,14 @@ function OpenCommandPaletteDialog(props: { }); } + // The side pane's tabs and the terminal, beside the focused chat. + const openBesideItem = activeThread || activeDraftThread ? buildOpenBesidePaletteItem() : null; + if (openBesideItem) actionItems.push(openBesideItem); + // The index shows the layout too once there is a project. + if (activeThread || activeDraftThread || (pathname === "/" && projects.length > 0)) { + actionItems.push(...buildLayoutPaletteItems()); + } + actionItems.push({ kind: "action", value: "action:open-file-picker", @@ -1728,7 +1795,7 @@ function OpenCommandPaletteDialog(props: { title: "Open settings", icon: , run: async () => { - await navigate({ to: "/settings" }); + await navigate({ to: "/settings", search: { pane: undefined } }); }, }); @@ -1821,6 +1888,7 @@ function OpenCommandPaletteDialog(props: { run: async () => { await navigate({ to: item.to, + search: { pane: undefined }, hash: item.targetId ?? item.id, replace: pathname === item.to, hashScrollIntoView: false, diff --git a/apps/web/src/components/ComposerPromptEditorTiptap.tsx b/apps/web/src/components/ComposerPromptEditorTiptap.tsx index 4946e5034ea98..1d59dd7bf17cb 100644 --- a/apps/web/src/components/ComposerPromptEditorTiptap.tsx +++ b/apps/web/src/components/ComposerPromptEditorTiptap.tsx @@ -1526,7 +1526,7 @@ function ComposerPromptEditorTiptapInner( // Push attributes before layout measurements; Tiptap updates them later in a passive effect. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes DOM attributes into the ProseMirror view before layout measurements read them useLayoutEffect(() => { - if (!editor?.isInitialized) return; + if (!editor?.isInitialized || editor.isDestroyed) return; editor.view.setProps({ attributes: editorAttributes }); }, [editor, editorAttributes]); @@ -1588,7 +1588,7 @@ function ComposerPromptEditorTiptapInner( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reconciles the controlled Tiptap document before paint, outside React's commit useLayoutEffect(() => { latestValueRef.current = value; - if (!editor) return; + if (!editor || editor.isDestroyed) return; // Tiptap mounts React node views with flushSync; leave React's commit before updating them. let active = true; const applyControlledUpdate = () => { @@ -1701,14 +1701,15 @@ function ComposerPromptEditorTiptapInner( // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- restores ProseMirror focus and caret scroll when the keyed editor remounts, capturing focus on teardown useLayoutEffect(() => { if (!editor) return; - if (restoreFocusRef.current) { + if (restoreFocusRef.current && !editor.isDestroyed) { restoreFocusRef.current = false; editor.view.focus(); scrollTiptapCaretIntoView(editor); } return () => { - // Capture focus before the keyed editor's DOM is removed. - restoreFocusRef.current = editor.view.hasFocus(); + // Capture focus before the keyed editor's DOM is removed. A pane move can unmount + // the view first; reading it then throws. + restoreFocusRef.current = !editor.isDestroyed && editor.view.hasFocus(); }; }, [editor, restoreFocusRef]); diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx index 7853ff28cea82..539f2e6c6d036 100644 --- a/apps/web/src/components/ConfirmDialogHost.tsx +++ b/apps/web/src/components/ConfirmDialogHost.tsx @@ -1,5 +1,5 @@ import { Trash2Icon } from "lucide-react"; -import { useSyncExternalStore } from "react"; +import { useRef, useSyncExternalStore } from "react"; import { completeConfirmDialogClose, @@ -7,6 +7,7 @@ import { respondToConfirmDialog, subscribeConfirmDialogHost, } from "../confirmDialog"; +import { returnKeyboardToFocusedPane } from "../layout/layoutPaneContext"; import { AlertDialog, AlertDialogClose, @@ -59,6 +60,8 @@ export function ConfirmDialogHost() { readConfirmDialogState, ); + const openerRef = useRef(null); + const copy = state.status !== "idle" && state.title ? { title: state.title, description: state.message.trim() || null } @@ -81,7 +84,24 @@ export function ConfirmDialogHost() { if (!open) completeConfirmDialogClose(); }} > - + { + openerRef.current = document.activeElement; + return true; + }} + finalFocus={() => { + const opener = openerRef.current; + if (opener?.isConnected && !opener.closest("[data-layout-strip]")) return true; + if (returnKeyboardToFocusedPane()) return false; + // A chat pane takes it in its composer. + return ( + document.querySelector( + "[data-layout-pane-focused] [contenteditable='true']", + ) ?? true + ); + }} + > {copy.title} {copy.description ? ( diff --git a/apps/web/src/components/ConnectionStatusDot.tsx b/apps/web/src/components/ConnectionStatusDot.tsx deleted file mode 100644 index f365a03c872a1..0000000000000 --- a/apps/web/src/components/ConnectionStatusDot.tsx +++ /dev/null @@ -1,75 +0,0 @@ -import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection"; - -import { cn } from "~/lib/utils"; -import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; - -export function connectionPhaseDotClassName(phase: EnvironmentConnectionPhase): string { - switch (phase) { - case "connected": - return "bg-success"; - case "connecting": - case "reconnecting": - return "bg-warning"; - case "error": - return "bg-error"; - default: - return "bg-dim-foreground"; - } -} - -export function connectionPhasePingClassName(phase: EnvironmentConnectionPhase): string | null { - return phase === "connecting" || phase === "reconnecting" ? "bg-warning/60 duration-2000" : null; -} - -type ConnectionStatusDotProps = { - tooltipText?: string | null; - dotClassName: string; - pingClassName?: string | null; -}; - -export function ConnectionStatusDot({ - tooltipText, - dotClassName, - pingClassName, -}: ConnectionStatusDotProps) { - const dotContent = ( - <> - {pingClassName ? ( - - ) : null} - - - ); - - if (!tooltipText) { - return ( - - {dotContent} - - ); - } - - const dot = ( - - ); - - return ( - - - - {tooltipText} - - - ); -} diff --git a/apps/web/src/components/DiffPanel.tsx b/apps/web/src/components/DiffPanel.tsx index 74a1b85d87a9e..604314ca33c7b 100644 --- a/apps/web/src/components/DiffPanel.tsx +++ b/apps/web/src/components/DiffPanel.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { PanelTabActions, PanelTabBreadcrumb, useInPanelTabBar } from "./PanelTabBreadcrumb"; import type { FileDiffContentsLoader, FileDiffMetadata } from "@pierre/diffs"; import { isAtomCommandInterrupted, @@ -9,6 +10,7 @@ import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { CHECKPOINT_DIFF_TRUNCATED_MARKER, type ReviewBranchCommitsResult, + type ScopedProjectRef, type ScopedThreadRef, type RunId, } from "@t3tools/contracts"; @@ -215,9 +217,19 @@ function DiffFileHeaderSuffix({ interface DiffPanelProps { mode?: DiffPanelMode; - composerDraftTarget: ScopedThreadRef | DraftId; + /** The composer comments go to; without one (a threadless surface) there are none. */ + composerDraftTarget?: ScopedThreadRef | DraftId | undefined; workspaceMutationId: string | null; visible: boolean; + /** + * The thread whose changes to show. A chat beside others in the layout + * passes its own; unset follows the route, as a lone thread view does. + */ + threadRef?: ScopedThreadRef | null | undefined; + /** With no thread to follow (a threadless surface), the project checkout whose changes to show. */ + projectRef?: ScopedProjectRef | null | undefined; + /** With no project either, the folder whose changes to show: the server's home. */ + folder?: string | null | undefined; } export default function DiffPanel({ @@ -225,6 +237,9 @@ export default function DiffPanel({ composerDraftTarget, workspaceMutationId, visible, + threadRef, + projectRef, + folder, }: DiffPanelProps) { const resolvedTheme = useRenderedAppearance(); const now = useNowMinute(); @@ -235,6 +250,7 @@ export default function DiffPanel({ timestampFormat: settings.timestampFormat, wordWrap: settings.wordWrap, })); + const inPanelTabBar = useInPanelTabBar(); const diffLayout = settings.diffLayout; const [wordWrapOverride, setWordWrapOverride] = useState(null); const wordWrap = wordWrapOverride ?? settings.wordWrap; @@ -252,9 +268,11 @@ export default function DiffPanel({ // The comments written here since the review tray was last dismissed, for its one-press Undo. // Tied to the draft they went into, so switching threads does not carry a batch across. const composerDraftKey = - typeof composerDraftTarget === "string" - ? composerDraftTarget - : scopedThreadKey(composerDraftTarget); + composerDraftTarget === undefined + ? "" + : typeof composerDraftTarget === "string" + ? composerDraftTarget + : scopedThreadKey(composerDraftTarget); const [reviewBatch, setReviewBatch] = useState<{ draftKey: string; commentIds: ReadonlyArray; @@ -268,9 +286,21 @@ export default function DiffPanel({ })); const dismissReviewBatch = () => setReviewBatch(null); - const routeThreadRef = useThreadRouteRef(); + const urlThreadRef = useThreadRouteRef(); + const routeThreadRef = threadRef === undefined ? urlThreadRef : threadRef; const activeThreadId = routeThreadRef?.threadId ?? null; - const activeThread = useQuietThreadShell(routeThreadRef); + const threadShell = useQuietThreadShell(routeThreadRef); + const projectEnvironmentId = projectRef?.environmentId ?? null; + const projectCheckoutId = projectRef?.projectId ?? null; + const folderEnvironmentId = folder ? (routeThreadRef?.environmentId ?? null) : null; + // A project's own checkout, or a folder, stands in for a thread's; it has no turns. + const projectCheckout = + projectEnvironmentId !== null && projectCheckoutId !== null + ? { environmentId: projectEnvironmentId, projectId: projectCheckoutId, worktreePath: null } + : folder && folderEnvironmentId !== null + ? { environmentId: folderEnvironmentId, projectId: null, worktreePath: folder } + : null; + const activeThread = threadShell ?? projectCheckout; const fileAccess = useFilesystemReadAccess(activeThread?.environmentId ?? null); const { canReadFiles } = fileAccess; const activeProjectId = activeThread?.projectId ?? null; @@ -328,7 +358,7 @@ export default function DiffPanel({ ); const toolCallItem = toolCallDetail.data?.item; const isGitRepo = gitStatusQuery.data?.isRepo ?? true; - const turnDiffSummaries = useThreadCheckpointSummaries(routeThreadRef); + const turnDiffSummaries = useThreadCheckpointSummaries(projectCheckout ? null : routeThreadRef); const orderedTurnDiffSummaries = turnDiffSummaries.toSorted((left, right) => { if (left.checkpointTurnCount !== right.checkpointTurnCount) { return right.checkpointTurnCount - left.checkpointTurnCount; @@ -852,13 +882,20 @@ export default function DiffPanel({ } }; - const headerRow = ( - <> -
+ // In tab headers, fold view controls before refs and scope labels as room shrinks. + // Keep the comparison picker reachable even when the labels truncate. + const crowdedTab = + inPanelTabBar && + selectedRunId === null && + selectedGitScope === "branch" && + !!selectedGitSource?.baseRef; + const scopeCrumb = ( + + } - className={cn("max-w-full", headerCommitSha !== null && "min-w-0 shrink")} + className="min-w-0 max-w-full shrink" aria-label={`Diff scope: ${selectedScopeLabel}`} data-workspace-focus-default > @@ -1026,15 +1063,21 @@ export default function DiffPanel({ {canSelectBranchBase && (
+ } > {comparisonHeadRef} @@ -1045,11 +1088,7 @@ export default function DiffPanel({ - } - > - vs - + }>vs {`${comparisonHeadRef} → ${comparisonBaseRef}`} @@ -1068,7 +1107,7 @@ export default function DiffPanel({ > } - className="min-w-9 max-w-48 shrink-0" + className="min-w-9 max-w-48 shrink" aria-label={`Change comparison target. Currently ${comparisonBaseRef}`} > {comparisonBaseRef} @@ -1153,36 +1192,53 @@ export default function DiffPanel({
)} +
+
+ ); + const viewControls = ( + + useDiffPanelSessionStore.getState().setIgnoreWhitespace(value)} + hasFiles={diffFileKeys.length > 0} + testFileCount={testFileCount} + hideTestFiles={hideTestFiles} + onHideTestFilesChange={setHideTestFiles} + foldBelow={crowdedTab ? "2xl" : "sm"} + fileTreeOpen={fileTreeOpen} + onFileTreeOpenChange={setFileTreeOpen} + allFilesCollapsed={allDiffFilesCollapsed} + onToggleAllFiles={toggleDiffFileCollapse} + refresh={ + canRefreshGitDiff + ? { refreshing: isRefreshingDiff, onRefresh: refreshDiffFromUserAction } + : undefined + } + /> + + ); + const headerRow = ( + <> +
+ {scopeCrumb}
- - useDiffPanelSessionStore.getState().setIgnoreWhitespace(value) - } - hasFiles={diffFileKeys.length > 0} - testFileCount={testFileCount} - hideTestFiles={hideTestFiles} - onHideTestFilesChange={setHideTestFiles} - fileTreeOpen={fileTreeOpen} - onFileTreeOpenChange={setFileTreeOpen} - allFilesCollapsed={allDiffFilesCollapsed} - onToggleAllFiles={toggleDiffFileCollapse} - refresh={ - canRefreshGitDiff - ? { refreshing: isRefreshingDiff, onRefresh: refreshDiffFromUserAction } - : undefined - } - /> + {viewControls}
); return ( - + + {inPanelTabBar ? ( + <> + {scopeCrumb} + {viewControls} + + ) : null} {!activeThread ? ( } @@ -1193,7 +1249,7 @@ export default function DiffPanel({ } title="Not a git repository" - description="Diffs compare git checkpoints, so this project has none to show." + description="Diffs compare git checkpoints, so this folder has none to show." /> ) : selectedRunId !== null && orderedTurnDiffSummaries.length === 0 ? ( -
+
{preview?.incomplete && ( -

+

{selectedPatchError ? "Showing tracked changes. Untracked changes could not be loaded. Refresh to retry." : "Showing tracked changes. Checking untracked files..."} @@ -1419,6 +1482,7 @@ export default function DiffPanel({

{fileTreeOpen ? ( ) : ( -
+

{renderablePatch?.kind === "raw" ? renderablePatch.reason : null} @@ -1453,11 +1517,13 @@ export default function DiffPanel({

)} - + {composerDraftTarget ? ( + + ) : null}
)} diff --git a/apps/web/src/components/DiffPanelShell.tsx b/apps/web/src/components/DiffPanelShell.tsx index 99d4f2e5ee7ec..996104f84b98a 100644 --- a/apps/web/src/components/DiffPanelShell.tsx +++ b/apps/web/src/components/DiffPanelShell.tsx @@ -21,7 +21,8 @@ function getDiffPanelHeaderRowClassName(mode: DiffPanelMode) { export function DiffPanelShell(props: { mode: DiffPanelMode; - header: ReactNode; + /** Null when a panel tab bar carries the header (PanelTabBreadcrumb, PanelTabActions). */ + header: ReactNode | null; children: ReactNode; }) { const shouldUseDragRegion = isElectron && props.mode !== "sheet" && props.mode !== "embedded"; @@ -29,13 +30,16 @@ export function DiffPanelShell(props: { return (
- {shouldUseDragRegion ? ( + {props.header === null ? null : shouldUseDragRegion ? (
{props.header}
) : (
@@ -93,7 +97,7 @@ function DiffCodeLineSkeleton({ width }: { width: "short" | "medium" | "long" }) export function DiffPanelLoadingState(props: { label: string }) { return (
{ if (gesture === null) { gesture = - event.ctrlKey || event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL + event.ctrlKey || + (event.metaKey && event.altKey) || + event.deltaMode !== WheelEvent.DOM_DELTA_PIXEL ? { kind: "ignored" } : { kind: "pending", path: event.composedPath(), y: event.clientY, sumX: 0, sumY: 0 }; } diff --git a/apps/web/src/components/NoProjectsHero.tsx b/apps/web/src/components/NoProjectsHero.tsx index 84f9e4a7af762..79cef7160aacf 100644 --- a/apps/web/src/components/NoProjectsHero.tsx +++ b/apps/web/src/components/NoProjectsHero.tsx @@ -1,7 +1,14 @@ import { workspaceAreaProps } from "../lib/workspaceFocus"; import { useAtomValue } from "@effect/atom-react"; -import { DownloadIcon, FolderPlusIcon, MessageSquareDashedIcon } from "lucide-react"; +import { + DownloadIcon, + FolderPlusIcon, + MessageSquareDashedIcon, + PlusIcon, + SquarePenIcon, +} from "lucide-react"; import { useNavigate } from "@tanstack/react-router"; +import type { ReactNode } from "react"; import { openCommandPalette } from "../commandPaletteBus"; import { isElectron } from "../env"; @@ -10,7 +17,13 @@ import { shortcutLabelForCommand } from "../keybindings"; import { usePrimaryEnvironmentId } from "../state/environments"; import { primaryServerKeybindingsAtom } from "../state/server"; import { DraftProjectGroupLabel, DraftProjectRow } from "./chat/DraftHeroHeadline"; +import { NoProjectsComposer } from "./chat/NoProjectsComposer"; +import { PanelLayoutControls } from "./chat/PanelLayoutControls"; +import { ChatPaneHeaderControls } from "./chat/ChatPaneHeader"; +import { PANE_TAB_CLASS, PaneNotch } from "./PaneNotch"; +import { Button } from "./ui/button"; import { SidebarInset } from "./ui/sidebar"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { WorkspacePageHeader } from "./WorkspacePageHeader"; /** @@ -19,23 +32,19 @@ import { WorkspacePageHeader } from "./WorkspacePageHeader"; */ export function NoProjectsHero() { const navigate = useNavigate(); - const contentArea = workspaceAreaProps("home", 1, "Home"); const primaryEnvironmentId = usePrimaryEnvironmentId(); - const { scratchEnvironmentId, startScratchThread } = useScratchProject(); + const { scratchEnvironmentId, scratchUnavailableReason, startScratchThread } = + useScratchProject(); const scratchTargetEnvironmentId = scratchEnvironmentId(primaryEnvironmentId); + const scratchOffReason = scratchUnavailableReason(primaryEnvironmentId); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const openAddProject = () => openCommandPalette({ open: "add-project" }); return ( - -
- {/* The desktop window only moves where CSS opts in, so keep a titlebar strip. */} - {isElectron ? : null} -
+ + {/* Like a draft: the list centered above the composer docked at the bottom. */} +
+

Add a project to start

@@ -52,7 +61,7 @@ export function NoProjectsHero() { detail="Continue from Claude Code, Codex or Pi" onSelect={() => void navigate({ to: "/welcome" })} /> - {scratchTargetEnvironmentId === null ? null : ( + {scratchTargetEnvironmentId === null && scratchOffReason === null ? null : ( <>
} label="No project" - detail="Its own folder, no git" - shortcut={shortcutLabelForCommand(keybindings, "chat.newWithoutProject")} - onSelect={() => void startScratchThread(scratchTargetEnvironmentId)} + detail={scratchOffReason ?? "Its own folder, no git"} + disabled={scratchTargetEnvironmentId === null} + shortcut={ + scratchTargetEnvironmentId === null + ? null + : shortcutLabelForCommand(keybindings, "chat.newWithoutProject") + } + onSelect={() => { + if (scratchTargetEnvironmentId !== null) { + void startScratchThread(scratchTargetEnvironmentId); + } + }} /> )}
+ {primaryEnvironmentId === null ? null : ( +
+
+ + void startScratchThread(scratchTargetEnvironmentId, { + sendCarriedDraft: true, + }) + } + startUnavailableReason={scratchOffReason} + /> +
+
+
+ )} +
+ + ); +} + +/** + * The pane a new draft opens in, with its one "New thread" tab in the notch. + * Empty, it holds the place while the app loads its projects. + */ +function NewThreadPane({ + addDisabledReason, + children, +}: { + readonly addDisabledReason: string; + readonly children?: ReactNode; +}) { + return ( + +
+ {/* The pane header a draft would have, with its one tab in the notch. */} + +
+ +
+ + New thread +
+ {/* A draft's "+" and details toggle, with nothing to act on yet. */} + + }> + + + {addDisabledReason} + +
+
+ + {}} + disabledReason="No thread yet" + /> + +
+ {children}
); diff --git a/apps/web/src/components/PaneNotch.tsx b/apps/web/src/components/PaneNotch.tsx index 7c337e734836e..27854ef2e0257 100644 --- a/apps/web/src/components/PaneNotch.tsx +++ b/apps/web/src/components/PaneNotch.tsx @@ -1,8 +1,9 @@ import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; -import type { ReactNode } from "react"; +import { useLayoutEffect, useRef, type ReactNode } from "react"; import { useMediaQuery } from "../hooks/useMediaQuery"; +import { observeResize } from "../lib/observeResize"; import { cn } from "../lib/utils"; /** @@ -33,20 +34,153 @@ export function PaneTabButton({ className, render, ...props }: useRender.Compone }); } -/** Render first in a positioned pane header; index.css paints the notch on inset panes. - * The final child may use data-pane-notch-add to retain tab-end padding without a surface. +/** + * The body under a pane header that has no surface of its own (a surface tab's, + * the launcher's, Settings'): it may reach up under the header by + * --pane-surface-reach (see [data-surface-reaches-under-header] in index.css), + * and stays a layer below it whatever its own z-indexes. */ -export function PaneNotch({ children }: { readonly children: ReactNode }) { +export const PANE_BODY_UNDER_HEADER_CLASS = + "isolate [--pane-surface-reach:var(--pane-header-overlap,0px)]"; + +// Notches inside each pane or inset. index.css squares the corner of a host +// marked data-pane-notched; matching hosts with :has([data-pane-notch]) +// instead had the browser recheck their whole subtree on every DOM change. +const notchCounts = { + left: new WeakMap(), + right: new WeakMap(), +}; + +function markNotchHost(host: Element | null, side: "left" | "right"): () => void { + if (!host) return () => {}; + const counts = notchCounts[side]; + const attribute = side === "left" ? "data-pane-notched" : "data-pane-notched-right"; + counts.set(host, (counts.get(host) ?? 0) + 1); + host.setAttribute(attribute, ""); + return () => { + const count = (counts.get(host) ?? 1) - 1; + counts.set(host, count); + if (count === 0) host.removeAttribute(attribute); + }; +} + +const PANE_NOTCH_MERGE_MARGIN_PX = 4; +const notchHeaders = new WeakMap< + HTMLElement, + { + readonly notches: Map; + readonly refresh: () => void; + } +>(); + +/** One subscription per header, including breakpoint-switched controls. */ +function observeNotchGap(notch: HTMLDivElement, side: "left" | "right"): () => void { + const header = notch.closest("[data-pane-header]"); + if (!header) return () => {}; + let registration = notchHeaders.get(header); + if (!registration) { + const notches = new Map(); + let stopObserving = () => {}; + const update = () => { + let left: HTMLDivElement | undefined; + let right: HTMLDivElement | undefined; + for (const [element, notchSide] of notches) { + if (element.offsetWidth === 0) continue; + if (notchSide === "left") left = element; + else right = element; + } + let merged = false; + if (left && right) { + const leftRect = left.getBoundingClientRect(); + const rightRect = right.getBoundingClientRect(); + // The pseudo-element resolves --pane-notch-tail to pixels, including rem sizing. + const tail = Number.parseFloat(getComputedStyle(left, "::after").width); + const mergeGap = 2 * tail + PANE_NOTCH_MERGE_MARGIN_PX; + // Compare in layout pixels even when the overview scales the pane. + const scale = leftRect.width / left.offsetWidth; + merged = scale > 0 && (rightRect.left - leftRect.right) / scale < mergeGap; + } + if (header.hasAttribute("data-pane-notches-merged") !== merged) { + header.toggleAttribute("data-pane-notches-merged", merged); + } + }; + const refresh = () => { + stopObserving(); + const sides = new Set(notches.values()); + // Header resizing moves fixed-width notches without resizing either notch. + stopObserving = + sides.size === 2 ? observeResize([header, ...notches.keys()], update) : () => {}; + update(); + }; + registration = { notches, refresh }; + notchHeaders.set(header, registration); + } + const { notches, refresh } = registration; + notches.set(notch, side); + refresh(); + return () => { + notches.delete(notch); + refresh(); + if (notches.size === 0) notchHeaders.delete(header); + }; +} + +/** Render first inside a positioned pane header starting at the pane edge. Keep the plus last so the + * notch includes its trailing padding. */ +export function PaneNotch({ + children, + side = "left", + marksHost = true, + className, +}: { + readonly children: ReactNode; + readonly side?: "left" | "right"; + /** Off for a notch drawn where it cannot show (a hidden overview thumbnail). */ + readonly marksHost?: boolean; + readonly className?: string; +}) { // The tab sits on the window chrome where the notch shows, so light, black // chrome gives it the dark tokens there (index.css). const isMobile = useMediaQuery("max-md"); + const notchRef = useRef(null); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Mark the mounted notch ancestors and remove those external DOM markers on cleanup. + useLayoutEffect(() => { + const notch = notchRef.current; + if (!notch || !marksHost) return; + const unmarkPane = markNotchHost( + notch.closest( + side === "right" + ? "[data-layout-tab-id], [data-pane-notch-host]" + : "[data-layout-tab-id]", + ), + side, + ); + const unmarkInset = markNotchHost(notch.closest('[data-slot="sidebar-inset"]'), side); + return () => { + unmarkPane(); + unmarkInset(); + }; + }, [marksHost, side]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Observe the notch DOM gap before paint and stop observing when its host changes. + useLayoutEffect(() => { + const notch = notchRef.current; + if (!notch || !marksHost || isMobile) return; + return observeNotchGap(notch, side); + }, [isMobile, marksHost, side]); return ( <> - +
[data-pane-notch-add]]:pe-3.5" + : "md:ps-(--pane-notch-tail)", + className, + )} > {children}
diff --git a/apps/web/src/components/PanelTabBreadcrumb.tsx b/apps/web/src/components/PanelTabBreadcrumb.tsx new file mode 100644 index 0000000000000..ab7181da3c84b --- /dev/null +++ b/apps/web/src/components/PanelTabBreadcrumb.tsx @@ -0,0 +1,53 @@ +import { ChevronRightIcon } from "lucide-react"; +import { createContext, useContext, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +/** + * Where the active panel tab hosts its surface's header (pen JiaZk): the + * breadcrumb after the tab's title and the actions at the row's end. + * RightPanelTabs provides it; the elements are null until the row mounts. + */ +export interface PanelTabSlots { + readonly breadcrumb: HTMLElement | null; + readonly actions: HTMLElement | null; +} + +export const PanelTabSlotsContext = createContext(null); + +/** + * Whether the surface renders inside a panel tab bar, which then carries its + * breadcrumb and actions. A surface drops its own header row when it does. + */ +export function useInPanelTabBar(): boolean { + return useContext(PanelTabSlotsContext) !== null; +} + +function PanelTabPortal({ slot, children }: { slot: keyof PanelTabSlots; children: ReactNode }) { + const slots = useContext(PanelTabSlotsContext); + if (!slots) return children; + const element = slots[slot]; + return element ? createPortal(children, element) : null; +} + +/** + * A surface's breadcrumb, shown in its tab after the title (pen JiaZk: + * "Diff › Latest turn"). Outside a panel tab bar it renders in place. + */ +export function PanelTabBreadcrumb({ children }: { children: ReactNode }) { + if (!useInPanelTabBar()) return children; + return ( + + + {children} + + ); +} + +/** + * A surface's header actions, shown at the end of the panel tab row (pen + * JiaZk: collapse, layout, wrap, whitespace, file tree). Outside a panel tab + * bar they render in place. + */ +export function PanelTabActions({ children }: { children: ReactNode }) { + return {children}; +} diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index f49e1b54ceed6..22e4d529dfcce 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -36,6 +36,11 @@ import { primaryServerKeybindingsAtom } from "../state/server"; import { environmentShell } from "../state/shell"; import { useAtomCommand } from "../state/use-atom-command"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { + isThreadlessSurfacesRef, + readThreadlessHome, + threadlessSurfacesProjectRef, +} from "../threadlessSurfaces"; import { buildDraftThreadRouteParams, buildThreadRouteParams, @@ -47,7 +52,6 @@ const PREVIEW_FORWARDED_COMMANDS = [ "workspace.focusNext", "workspace.focusPrevious", "sidebar.toggle", - "rightPanel.toggle", "preview.focusUrl", "preview.zoomIn", "preview.zoomOut", @@ -100,10 +104,14 @@ export function ReopenClosedViewShortcut() { panel, }; } + // A threadless surface's owner is its project, or nothing to lose. + const threadlessProject = threadlessSurfacesProjectRef(ref); return { environmentKnown: catalog.entries.has(ref.environmentId), catalogReady: catalog.isReady, - ownerExists: readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null, + ownerExists: isThreadlessSurfacesRef(ref) + ? threadlessProject === null || readProject(threadlessProject) !== null + : readThreadShell(ref) !== null || drafts.getDraftThreadByRef(ref) !== null, sideChatExists: entry.kind !== "panel-tab" || entry.surface.kind !== "side-chat" || @@ -120,8 +128,13 @@ export function ReopenClosedViewShortcut() { const globalPullRequests = isGlobalPullRequests(ref); const thread = globalPullRequests ? null : readThreadShell(ref); const owner = thread ?? drafts.getDraftThreadByRef(ref); - const project = owner ? readProject(scopeProjectRef(ref.environmentId, owner.projectId)) : null; - if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable: project !== null }))) { + const ownerProject = owner + ? scopeProjectRef(ref.environmentId, owner.projectId) + : threadlessSurfacesProjectRef(ref); + const project = ownerProject ? readProject(ownerProject) : null; + // A project-less threadless ref's files are the server's home. + const workspaceAvailable = project !== null || readThreadlessHome(ref) !== null; + if (!(await reopenClosedView(restore, { openPreview, workspaceAvailable }))) { useClosedViewStore.getState().defer(restore.id); return; } @@ -135,7 +148,8 @@ export function ReopenClosedViewShortcut() { to: "/pull-requests", search: (previous) => pullRequestsSearchForRestore(previous, selected), }); - } else { + } else if (!isThreadlessSurfacesRef(ref)) { + // A threadless surface has no route; its tab shows it. const draftId = thread === null ? drafts.getDraftIdByRef(ref) : null; if (draftId !== null) await navigate({ to: "/draft/$draftId", params: buildDraftThreadRouteParams(draftId) }); diff --git a/apps/web/src/components/RightPanelSheet.browser.test.tsx b/apps/web/src/components/RightPanelSheet.browser.test.tsx deleted file mode 100644 index c99e1e218b41c..0000000000000 --- a/apps/web/src/components/RightPanelSheet.browser.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { expect, it } from "vite-plus/test"; -import { page } from "vite-plus/test/browser"; - -import { render } from "../../test/render"; -import { RightPanelSheet } from "./RightPanelSheet"; - -it("names the open right-panel dialog", async () => { - await using _screen = await render( - {}}> -
Panel content
-
, - ); - const dialog = page.getByRole("dialog"); - await expect.element(dialog).toBeVisible(); - await expect.element(dialog).not.toHaveAccessibleName(""); -}); diff --git a/apps/web/src/components/RightPanelSheet.tsx b/apps/web/src/components/RightPanelSheet.tsx deleted file mode 100644 index 56846307b83da..0000000000000 --- a/apps/web/src/components/RightPanelSheet.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { type ReactNode } from "react"; - -import { Sheet, SheetHeader, SheetPopup, SheetTitle } from "./ui/sheet"; - -export function RightPanelSheet(props: { - animationDurationMs: number; - children: ReactNode; - open: boolean; - onClose: () => void; -}) { - return ( - { - if (!open) { - props.onClose(); - } - }} - > - - - Workspace panel - - {props.children} - - - ); -} diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index eaa73aeec32d7..f6483bc147cf9 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -10,6 +10,19 @@ import { useServerConfigs, useThreadShells, } from "~/state/entities"; +import { useLayoutPane, useLayoutPaneFocusedRef } from "../layout/layoutPaneContext"; +import { beginLayoutTabDrag } from "../layout/layoutDrag"; +import { useLayoutStore } from "../layout/layoutStore"; +import { + buildLayoutTabMenuItems, + isLayoutTabMenuId, + LayoutPaneTab, + runLayoutTabMenuAction, + useLayoutPaneSiblingTabs, + useLayoutTabLabel, +} from "../layout/PaneTabs"; +import { findLayoutTab, type LayoutTab } from "../layout/layoutModel"; +import { surfaceTabTitle } from "../layout/surfaceTabLabels"; import { threadPullRequestKeysEqual, visibleThreadPullRequests, @@ -23,7 +36,6 @@ import type { ResolvedKeybindingsConfig, ThreadId, } from "@t3tools/contracts"; -import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import { closestCenter, DndContext, @@ -45,6 +57,7 @@ import { Globe2, MessagesSquare, Plus, + SquarePen, TerminalSquare, Volume2Icon, VolumeOffIcon, @@ -54,8 +67,7 @@ import { type MouseEvent as ReactMouseEvent, type ReactElement, type ReactNode, - useEffect, - useEffectEvent, + type Ref, useRef, useState, } from "react"; @@ -64,13 +76,11 @@ import { isElectron } from "~/env"; import type { DesktopPreviewOverlay } from "~/previewStateStore"; import type { RightPanelSurface } from "~/rightPanelStore"; import { cn } from "~/lib/utils"; -import { resolveShortcutCommand, type ShortcutMatchContext } from "~/keybindings"; import { readLocalApi } from "~/localApi"; import { useStableCallback } from "~/hooks/useStableCallback"; import { Button } from "~/components/ui/button"; import { AndroidIcon, AppleIcon } from "~/components/Icons"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; -import { Kbd } from "~/components/ui/kbd"; import { Menu, MenuItem, @@ -88,6 +98,8 @@ import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { useTheme } from "~/hooks/useTheme"; import { useDeviceState } from "~/state/device"; import type { PreviewPanelInlineSize } from "~/hooks/usePreviewPanelInlineSize"; +import { useRevealActiveTab } from "~/hooks/useRevealActiveTab"; +import { FOLDED_TAB_CLASS, usePaneTabStripFit } from "~/hooks/usePaneTabStripFit"; import { newestPullRequestSummary, pullRequestEnvironment, @@ -97,6 +109,8 @@ import { useEnvironmentQuery } from "~/state/query"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; +import { PanelTabSlotsContext } from "./PanelTabBreadcrumb"; +import { PANE_BODY_UNDER_HEADER_CLASS, PaneNotch } from "./PaneNotch"; import { FaviconImage } from "./preview/PreviewFaviconIcon"; import { previewBridge } from "./preview/previewBridge"; import { dispatchPreviewAction } from "./preview/previewActionBus"; @@ -105,16 +119,31 @@ import { PierreEntryIcon } from "./chat/PierreEntryIcon"; import { resolvePullRequestState } from "./pullRequest/pullRequestPresentation"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { observeResize } from "~/lib/observeResize"; -import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; +import { + SurfaceLauncherRows, + surfaceShortcutActionForKey, + useLauncherElement, + useSurfaceLauncherKeys, + type SurfaceLauncherAction, +} from "./SurfaceLauncher"; + +export { + surfaceShortcutActionForKey, + surfaceShortcutTargetsTypingContext, +} from "./SurfaceLauncher"; export interface RightPanelTabsProps { mode: PreviewPanelMode; maximized?: boolean; open?: boolean; keybindings: ResolvedKeybindingsConfig; - getShortcutContext: () => ShortcutMatchContext; widthStorageKey?: string; defaultWidth?: number; + /** + * An inline panel drawn as its own inset pane, notch and all, beside a page's pane (the + * pull requests page's review, pen bETcv). + */ + pane?: boolean; inlineSize?: PreviewPanelInlineSize; layoutControls?: ReactNode; surfaces: readonly RightPanelSurface[]; @@ -134,9 +163,13 @@ export interface RightPanelTabsProps { onCloseAllSurfaces: () => void; onMoveSurface?: (surfaceId: string, toIndex: number) => void; onCopyFilePath: (relativePath: string) => void; + /** Escape on the empty pane's chooser closes the pane; the sheet closes itself. */ + onClose?: () => void; onAddBrowser: () => void; /** Keep separate from the DOM click handler so a MouseEvent cannot become a profile id. */ onAddBrowserInProfile: (profileId: string) => void; + /** The "+" menu's New thread, in the thread's project; omitted, it has none. */ + onNewThread?: (() => void) | undefined; onAddTerminal: () => void; onAddDiff: () => void; onAddFiles: () => void; @@ -146,6 +179,8 @@ export interface RightPanelTabsProps { /** Absent where side chats are not offered. */ onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; + /** Why the browser cannot open, when it cannot. */ + browserUnavailableReason?: string | null; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; @@ -156,6 +191,8 @@ export interface RightPanelTabsProps { /** Overrides the default reason while side chats are available but cannot start. */ sideChatDisabledReason?: string | null; pullRequestStatusSeeds?: Readonly>; + /** The linked thread stays in the tab's tooltip when its surface moves elsewhere. */ + threadTitle?: string | null; children: ReactNode; } @@ -184,31 +221,6 @@ const SURFACE_DISABLED_REASONS = { sideChat: "Side chats start after the first message.", } as const; -// Closed keepMounted popups must stop blocking shortcuts once their exit transition ends. -const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ - "dialog-popup", - "alert-dialog-popup", - "command-dialog-popup", - "menu-popup", - "select-popup", - "popover-popup", - "combobox-popup", - "autocomplete-popup", -] - .map((slot) => `[data-slot="${slot}"]:is([data-open],[data-ending-style])`) - .join(","); - -const SURFACE_UNAVAILABLE_HINTS = { - browser: "Only available in the desktop app.", - terminal: "Available when a project is open.", - files: "Available when a project is open.", - diff: "Available for Git repositories.", - pullRequest: "No pull request on this branch yet.", - pullRequests: "No linked pull requests available.", - device: "Available from a thread.", - sideChat: "Available after the first message.", -} as const; - type TabContextMenuAction = | "rename" | "copy-path" @@ -256,30 +268,72 @@ function tabAudioState(overlay: DesktopPreviewOverlay | null): TabAudioState { return overlay.audioMuted ? "muted" : "audible"; } -type SurfaceShortcutEvent = Pick< - KeyboardEvent, - "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" -> & - Partial>; +/** + * A layout pane's tabs sit in its notch with "+" as the notch's last child, + * like a chat's and the terminal's; the sheet and inline panel keep a plain row. + */ +/** + * The row the tabs sit in: in a notch it clips and the tabs fit it + * (usePaneTabStripFit); elsewhere it scrolls, with arrows on overflow. + */ +function PanelTabStrip({ + notched, + noDrag, + ref, + children, +}: { + notched: boolean; + noDrag: boolean; + ref: Ref; + children: ReactNode; +}) { + if (notched) { + return ( + // Sized by its tabs like the layout tabs beside it, which give way first, + // so a narrow pane never squeezes its shown tab away. +
+ {children} +
+ ); + } + return ( + +
{children}
+
+ ); +} -export function surfaceShortcutActionForKey< - const Action extends { available: boolean; shortcut: string }, ->(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { - if (event.defaultPrevented || event.isComposing) return null; - if (event.metaKey || event.ctrlKey || event.altKey) return null; - const key = surfaceShortcutKey(event); +function PanelTabsNotch(props: { notched: boolean; addButton: ReactNode; children: ReactNode }) { + if (!props.notched) return props.children; return ( - actions.find((action) => action.available && action.shortcut.toLowerCase() === key) ?? null + + {props.children} + {props.addButton ? ( + + {props.addButton} + + ) : null} + ); } -/** Skip contenteditable=false islands to find their editable host, matching the composer typing guard. */ -export function surfaceShortcutTargetsTypingContext( - target: { closest(selectors: string): unknown } | null, -): boolean { +function PanelActionsNotch(props: { notched: boolean; children: ReactNode }) { + if (!props.notched) return props.children; return ( - target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != - null + + {props.children} + ); } @@ -294,7 +348,7 @@ function DisabledReasonTooltip(props: { reason: string; trigger: ReactElement }) function SurfaceMenuItem(props: { available: boolean; - disabledReason?: string; + disabledReason?: string | undefined; shortcut: string; onClick: () => void; children: ReactNode; @@ -314,7 +368,12 @@ function SurfaceMenuItem(props: { return ; } +/** + * Pane-wide letter shortcuts must yield to typing contexts. + * The focused launcher also supports arrows and explains unavailable surfaces. + */ function RightPanelEmptyState(props: { + onClose: (() => void) | undefined; onAddBrowser: () => void; onAddBrowserInProfile: (profileId: string) => void; browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; @@ -327,6 +386,7 @@ function RightPanelEmptyState(props: { /** Absent where side chats are not offered. */ onAddSideChat?: (() => void) | undefined; browserAvailable: boolean; + browserUnavailableReason: string | null | undefined; terminalAvailable: boolean; diffAvailable: boolean; filesAvailable: boolean; @@ -336,234 +396,80 @@ function RightPanelEmptyState(props: { sideChatAvailable: boolean; sideChatDisabledReason: string | null; }) { - const [highlight, setHighlight] = useState(-1); - - const actions = [ + const actions: SurfaceLauncherAction[] = [ { - label: "Browser", - icon: Globe2, + kind: "browser", shortcut: "B", available: props.browserAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, + ...(props.browserUnavailableReason ? { disabledReason: props.browserUnavailableReason } : {}), onClick: props.onAddBrowser, }, { - label: "Terminal", - icon: TerminalSquare, + kind: "terminal", shortcut: "T", available: props.terminalAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.terminal, onClick: props.onAddTerminal, }, + { kind: "files", shortcut: "F", available: props.filesAvailable, onClick: props.onAddFiles }, + { kind: "diff", shortcut: "D", available: props.diffAvailable, onClick: props.onAddDiff }, { - label: "Files", - icon: Files, - shortcut: "F", - available: props.filesAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.files, - onClick: props.onAddFiles, - }, - { - label: "Diff", - icon: FileDiff, - shortcut: "D", - available: props.diffAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.diff, - onClick: props.onAddDiff, - }, - { - label: "Pull request", - icon: PullRequestGlyph.pullRequest, + kind: "pull-request", shortcut: "P", available: props.pullRequestAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequest, onClick: props.onAddPullRequest, }, { - label: "Linked pull requests", - icon: PullRequestGlyph.link, + kind: "pull-requests", shortcut: "L", available: props.pullRequestsAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.pullRequests, onClick: props.onAddPullRequests, }, - { - label: "Device", - description: "Watch an iOS Simulator or Android Emulator.", - icon: Smartphone, - shortcut: "M", - available: props.deviceAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.device, - onClick: props.onAddDevice, - }, + { kind: "device", shortcut: "M", available: props.deviceAvailable, onClick: props.onAddDevice }, ...(props.onAddSideChat ? [ { - label: "Side chat", - description: "Ask a side question while this thread keeps working.", - icon: MessagesSquare, + kind: "side-chat" as const, shortcut: "S", available: props.sideChatAvailable && !props.sideChatDisabledReason, - disabledReason: props.sideChatDisabledReason ?? SURFACE_UNAVAILABLE_HINTS.sideChat, onClick: props.onAddSideChat, }, ] : []), - ] as const; - - type SurfaceAction = (typeof actions)[number]; - - const availableActions = actions.filter((action) => action.available); - const highlightIndex = - availableActions.length === 0 ? -1 : Math.min(highlight, availableActions.length - 1); - - const handleShortcut = useEffectEvent((event: KeyboardEvent) => { - const action = surfaceShortcutActionForKey(availableActions, event); - if (!action) return; - if (document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; - const target = event.composedPath()[0] ?? event.target; - if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; - event.preventDefault(); - event.stopPropagation(); - action.onClick(); + ]; + const paneFocusedRef = useLayoutPaneFocusedRef(); + const [launcherRef, attachLauncher] = useLauncherElement(paneFocusedRef, false); + useSurfaceLauncherKeys({ + launcherRef, + activeRef: paneFocusedRef, + actions, + onClose: props.onClose, }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a capture-phase window keydown listener for surface shortcuts while the menu is mounted - useEffect(() => { - const handler = (event: KeyboardEvent) => handleShortcut(event); - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); - }, []); - - const handleKeyDown = (event: ReactKeyboardEvent) => { - if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return; - if (availableActions.length === 0) return; - if (event.key === "ArrowDown" || event.key === "ArrowRight") { - event.preventDefault(); - setHighlight((highlightIndex + 1) % availableActions.length); - return; - } - if (event.key === "ArrowUp" || event.key === "ArrowLeft") { - event.preventDefault(); - setHighlight( - highlightIndex === -1 - ? availableActions.length - 1 - : (highlightIndex - 1 + availableActions.length) % availableActions.length, - ); - return; - } - if (event.key === "Enter") { - if (event.target !== event.currentTarget) return; - const action = availableActions[highlightIndex]; - if (!action) return; - event.preventDefault(); - action.onClick(); - } - }; - - const isHighlighted = (action: SurfaceAction) => - highlightIndex !== -1 && availableActions[highlightIndex] === action; - - const actionIcon = (action: SurfaceAction, iconClassName = "size-4") => { - const Icon = action.icon; - return ( - - - - ); - }; return (
action.shortcut).join("")} + data-surface-launcher-keys={actions + .filter((action) => action.available) + .map((action) => action.shortcut) + .join("")} className={cn( - "scrollbar-gutter-both flex min-h-0 flex-1 items-center justify-center overflow-y-auto px-6 outline-none", + "scrollbar-gutter-both flex min-h-0 flex-1 items-center-safe justify-center overflow-y-auto px-4 outline-none", "pb-(--workspace-topbar-height)", )} > -
-

Open a surface

-
- {actions.map((action) => - action.available ? ( -
setHighlight(availableActions.indexOf(action))} - onMouseLeave={() => - setHighlight((current) => - current === availableActions.indexOf(action) ? -1 : current, - ) - } - > - - {action.label === "Browser" && props.browserProfiles.length > 1 ? ( - - - } - > - - - - {props.browserProfiles.map((profile) => ( - props.onAddBrowserInProfile(profile.id)} - > - {profile.name} - - ))} - - - ) : null} -
- ) : ( - - {actionIcon(action, "size-4")} - {action.label} - {action.shortcut} -
- } - /> - ), - )} -
+
+
); @@ -574,39 +480,11 @@ function surfaceTitle( sessions: Readonly>, terminalLabelsById: ReadonlyMap, ): string { - switch (surface.kind) { - case "diff": - return "Diff"; - case "files": - return "Files"; - case "file": - return surface.relativePath.slice( - Math.max(surface.relativePath.lastIndexOf("/"), surface.relativePath.lastIndexOf("\\")) + 1, - ); - case "terminal": - return ( - terminalLabelsById.get(surface.activeTerminalId) ?? - getTerminalLabel(surface.activeTerminalId) - ); - case "pull-request": - return `#${surface.number}`; - case "pull-requests": - return "Pull requests"; - case "device": - return surface.title ?? surface.target?.name ?? "Device"; - case "side-chat": - return "Side chat"; - case "preview": { - const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; - if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; - if (snapshot.navStatus.title.trim().length > 0) return snapshot.navStatus.title; - try { - return new URL(snapshot.navStatus.url).host || "Browser"; - } catch { - return "Browser"; - } - } - } + return surfaceTabTitle( + surface, + surface.kind === "preview" && surface.resourceId ? sessions[surface.resourceId] : null, + surface.kind === "terminal" ? terminalLabelsById.get(surface.activeTerminalId) : undefined, + ); } function PreviewFavicon({ capturedUrl, url }: { capturedUrl: string | null; url: string | null }) { @@ -764,7 +642,7 @@ export function resolvePullRequestTabLink( return newest; } -function PullRequestSurfaceIcon({ +export function PullRequestSurfaceIcon({ surface, environmentId, seed, @@ -864,6 +742,11 @@ interface RightPanelTabHandlers { onFinishRename: (surfaceId: string, title: string) => void; } +const NO_LAYOUT_TAB: LayoutTab = { + id: "panel", + content: { kind: "launcher", id: "panel", context: null }, +}; + // Chromium on Linux only suppresses primary-selection paste when mouseup is cancelled. function preventTabMiddleButtonDefault(event: ReactMouseEvent) { if (event.button !== 1) return; @@ -888,12 +771,35 @@ function RightPanelTab( renaming: boolean; canRename: boolean; ownsDesktopTitleBar: boolean; + folded: boolean; + squeezed: boolean; + notched: boolean; + layoutTabId: string | null; + threadTitle: string | null; + onBreadcrumbSlotChange: (element: HTMLSpanElement | null) => void; }, ) { const { + dragDisabled, + squeezed, + previewUrl, + previewCapturedUrl, + theme, + environmentId, + pullRequestStatusSeed, + renaming, + canRename, + threadTitle, + onBreadcrumbSlotChange, surface, audio, audioRuntimeTabId, + active, + pending, + folded, + notched, + layoutTabId, + ownsDesktopTitleBar, onActivate, onClose, onAuxClick, @@ -903,131 +809,210 @@ function RightPanelTab( } = props; const { listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: surface.id, - disabled: props.dragDisabled, + disabled: dragDisabled, }); const sideChatShell = useQuietThreadShell( surface.kind === "side-chat" && props.environmentId !== null ? scopeThreadRef(props.environmentId, surface.threadId) : null, ); - const title = sideChatShell?.title ?? props.title; + const layoutTab = useLayoutStore((state) => + layoutTabId === null + ? null + : (findLayoutTab(state.layout, (tab) => tab.id === layoutTabId)?.tab ?? null), + ); + const layoutLabel = useLayoutTabLabel(layoutTab ?? NO_LAYOUT_TAB); + const title = layoutTab ? layoutLabel.title : (sideChatShell?.title ?? props.title); + const tooltip = layoutTab + ? layoutLabel.tooltip + : [title, threadTitle, previewUrl, surface.kind === "file" ? surface.relativePath : null] + .filter(Boolean) + .join("\n"); const sideChatState = sideChatTabState(sideChatShell); - const content = ( - <> - onClose(surface)}> - - {props.pending ? ( - - ) : null} - - {audio === "none" || !audioRuntimeTabId ? null : ( - - { - event.stopPropagation(); - void previewBridge - ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") - .catch(() => undefined); - }} - > - {audio === "muted" ? ( - - ) : ( - - )} - - } - /> - {audio === "muted" ? "Unmute tab" : "Mute tab"} - + // An open workspace file reads "Files › file.tsx": its breadcrumb names it. + const filesCrumb = active && surface.kind === "file" && !surface.attachment; + return ( +
beginLayoutTabDrag(event, layoutTabId) : undefined + } + onMouseDown={preventTabMiddleButtonDefault} + onMouseUp={preventTabMiddleButtonDefault} + onAuxClick={(event) => onAuxClick(event, surface)} + onContextMenu={(event) => onContextMenu(event, surface)} + {...listeners} + className={cn( + "cursor-pointer group/tab flex items-center", + isDragging && "relative z-10", + active && squeezed ? "min-w-0" : "shrink-0", + // In the notch a tab is the chat's 28px notch tab (pen JiaZk). + notched + ? "h-7 gap-1.75 rounded-control px-2.5 text-label" + : "h-6 gap-0.5 rounded-item pr-2 pl-1.5 text-xs", + folded && FOLDED_TAB_CLASS, + // The active tab grows with its breadcrumb; its title caps below. + !active && "max-w-36", + ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", + active + ? cn("text-strong-foreground", !notched && "font-medium") + : "text-muted-foreground hover:surface-ghost-hover hover:text-foreground", )} - {props.renaming ? ( - { - element?.focus(); - element?.select(); - }} - onBlur={(event) => onFinishRename(surface.id, event.currentTarget.value)} - onKeyDown={(event) => { - event.stopPropagation(); - if (event.nativeEvent.isComposing || event.keyCode === 229) return; - if (event.key === "Enter") event.currentTarget.blur(); - if (event.key === "Escape") { - event.currentTarget.value = title; - event.currentTarget.blur(); - } - }} - /> - ) : ( + > + {folded ? ( + // A folded tab is its icon alone, so a click shows it + // rather than turning the icon into its close button. { - if (surface.kind === "device" && props.canRename) onStartRename(surface.id); - }} - className="cursor-pointer flex min-w-0 items-center" - aria-label={sideChatState === null ? undefined : `${title}, ${sideChatState}`} + aria-label={sideChatState === null ? title : `${title}, ${sideChatState}`} + className="cursor-pointer relative flex size-full items-center justify-center rounded-control outline-none focus-visible:inset-ring-2 focus-visible:inset-ring-ring" onClick={() => onActivate(surface)} - > - {title} - + /> } - /> - - {surface.kind === "device" ? ( - + + {pending ? ( + + ) : null} + + {tooltip} + + ) : ( + <> + onClose(surface)}> + {filesCrumb ? ( + + ) : surface.kind === "terminal" && layoutTab ? ( + layoutLabel.icon ) : ( - title + )} - - - )} - - ); - return ( -
onAuxClick(event, surface)} - onContextMenu={(event) => onContextMenu(event, surface)} - {...listeners} - ref={setNodeRef} - style={{ transform: CSS.Translate.toString(transform), transition }} - className={cn( - "cursor-pointer group/tab flex h-6 max-w-36 shrink-0 items-center gap-0.5 rounded-item pr-2 pl-1.5 text-xs", - props.ownsDesktopTitleBar && "[-webkit-app-region:no-drag]", - props.active - ? "font-medium text-strong-foreground" - : "text-muted-foreground hover:surface-ghost-hover hover:text-foreground", - isDragging && "relative z-10", + {pending ? ( + + ) : null} + + {audio === "none" || !audioRuntimeTabId ? null : ( + + { + event.stopPropagation(); + void previewBridge + ?.setAudioMuted(audioRuntimeTabId, audio !== "muted") + .catch(() => undefined); + }} + > + {audio === "muted" ? ( + + ) : ( + + )} + + } + /> + {audio === "muted" ? "Unmute tab" : "Mute tab"} + + )} + {renaming ? ( + { + element?.focus(); + element?.select(); + }} + onBlur={(event) => onFinishRename(surface.id, event.currentTarget.value)} + onKeyDown={(event) => { + event.stopPropagation(); + if (event.nativeEvent.isComposing || event.keyCode === 229) return; + if (event.key === "Enter") event.currentTarget.blur(); + if (event.key === "Escape") { + event.currentTarget.value = title; + event.currentTarget.blur(); + } + }} + /> + ) : ( + + { + if (surface.kind === "device" && canRename) onStartRename(surface.id); + }} + className={cn( + "cursor-pointer flex min-w-0 items-center", + (filesCrumb || (active && surface.kind === "diff")) && "shrink-0", + )} + onClick={() => onActivate(surface)} + > + + {filesCrumb ? "Files" : title} + + + } + /> + + {surface.kind === "device" ? ( + <> + {threadTitle ? ( + <> + {threadTitle} +
+ + ) : null} + + + ) : ( + tooltip + )} +
+
+ )} + {active ? ( + + ) : null} + )} - > - {content}
); } @@ -1038,8 +1023,19 @@ export function RightPanelTabs(props: RightPanelTabsProps) { const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); const tabListRef = useRef(null); - const addSurfaceTriggerRef = useRef(null); + // Embedded, this is one surface's tab in the layout: it drags like any tab + // and its pane's other tabs sit around it in the notch. + const layoutPaneTabId = useLayoutPane().tabId; + const layoutTabId = props.mode === "embedded" ? layoutPaneTabId : null; + const siblingTabs = useLayoutPaneSiblingTabs(); const [renamingDevice, setRenamingDevice] = useState(null); + // The active tab's breadcrumb and the row's actions slot; its surface + // portals its header into them (PanelTabBreadcrumb, PanelTabActions). + const [breadcrumbSlot, setBreadcrumbSlot] = useState(null); + const [actionsSlot, setActionsSlot] = useState(null); + const tabSlots = { breadcrumb: breadcrumbSlot, actions: actionsSlot }; + // A layout pane's tabs sit in its notch (pen JiaZk), like a chat's. + const notched = props.mode === "embedded" || props.pane === true; const [addSurfaceMenuOpen, setAddSurfaceMenuOpen] = useState(false); const [tabScrollState, setTabScrollState] = useState({ hasOverflow: false, @@ -1049,29 +1045,6 @@ export function RightPanelTabs(props: RightPanelTabsProps) { if (props.open === false && addSurfaceMenuOpen) setAddSurfaceMenuOpen(false); - const onNewSurfaceKeyDown = useEffectEvent((event: KeyboardEvent) => { - if (event.defaultPrevented || event.isComposing) return; - if ( - resolveShortcutCommand(event, props.keybindings, { - context: { ...props.getShortcutContext(), rightPanelOpen: true }, - }) !== "rightPanel.new" - ) - return; - if (!addSurfaceMenuOpen && document.querySelector(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; - event.preventDefault(); - event.stopPropagation(); - if (!event.repeat) { - addSurfaceTriggerRef.current?.focus(); - setAddSurfaceMenuOpen(true); - } - }); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a document keydown listener for the new surface shortcut while the panel is open - useEffect(() => { - if (props.open === false) return; - document.addEventListener("keydown", onNewSurfaceKeyDown, true); - return () => document.removeEventListener("keydown", onNewSurfaceKeyDown, true); - }, [props.open]); - const updateTabScrollState = (viewport: HTMLDivElement) => { const hasOverflow = viewport.scrollWidth - viewport.clientWidth > TAB_SCROLL_EDGE_TOLERANCE; const canScrollLeft = hasOverflow && viewport.scrollLeft > TAB_SCROLL_EDGE_TOLERANCE; @@ -1101,12 +1074,25 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }; const addSurfaceActions = [ + // A surface tab in the layout offers a new thread first, as a chat's "+" does. + ...(props.onNewThread + ? [ + { + label: "New thread", + icon: SquarePen, + shortcut: "N", + available: true, + disabledReason: undefined, + onClick: props.onNewThread, + }, + ] + : []), { label: "Browser", icon: Globe2, shortcut: "B", available: props.browserAvailable, - disabledReason: SURFACE_DISABLED_REASONS.browser, + disabledReason: props.browserUnavailableReason ?? SURFACE_DISABLED_REASONS.browser, onClick: props.onAddBrowser, }, { @@ -1208,6 +1194,37 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }), }); } + // A surface tab in the layout offers the layout's tab actions after its own. + if (layoutTabId !== null) { + const keybindings = props.keybindings; + const layoutItems = buildLayoutTabMenuItems( + useLayoutStore.getState().layout, + layoutTabId, + keybindings, + ).map((item, index) => + index === 0 && items.length > 0 ? { ...item, separatorBefore: true } : item, + ); + const picked = await api.contextMenu.show< + TabContextMenuAction | (typeof layoutItems)[number]["id"] + >([...items, ...layoutItems], { x: event.clientX, y: event.clientY }); + if (picked !== null && isLayoutTabMenuId(picked)) { + runLayoutTabMenuAction(picked, layoutTabId); + return; + } + if (picked === "rename") setRenamingDevice(surface.id); + else if (picked === "copy-path" && surface.kind === "file" && !surface.attachment) { + props.onCopyFilePath(surface.relativePath); + } else if (picked === "toggle-mute") { + const runtimeTabId = + menuPreviewTabId && menuOverlay + ? (props.previewRuntimeTabId?.(menuPreviewTabId) ?? null) + : null; + if (runtimeTabId) { + void previewBridge?.setAudioMuted(runtimeTabId, !menuMuted).catch(() => undefined); + } + } + return; + } items.push( { id: "close", label: "Close" }, { @@ -1283,13 +1300,16 @@ export function RightPanelTabs(props: RightPanelTabsProps) { setRenamingDevice(null); }); - // A newly active tab, or tabs that start overflowing, can sit outside the scrolled strip. - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- scrolls the active tab DOM node into view after it changes or the tab strip starts overflowing - useEffect(() => { - if (!props.activeSurfaceId || !tabScrollState.hasOverflow) return; - const activeTab = tabListRef.current?.querySelector("[data-active-tab='true']"); - activeTab?.scrollIntoView({ block: "nearest", inline: "nearest" }); - }, [props.activeSurfaceId, tabScrollState.hasOverflow]); + useRevealActiveTab(tabListRef, props.activeSurfaceId); + const fitTabs = props.surfaces.map((surface) => ({ + key: surface.id, + title: surfaceTitle(surface, props.previewSessions, props.terminalLabelsById), + })); + // In the notch the tabs fold and overflow into a menu instead of scrolling. + const tabFit = usePaneTabStripFit(tabListRef, fitTabs, props.activeSurfaceId); + const hiddenSurfaces = notched + ? props.surfaces.filter((surface) => tabFit.hidden.has(surface.id)) + : []; const attachTabList = (root: HTMLDivElement | null) => { tabListRef.current = root; @@ -1326,6 +1346,81 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }; }; + const addSurfaceMenu = + // A notch keeps it while empty too: an empty notch reads as a broken corner. + props.open !== false || props.surfaces.length > 0 || notched ? ( + + + } + > + + + + {addSurfaceActions.map((action) => { + const Icon = action.icon; + if (action.label === "Browser" && action.available) { + return ( + + { + const pointerType = + "pointerType" in event.nativeEvent && + typeof event.nativeEvent.pointerType === "string" + ? event.nativeEvent.pointerType + : undefined; + if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType)) return; + setAddSurfaceMenuOpen(false); + action.onClick(); + }} + > + + {action.label} + {action.shortcut} + + + {browserProfiles.map((profile) => ( + props.onAddBrowserInProfile(profile.id)} + > + {profile.name} + + ))} + + + ); + } + return ( + + + {action.label} + + ); + })} + + + ) : null; + return ( @@ -1351,28 +1447,34 @@ export function RightPanelTabs(props: RightPanelTabsProps) { >
- -
+ + {layoutTabId !== null + ? siblingTabs.before.map((tab) => ) + : null} + {props.surfaces.map((surface) => { + if (tabFit.hidden.has(surface.id)) return null; const previewTabId = previewTabIdOf(surface, props.previewSessions); const favicon = previewFaviconOf( surface, @@ -1383,7 +1485,10 @@ export function RightPanelTabs(props: RightPanelTabsProps) { - {props.open !== false ? ( - + {hiddenSurfaces.length > 0 ? ( + } > - + - - {addSurfaceActions.map((action) => { - const Icon = action.icon; - if (action.label === "Browser" && action.available) { - return ( - - { - const pointerType = - "pointerType" in event.nativeEvent && - typeof event.nativeEvent.pointerType === "string" - ? event.nativeEvent.pointerType - : undefined; - if (!shouldOpenDefaultBrowserProfileFromMenuClick(pointerType)) - return; - setAddSurfaceMenuOpen(false); - action.onClick(); - }} - > - - {action.label} - {action.shortcut} - - - {browserProfiles.map((profile) => ( - props.onAddBrowserInProfile(profile.id)} - > - {profile.name} - - ))} - - - ); - } + + {hiddenSurfaces.map((surface) => { + const favicon = previewFaviconOf( + surface, + props.previewSessions, + props.desktopByTabId, + ); return ( - - - {action.label} - + props.onActivate(surface)}> + + + {surfaceTitle(surface, props.previewSessions, props.terminalLabelsById)} + + ); })} ) : null} -
-
- {tabScrollState.hasOverflow ? ( + {notched ? null : addSurfaceMenu} + + {layoutTabId !== null + ? siblingTabs.after.map((tab) => ) + : null} + + {/* In the notch the tabs scroll by wheel and ctrl+Tab; the actions need the room. */} + {!notched && tabScrollState.hasOverflow ? (
) : null} - {props.layoutControls} + +
+ {props.layoutControls} + {ownsDesktopTitleBar && !props.layoutControls ? ( ) : null} @@ -1542,9 +1632,13 @@ export function RightPanelTabs(props: RightPanelTabsProps) { /> ) : null}
-
+
{props.activeSurfaceId === null ? ( ) : ( - props.children + {props.children} )}
diff --git a/apps/web/src/components/Sidebar.motion.ts b/apps/web/src/components/Sidebar.motion.ts index 0e86c3c9a0e94..3be067ab03f71 100644 --- a/apps/web/src/components/Sidebar.motion.ts +++ b/apps/web/src/components/Sidebar.motion.ts @@ -117,7 +117,12 @@ export function createSidebarListMotion(parent: HTMLUListElement) { ); }; - return { + let frame = 0; + let frameAnimates = false; + const view = parent.ownerDocument.defaultView; + + const api = { + /** Read every row's position and animate rows that moved. Reads layout. */ update(animate: boolean) { if (disposed) return; // A hidden list (the closed narrow sheet stays mounted) reports zero @@ -234,6 +239,19 @@ export function createSidebarListMotion(parent: HTMLUListElement) { } positions = next; }, + /** `update` in the next frame, before it paints, so a commit's own layout + * pass is the one read instead of a forced one mid-commit. A chat + * mounting in the same commit made that forced layout expensive. Calls + * before the frame coalesce; the last one decides whether rows animate. */ + scheduleUpdate(animate: boolean) { + if (disposed || !view) return; + frameAnimates = animate; + if (frame !== 0) return; + frame = view.requestAnimationFrame(() => { + frame = 0; + api.update(frameAnimates); + }); + }, release() { suspend(); const origin = parent.getBoundingClientRect().top; @@ -246,7 +264,9 @@ export function createSidebarListMotion(parent: HTMLUListElement) { suspend, dispose() { suspend(); + if (frame !== 0) view?.cancelAnimationFrame(frame); disposed = true; }, }; + return api; } diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 8358f6de33274..e1941d90918e4 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -7,6 +7,17 @@ import { moveThreadContextDrag as moveThreadContextDragGhost, } from "./chat/threadContextDrag"; import { appearanceFontStack } from "../appearanceFonts"; +import { selectLayoutThreadKeys } from "../layout/layoutModel"; +import { useLayoutStore } from "../layout/layoutStore"; +import { + cancelLayoutDrag, + dropLayoutDrag, + moveLayoutDrag, + OPEN_THREAD_TO_THE_SIDE_MENU_ID, + openThreadToTheSide, + withOpenThreadToTheSideItem, +} from "../layout/layoutDrag"; +import { wasThreadSettledByUser } from "../layout/settledByUser"; import { discardComposerDraft } from "../lib/discardComposerDraft"; import { requestCustomSnooze } from "./CustomSnoozeDialog"; import { useAtomValue } from "@effect/atom-react"; @@ -252,6 +263,7 @@ import { import { useShownThreadRouteTarget } from "../hooks/useShownThreadRouteTarget"; import { SidebarSearchResultRow, + SidebarThreadRow, SortableThreadRow, type SidebarProviderEntry, type SidebarSweepAction, @@ -346,6 +358,7 @@ interface SidebarThreadPartitionInput { readonly serverConfigs: ReadonlyMap; readonly connectedEnvironmentIds: readonly EnvironmentId[]; readonly optimisticDrop: SidebarOptimisticDrop | null; + readonly layoutThreadKeys: ReadonlySet; readonly workingShelfEnabled: boolean; /** Boring mode: folders list active threads by the user's latest message. */ readonly sortActiveBySend: boolean; @@ -418,6 +431,7 @@ function useSidebarThreads(input: { readonly folderGroups: readonly SidebarProjectSnapshot[] | null; // Keeps folders with no active threads, so a project the user filtered to or pinned can start one. readonly keepEmptyFolders: (group: SidebarProjectSnapshot) => boolean; + readonly layoutThreadKeys: ReadonlySet; }) { const { projectScopeKey, @@ -430,6 +444,7 @@ function useSidebarThreads(input: { routeRowThreadKey, folderGroups, keepEmptyFolders: keepEmpty, + layoutThreadKeys, } = input; // Folders already separate the work, so boring mode has no Working shelf. const workingShelfEnabled = @@ -476,6 +491,7 @@ function useSidebarThreads(input: { serverConfigs, connectedEnvironmentIds, optimisticDrop, + layoutThreadKeys, workingShelfEnabled, sortActiveBySend: folderGroups !== null, clockKey: `${nowMinute}:${snoozeWakeClock}:${inboxReturnsVersion}`, @@ -632,9 +648,18 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { serverConfigs, connectedEnvironmentIds: input.connectedEnvironmentIds, }); + const { layoutThreadKeys } = input; + for (const key of settledWhenOpened.keys()) { + if (!layoutThreadKeys.has(key)) settledWhenOpened.delete(key); + } for (const thread of visible) { const configLoaded = serverConfigs.has(thread.environmentId); const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); + // A thread seen active while open stays active after automatic settlement. + if (layoutThreadKeys.has(threadKey)) { + if (thread.settledOverride !== "settled") settledWhenOpened.set(threadKey, false); + else if (!settledWhenOpened.has(threadKey)) settledWhenOpened.set(threadKey, true); + } if (optimisticDrop?.key === threadKey) { const projected = applySidebarThreadDrop( thread, @@ -655,7 +680,12 @@ function partitionSidebarThreads(input: SidebarThreadPartitionInput) { } else { const section = resolveSidebarThreadSection({ snoozed: configLoaded && effectiveSnoozed(thread, { now: preciseNow }), - settled: configLoaded && thread.settledOverride === "settled", + settled: + configLoaded && + thread.settledOverride === "settled" && + (!layoutThreadKeys.has(threadKey) || + settledWhenOpened.get(threadKey) !== false || + wasThreadSettledByUser(threadKey)), pinned: thread.pinnedAt != null, }); (section === "snoozed" @@ -933,6 +963,14 @@ function revealSidebarRow(viewport: HTMLElement, row: HTMLElement) { if (target !== scrollTop) viewport.scrollTop = Math.min(maxScroll, Math.max(0, target)); } +/** + * Whether each open thread was settled every time the sidebar saw it while its tab was open. Only + * one settled while open (a merge) stays in the active list; opening a settled thread to read it + * leaves it on the shelf. Module scope: the sidebar unmounts on Settings routes, and the answer + * must outlive that. + */ +const settledWhenOpened = new Map(); + export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReveal }) { const { projects, @@ -1350,6 +1388,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }, ); + // A signature avoids sidebar updates for pane focus and resize alone. + const onScreenThreadKeys = useLayoutStore((state) => state.onScreenThreadKeys); + const layoutThreadKeysSignature = useLayoutStore((state) => + selectLayoutThreadKeys(state.layout).join("\n"), + ); + const layoutThreadKeys = new Set( + layoutThreadKeysSignature === "" ? [] : layoutThreadKeysSignature.split("\n"), + ); const { workingShelfEnabled, pinnedThreads, @@ -1403,6 +1449,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : null, keepEmptyFolders: (group) => scopedProjectGroups.length > 0 || (group.pinned && !group.archived), + layoutThreadKeys, }); // Ranked across environments as the palette ranks them, so one @@ -1503,6 +1550,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if (isMobile) { setOpenMobile(false); } + // A tab already open in the layout takes focus even if its URL is current. + useLayoutStore.getState().focusChat({ kind: "server", threadRef }); return router.navigate({ to: "/$environmentId/$threadId", params: buildThreadRouteParams(threadRef), @@ -1644,6 +1693,8 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve coParkingKeys?: ReadonlySet, ): (() => void) | null => { if (readRouteThreadKey(router) !== threadKey) return null; + // Closing a parked thread's layout tabs chooses the neighboring pane or launcher. + if (selectLayoutThreadKeys(useLayoutStore.getState().layout).includes(threadKey)) return null; const shell = threadByKeyRef.current.get(threadKey); const orderedKeys = orderedThreadKeysRef.current; const settledKeys = settledThreadKeysRef.current; @@ -1787,6 +1838,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve contextDragKeyRef.current = null; dragScrollRef.current = null; endThreadContextDrag(); + cancelLayoutDrag(); if (started) { listMotionRef.current?.release(); setDragState(null); @@ -1828,19 +1880,34 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ); if (!contextDrag) { endThreadContextDrag(); + cancelLayoutDrag(); return false; } restoreDragScroll(); const threads = contextDragThreads(); const threadRef = parseScopedThreadKey(contextDragKeyRef.current ?? ""); const title = (threadRef && readThreadShell(threadRef)?.title.trim()) || "Thread"; - moveThreadContextDragGhost(point, { title, count: threads.length }); + const overComposer = moveThreadContextDragGhost(point, { + title, + count: threads.length, + width: threadListRef.current?.getBoundingClientRect().width ?? 0, + }); + // One thread can also land in the layout (pen jVRc4). A composer under + // the pointer keeps the drop as context; the zones stay lit but idle. + const [thread] = threads; + if (threads.length === 1 && thread) { + moveLayoutDrag({ kind: "thread", threadRef: thread }, point, { + allowTarget: !overComposer, + }); + } else { + cancelLayoutDrag(); + } return true; }; const dropThreadContextDrag = (point: { x: number; y: number }) => { if (!pointerOutsideThreadList(point)) return false; restoreDragScroll(); - dropThreadContext(point, contextDragThreads()); + if (!dropThreadContext(point, contextDragThreads())) dropLayoutDrag(); return true; }; // useSensor memoizes options identity; fresh options rebuild DndContext and every sortable row. @@ -2075,7 +2142,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve // Shelf rows would travel over stationary headers below the bottom-anchored shelf. const shelvesUnchanged = previousShelfExpansionKeyRef.current === shelfExpansionKey; previousShelfExpansionKeyRef.current = shelfExpansionKey; - listMotionRef.current?.update(listMotionPass.animate && shelvesUnchanged); + listMotionRef.current?.scheduleUpdate(listMotionPass.animate && shelvesUnchanged); }, [listMotionPass, shelfExpansionKey]); const handleThreadDragOver = (event: DragOverEvent) => { const target = event.over @@ -2719,44 +2786,50 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) ?? null; const clicked = await settlePromise(() => api.contextMenu.show( - buildThreadActionMenuItems({ - canOperate: readEnvironmentScope( - threadRef.environmentId, - AuthOrchestrationOperateScope, - ), - branch: thread.branch ?? null, - // An archived project is not a filter choice. - projectFilter: - threadProjectGroup && !threadProjectGroup.archived - ? { - label: threadProjectGroup.displayName, - isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), - } - : null, - isPinned, - isSettled, - autoSettleEnabled: thread.autoSettleDisabledAt == null, - isSnoozed, - canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), - isRegeneratingTitle, - isRunning: !threadRuntimeCanArchive(thread.runtime), - canShareWithGuest: appAtomRegistry.get( - canShareWithGuestAtom(threadRef.environmentId), - ), - canTurnIntoProject: canTurnThreadIntoProject(threadRef), - supports: { - settlement: configLoaded, - autoSettleOptOut: supportsAutoSettleOptOut, - snooze: configLoaded, - pinning: configLoaded, - titleRegeneration: configLoaded, - }, - snoozePresets, - }), + withOpenThreadToTheSideItem( + threadRef, + buildThreadActionMenuItems({ + canOperate: readEnvironmentScope( + threadRef.environmentId, + AuthOrchestrationOperateScope, + ), + branch: thread.branch ?? null, + projectFilter: + threadProjectGroup && !threadProjectGroup.archived + ? { + label: threadProjectGroup.displayName, + isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), + } + : null, + isPinned, + isSettled, + autoSettleEnabled: thread.autoSettleDisabledAt == null, + isSnoozed, + canSnoozeNow: canSnooze(thread, { now: new Date().toISOString() }), + isRegeneratingTitle, + isRunning: !threadRuntimeCanArchive(thread.runtime), + canShareWithGuest: appAtomRegistry.get( + canShareWithGuestAtom(threadRef.environmentId), + ), + canTurnIntoProject: canTurnThreadIntoProject(threadRef), + supports: { + settlement: configLoaded, + autoSettleOptOut: supportsAutoSettleOptOut, + snooze: configLoaded, + pinning: configLoaded, + titleRegeneration: configLoaded, + }, + snoozePresets, + }), + ), position, ), ); if (clicked._tag === "Failure" || clicked.value === null) return; + if (clicked.value === OPEN_THREAD_TO_THE_SIDE_MENU_ID) { + openThreadToTheSide(threadRef); + return; + } if (threadActionRequiresOperate(clicked.value) && !checkThreadOperations([thread])) return; if (clicked.value?.startsWith("snooze:")) { const preset = @@ -3354,7 +3427,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve snoozedThreads.length === 0 && (settledCount === 0 || scopedProjectGroups.length > 0) ? ( -
+
{projects.length === 0 ? "No projects yet" @@ -3417,14 +3495,15 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const renderThreadRow = ( thread: EnvironmentThreadShell, section: SidebarSection, - // The tree row of the project folder this row sits in. - folderRow?: string, + folderOrGhost?: string | boolean, ) => { const threadKey = scopedThreadKey( scopeThreadRef(thread.environmentId, thread.id), ); const isCard = section === "active" || section === "pinned" || section === "working"; + const folderRow = + typeof folderOrGhost === "string" ? folderOrGhost : undefined; const rowVariant = folderRow ? folderRowsMultiline ? "folder-multiline" @@ -3432,8 +3511,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve : isCard ? "card" : "slim"; + const Row = folderOrGhost === true ? SidebarThreadRow : SortableThreadRow; return ( - + {renderThreadRow(contextDragThread, dragState.activeSection, true)} + , + ); + } return items; })()} {settledShelfExpanded && hiddenSettledCount > 0 ? ( diff --git a/apps/web/src/components/SurfaceLauncher.tsx b/apps/web/src/components/SurfaceLauncher.tsx new file mode 100644 index 0000000000000..3ceb3c6751423 --- /dev/null +++ b/apps/web/src/components/SurfaceLauncher.tsx @@ -0,0 +1,341 @@ +import { + ChevronDown, + FileDiff, + Files, + Globe2, + MessagesSquare, + Smartphone, + TerminalSquare, +} from "lucide-react"; +import { + type ComponentType, + type RefObject, + useEffect, + useEffectEvent, + useRef, + useState, +} from "react"; + +import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; +import { Button } from "~/components/ui/button"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { openLayerMatches } from "~/lib/openLayers"; +import { surfaceShortcutKey } from "~/lib/surfaceShortcutKey"; +import { registerCommands } from "~/shortcuts/dispatcher"; + +import { DraftProjectGroupLabel, DraftProjectRow } from "./chat/DraftHeroHeadline"; + +/** What a launcher can open for a thread. */ +export type LauncherSurfaceKind = + | "browser" + | "terminal" + | "files" + | "diff" + | "pull-request" + | "pull-requests" + | "device" + | "side-chat"; + +const LAUNCHER_SHORTCUT_BLOCKING_LAYERS = [ + '[data-slot="dialog-popup"]', + '[data-slot="alert-dialog-popup"]', + '[data-slot="command-dialog-popup"]', + '[data-slot="menu-popup"]', + '[data-slot="select-popup"]', + '[data-slot="popover-popup"]', + '[data-slot="combobox-popup"]', + '[data-slot="autocomplete-popup"]', +] + .map((selector) => `${selector}:is([data-open],[data-ending-style])`) + .join(","); + +/** The surfaces a launcher offers, in its order, with their letter and one-line hint. */ +export const SURFACE_LAUNCHER_ENTRIES: ReadonlyArray<{ + readonly kind: LauncherSurfaceKind; + readonly label: string; + readonly icon: ComponentType<{ className?: string }>; + readonly shortcut: string; + /** Why it is unavailable, on the row in place of its detail. */ + readonly hint: string; +}> = [ + { + kind: "browser", + label: "Browser", + icon: Globe2, + shortcut: "B", + hint: "Only available in the desktop app.", + }, + { + kind: "terminal", + label: "Terminal", + icon: TerminalSquare, + shortcut: "T", + hint: "Available when a project is open.", + }, + { + kind: "files", + label: "Files", + icon: Files, + shortcut: "F", + hint: "Available when a project is open.", + }, + { + kind: "diff", + label: "Diff", + icon: FileDiff, + shortcut: "D", + hint: "Available for Git repositories.", + }, + { + kind: "pull-request", + label: "Pull request", + icon: PullRequestGlyph.pullRequest, + shortcut: "P", + hint: "No pull request on this branch yet.", + }, + { + kind: "pull-requests", + label: "Linked pull requests", + icon: PullRequestGlyph.link, + shortcut: "L", + hint: "No linked pull requests available.", + }, + { + kind: "side-chat", + label: "Side chat", + icon: MessagesSquare, + shortcut: "S", + hint: "Available after the first message.", + }, + { + kind: "device", + label: "Device", + icon: Smartphone, + shortcut: "M", + hint: "Available from a thread.", + }, +]; + +export interface SurfaceLauncherAction { + readonly kind: LauncherSurfaceKind; + readonly shortcut: string; + readonly available: boolean; + /** Shown on the row while unavailable; defaults to the surface's hint. */ + readonly disabledReason?: string; + readonly onClick: () => void; +} + +type SurfaceShortcutEvent = Pick< + KeyboardEvent, + "altKey" | "ctrlKey" | "defaultPrevented" | "isComposing" | "key" | "metaKey" +> & + Partial>; + +export function surfaceShortcutActionForKey< + const Action extends { available: boolean; shortcut: string }, +>(actions: readonly Action[], event: SurfaceShortcutEvent): Action | null { + if (event.defaultPrevented || event.isComposing) return null; + if (event.metaKey || event.ctrlKey || event.altKey) return null; + const key = surfaceShortcutKey(event); + return ( + actions.find((action) => action.available && action.shortcut.toLowerCase() === key) ?? null + ); +} + +/** + * The `:not` clause lets `closest` see past non-editable islands + * (`contenteditable="false"`) to an editable host around them, matching + * ComposerPendingUserInputPanel's typing guard. + */ +export function surfaceShortcutTargetsTypingContext( + target: { closest(selectors: string): unknown } | null, +): boolean { + return ( + target?.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])') != + null + ); +} + +/** + * Arrows move focus between the launcher's rows; Enter is the focused row's + * own. Focus may sit on the pane around the launcher (the layout focuses + * the pane), so this runs from the window rather than the launcher. + */ +function launcherHasFocus(launcher: HTMLElement | null): boolean { + const active = document.activeElement; + return !!launcher && !!active && (launcher.contains(active) || active.contains(launcher)); +} + +function launcherRows(launcher: HTMLElement): HTMLButtonElement[] { + return Array.from( + launcher.querySelectorAll('[role="listitem"] > button:not(:disabled)'), + ); +} + +function moveLauncherFocus(launcher: HTMLElement | null, event: KeyboardEvent): boolean { + if (!launcher || event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) { + return false; + } + const forward = event.key === "ArrowDown" || event.key === "ArrowRight"; + if (!forward && event.key !== "ArrowUp" && event.key !== "ArrowLeft") return false; + if (!launcherHasFocus(launcher)) return false; + const active = document.activeElement; + const rows = launcherRows(launcher); + if (rows.length === 0) return false; + const index = rows.findIndex((row) => row === active); + const next = + index === -1 + ? forward + ? 0 + : rows.length - 1 + : (index + (forward ? 1 : -1) + rows.length) % rows.length; + rows[next]?.focus(); + return true; +} + +/** Capture shortcuts for the focused launcher pane, preserving typing contexts and handled events; + * row focus alone is too fragile. */ +export function useSurfaceLauncherKeys(input: { + readonly launcherRef: RefObject; + readonly activeRef: RefObject; + readonly actions: readonly SurfaceLauncherAction[]; + readonly onClose: (() => void) | undefined; +}): void { + const { launcherRef, activeRef } = input; + const handler = useEffectEvent((event: KeyboardEvent) => { + if (!activeRef.current) return; + if (openLayerMatches(LAUNCHER_SHORTCUT_BLOCKING_LAYERS)) return; + if (moveLauncherFocus(launcherRef.current, event)) { + event.preventDefault(); + event.stopPropagation(); + return; + } + if (event.key === "Escape" && input.onClose && launcherHasFocus(launcherRef.current)) { + event.preventDefault(); + event.stopPropagation(); + input.onClose(); + return; + } + const action = surfaceShortcutActionForKey(input.actions, event); + if (!action) return; + const target = event.composedPath()[0] ?? event.target; + if (target instanceof Element && surfaceShortcutTargetsTypingContext(target)) return; + event.preventDefault(); + event.stopPropagation(); + action.onClick(); + }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Register the launcher capture scope for its mounted lifetime. + useEffect( + () => + registerCommands( + {}, + { + capture: true, + onKeyDown: (event) => { + handler(event); + return event.defaultPrevented; + }, + }, + ), + [], + ); +} + +/** + * The "Open a surface" rows, in the new thread hero's style. Unavailable + * surfaces stay visible with a one-line reason. The browser row offers its + * profiles once there is more than one to choose between. + */ +export function SurfaceLauncherRows(props: { + readonly actions: readonly SurfaceLauncherAction[]; + readonly browserProfiles: ReadonlyArray<{ readonly id: string; readonly name: string }>; + readonly onAddBrowserInProfile: (profileId: string) => void; +}) { + return ( + <> + Open a surface + {SURFACE_LAUNCHER_ENTRIES.map((entry) => { + const action = props.actions.find((candidate) => candidate.kind === entry.kind); + if (!action) return null; + const Icon = entry.icon; + // Same choice the tab bar's "+" menu offers: the row opens the + // default profile, the chevron picks another. + const profileChooser = + entry.kind === "browser" && action.available && props.browserProfiles.length > 1 ? ( + + + } + > + + + + {props.browserProfiles.map((profile) => ( + props.onAddBrowserInProfile(profile.id)} + > + {profile.name} + + ))} + + + ) : null; + return ( + } + label={entry.label} + detail={action.available ? "" : (action.disabledReason ?? entry.hint)} + shortcut={entry.shortcut} + disabled={!action.available} + action={profileChooser} + onSelect={action.onClick} + /> + ); + })} + + ); +} + +/** Where the keyboard lands in a launcher: its selected row, else its first, else itself. */ +export function launcherFocusTarget(launcher: HTMLElement): HTMLElement { + return ( + launcher.querySelector( + '[role="listitem"][data-selected] > button:not(:disabled)', + ) ?? + launcherRows(launcher)[0] ?? + launcher + ); +} + +/** + * The launcher's element, focused on mount when its pane has focus. In the + * layout strip a plain focus() scrolls the strip, so it never scrolls. + * `focusFirstRow` puts the keyboard on the selected row, or the first, so + * Enter picks it. + */ +export function useLauncherElement( + activeRef: RefObject, + focusFirstRow: boolean, +): [RefObject, (node: HTMLDivElement | null) => void] { + const launcherRef = useRef(null); + const focusFirstRowRef = useRef(focusFirstRow); + // Stable identity so React only runs this callback ref on mount/unmount; + // an inline arrow would re-attach and re-focus on every render. + const [attach] = useState(() => (node: HTMLDivElement | null) => { + launcherRef.current = node; + if (!node || !activeRef.current) return; + const active = document.activeElement; + // Focus held outside the strip (the palette, the sidebar's search) stays. + if (active && active !== document.body && !active.closest("[data-layout-strip]")) return; + const target = focusFirstRowRef.current ? launcherFocusTarget(node) : node; + target.focus({ preventScroll: true }); + }); + return [launcherRef, attach]; +} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index 0268da23dd2e1..fddc5e07916d9 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -20,6 +20,8 @@ import { import { getClientSettings, subscribeClientSettings } from "../hooks/useSettings"; import { isWindowsPlatform } from "../lib/utils"; +import { selectVisibleLayoutTabs } from "../layout/layoutModel"; +import { useLayoutStore } from "../layout/layoutStore"; import { environmentSummaries } from "../state/presentation"; import { environmentShell } from "../state/shell"; import { environmentThreadShells } from "../state/threads"; @@ -98,6 +100,7 @@ export function startThreadNotifications( (router.state.matches.at(-1)?.params ?? {}) as { environmentId?: EnvironmentId; threadId?: ThreadId; + draftId?: string; }; const closeOpenedThreadToasts = () => { const { environmentId, threadId } = activeThread(); @@ -142,6 +145,15 @@ export function startThreadNotifications( return; const { notificationMode: mode, inAppNotificationsEnabled } = getClientSettings(); const active = activeThread(); + const threadVisible = + (active.threadId !== undefined || active.draftId !== undefined) && + selectVisibleLayoutTabs(useLayoutStore.getState().layout).some( + (tab) => + tab.content.kind === "chat" && + tab.content.target.kind === "server" && + tab.content.target.threadRef.environmentId === environmentId && + tab.content.target.threadRef.threadId === thread.id, + ); const title = kind === "completion" ? "Thread completed" @@ -163,7 +175,8 @@ export function startThreadNotifications( inAppNotificationsEnabled && document.visibilityState === "visible" && document.hasFocus() && - (active.environmentId !== environmentId || active.threadId !== thread.id) + (active.environmentId !== environmentId || active.threadId !== thread.id) && + !threadVisible ) { const toastKey = threadKey({ environmentId, threadId: thread.id }); const toastId: ToastId = toastManager.add({ diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index a7b7da0a6b53b..3fe29ac3e673e 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -1,5 +1,9 @@ import { useAtomValue } from "@effect/atom-react"; -import { parseScopedThreadKey, scopedThreadKey } from "@t3tools/client-runtime/environment"; +import { + parseScopedThreadKey, + scopedThreadKey, + scopeThreadRef, +} from "@t3tools/client-runtime/environment"; import { parseThreadKey, threadKey } from "@t3tools/client-runtime/state/entities"; import { visibilityParentKey } from "@t3tools/client-runtime/state/threads"; import type { ScopedThreadRef } from "@t3tools/contracts"; @@ -9,8 +13,8 @@ import { useEffect, useState } from "react"; import ChatView from "./ChatView"; import { BackgroundThreadContext } from "./chat/backgroundThread"; -import { SidebarInset } from "./ui/sidebar"; import { useComposerDraftStore } from "../composerDraftStore"; +import { closeLayoutThread } from "../layout/layoutActions"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; import { useEnvironmentThreadRefs, @@ -45,17 +49,16 @@ function useOpenThreadFamily(threadRef: ScopedThreadRef | null, missing: boolean }, [mountedKey]); } -/** - * Unkeyed so navigating between server threads reuses one ChatView. Rendered by the `_chat` - * layout, since an element only survives a route swap when the same parent renders it. Draft - * promotion runs in the draft store; this only follows the redirect it leaves behind. - * `background` keeps the thread mounted, inert and in place under Settings or Usage. - */ +/** Each layout tab retains ChatView across route swaps; only the focused tab owns the URL. */ export function ThreadRouteView({ target, + tabId = null, + focused = true, background = false, }: { target: ThreadRouteTarget; + tabId?: string | null; + focused?: boolean; background?: boolean; }) { const navigate = useNavigate(); @@ -71,6 +74,9 @@ export function ThreadRouteView({ const shellStatus = useEnvironmentShellStatus(serverThreadRef?.environmentId ?? null); const environmentThreadRefs = useEnvironmentThreadRefs(serverThreadRef?.environmentId ?? null); const bootstrapComplete = isThreadRouteSnapshotAuthoritative(shellStatus); + // The cached snapshot can predate a thread made just before a reload; only + // the server's own snapshot can say a thread is gone. + const shellLive = shellStatus === "live"; const draftThread = useComposerDraftStore((store) => serverThreadRef ? store.getDraftThreadByRef(serverThreadRef) : null, ); @@ -81,11 +87,14 @@ export function ThreadRouteView({ // The ChatView that carried a draft keeps its key through the redirect to its thread, and only // until the route leaves that thread, so later visits reuse the shared unkeyed ChatView. const [handoffKey, setHandoffKey] = useState<{ threadKey: string; key: string } | null>(null); + const draftThreadRef = draftSession + ? scopeThreadRef(draftSession.environmentId, draftSession.threadId) + : draftRedirect; const nextHandoffKey = - draftId !== null && draftSession === null && draftRedirect - ? handoffKey?.key === draftId + draftId !== null && draftThreadRef + ? handoffKey?.key === draftId && handoffKey.threadKey === scopedThreadKey(draftThreadRef) ? handoffKey - : { threadKey: scopedThreadKey(draftRedirect), key: draftId } + : { threadKey: scopedThreadKey(draftThreadRef), key: draftId } : serverThreadRef && handoffKey?.threadKey === scopedThreadKey(serverThreadRef) ? handoffKey : serverThreadRef && serverThreadDraftId @@ -142,17 +151,21 @@ export function ThreadRouteView({ // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- clears pending file drops and navigates the router home once a server thread is confirmed missing useEffect(() => { - if (target.kind !== "server" || !bootstrapComplete) { + if (target.kind !== "server" || !shellLive) { return; } if (renderState === "missing") { const { clearPendingFileDropsForThread } = useSidebarPendingFileDropStore.getState(); clearPendingFileDropsForThread(target.threadRef); - if (environmentHasAnyThreads && !background) { + if (tabId !== null) { + closeLayoutThread(target.threadRef); + return; + } + if (!background && environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); } } - }, [background, bootstrapComplete, environmentHasAnyThreads, navigate, renderState, target]); + }, [background, environmentHasAnyThreads, navigate, renderState, shellLive, tabId, target]); let view: React.ReactNode = null; let redirect: React.ReactNode = null; @@ -225,12 +238,9 @@ export function ThreadRouteView({ } return ( - - {view} - {background ? null : redirect} - + + {view} + {background || !focused ? null : redirect} + ); } diff --git a/apps/web/src/components/ThreadSurfaceTab.tsx b/apps/web/src/components/ThreadSurfaceTab.tsx new file mode 100644 index 0000000000000..9cd94ce528589 --- /dev/null +++ b/apps/web/src/components/ThreadSurfaceTab.tsx @@ -0,0 +1,1155 @@ +import type { SideChatParentPanel } from "~/lib/sideChatPane"; +import { useSideChat } from "./chat/useSideChat"; +import { threadEnvironment } from "../state/threads"; +/** Resolve surface context independently of ChatView so tabs remain usable after the chat closes, + * and report when their thread is gone. */ +import { scopedThreadKey, scopeProjectRef } from "@t3tools/client-runtime/environment"; +import { + AuthOrchestrationOperateScope, + orchestrationV2WorktreePending, + AuthPreviewOperateScope, + AuthTerminalOperateScope, + AuthTerminalReadScope, + type PreviewAnnotationPayload, + type ProjectId, + type ScopedThreadRef, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { + projectScriptCwd, + projectScriptRuntimeEnv, + resolveProjectScripts, +} from "@t3tools/shared/projectScripts"; +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests"; +import { FolderX, Unlink2Icon } from "lucide-react"; +import { lazy, Suspense, useEffect, useState } from "react"; + +import { BrowserSettingsReadError } from "../browser/openFileInPreview"; +import { previewRuntimeTabId } from "../browser/previewRuntimeTabId"; +import { type ComposerImageAttachment, useComposerDraftStore } from "../composerDraftStore"; +import { useDiffPanelStore } from "../diffPanelStore"; +import { isElectron } from "../env"; +import { useNewThreadHandler } from "../hooks/useHandleNewThread"; +import { useEnvironmentSettings } from "../hooks/useSettings"; +import { useLayoutPane } from "../layout/layoutPaneContext"; +import type { LauncherSurfaceKind } from "./SurfaceLauncher"; +import { THREAD_TERMINAL_SURFACE_ID } from "../layout/layoutModel"; +import { runInNewLayoutTab } from "../layout/layoutStore"; +import { + useThreadChatBridge, + useThreadChatBridgeStore, + waitForThreadChatSendReady, +} from "../layout/threadSurfaceBridge"; +import { showThreadChatBeside } from "../layout/openRules"; +import { shortcutLabelForCommand } from "../keybindings"; +import { readLocalApi } from "../localApi"; +import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; +import { resolvePathLinkTarget } from "@t3tools/shared/fileLinks"; +import { randomUUID } from "../lib/utils"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { browserMiniPlayerSource, usePreviewMiniPlayerStore } from "../previewMiniPlayerStore"; +import { + isPreviewSupportedInRuntime, + readThreadPreviewState, + setActivePreviewTab, + useThreadPreviewState, +} from "../previewStateStore"; +import { + selectThreadRightPanelState, + useRightPanelStore, + type RightPanelSurface, +} from "../rightPanelStore"; +import { + useAllEnvironmentShellsBootstrapped, + useEnvironmentSupportsServerBrowser, + useProject, + useServerConfigs, + useQuietThreadShell, + useQuietThreadProjection, +} from "../state/entities"; +import { previewEnvironment } from "../state/preview"; +import { primaryServerAvailableEditorsAtom, primaryServerKeybindingsAtom } from "../state/server"; +import { + readEnvironmentScope, + useEnvironmentScope, + useEnvironmentSessionStatus, +} from "../state/session"; +import { terminalEnvironment } from "../state/terminal"; +import { useKnownTerminalSessions } from "../state/terminalSessions"; +import { useEnvironmentQuery } from "../state/query"; +import { focusActivePanelTerminal, focusTerminal } from "../lib/terminalFocus"; +import type { TerminalContextSelection } from "../lib/terminalContext"; +import { useAtomCommand } from "../state/use-atom-command"; +import { vcsEnvironment } from "../state/vcs"; +import { + isThreadlessSurfacesRef, + threadlessSurfacesProjectRef, + useThreadlessHome, +} from "../threadlessSurfaces"; +import { findProjectForChangeRequest } from "@t3tools/client-runtime/change-request-links"; +import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; +import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { MAX_TERMINALS_PER_GROUP } from "../types"; +import { agentControlledBrowserCloseConfirmation } from "./ChatView.logic"; +import { FileSurfaceState } from "./files/fileSurfaceChrome"; +import { addBrowserSurface } from "./preview/addBrowserSurface"; +import { closePreviewSession } from "./preview/closePreviewSession"; +import { getConfiguredPreviewUrls } from "./preview/previewEmptyStateLogic"; +import { PullRequestDetailGhost } from "./pullRequest/PullRequestGhosts"; +import { + pullRequestPanelContext, + threadPullRequestPanelTarget, +} from "./pullRequest/pullRequestDetail.logic"; +import { ThreadPullRequestsPanel } from "./pullRequest/ThreadPullRequestsPanel"; +import { RightPanelTabs } from "./RightPanelTabs"; +import { + PersistentThreadTerminalDrawer, + PersistentThreadTerminalPanel, +} from "./ThreadTerminalSurfaces"; +import { Button } from "./ui/button"; +import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from "./ui/empty"; +import { stackedThreadToast, toastManager } from "./ui/toast"; +import { confirmTerminalClose } from "../lib/terminalCloseConfirm"; + +const SideChatPane = lazy(() => import("./chat/SideChatPane")); +const PreviewPanel = lazy(() => + import("./preview/PreviewPanel").then((module) => ({ default: module.PreviewPanel })), +); +const DiffPanel = lazy(() => import("./DiffPanel")); +const DevicePanel = lazy(() => + import("./device/DevicePanel").then((module) => ({ default: module.DevicePanel })), +); +const FilePreviewPanel = lazy(() => import("./files/FilePreviewPanel")); +const PullRequestDetailPanel = lazy(() => + import("./pullRequest/PullRequestDetailPanel").then((module) => ({ + default: module.PullRequestDetailPanel, + })), +); + +const EMPTY_PENDING: ReadonlySet = new Set(); +const noop = () => undefined; + +/** What a thread's surfaces work in: its project, checkout and capabilities. */ +function useThreadSurfaceContext(threadRef: ScopedThreadRef) { + const serverThread = useQuietThreadShell(threadRef); + const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + const draftId = useComposerDraftStore((store) => store.getDraftIdByRef(threadRef)); + const environmentId = threadRef.environmentId; + const threadless = isThreadlessSurfacesRef(threadRef); + const projectId = + serverThread?.projectId ?? + draftThread?.projectId ?? + threadlessSurfacesProjectRef(threadRef)?.projectId ?? + null; + const projectRef = projectId === null ? null : scopeProjectRef(environmentId, projectId); + const project = useProject(projectRef); + const settings = useEnvironmentSettings(environmentId); + const projectScripts = project ? resolveProjectScripts(settings, project) : []; + const worktreePath = serverThread?.worktreePath ?? draftThread?.worktreePath ?? null; + // Without a project, a threadless ref works in the server's home. + const home = useThreadlessHome(threadRef); + const workspaceRoot = worktreePath ?? project?.workspaceRoot ?? home; + const gitCwd = project + ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, worktreePath }) + : home; + const serverConfig = useServerConfigs().get(environmentId) ?? null; + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const isServerThread = serverThread !== null; + return { + serverThread, + draftThread, + isServerThread, + threadless, + project, + projectScripts, + worktreePath, + workspaceRoot, + gitCwd, + serverConfig, + projectRef, + // A threadless surface has no composer, so nothing to comment into. + composerDraftTarget: threadless + ? undefined + : isServerThread + ? threadRef + : (draftId ?? threadRef), + // A threadless ref is gone with its project; one without a project never is. + gone: + bootstrapped && + (threadless + ? projectId !== null && project === null + : serverThread === null && draftThread === null), + }; +} + +/** + * What a thread can open as a surface, and how: the launcher's rows and a + * surface tab's "+". Each opens through `rightPanelStore`, and the layout + * gives it a tab (surfaceSync.ts). + */ +export function useThreadSurfaceOpeners(threadRef: ScopedThreadRef) { + const ref = threadRef; + const context = useThreadSurfaceContext(ref); + const canOperatePreview = useEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope); + const sessionStatus = useEnvironmentSessionStatus(threadRef.environmentId); + const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const serverBrowser = useEnvironmentSupportsServerBrowser(threadRef.environmentId); + const openPreview = useAtomCommand(previewEnvironment.open, { reportFailure: false }); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const allocateTerminalId = useTerminalIdAllocator(ref); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const canOperateThread = useEnvironmentScope( + threadRef.environmentId, + AuthOrchestrationOperateScope, + ); + const projection = useQuietThreadProjection(threadRef)?.projection; + const sideChat = useSideChat({ + threadRef: + canOperateThread && + context.serverThread !== null && + context.serverThread.lineage.sideChat !== true && + context.serverThread.lineage.relationshipToParent !== "subagent" && + context.serverThread.delegatedFrom === undefined && + !orchestrationV2WorktreePending(context.worktreePath, projection?.runs ?? []) && + context.serverConfig?.environment.capabilities.sideChats === true + ? threadRef + : null, + onError: (description) => + toastManager.add({ type: "error", title: "Could not open side chat", description }), + }); + const serverConfigLoaded = context.serverConfig !== null; + const shell = context.serverThread; + const threadPullRequestTarget = + shell && serverConfigLoaded + ? threadPullRequestPanelTarget({ + projectId: shell.projectId, + pullRequests: shell.pullRequests, + linkedPullRequest: shell.linkedPullRequest, + branchPullRequest: shell.branchPullRequest, + }) + : null; + // A threadless ref's pull request is the one for its project checkout's branch. + const projectStatus = useEnvironmentQuery( + context.threadless && context.gitCwd !== null && serverConfigLoaded + ? vcsEnvironment.status({ + environmentId: threadRef.environmentId, + input: { cwd: context.gitCwd }, + }) + : null, + ); + const pullRequestTarget = + threadPullRequestTarget ?? + projectPullRequestTarget( + context.project, + projectStatus.data?.pr?.url ?? null, + serverConfigLoaded, + ); + const linkedCount = visibleThreadPullRequests(shell?.pullRequests ?? []).length; + // Why the browser cannot open here: each case its own reason. + const browserUnavailableReason = + sessionStatus === "Initial" + ? "Connecting to the environment." + : sessionStatus === "Failure" + ? "Can't reach the environment." + : !canOperatePreview + ? "Ask the environment owner for preview access." + : isPreviewSupportedInRuntime() || serverBrowser + ? null + : "Only available in the desktop app."; + const available: Record = { + browser: browserUnavailableReason === null, + terminal: context.gitCwd !== null && canOperateTerminal, + files: context.workspaceRoot !== null, + // Outside a Git repository it opens on its empty state. + diff: (context.isServerThread || context.threadless) && context.gitCwd !== null, + "pull-request": pullRequestTarget !== null, + "pull-requests": context.isServerThread && serverConfigLoaded && linkedCount > 0, + device: true, + "side-chat": sideChat.available, + }; + const open = (kind: LauncherSurfaceKind, browserProfileId?: string) => { + if (!available[kind]) return; + const store = useRightPanelStore.getState(); + switch (kind) { + case "side-chat": + void sideChat.open(); + return; + case "diff": + // A project checkout opens on its uncommitted changes; a thread on its branch's. + useDiffPanelStore + .getState() + .selectGitScope(threadRef, context.threadless ? "unstaged" : "branch"); + store.open(threadRef, "diff"); + return; + case "files": + store.open(threadRef, "files"); + return; + case "pull-requests": + store.open(threadRef, "pull-requests"); + return; + case "device": + store.open(threadRef, "device"); + return; + case "pull-request": + if (pullRequestTarget) store.openPullRequest(threadRef, pullRequestTarget); + return; + case "browser": + void addBrowserSurface({ + threadRef, + openPreview, + ...(browserProfileId === undefined ? {} : { profileId: browserProfileId }), + }).then((result) => { + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + if (error instanceof BrowserSettingsReadError) { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Unable to open browser", + description: error.message, + }), + ); + } + }); + return; + case "terminal": { + if (!readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) return; + const terminalId = allocateTerminalId(); + const input = terminalOpenInput(threadRef, terminalId, context); + if (input === null) return; + store.openTerminal(threadRef, terminalId); + focusTerminal(threadRef, terminalId); + void openTerminal({ environmentId: threadRef.environmentId, input }); + return; + } + } + }; + const runShellCommand = (command: string) => { + if (!readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) return; + const terminalId = allocateTerminalId(); + const input = terminalOpenInput(threadRef, terminalId, context); + if (input === null) return; + useRightPanelStore.getState().openTerminal(threadRef, terminalId); + focusTerminal(threadRef, terminalId); + void openTerminal({ environmentId: threadRef.environmentId, input }).then((result) => { + if (result._tag !== "Success") return; + return writeTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId: threadRef.threadId, + terminalId, + data: `${command}\n`, + }, + }); + }); + }; + return { available, browserUnavailableReason, open, runShellCommand }; +} + +/** A new shell in a surface's folder: its checkout, or the server's home. */ +function terminalOpenInput( + threadRef: ScopedThreadRef, + terminalId: string, + context: ReturnType, +) { + const { gitCwd, project, worktreePath } = context; + if (gitCwd === null) return null; + return { + threadId: threadRef.threadId, + terminalId, + cwd: gitCwd, + ...(worktreePath != null ? { worktreePath } : {}), + ...(project + ? { env: projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath }) } + : {}), + }; +} + +/** + * A project checkout's branch pull request as a surface target, the way a + * link to it opens one; null when its URL is not this project's repository. + */ +function projectPullRequestTarget( + project: EnvironmentProject | null, + url: string | null, + threadPullRequests: boolean, +) { + if (!project || url === null) return null; + const link = sourceControlClients.parseChangeRequestUrl(url)?.link; + if (!link || !findProjectForChangeRequest([project], link)) return null; + return { + projectId: project.id, + ...(threadPullRequests ? { host: link.authority ?? link.host } : {}), + repository: threadPullRequests + ? link.repository + : (sourceControlClients.repositorySelector(project.repositoryIdentity) ?? link.repository), + number: link.number, + url, + }; +} + +/** A terminal id the thread has not used, as ChatView picks them. */ +function useTerminalIdAllocator(threadRef: ScopedThreadRef): () => string { + const known = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const drawerIds = useTerminalUiStateStore( + (state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef).terminalIds, + ); + const panelSurfaces = useRightPanelStore( + (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, + ); + return () => { + const ids = new Set(drawerIds); + for (const session of known ?? []) { + if (session.target.threadId === threadRef.threadId) ids.add(session.target.terminalId); + } + for (const surface of panelSurfaces) { + if (surface.kind === "terminal") for (const id of surface.terminalIds) ids.add(id); + } + const canReuse = + known !== null && readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope); + return nextTerminalId([...ids], canReuse ? undefined : randomUUID()); + }; +} + +/** The terminal keys' labels for its tooltips (split, new, close), as a focused terminal hears them. */ +function useTerminalShortcutLabels() { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const options = { context: { terminalFocus: true, terminalOpen: true } }; + const label = ( + command: "terminal.split" | "terminal.splitVertical" | "terminal.new" | "terminal.close", + ) => shortcutLabelForCommand(keybindings, command, options) ?? undefined; + return { + splitShortcutLabel: label("terminal.split"), + splitVerticalShortcutLabel: label("terminal.splitVertical"), + newShortcutLabel: label("terminal.new"), + closeShortcutLabel: label("terminal.close"), + }; +} + +function useTerminalLabels(threadRef: ScopedThreadRef): ReadonlyMap { + const known = useKnownTerminalSessions({ + environmentId: threadRef.environmentId, + threadId: threadRef.threadId, + }); + const labels = new Map(); + for (const session of known ?? []) { + if (session.target.threadId !== threadRef.threadId) continue; + labels.set( + session.target.terminalId, + resolveTerminalSessionLabel(session.target.terminalId, session.state.summary), + ); + } + return labels; +} + +/** A surface tab: the thread's terminal, or one of its `rightPanelStore` surfaces. */ +export function ThreadSurfaceTab({ + threadRef, + surfaceId, +}: { + threadRef: ScopedThreadRef; + surfaceId: string; +}) { + if (surfaceId === THREAD_TERMINAL_SURFACE_ID) return ; + return ; +} + +function ThreadTerminalTab({ threadRef }: { threadRef: ScopedThreadRef }) { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const bridge = useThreadChatBridge(threadRef); + const shortcutLabels = useTerminalShortcutLabels(); + const { paneId } = useLayoutPane(); + return ( + addTerminalContextFromChat(threadRef, paneId, selection) + } + paneMode + /> + ); +} + +/** The linked thread stays discoverable in a surface tab's tooltip. */ +function useThreadTitle(threadRef: ScopedThreadRef): string | null { + const shell = useQuietThreadShell(threadRef); + const draft = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); + return shell?.title ?? (draft ? "New thread" : null); +} + +function ThreadSurfacePane({ + threadRef, + surfaceId, +}: { + threadRef: ScopedThreadRef; + surfaceId: string; +}) { + const surface = useRightPanelStore( + (state) => + selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces.find( + (candidate) => candidate.id === surfaceId, + ) ?? null, + ); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const context = useThreadSurfaceContext(threadRef); + const openers = useThreadSurfaceOpeners(threadRef); + const previewState = useThreadPreviewState(threadRef); + const terminalLabels = useTerminalLabels(threadRef); + const threadTitle = useThreadTitle(threadRef); + const { visible } = useLayoutPane(); + const handleNewThread = useNewThreadHandler(); + const { projectRef } = context; + const getShortcutContext = () => ({ + terminalFocus: false, + terminalOpen: false, + previewFocus: false, + previewOpen: surface?.kind === "preview", + isWeb: !isElectron, + isDesktop: isElectron, + }); + const resolveRuntimeTabId = (tabId: string) => + previewRuntimeTabId(threadRef, previewState.serverEpoch, tabId); + // A browser tab shown makes its preview the thread's active one. + const previewResourceId = surface?.kind === "preview" ? surface.resourceId : null; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Keep the external preview store active tab aligned with the visible layout surface. + useEffect(() => { + if (visible && previewResourceId) setActivePreviewTab(threadRef, previewResourceId); + }, [previewResourceId, threadRef, visible]); + if (!surface) return null; + const close = () => void closeThreadSurface(threadRef, surface.id); + return ( + + useRightPanelStore.getState().renameDevice(threadRef, id, title) + } + onCloseOtherSurfaces={noop} + onCloseSurfacesToRight={noop} + onCloseAllSurfaces={noop} + onCopyFilePath={(path) => + void navigator.clipboard?.writeText( + formatFilePathForClipboard( + path, + resolvePathLinkTarget(path, context.gitCwd ?? context.workspaceRoot ?? ""), + ), + ) + } + onAddBrowser={() => openers.open("browser")} + onAddBrowserInProfile={(profileId) => openers.open("browser", profileId)} + onNewThread={ + projectRef === null + ? undefined + : () => void runInNewLayoutTab(() => handleNewThread(projectRef)) + } + onAddTerminal={() => openers.open("terminal")} + onAddDiff={() => openers.open("diff")} + onAddFiles={() => openers.open("files")} + onAddPullRequest={() => openers.open("pull-request")} + onAddPullRequests={() => openers.open("pull-requests")} + onAddDevice={() => openers.open("device")} + onAddSideChat={() => openers.open("side-chat")} + sideChatAvailable={openers.available["side-chat"]} + browserAvailable={openers.available.browser} + browserUnavailableReason={openers.browserUnavailableReason} + terminalAvailable={openers.available.terminal} + diffAvailable={openers.available.diff} + filesAvailable={openers.available.files} + pullRequestAvailable={openers.available["pull-request"]} + pullRequestsAvailable={openers.available["pull-requests"]} + deviceAvailable={openers.available.device} + > + {context.gone ? ( + + ) : ( + + )} + + ); +} + +function ThreadGoneState({ threadless, onClose }: { threadless: boolean; onClose: () => void }) { + return ( + + + + + + {threadless ? "Its project is gone" : "Its thread is gone"} + + {threadless + ? "The project this was opened in no longer exists." + : "The thread this was opened from no longer exists."} + +
+ +
+
+
+ ); +} + +function ThreadSurfaceContent({ + threadRef, + surface, + context, + visible, + openers, + getShortcutContext, +}: { + threadRef: ScopedThreadRef; + surface: RightPanelSurface; + context: ReturnType; + visible: boolean; + openers: ReturnType; + getShortcutContext: () => { + terminalFocus: boolean; + terminalOpen: boolean; + previewFocus: boolean; + previewOpen: boolean; + isWeb: boolean; + isDesktop: boolean; + }; +}) { + const bridge = useThreadChatBridge(threadRef); + const { paneId } = useLayoutPane(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const availableEditors = useAtomValue(primaryServerAvailableEditorsAtom); + const workspaceMutationId = bridge?.workspaceMutationId ?? null; + const [pendingFiles, setPendingFiles] = useState>(EMPTY_PENDING); + const onPendingChange = (relativePath: string, pending: boolean) => { + setPendingFiles((current) => { + const id = `file:${relativePath}`; + if (current.has(id) === pending) return current; + const next = new Set(current); + if (pending) next.add(id); + else next.delete(id); + return next; + }); + }; + const configuredUrls = getConfiguredPreviewUrls(context.projectScripts); + const terminal = useTerminalSurfaceActions(threadRef, surface, context); + const shortcutLabels = useTerminalShortcutLabels(); + const shell = context.serverThread; + const sideChatParentPanel: SideChatParentPanel = { + openToolDiff: (target) => { + useDiffPanelStore.getState().selectToolCall(threadRef, target); + useRightPanelStore.getState().open(threadRef, "diff"); + }, + runShellCommand: openers.runShellCommand, + }; + + switch (surface.kind) { + case "side-chat": + return ( + + void closeThreadSurface(threadRef, surface.id)} + /> + + ); + case "preview": + return ( + + void sendAnnotationFromChat(threadRef, paneId, annotation, image), + })} + /> + + ); + case "terminal": + return ( + addTerminalContextFromChat(threadRef, paneId, selection) + } + onSplitTerminal={terminal.split} + onSplitTerminalVertical={terminal.splitVertical} + onNewTerminal={() => openers.open("terminal")} + onActiveTerminalChange={terminal.activate} + onCloseTerminal={terminal.close} + {...shortcutLabels} + /> + ); + case "diff": + return ( + + + + ); + case "pull-request": + if (!context.serverConfig) return ; + return ( + }> + + useRightPanelStore.getState().setPullRequestDetailTab(threadRef, surface.id, tab) + } + getShortcutContext={getShortcutContext} + shortcutsEnabled={visible} + environmentId={threadRef.environmentId} + onSelectPullRequest={(reference) => + useRightPanelStore.getState().openPullRequest(threadRef, { + projectId: reference.projectId, + repository: reference.repository, + number: reference.number, + ...(reference.host ? { host: reference.host } : {}), + }) + } + threadRef={context.threadless ? null : threadRef} + reference={{ + projectId: surface.projectId as ProjectId, + ...(surface.host ? { host: surface.host } : {}), + repository: surface.repository, + number: surface.number, + }} + context={pullRequestPanelContext( + { + projectId: shell?.projectId ?? context.project?.id ?? null, + pullRequests: visibleThreadPullRequests(shell?.pullRequests ?? []), + linkedPullRequest: shell?.linkedPullRequest ?? null, + branchPullRequest: shell?.branchPullRequest ?? null, + }, + surface, + )} + // Threadless, its hand-offs start a thread, as the pull requests page's do. + {...(context.composerDraftTarget + ? { composerDraftTarget: context.composerDraftTarget } + : {})} + onBack={ + openers.available["pull-requests"] && + visibleThreadPullRequests(shell?.pullRequests ?? []).length > 1 + ? () => openers.open("pull-requests") + : undefined + } + /> + + ); + case "pull-requests": + return ; + case "device": + return ( + + void closeThreadSurface(threadRef, surface.id)} + /> + + ); + case "files": + case "file": { + const attachment = surface.kind === "file" ? surface.attachment : undefined; + if (!context.workspaceRoot && !attachment) { + return ( + } + title="No project folder" + description="This thread has no workspace folder, so there are no files to show." + /> + ); + } + return ( + + + useRightPanelStore.getState().openFile(threadRef, relativePath) + } + onNavigateFolder={(relativePath, folderTrail, keepSource) => + useRightPanelStore + .getState() + .navigateFolder(threadRef, surface.id, relativePath, folderTrail, keepSource) + } + folderTrail={surface.folderTrail} + onPendingChange={onPendingChange} + selectedFilePending={surface.kind === "file" && pendingFiles.has(surface.id)} + workspaceMutationId={workspaceMutationId} + /> + + ); + } + } +} + +async function addTerminalContextFromChat( + threadRef: ScopedThreadRef, + paneId: string | null, + selection: TerminalContextSelection, +): Promise { + let bridge = useThreadChatBridgeStore.getState().byThreadKey[scopedThreadKey(threadRef)] ?? null; + if (!bridge) { + showThreadChatBeside(threadRef, paneId); + bridge = await waitForThreadChatSendReady(threadRef, 15_000); + } + if (bridge) { + bridge.addTerminalContext(selection); + return true; + } + toastManager.add({ + type: "error", + title: "Could not add terminal output to chat", + description: "The chat is not ready. Your terminal selection is still available to add again.", + }); + return false; +} + +/** + * Send a browser annotation the way the chat sends anything: from its + * composer, which already holds the annotation. A closed chat opens beside + * the browser first and sends once it can. + */ +async function sendAnnotationFromChat( + threadRef: ScopedThreadRef, + paneId: string | null, + annotation: PreviewAnnotationPayload, + image: ComposerImageAttachment | null, +): Promise { + const open = useThreadChatBridgeStore.getState().byThreadKey[scopedThreadKey(threadRef)]; + if (open) { + open.sendAnnotation(annotation, image); + return; + } + showThreadChatBeside(threadRef, paneId); + const bridge = await waitForThreadChatSendReady(threadRef, 15_000); + if (bridge) { + bridge.sendAnnotation(annotation, image); + return; + } + toastManager.add( + stackedThreadToast({ + type: "info", + title: "Annotation attached to draft", + description: "The chat could not send it yet. Send it from the chat.", + }), + ); +} + +/** A terminal surface's own split, switch and close, as ChatView's panel had them. */ +function useTerminalSurfaceActions( + threadRef: ScopedThreadRef, + surface: RightPanelSurface, + context: ReturnType, +) { + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + const allocateTerminalId = useTerminalIdAllocator(threadRef); + const canWrite = () => readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const split = (direction: "horizontal" | "vertical" = "horizontal") => { + if ( + !canWrite() || + surface.kind !== "terminal" || + surface.terminalIds.length >= MAX_TERMINALS_PER_GROUP + ) { + return; + } + const terminalId = allocateTerminalId(); + const input = terminalOpenInput(threadRef, terminalId, context); + if (input === null) return; + useRightPanelStore.getState().splitTerminal(threadRef, surface.id, terminalId, direction); + focusTerminal(threadRef, terminalId); + void openTerminal({ environmentId: threadRef.environmentId, input }); + }; + return { + split: () => split("horizontal"), + splitVertical: () => split("vertical"), + activate: (terminalId: string) => { + if (surface.kind !== "terminal") return; + useRightPanelStore.getState().activateTerminal(threadRef, surface.id, terminalId); + focusTerminal(threadRef, terminalId); + }, + close: (terminalId: string) => { + if (!canWrite() || surface.kind !== "terminal") return; + void closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId, deleteHistory: true }, + }).then((result) => { + if ( + result._tag === "Failure" && + !isAtomCommandInterrupted(result) && + readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope) + ) { + return writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId, data: "exit\n" }, + }); + } + }); + storeCloseTerminal(threadRef, terminalId); + useRightPanelStore.getState().closeTerminal(threadRef, surface.id, terminalId); + focusActivePanelTerminal(threadRef); + }, + }; +} + +// Closing + +type SurfaceCloser = (threadRef: ScopedThreadRef, surfaces: readonly RightPanelSurface[]) => void; +/** Registered by `ThreadSurfaceServices`: ends what closed surfaces ran. */ +let surfaceServices: { + cleanup: SurfaceCloser; + endTerminals: (threadRef: ScopedThreadRef, terminalIds: readonly string[]) => void; +} | null = null; + +/** Closing a surface ends its resources; cmd+J preserves the thread terminal's shells. */ +export async function closeThreadSurface( + threadRef: ScopedThreadRef, + surfaceId: string, + options: { bulk?: boolean } = {}, +): Promise { + if (surfaceId === THREAD_TERMINAL_SURFACE_ID) { + if (readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) { + const { terminalIds } = selectThreadTerminalUiState( + useTerminalUiStateStore.getState().terminalUiStateByThreadKey, + threadRef, + ); + const [first, ...rest] = terminalIds.map((terminalId) => getTerminalLabel(terminalId)); + if (first !== undefined) { + if (!options.bulk && !(await confirmTerminalClose([first, ...rest]))) return; + surfaceServices?.endTerminals(threadRef, terminalIds); + } + } + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, false); + return; + } + const surface = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + threadRef, + ).surfaces.find((candidate) => candidate.id === surfaceId); + if (!surface) return; + const finish = () => { + surfaceServices?.cleanup(threadRef, [surface]); + useRightPanelStore.getState().closeSurface(threadRef, surface.id); + }; + if (surface.kind === "preview") { + const message = agentControlledBrowserCloseConfirmation( + [surface], + readThreadPreviewState(threadRef).desktopByTabId, + ); + if (message) { + const confirmed = await readLocalApi() + ?.dialogs.confirm(message, { variant: "destructive" }) + .catch(() => false); + if (!confirmed) return; + } + finish(); + return; + } + if ( + surface.kind === "terminal" && + !options.bulk && + readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope) + ) { + const [first = getTerminalLabel(surface.activeTerminalId), ...rest] = surface.terminalIds.map( + (terminalId) => getTerminalLabel(terminalId), + ); + if (!(await confirmTerminalClose([first, ...rest]))) return; + } + finish(); +} + +/** + * Take a thread's surface tabs off the layout, its terminal tab included. + * Nothing they run ends; alt+cmd+B and cmd+J bring them back. + */ +export function hideThreadSurfaceTabs(threadRef: ScopedThreadRef): void { + useRightPanelStore.getState().close(threadRef); + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, false); +} + +/** + * Close every surface of a thread without asking, because the thread itself is + * being discarded: browsers and terminals end, and its terminal tab hides. + */ +export function discardThreadSurfaces(threadRef: ScopedThreadRef): void { + const { surfaces } = selectThreadRightPanelState( + useRightPanelStore.getState().byThreadKey, + threadRef, + ); + surfaceServices?.cleanup(threadRef, surfaces); + useRightPanelStore.getState().closeAllSurfaces(threadRef); + useTerminalUiStateStore.getState().setTerminalOpen(threadRef, false); +} + +/** + * Hide a thread's surfaces (alt+cmd+B): a live browser or device on screen + * floats in its mini player instead of disappearing. + */ +export function hideThreadSurfaces(threadRef: ScopedThreadRef): void { + const state = selectThreadRightPanelState(useRightPanelStore.getState().byThreadKey, threadRef); + const active = state.surfaces.find((surface) => surface.id === state.activeSurfaceId); + if (active?.kind === "preview" && active.resourceId) { + usePreviewMiniPlayerStore + .getState() + .open(threadRef, browserMiniPlayerSource(active.resourceId)); + } else if (active?.kind === "device" && active.target) { + usePreviewMiniPlayerStore.getState().open(threadRef, { kind: "device", ...active.target }); + } + useRightPanelStore.getState().close(threadRef); +} + +/** + * Mounted once with the layout host: ends what closed surfaces ran (preview + * sessions, terminal sessions) through this client's connections. + */ +export function ThreadSurfaceServices() { + const deleteThread = useAtomCommand(threadEnvironment.delete, { reportFailure: false }); + const closePreview = useAtomCommand(previewEnvironment.close, "preview close"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Register mounted surface resource closers with the external lifecycle registry. + useEffect(() => { + const end = (threadRef: ScopedThreadRef, terminalIds: readonly string[]) => { + if (!readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope)) return; + for (const terminalId of terminalIds) { + storeCloseTerminal(threadRef, terminalId); + void closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId, deleteHistory: true }, + }).then((result) => { + if ( + result._tag === "Failure" && + !isAtomCommandInterrupted(result) && + readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope) + ) { + return writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId, terminalId, data: "exit\n" }, + }); + } + }); + } + }; + const closer: SurfaceCloser = (threadRef, surfaces) => { + for (const surface of surfaces) { + if ( + surface.kind === "side-chat" && + readEnvironmentScope(threadRef.environmentId, AuthOrchestrationOperateScope) + ) { + void deleteThread({ + environmentId: threadRef.environmentId, + input: { threadId: surface.threadId, onlyIfUnused: true }, + }); + } + if ( + surface.kind === "preview" && + surface.resourceId && + readEnvironmentScope(threadRef.environmentId, AuthPreviewOperateScope) + ) { + void closePreviewSession({ + closePreview, + snapshot: readThreadPreviewState(threadRef).sessions[surface.resourceId] ?? null, + tabId: surface.resourceId, + threadRef, + }); + } + if ( + surface.kind === "terminal" && + readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope) + ) { + end(threadRef, surface.terminalIds); + } + } + }; + const services = { cleanup: closer, endTerminals: end }; + surfaceServices = services; + return () => { + if (surfaceServices === services) surfaceServices = null; + }; + }, [closePreview, closeTerminalMutation, storeCloseTerminal, writeTerminal, deleteThread]); + return null; +} diff --git a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx index 739136162d31f..4c46a4e3a8daa 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.browser.test.tsx @@ -17,6 +17,7 @@ import { extractTerminalLinks } from "../terminal-links"; import { expect, it, vi } from "vite-plus/test"; import { act, fakeTimers, render } from "../../test/render"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; +import type { TerminalContextSelection } from "../lib/terminalContext"; const fixture = vi.hoisted(() => ({ statusByThreadId: new Map(), @@ -40,6 +41,10 @@ const fixture = vi.hoisted(() => ({ string, Atom.Writable> >(), + contextMenu: undefined as ((event: MouseEvent) => void) | undefined, + showContextMenu: vi.fn(), + selection: "", + clearSelection: vi.fn(), })); // Every other surface method is a no-op; `then` stays undefined so awaiting the surface resolves to it. @@ -51,6 +56,14 @@ const fakeSurface: object = new Proxy( const size = fixture.reportedSize; if (size) fixture.surfaceOptions.at(-1)!.onResize(size.cols, size.rows); }, + hasSelection: () => fixture.selection.length > 0, + getSelection: () => fixture.selection, + getSelectionPosition: () => ({ start: { y: 0 }, end: { y: 1 } }), + getSelectionEndClientRect: () => null, + clearSelection: () => { + fixture.clearSelection(); + fixture.selection = ""; + }, }, { get: (target, key) => @@ -62,6 +75,7 @@ vi.mock("~/terminal/ghostty/surface", () => ({ create: (_mount: HTMLElement, options: GhosttyTerminalSurfaceOptions) => new Promise((resolve) => { fixture.surfaceOptions.push(options); + fixture.contextMenu = options.onContextMenu; fixture.pendingSurfaces.push(() => { fixture.typeIntoSurface = options.onData; fixture.pressKey = options.beforeKey ?? null; @@ -152,9 +166,15 @@ vi.mock("../state/projects", async () => { }, }; }); +vi.mock("../layout/PaneTabs", () => ({ + LayoutPaneTab: () => null, + useLayoutPaneSiblingTabs: () => ({ before: [], after: [] }), +})); vi.mock("../editorPreferences", () => ({ useOpenInPreferredEditor: () => () => {} })); vi.mock("./preview/openTerminalLinkInPreview", () => ({ openTerminalLinkInPreview: () => {} })); -vi.mock("~/localApi", () => ({ readLocalApi: () => null })); +vi.mock("~/localApi", () => ({ + readLocalApi: () => ({ contextMenu: { show: fixture.showContextMenu } }), +})); vi.mock("../hooks/useSettings", async () => { const { DEFAULT_CLIENT_SETTINGS } = await import("@t3tools/contracts/settings"); return { @@ -168,6 +188,7 @@ function Drawer(props: { cwd?: string; onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; keybindings?: ResolvedKeybindingsConfig; + onAddTerminalContext?: (selection: TerminalContextSelection) => Promise; }) { return ( {}} onCloseTerminal={props.onCloseTerminal} onHeightChange={() => {}} - onAddTerminalContext={() => {}} + onAddTerminalContext={props.onAddTerminalContext} keybindings={props.keybindings ?? []} /> ); @@ -215,6 +236,10 @@ function setup(originalStatus: "starting" | "exited" = "exited") { fixture.metadata.clear(); useRightPanelStore.setState(useRightPanelStore.getInitialState()); useTerminalUiStateStore.setState(useTerminalUiStateStore.getInitialState()); + fixture.contextMenu = undefined; + fixture.showContextMenu.mockReset().mockResolvedValue("add-to-chat"); + fixture.selection = "selected output"; + fixture.clearSelection.mockReset(); return { original, other }; } @@ -412,3 +437,41 @@ it("resends a failed resize once in each writable session", async () => { }; expect(resizes).toEqual([resize, resize, resize]); }); + +it.each([false, true])("clears selected output only after chat accepts it: %s", async (added) => { + const { other } = setup(); + let accept: (added: boolean) => void = () => {}; + const onAddTerminalContext = vi.fn( + () => + new Promise((resolve) => { + accept = resolve; + }), + ); + await using _screen = await render( + {}} + onAddTerminalContext={onAddTerminalContext} + />, + ); + await act(() => fixture.pendingSurfaces.shift()?.()); + await act(() => fixture.contextMenu?.(new MouseEvent("contextmenu", { cancelable: true }))); + expect(onAddTerminalContext).toHaveBeenCalledWith( + expect.objectContaining({ text: "selected output" }), + ); + expect(fixture.clearSelection).not.toHaveBeenCalled(); + await act(() => accept(added)); + expect(fixture.clearSelection).toHaveBeenCalledTimes(added ? 1 : 0); +}); + +it("omits add to chat when the terminal has no composer target", async () => { + const { other } = setup(); + await using _screen = await render( {}} />); + await act(() => fixture.pendingSurfaces.shift()?.()); + await act(() => fixture.contextMenu?.(new MouseEvent("contextmenu", { cancelable: true }))); + const items = fixture.showContextMenu.mock.calls[0]![0]; + expect(items).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: "add-to-chat" })]), + ); + expect(fixture.clearSelection).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 6631054a46a25..0a60d6dd2d400 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -14,6 +14,7 @@ import { type TerminalSessionState, } from "@t3tools/client-runtime/state/terminal"; import { + ChevronDown, Plus, SquareSplitHorizontal, SquareSplitVertical, @@ -32,11 +33,14 @@ import { type ThreadId, } from "@t3tools/contracts"; import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; +import { terminalTabTitle } from "../layout/surfaceTabLabels"; import * as Schema from "effect/Schema"; import { AsyncResult } from "effect/reactivity"; import { + type KeyboardEvent as ReactKeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode, + type RefObject, useEffect, useContext, useEffectEvent, @@ -47,11 +51,17 @@ import { import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { Button } from "~/components/ui/button"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; +import { PaneNotch } from "~/components/PaneNotch"; +import { beginLayoutTabDrag } from "~/layout/layoutDrag"; +import { useLayoutPane, useLayoutPaneFocusedRef } from "~/layout/layoutPaneContext"; +import { LayoutPaneTab, useLayoutPaneSiblingTabs } from "~/layout/PaneTabs"; import { ScrollArea } from "~/components/ui/scroll-area"; +import { Menu, MenuItem, MenuPopup, MenuTrigger } from "~/components/ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { stackedThreadToast, toastManager } from "~/components/ui/toast"; import { readTextFromClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; import { cn } from "~/lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; import { type TerminalContextSelection } from "~/lib/terminalContext"; import { GhosttyTerminalSurface, @@ -68,6 +78,7 @@ import { isTerminalSplitVerticalShortcut, isTerminalToggleShortcut, isThreadNavigationShortcut, + resolveShortcutCommand, terminalDeleteShortcutData, terminalNavigationShortcutData, } from "../keybindings"; @@ -77,13 +88,16 @@ import { type ThreadTerminalGroup, } from "../types"; import { readLocalApi } from "~/localApi"; -import { confirmTerminalClose } from "~/lib/terminalCloseConfirm"; +import { confirmTerminalClose, isTerminalCloseConfirmPending } from "~/lib/terminalCloseConfirm"; +import { getTerminalFocusOwner } from "~/lib/terminalFocus"; import { useClientSettings } from "../hooks/useSettings"; import { getFileMetadataQueryAtom } from "../hooks/useFileMetadata"; import { FileMetadataThreadContext } from "../hooks/FileMetadataThreadProvider"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { attachedTerminalSessionAtom } from "../state/terminalSessions"; import { appAtomRegistry } from "../rpc/atomRegistry"; +import { useRevealActiveTab } from "../hooks/useRevealActiveTab"; +import { FOLDED_TAB_CLASS, usePaneTabStripFit } from "../hooks/usePaneTabStripFit"; import { serverEnvironment } from "../state/server"; import { previewEnvironment } from "../state/preview"; import { readEnvironmentScope } from "../state/session"; @@ -469,7 +483,9 @@ interface TerminalViewportProps { runtimeEnv?: Record; providerInstanceId?: ProviderInstanceId; onSessionExited: () => void; - onAddTerminalContext?: (selection: TerminalContextSelection) => void; + onAddTerminalContext?: + | ((selection: TerminalContextSelection) => void | Promise) + | undefined; /** Focuses the viewport when it mounts; otherwise focus arrives through `focusTerminal`. */ autoFocus?: boolean; visible: boolean; @@ -514,6 +530,7 @@ function TerminalSessionViewport({ const autoFocusedRef = useRef(false); const terminalRef = useRef(null); const visibleRef = useRef(visible); + const paneFocusedRef = useLayoutPaneFocusedRef(); const environmentId = threadRef.environmentId; const canOperateTerminal = useEnvironmentScope(environmentId, AuthTerminalOperateScope); const hasTerminalWriteAccess = useEffectEvent(() => @@ -583,7 +600,7 @@ function TerminalSessionViewport({ if (hasTerminalWriteAccess()) onSessionExited(); }); const handleAddTerminalContext = useEffectEvent((selection: TerminalContextSelection) => { - onAddTerminalContext?.(selection); + return onAddTerminalContext?.(selection); }); const canAddSelectionToChat = useEffectEvent(() => onAddTerminalContext !== undefined); const readTerminalLabel = useEffectEvent(() => terminalLabel); @@ -827,14 +844,21 @@ function TerminalSessionViewport({ }; }; - const addSelectionToChat = (clipboardText: string) => { + const addSelectionToChat = async (clipboardText: string) => { // Output keeps moving the selected rows while a menu is open, so take // the line numbers now, and only if the same text is still selected. const current = readSelectionAction(); if (current?.clipboardText !== clipboardText) return; - handleAddTerminalContext(current.selection); - terminalRef.current?.clearSelection(); - terminalRef.current?.focus(); + const activeTerminal = terminalRef.current; + const added = await handleAddTerminalContext(current.selection); + if ( + added === false || + terminalRef.current !== activeTerminal || + activeTerminal?.getSelection() !== clipboardText + ) + return; + activeTerminal.clearSelection(); + activeTerminal.focus(); }; const reportIfCurrent = (requestId: number, error: unknown, fallback: string) => { @@ -905,7 +929,7 @@ function TerminalSessionViewport({ switch (clicked) { case "add-to-chat": if (selectionAction && canAddSelectionToChat()) { - addSelectionToChat(selectionAction.clipboardText); + await addSelectionToChat(selectionAction.clipboardText); } return; case "copy": @@ -961,6 +985,9 @@ function TerminalSessionViewport({ ) { return false; } + // Tab switching belongs to the layout, so it bubbles to the app's handler. + const command = resolveShortcutCommand(event, currentKeybindings, options); + if (command === "tab.next" || command === "tab.previous") return false; const navigationData = terminalNavigationShortcutData(event); if (navigationData !== null) { @@ -1109,7 +1136,8 @@ function TerminalSessionViewport({ focusTerminalViewport({ mount, surface: terminalRef.current, - canOperate: readEnvironmentScope(environmentId, AuthTerminalOperateScope), + canOperate: + paneFocusedRef.current && readEnvironmentScope(environmentId, AuthTerminalOperateScope), visible: visibleRef.current, }); // Once per viewport: a re-attached ref (a fresh callback identity) must not @@ -1141,9 +1169,11 @@ function TerminalSessionViewport({ } interface ThreadTerminalDrawerProps { - mode?: "drawer" | "panel"; + /** "pane" fills a layout pane (cmd+J) and still owns the drawer's terminals. */ + mode?: "drawer" | "panel" | "pane"; threadRef: ScopedThreadRef; threadId: ThreadId; + threadTitle?: string | null; cwd: string; worktreePath?: string | null; runtimeEnv?: Record; @@ -1165,58 +1195,16 @@ interface ThreadTerminalDrawerProps { onActiveTerminalChange: (terminalId: string) => void; onCloseTerminal: (terminalId: string, threadRef?: ScopedThreadRef) => void; onHeightChange: (height: number) => void; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext: + | ((selection: TerminalContextSelection) => void | Promise) + | undefined; keybindings: ResolvedKeybindingsConfig; terminalLabelsById?: ReadonlyMap; /** Run status per terminal for the tab's tooltip; missing ids read as an idle shell. */ terminalStatusById?: ReadonlyMap; terminalLaunchLocationsById?: ReadonlyMap; -} - -interface TerminalActionButtonProps { - label: string; - onClick: () => void; - /** Keeps the tooltip reachable (it explains the limit), unlike `disabled`. */ - unavailable?: boolean; - children: ReactNode; - disabled?: boolean; -} - -function TerminalActionButton({ - label, - onClick, - unavailable, - children, -}: TerminalActionButtonProps) { - return ( - - { - if (!unavailable) onClick(); - }} - aria-label={label} - aria-disabled={unavailable || undefined} - /> - } - > - {children} - - - {label} - - - ); + /** Its sessions are still loading: with none to show yet, it draws just its background. */ + attaching?: boolean; } function TerminalSplitPanes({ @@ -1244,7 +1232,9 @@ function TerminalSplitPanes({ advancedTypography: boolean; threadRef: ScopedThreadRef; threadId: ThreadId; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext: + | ((selection: TerminalContextSelection) => void | Promise) + | undefined; visible: boolean; keybindings: ResolvedKeybindingsConfig; }) { @@ -1304,10 +1294,119 @@ function TerminalSplitPanes({ ); } +interface TerminalActionButtonProps { + label: string; + onClick: () => void; + /** Keeps the tooltip reachable (it explains the limit), unlike `disabled`. */ + unavailable?: boolean; + children: ReactNode; + disabled?: boolean; +} + +/** + * The terminal pane's tabs: in a layout pane they sit in the pane's notch with + * "+" as its last child, like a chat's tabs; the drawer keeps a plain row. + */ +function TerminalTabsNotch({ + notch, + newTerminalButton, + children, +}: { + notch: boolean; + newTerminalButton: ReactNode; + children: ReactNode; +}) { + if (!notch) return children; + return ( + + {children} + + {newTerminalButton} + + + ); +} + +/** + * The terminal tabs' row: in a pane's notch it clips and the tabs fit it + * (usePaneTabStripFit); the drawer's row scrolls. + */ +function TerminalTabStrip({ + notch, + ref, + children, +}: { + notch: boolean; + ref: RefObject; + children: ReactNode; +}) { + if (notch) { + return ( +
+ {children} +
+ ); + } + return ( + +
+ {children} +
+
+ ); +} + +function TerminalActionButton({ + label, + onClick, + unavailable, + children, +}: TerminalActionButtonProps) { + return ( + + { + if (!unavailable) onClick(); + }} + aria-label={label} + aria-disabled={unavailable || undefined} + /> + } + > + {children} + + + {label} + + + ); +} + export default function ThreadTerminalDrawer({ mode = "drawer", threadRef, threadId, + threadTitle, cwd, worktreePath, runtimeEnv, @@ -1333,10 +1432,16 @@ export default function ThreadTerminalDrawer({ terminalLabelsById, terminalStatusById, terminalLaunchLocationsById, + attaching = false, }: ThreadTerminalDrawerProps) { const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); const terminalArea = workspaceAreaProps("terminal", 5, "Terminal"); const isPanel = mode === "panel"; + const fillsContainer = mode !== "drawer"; + const terminalOwner = mode === "panel" ? "right-panel" : "drawer"; + // As a pane, this is a layout tab: dragging one of its terminals moves it. + const { tabId: layoutTabId, leadingEdge } = useLayoutPane(); + const siblingTabs = useLayoutPaneSiblingTabs(); const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, false, @@ -1476,6 +1581,17 @@ export default function ThreadTerminalDrawer({ const splitDirection = resolvedTerminalGroups[resolvedActiveGroupIndex]?.splitDirection ?? "horizontal"; const isSplitView = visibleTerminalIds.length > 1; + const tabStripRef = useRef(null); + useRevealActiveTab(tabStripRef, resolvedActiveTerminalId); + const fitTabs = resolvedTerminalGroups.flatMap((group) => + group.terminalIds.map((terminalId) => ({ + key: terminalId, + title: terminalLabelById.get(terminalId) ?? "Terminal", + })), + ); + // In a pane's notch the tabs fold and overflow into a menu instead of scrolling. + const tabFit = usePaneTabStripFit(tabStripRef, fitTabs, resolvedActiveTerminalId); + const hiddenTerminalTabs = fitTabs.filter((tab) => tabFit.hidden.has(tab.key)); const resolveTerminalLaunchLocation = (terminalId: string): TerminalLaunchLocation => terminalLaunchLocationsById?.get(terminalId) ?? { cwd, @@ -1521,6 +1637,31 @@ export default function ThreadTerminalDrawer({ }); }; + // The terminal's own keys answer here, so they work whether or not its + // thread's chat is open. Without operate access they keep their native meaning. + const onShortcutKeyDown = (event: ReactKeyboardEvent) => { + if (!canOperateTerminal || getTerminalFocusOwner() === null) return; + const command = resolveShortcutCommand(event.nativeEvent, keybindings, { + context: { terminalFocus: true, terminalOpen: true }, + }); + if ( + command !== "terminal.split" && + command !== "terminal.splitVertical" && + command !== "terminal.new" && + command !== "terminal.close" + ) { + return; + } + event.preventDefault(); + event.stopPropagation(); + if (command === "terminal.split") onSplitTerminal(); + else if (command === "terminal.splitVertical") onSplitTerminalVertical(); + else if (command === "terminal.new") onNewTerminalAction(); + else if (!event.repeat && !isTerminalCloseConfirmPending()) { + confirmCloseTerminal(resolvedActiveTerminalId); + } + }; + const handleResizePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; event.preventDefault(); @@ -1597,7 +1738,7 @@ export default function ThreadTerminalDrawer({ } // The column and window may have resized while the drawer was hidden and // not listening, so measure before the first visible paint. - const column = isPanel ? null : chatColumnOf(drawerRef.current); + const column = fillsContainer ? null : chatColumnOf(drawerRef.current); const onWindowResize = () => updateDrawerLayout(column, "window"); onWindowResize(); const header = column?.querySelector("[data-chat-header]"); @@ -1611,9 +1752,9 @@ export default function ThreadTerminalDrawer({ stopObserving?.(); window.removeEventListener("resize", onWindowResize); }; - }, [isPanel, visible]); + }, [fillsContainer, visible]); - const resizeHandle = isPanel ? null : ( + const resizeHandle = fillsContainer ? null : (
{resizeHandle} -
-

No terminal sessions for this thread yet.

- -
+ {attaching ? null : ( +
+

No terminal sessions for this thread yet.

+ +
+ )} ); } @@ -1700,9 +1844,10 @@ export default function ThreadTerminalDrawer({ ref={drawerRef} {...(!isPanel && visible ? terminalArea : {})} data-thread-terminal-drawer - data-terminal-owner={isPanel ? "right-panel" : "drawer"} + data-terminal-owner={terminalOwner} className={drawerClassName} style={drawerStyle} + onKeyDown={onShortcutKeyDown} > {resizeHandle} @@ -1713,85 +1858,176 @@ export default function ThreadTerminalDrawer({ ) : ( // The terminal pane's header (pen bKxd4): one tab per terminal, "+" // after the last tab. -
- -
+
+ + {mode === "pane" + ? siblingTabs.before.map((tab) => ) + : null} + {resolvedTerminalGroups.flatMap((terminalGroup, groupIndex) => terminalGroup.terminalIds.map((terminalId) => { + if (tabFit.hidden.has(terminalId)) return null; const isActive = terminalId === resolvedActiveTerminalId; + const folded = tabFit.folded && !isActive; // Split siblings of the active tab are on screen too. const isOnScreen = isSplitView && groupIndex === resolvedActiveGroupIndex; const terminalLabel = terminalLabelById.get(terminalId) ?? "Terminal"; const status = terminalStatusById?.get(terminalId) ?? "idle"; const statusLabel = terminalActivityStatusLabel(status); + const tabTitle = terminalTabTitle(terminalId, terminalLabel); const splitLabel = terminalGroup.terminalIds.length < 2 ? null : terminalGroup.splitDirection === "vertical" ? "Stacked" : "Side by side"; + const tooltip = [ + terminalLabel, + threadTitle, + terminalLaunchLocationsById?.get(terminalId)?.cwd ?? worktreePath ?? cwd, + statusLabel ?? "Idle", + splitLabel, + ] + .filter(Boolean) + .join("\n"); const closeTerminalLabel = `Close ${terminalLabel}${ isActive && closeShortcutLabel ? ` (${closeShortcutLabel})` : "" }`; return (
beginLayoutTabDrag(event, layoutTabId) + : undefined + } className={cn( - "group/tab flex h-7 shrink-0 items-center gap-1.5 rounded-item px-2.5 text-label", + "group/tab flex h-7 items-center gap-1.75 rounded-control px-2.5 text-label", + isActive && tabFit.squeezed ? "min-w-0" : "shrink-0", + folded && FOLDED_TAB_CLASS, isActive ? "text-strong-foreground" : isOnScreen ? "text-foreground hover:surface-ghost-hover" - : "text-dim-foreground hover:surface-ghost-hover hover:text-foreground", + : "text-muted-foreground hover:surface-ghost-hover hover:text-foreground", )} > - {/* The terminal icon turns into the close button on hover, like the panel tabs. */} - confirmCloseTerminal(terminalId)} - > - - - - onActiveTerminalChange(terminalId)} + {folded ? ( + // A folded tab is its icon alone, so a click shows it + // rather than turning the icon into its close button. + + onActiveTerminalChange(terminalId)} + /> + } + > + + + {tooltip} + + ) : ( + <> + {/* The terminal icon turns into the close button on hover, like the panel tabs. */} + confirmCloseTerminal(terminalId)} + > + - } - > - {terminalLabel} - {statusLabel ? , {statusLabel} : null} - - - - {[terminalLabel, statusLabel, splitLabel] - .filter(Boolean) - .map((label) => ( - {label} - ))} - - - + + + onActiveTerminalChange(terminalId)} + /> + } + > + {tabTitle} + {statusLabel ? ( + , {statusLabel} + ) : null} + + {tooltip} + + + )}
); }), )} - {terminalActions} -
- + {hiddenTerminalTabs.length > 0 ? ( + + + } + > + + + + {hiddenTerminalTabs.map((tab) => ( + onActiveTerminalChange(tab.key)}> + + {tab.title} + + ))} + + + ) : null} + {mode === "pane" ? null : terminalActions} + + {mode === "pane" + ? siblingTabs.after.map((tab) => ) + : null} +
)} @@ -1799,9 +2035,9 @@ export default function ThreadTerminalDrawer({
diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/ThreadTerminalSurfaces.tsx similarity index 51% rename from apps/web/src/components/PersistentThreadTerminals.tsx rename to apps/web/src/components/ThreadTerminalSurfaces.tsx index 3d157b88e1ff8..6ffb7911af3b5 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/ThreadTerminalSurfaces.tsx @@ -1,4 +1,11 @@ +/** + * A thread's terminals as layout tabs: the thread's terminal (cmd+J), with + * its groups and splits, and a terminal surface opened from a launcher or a + * surface's "+". Each takes its thread and works out its own context, so the + * tab shows it whether or not the thread's chat is open. + */ import { + AuthTerminalReadScope, AuthTerminalOperateScope, type EnvironmentId, type ResolvedKeybindingsConfig, @@ -11,32 +18,47 @@ import { } from "@t3tools/client-runtime/state/terminal"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; -import { resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; import { memo, useEffect } from "react"; -import { useComposerDraftStore } from "../composerDraftStore"; -import { useStableCallback } from "../hooks/useStableCallback"; -import type { TerminalContextSelection } from "../lib/terminalContext"; import { focusActiveDrawerTerminal, focusTerminal } from "../lib/terminalFocus"; -import { cn } from "~/lib/utils"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import { selectThreadRightPanelState, - useRightPanelStore, type RightPanelSurface, + useRightPanelStore, } from "../rightPanelStore"; -import { useProject, useQuietThreadShell } from "../state/entities"; -import { useEnvironmentScope } from "../state/session"; -import { useKnownTerminalSessions } from "../state/terminalSessions"; -import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; -import { useThreadTerminalActions } from "./terminal/useThreadTerminalActions"; import ThreadTerminalDrawer from "./ThreadTerminalDrawer"; import { resolveTerminalWorktreePath } from "./ChatView.logic"; +import { cn, randomUUID } from "~/lib/utils"; +import { useComposerDraftStore } from "../composerDraftStore"; +import { type TerminalContextSelection } from "../lib/terminalContext"; +import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; +import { useKnownTerminalSessions } from "../state/terminalSessions"; +import { useEnvironmentScope, useEnvironmentSessionLoaded } from "~/state/session"; +import { terminalEnvironment } from "../state/terminal"; +import { useProject, useQuietThreadShell } from "../state/entities"; +import { threadlessSurfacesProjectRef, useThreadlessHome } from "../threadlessSurfaces"; +import { readEnvironmentScope } from "../state/session"; +import { useAtomCommand } from "../state/use-atom-command"; +import { MAX_TERMINALS_PER_GROUP } from "../types"; + +export interface TerminalLaunchContext { + threadId: ThreadId; + cwd: string; + worktreePath: string | null; +} -/** Where a drawer or panel opens new terminals when a script run overrides the thread's own. */ -export type PersistentTerminalLaunchContext = { - readonly cwd: string; - readonly worktreePath: string | null; -}; +export type PersistentTerminalLaunchContext = Pick; +/** A shell's env: its project's script variables, or none in the server's home. */ +function shellRuntimeEnv( + project: { readonly workspaceRoot: string } | null, + worktreePath: string | null, +): Record { + return project + ? projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath }) + : {}; +} function terminalIdListsEqual(left: readonly string[], right: readonly string[]): boolean { if (left.length !== right.length) { return false; @@ -54,7 +76,10 @@ function terminalIdListsEqual(left: readonly string[], right: readonly string[]) return true; } -/** Server sessions lag terminal.open; reconciling strict subsets would discard new split layouts. */ +/** + * Typical right after `terminal.open`: known-session list lags; reconciling would drop the new id + * and later re-add it as a separate group (no split layout). + */ function serverTerminalIdsStrictSubsetOfClient( serverIds: readonly string[], clientIds: readonly string[], @@ -71,31 +96,27 @@ function serverTerminalIdsStrictSubsetOfClient( return true; } -interface TerminalLaunchLocationWithEnv { - readonly cwd: string; - readonly worktreePath: string | null; - readonly runtimeEnv: Record; -} - interface PersistentThreadTerminalDrawerProps { threadRef: { environmentId: EnvironmentId; threadId: ThreadId }; threadId: ThreadId; active: boolean; - composerHeight: number; launchContext: PersistentTerminalLaunchContext | null; splitShortcutLabel: string | undefined; splitVerticalShortcutLabel: string | undefined; newShortcutLabel: string | undefined; closeShortcutLabel: string | undefined; keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext: + | ((selection: TerminalContextSelection) => void | Promise) + | undefined; + /** As a layout tab the terminal fills its pane (cmd+J), not a drawer. */ + paneMode: boolean; } export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerminalDrawer({ threadRef, threadId, active, - composerHeight, launchContext, splitShortcutLabel, splitVerticalShortcutLabel, @@ -103,17 +124,25 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm closeShortcutLabel, keybindings, onAddTerminalContext, + paneMode, }: PersistentThreadTerminalDrawerProps) { const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); - const terminalActions = useThreadTerminalActions(threadRef); + const hasTerminalWriteAccess = () => + readEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const openTerminal = useAtomCommand(terminalEnvironment.open, "terminal open"); + const writeTerminal = useAtomCommand(terminalEnvironment.write, "terminal write"); + const closeTerminalMutation = useAtomCommand(terminalEnvironment.close, "terminal close"); const serverThread = useQuietThreadShell(threadRef); const draftThread = useComposerDraftStore((store) => store.getDraftThreadByRef(threadRef)); const projectRef = serverThread ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; + : threadlessSurfacesProjectRef(threadRef); const project = useProject(projectRef); + // Shells open in its project's root, or a project-less threadless ref's server home. + const home = useThreadlessHome(threadRef); + const root = project?.workspaceRoot ?? home; const terminalUiState = useTerminalUiStateStore((state) => selectThreadTerminalUiState(state.terminalUiStateByThreadKey, threadRef), ); @@ -122,6 +151,10 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm environmentId: threadRef.environmentId, threadId, }); + // Until the grant and the session list load, which terminals exist is not known yet. + const canReadTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope); + const sessionLoaded = useEnvironmentSessionLoaded(threadRef.environmentId); + const attaching = !sessionLoaded || (canReadTerminal && knownTerminalSessions === null); const panelSurfaces = useRightPanelStore( (state) => selectThreadRightPanelState(state.byThreadKey, threadRef).surfaces, ); @@ -142,24 +175,46 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm for (const session of drawerTerminalSessions) { terminalStatusById.set(session.target.terminalId, resolveTerminalActivityStatus(session.state)); } - const terminalLaunchLocationsById = new Map(); - for (const session of drawerTerminalSessions) { - const summary = session.state.summary; - if (!project || !summary) continue; - const worktreePathForLaunch = - launchContext !== null ? launchContext.worktreePath : summary.worktreePath; - terminalLaunchLocationsById.set(session.target.terminalId, { - cwd: launchContext?.cwd ?? summary.cwd, - worktreePath: worktreePathForLaunch, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, + const terminalLaunchLocationsById = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + if (root !== null) { + for (const session of drawerTerminalSessions) { + const summary = session.state.summary; + if (!summary) { + continue; + } + const worktreePathForLaunch = + launchContext !== null ? launchContext.worktreePath : summary.worktreePath; + terminalLaunchLocationsById.set(session.target.terminalId, { + cwd: launchContext?.cwd ?? summary.cwd, worktreePath: worktreePathForLaunch, - }), - }); + runtimeEnv: shellRuntimeEnv(project, worktreePathForLaunch), + }); + } } const serverOrderedTerminalIds = drawerTerminalSessions.map( (session) => session.target.terminalId, ); + // Every client-side id source participates in allocation: the server list + // lags fresh opens, and panel terminals are filtered out of the drawer's + // sessions — an id collision attaches two viewports to one PTY session. + const allocatableTerminalIds = [ + ...new Set([...serverOrderedTerminalIds, ...terminalUiState.terminalIds, ...panelTerminalIds]), + ]; + const allocateTerminalId = () => + nextTerminalId( + allocatableTerminalIds, + knownTerminalSessions === null || + !readEnvironmentScope(threadRef.environmentId, AuthTerminalReadScope) + ? randomUUID() + : undefined, + ); const storeSetTerminalHeight = useTerminalUiStateStore((state) => state.setTerminalHeight); const storeSplitTerminal = useTerminalUiStateStore((state) => state.splitTerminal); const storeSplitTerminalVertical = useTerminalUiStateStore( @@ -170,7 +225,7 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm const storeCloseTerminal = useTerminalUiStateStore((state) => state.closeTerminal); const reconcileTerminalIds = useTerminalUiStateStore((state) => state.reconcileTerminalIds); - // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- reconciles the terminal UI store's ids with the server's terminal list when they diverge + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- Reconcile terminal UI ids with asynchronously discovered server sessions while preserving pending client terminals. useEffect(() => { if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { return; @@ -186,66 +241,160 @@ export const PersistentThreadTerminalDrawer = memo(function PersistentThreadTerm const effectiveWorktreePath = launchContext !== null ? launchContext.worktreePath : worktreePath; const cwd = launchContext?.cwd ?? - (project - ? projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) + (root !== null + ? projectScriptCwd({ project: { cwd: root }, worktreePath: effectiveWorktreePath }) : null); - const runtimeEnv = project - ? projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: effectiveWorktreePath, - }) - : {}; + const runtimeEnv = shellRuntimeEnv(project, effectiveWorktreePath); + + const requestTerminalFocus = (terminalId?: string) => { + if (!visible) return; + if (terminalId) focusTerminal(threadRef, terminalId); + else focusActiveDrawerTerminal(threadRef); + }; const setTerminalHeight = (height: number) => { storeSetTerminalHeight(threadRef, height); }; - const launchDrawerTerminal = (place: (terminalId: string) => void) => { - if (!cwd) return; - const launched = terminalActions.launchTerminal({ - place, - location: { cwd, worktreePath: effectiveWorktreePath }, + const splitLimitReached = + (terminalUiState.terminalGroups.find( + (group) => group.id === terminalUiState.activeTerminalGroupId, + )?.terminalIds.length ?? 0) >= MAX_TERMINALS_PER_GROUP; + const splitTerminal = () => { + if (!hasTerminalWriteAccess() || !cwd || splitLimitReached) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminal(threadRef, terminalId); + requestTerminalFocus(terminalId); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }; + const splitTerminalVertical = () => { + if (!hasTerminalWriteAccess() || !cwd || splitLimitReached) { + return; + } + const terminalId = allocateTerminalId(); + storeSplitTerminalVertical(threadRef, terminalId); + requestTerminalFocus(terminalId); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, + }); + }; + + const createNewTerminal = () => { + if (!hasTerminalWriteAccess() || !cwd) { + return; + } + const terminalId = allocateTerminalId(); + storeNewTerminal(threadRef, terminalId); + requestTerminalFocus(terminalId); + void openTerminal({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + cwd, + ...(effectiveWorktreePath != null ? { worktreePath: effectiveWorktreePath } : {}), + env: runtimeEnv, + }, }); - if (!launched) return; - if (visible) focusTerminal(threadRef, launched.terminalId); - void launched.result; }; - const splitTerminal = useStableCallback(() => - launchDrawerTerminal((terminalId) => storeSplitTerminal(threadRef, terminalId)), - ); - const splitTerminalVertical = useStableCallback(() => - launchDrawerTerminal((terminalId) => storeSplitTerminalVertical(threadRef, terminalId)), - ); - const createNewTerminal = useStableCallback(() => - launchDrawerTerminal((terminalId) => storeNewTerminal(threadRef, terminalId)), - ); const activateTerminal = (terminalId: string) => { storeSetActiveTerminal(threadRef, terminalId); - if (visible) focusTerminal(threadRef, terminalId); + requestTerminalFocus(terminalId); }; - const closeTerminal = useStableCallback((terminalId: string) => { - if (!terminalActions.canWrite()) return; - terminalActions.closeTerminalSession(terminalId); + const closeTerminal = (terminalId: string) => { + if (!hasTerminalWriteAccess()) return; + const fallbackExitWrite = () => + writeTerminal({ + environmentId: threadRef.environmentId, + input: { threadId, terminalId, data: "exit\n" }, + }); + + void (async () => { + const closeResult = await closeTerminalMutation({ + environmentId: threadRef.environmentId, + input: { + threadId, + terminalId, + deleteHistory: true, + }, + }); + if ( + closeResult._tag === "Failure" && + !isAtomCommandInterrupted(closeResult) && + hasTerminalWriteAccess() + ) { + await fallbackExitWrite(); + } + })(); + storeCloseTerminal(threadRef, terminalId); - if (visible) focusActiveDrawerTerminal(threadRef); - }); + requestTerminalFocus(); + }; const handleAddTerminalContext = (selection: TerminalContextSelection) => { - if (!visible) { - return; - } - onAddTerminalContext(selection); + if (!visible || !onAddTerminalContext) return Promise.resolve(false); + return onAddTerminalContext(selection); }; - if (!project || (!terminalUiState.terminalOpen && !active) || !cwd) { + if (root === null || (!terminalUiState.terminalOpen && !active) || !cwd) { return null; } + const drawer = ( + + ); + if (paneMode) return drawer; + return (
-
- -
+
{drawer}
); }); -export interface PersistentThreadTerminalPanelProps { +interface PersistentThreadTerminalPanelProps { visible: boolean; threadRef: ScopedThreadRef; surface: Extract; launchContext: PersistentTerminalLaunchContext | null; keybindings: ResolvedKeybindingsConfig; - onAddTerminalContext: (selection: TerminalContextSelection) => void; + onAddTerminalContext: + | ((selection: TerminalContextSelection) => void | Promise) + | undefined; onSplitTerminal: () => void; onSplitTerminalVertical: () => void; onNewTerminal: () => void; @@ -333,8 +453,11 @@ export const PersistentThreadTerminalPanel = memo(function PersistentThreadTermi ? scopeProjectRef(serverThread.environmentId, serverThread.projectId) : draftThread ? scopeProjectRef(draftThread.environmentId, draftThread.projectId) - : null; + : threadlessSurfacesProjectRef(threadRef); const project = useProject(projectRef); + // Shells open in its project's root, or a project-less threadless ref's server home. + const home = useThreadlessHome(threadRef); + const root = project?.workspaceRoot ?? home; const knownTerminalSessions = useKnownTerminalSessions({ environmentId: threadRef.environmentId, threadId: threadRef.threadId, @@ -351,40 +474,47 @@ export const PersistentThreadTerminalPanel = memo(function PersistentThreadTermi const cwd = launchContext?.cwd ?? activeSummary?.cwd ?? - (project ? projectScriptCwd({ project: { cwd: project.workspaceRoot }, worktreePath }) : null); - const runtimeEnv = project - ? projectScriptRuntimeEnv({ project: { cwd: project.workspaceRoot }, worktreePath }) - : {}; + (root !== null ? projectScriptCwd({ project: { cwd: root }, worktreePath }) : null); + const runtimeEnv = shellRuntimeEnv(project, worktreePath); const terminalLabelsById = new Map(); - const terminalLaunchLocationsById = new Map(); for (const terminalId of surface.terminalIds) { const summary = knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state .summary ?? null; terminalLabelsById.set(terminalId, resolveTerminalSessionLabel(terminalId, summary)); - if (!project) continue; + } + const terminalLaunchLocationsById = new Map< + string, + { + readonly cwd: string; + readonly worktreePath: string | null; + readonly runtimeEnv: Record; + } + >(); + for (const terminalId of surface.terminalIds) { + const summary = + knownTerminalSessions?.find((session) => session.target.terminalId === terminalId)?.state + .summary ?? null; const terminalWorktreePath = resolveTerminalWorktreePath({ launchContext, summary, threadWorktreePath, }); + const terminalCwd = + launchContext?.cwd ?? + summary?.cwd ?? + (root !== null + ? projectScriptCwd({ project: { cwd: root }, worktreePath: terminalWorktreePath }) + : null); + if (!terminalCwd || root === null) continue; terminalLaunchLocationsById.set(terminalId, { - cwd: - launchContext?.cwd ?? - summary?.cwd ?? - projectScriptCwd({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }), + cwd: terminalCwd, worktreePath: terminalWorktreePath, - runtimeEnv: projectScriptRuntimeEnv({ - project: { cwd: project.workspaceRoot }, - worktreePath: terminalWorktreePath, - }), + runtimeEnv: shellRuntimeEnv(project, terminalWorktreePath), }); } - if (!project || !cwd) return null; + if (root === null || !cwd) return null; return ( & { readonly electron?: boolean; readonly reserveNativeControls?: boolean; + /** Room for the collapsed sidebar's controls; a layout pane off the window's corner has none. */ + readonly collapsedSidebarInset?: boolean; }) { return (
1) { diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index d0fd819eaee71..661ef16f64e93 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -5,6 +5,7 @@ import { insertAttachmentReferencesForDraft } from "./composerAttachmentReferenc import type { ComposerAttachmentInsertion } from "../composerAttachmentInsertion"; import { DESKTOP_PASTE_AS_TEXT_EVENT } from "../../lib/desktopPasteAsText"; import { BackgroundThreadContext } from "./backgroundThread"; +import { useLayoutPaneFocusedRef } from "../../layout/layoutPaneContext"; import { resolveCompatibleRuntimeMode, runtimeModeConfig, @@ -87,7 +88,10 @@ import { extendReplacementRangeForTrailingSpace, replaceTextRange, } from "../../composer-logic"; -import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder"; +import { + COMPOSER_PROMPT_PLACEHOLDER, + DISCONNECTED_COMPOSER_PLACEHOLDER, +} from "../../composerPlaceholder"; import { deriveComposerSendState, getAntigravitySendBlockReason } from "../ChatView.logic"; import { threadHasStarted } from "../../lib/chatThreadActions"; import { @@ -116,7 +120,6 @@ import { ComposerStashBadge } from "./ComposerStashBadge"; import { ComposerStashMenu } from "./ComposerStashMenu"; import { useComposerTriggerState } from "./useComposerTriggerState"; import { useComposerMenuItems } from "./useComposerMenuItems"; -import { observeComposerPromptLayout } from "./observeComposerPromptLayout"; import { useComposerMultilinePrompt } from "./useComposerMultilinePrompt"; import { reconcileAttachmentContextReferences, @@ -1053,12 +1056,10 @@ export function ChatComposer(props: ChatComposerProps) { const hasMultilinePrompt = prompt.includes("\n") || hasWrappedPrompt; const attachComposerBody = useStableCallback((node: HTMLDivElement | null) => { setComposerMenuAnchor(node); - const stopLayout = node ? observeComposerPromptLayout(node) : undefined; const stopObserving = observeWrappedPrompt(node); return () => { setComposerMenuAnchor(null); stopObserving?.(); - stopLayout?.(); }; }); // A pending question stays expanded: its card moves into the banner dock, @@ -1109,6 +1110,8 @@ export function ChatComposer(props: ChatComposerProps) { const mobileComposerExpandInFlightRef = useRef(false); const pendingThreadImportsRef = useRef>(new Map()); const isRevertingCheckpointRef = useRef(isRevertingCheckpoint); + // Window-level shortcuts belong to the focused pane's composer. + const paneFocusedRef = useLayoutPaneFocusedRef(); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- publishes the committed checkpoint revert flag to a ref read by async handlers useLayoutEffect(() => { isRevertingCheckpointRef.current = isRevertingCheckpoint; @@ -1138,6 +1141,7 @@ export function ChatComposer(props: ChatComposerProps) { pasteAsTextShortcutUntilRef.current = 0; }; const onDesktopPasteAsText = () => { + if (!paneFocusedRef.current) return; const activeElement = document.activeElement; const blocksPasteToFocus = activeElement instanceof Element && @@ -1161,7 +1165,7 @@ export function ChatComposer(props: ChatComposerProps) { target.removeEventListener("keydown", onKeyDown, true); window.removeEventListener("blur", onBlur); }; - }, [backgroundThread]); + }, [backgroundThread, paneFocusedRef]); const composerSendState = deriveComposerSendState({ prompt, @@ -1569,6 +1573,9 @@ export function ChatComposer(props: ChatComposerProps) { setIsDragOverComposer(false); } + // The observer reports the form's width before the first paint, so neither + // mounting nor a resize reads layout. The form has no padding or border, so + // its content box is its client width. // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- observes composer form resizes to switch the footer and primary actions between compact layouts useLayoutEffect(() => { const composerForm = composerFormRef.current; @@ -1933,6 +1940,7 @@ export function ChatComposer(props: ChatComposerProps) { terminalOpen, modelPickerOpen: isComposerModelPickerOpen, ...(backgroundThread ? { targetRef: composerFormRef } : {}), + ownsFocus: () => paneFocusedRef.current, }); // The editor freezes while dictation runs and drops focus; collapsing then would hide the recording pill. const dictationActiveRef = useRef(false); @@ -3804,8 +3812,7 @@ export function ChatComposer(props: ChatComposerProps) { ? "Enable a provider in Settings to send a message" : environmentDisconnected ? DISCONNECTED_COMPOSER_PLACEHOLDER - : (offeredPromptSuggestion ?? - "Ask anything, @tag files/folders, $use skills, or / for commands") + : (offeredPromptSuggestion ?? COMPOSER_PROMPT_PLACEHOLDER) } placeholderSuffix={ offeredPromptSuggestion !== null ? ( diff --git a/apps/web/src/components/chat/ChatHeader.browser.test.tsx b/apps/web/src/components/chat/ChatHeader.browser.test.tsx index 2f54eef77c8d3..c111305a302ec 100644 --- a/apps/web/src/components/chat/ChatHeader.browser.test.tsx +++ b/apps/web/src/components/chat/ChatHeader.browser.test.tsx @@ -37,6 +37,8 @@ function header(title: string) { onOpenThread={() => {}} rightPanelOpen={false} onNewThreadInProject={() => {}} + besideAvailable={new Set()} + onOpenBeside={() => {}} />
Outside rename
diff --git a/apps/web/src/components/chat/ChatHeader.tsx b/apps/web/src/components/chat/ChatHeader.tsx index 1c6d282548460..1455bc3c5a037 100644 --- a/apps/web/src/components/chat/ChatHeader.tsx +++ b/apps/web/src/components/chat/ChatHeader.tsx @@ -10,7 +10,7 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { PlusIcon, SquarePenIcon } from "lucide-react"; +import { PlusIcon, SquarePenIcon, SquareTerminal } from "lucide-react"; import { memo, useRef, @@ -24,17 +24,37 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; import { useThreadActionMenu } from "~/hooks/useThreadActionMenu"; import { readLocalApi } from "~/localApi"; +import { useAtomValue } from "@effect/atom-react"; +import { primaryServerKeybindingsAtom } from "../../state/server"; +import type { ContextMenuItem } from "@t3tools/contracts"; +import type { LayoutBesideKind } from "../../layout/layoutActions"; +import { beginLayoutTabDrag } from "../../layout/layoutDrag"; import { threadEnvironment } from "../../state/threads"; import { canShareWithGuestAtom } from "./guestShareAvailability"; -import { useAtomValue } from "@effect/atom-react"; import { useOrchestrationCommand } from "../../state/use-orchestration-command"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; import { ProjectFavicon } from "../ProjectFavicon"; import { PANE_TAB_CLASS, PaneNotch, PaneTabButton } from "../PaneNotch"; import { Button } from "../ui/button"; +import { Menu, MenuItem, MenuPopup, MenuShortcut, MenuTrigger } from "../ui/menu"; +import { surfaceShortcutActionForKey } from "../RightPanelTabs"; +import { SURFACE_LAUNCHER_ENTRIES } from "../SurfaceLauncher"; import { cn } from "~/lib/utils"; import { textDirection } from "~/textDirection"; import { GuestShareIndicator } from "./GuestShareControls"; +import { useLayoutPane } from "../../layout/layoutPaneContext"; +import { + LAYOUT_TAB_ICON_CLASS, + LAYOUT_TAB_SLOT_CLASS, + LayoutPaneTab, + LayoutTabCloseButton, + buildLayoutTabMenuItems, + closeLayoutTabOnMiddleClick, + isLayoutTabMenuId, + runLayoutTabMenuAction, + useLayoutPaneSiblingTabs, +} from "../../layout/PaneTabs"; +import { useLayoutStore } from "../../layout/layoutStore"; interface ChatHeaderProps { activeThreadEnvironmentId: EnvironmentId; @@ -46,9 +66,26 @@ interface ChatHeaderProps { parentThreadLink: { threadId: ThreadId; title: string } | null; onOpenThread: (threadId: ThreadId) => void; rightPanelOpen: boolean; + /** + * Room the header's floating controls take at its end, in px, so the notch's + * tabs truncate before they run under them. Unset keeps the default room. + */ + trailingInset?: number | null | undefined; + /** Opens a new thread in this project as a new tab of the pane. */ onNewThreadInProject: () => void; + /** What the "+" menu can open beside the chat after a new thread. */ + besideAvailable: ReadonlySet; + onOpenBeside: (kind: LayoutBesideKind) => void; + newTabShortcutLabel?: string | null | undefined; onOpenProjectSettings?: (() => void) | undefined; onFindInThread?: ((threadRef: ScopedThreadRef) => void) | undefined; + /** The chat's terminal pane while hidden (cmd+J): its notch offers it back. */ + hiddenTerminal?: HiddenTerminalChip | null | undefined; +} + +export interface HiddenTerminalChip { + readonly shortcutLabel: string | null; + readonly onShow: () => void; } function resolveRenameCommit(input: { @@ -61,6 +98,12 @@ function resolveRenameCommit(input: { return { action: "commit", title: trimmed }; } +const OPEN_BESIDE_MENU: ContextMenuItem[] = [ + { id: "browser", label: "Browser", icon: "globe" }, + { id: "terminal", label: "Terminal", icon: "terminal" }, + { id: "files", label: "Files", icon: "folder" }, + { id: "diff", label: "Diff", icon: "file-diff" }, +]; export const ChatHeader = memo(function ChatHeader({ activeThreadEnvironmentId, activeThreadId, @@ -71,10 +114,41 @@ export const ChatHeader = memo(function ChatHeader({ parentThreadLink, onOpenThread, rightPanelOpen, + trailingInset, onNewThreadInProject, + besideAvailable, + onOpenBeside, + newTabShortcutLabel, onOpenProjectSettings, onFindInThread, + hiddenTerminal, }: ChatHeaderProps) { + // Right-click "+" for a terminal, browser, files or diff beside the chat (pen HQTgu). + const openBesideMenu = (event: React.MouseEvent) => { + const api = readLocalApi(); + const items = OPEN_BESIDE_MENU.filter((item) => besideAvailable.has(item.id)); + if (!api || items.length === 0) return; + event.preventDefault(); + // The header's own menu would replace this one. + event.stopPropagation(); + void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((kind) => { + if (kind) onOpenBeside(kind); + }); + }; + const { tabId: layoutTabId } = useLayoutPane(); + // Drag the tab out of its notch to split (pen T5Hy0Q). + const handleTabPointerDown = (event: React.PointerEvent) => { + if (layoutTabId !== null) beginLayoutTabDrag(event, layoutTabId); + }; + const siblingTabs = useLayoutPaneSiblingTabs(); + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const layoutMenuItems = () => + layoutTabId === null + ? [] + : buildLayoutTabMenuItems(useLayoutStore.getState().layout, layoutTabId, keybindings); + const runLayoutMenuAction = (id: string) => { + if (layoutTabId !== null && isLayoutTabMenuId(id)) runLayoutTabMenuAction(id, layoutTabId); + }; const activeProjectName = activeProject?.isScratch ? "No project" : activeProject?.title; const activeProjectCwd = activeProject?.workspaceRoot ?? null; const activeThreadRef = scopeThreadRef(activeThreadEnvironmentId, activeThreadId); @@ -151,6 +225,8 @@ export const ChatHeader = memo(function ChatHeader({ onStartRename: startRename, onFind: onFindInThread, canShareWithGuest, + leadingItems: layoutMenuItems, + onLeadingAction: runLayoutMenuAction, }); const openMenuFromTitle = (event: ReactMouseEvent) => { if (isTrailingDoubleClick(event.detail)) return; @@ -166,23 +242,42 @@ export const ChatHeader = memo(function ChatHeader({ }; const handleHeaderContextMenu = (event: ReactMouseEvent) => { if (renamingTitle !== null) return; - if (!isServerThread && onOpenProjectSettings === undefined) return; - event.preventDefault(); if (!isServerThread) { + const layoutItems = layoutMenuItems(); + if (onOpenProjectSettings === undefined && layoutItems.length === 0) return; + event.preventDefault(); const api = readLocalApi(); if (!api) return; void api.contextMenu - .show([{ id: "project-settings", label: "Project settings", icon: "settings" }], { - x: event.clientX, - y: event.clientY, - }) + .show( + [ + ...layoutItems, + ...(onOpenProjectSettings + ? [ + { + id: "project-settings", + label: "Project settings", + icon: "settings", + separatorBefore: layoutItems.length > 0, + }, + ] + : []), + ], + { x: event.clientX, y: event.clientY }, + ) .then((action) => { if (action === "project-settings") onOpenProjectSettings?.(); + else if (action !== null) runLayoutMenuAction(action); }); return; } + event.preventDefault(); openMenu({ x: event.clientX, y: event.clientY }); }; + const handleTabAuxClick = (event: ReactMouseEvent) => { + if (layoutTabId === null) return; + closeLayoutTabOnMiddleClick(event, layoutTabId); + }; const handleRenameKeyDown = (event: ReactKeyboardEvent) => { if (event.nativeEvent.isComposing || event.keyCode === 229) return; if (event.key === "Enter") { @@ -210,85 +305,126 @@ export const ChatHeader = memo(function ChatHeader({
- {parentThreadLink ? ( - - ) : null} - {renamingTitle !== null ? ( -
- {tabIcon} - { - if (renameCommittedRef.current) return; - if ( - event.relatedTarget instanceof HTMLElement && - event.relatedTarget.closest("button") - ) { - setRenaming(null); - return; - } - commitRename(event.currentTarget.value); - }} - onFocus={(event) => event.currentTarget.select()} - onKeyDown={handleRenameKeyDown} - /> -
- ) : ( - - - ) : ( -
- ) - } + {/* Tabs that still do not fit clip here instead of running under "+". */} +
+ {siblingTabs.before.map((tab) => ( + + ))} + {parentThreadLink ? ( + + ) : null} + {renamingTitle !== null ? ( +
{tabIcon} -

- {activeThreadTitle} -

- - {tabTooltip} - - )} + { + if (renameCommittedRef.current) return; + if ( + event.relatedTarget instanceof HTMLElement && + event.relatedTarget.closest("button") + ) { + setRenaming(null); + return; + } + commitRename(event.currentTarget.value); + }} + onFocus={(event) => event.currentTarget.select()} + onKeyDown={handleRenameKeyDown} + /> +
+ ) : ( + // The pane's own tab always keeps its icon. + + + + ) : ( +
+ ) + } + > + {tabIcon} +

+ {activeThreadTitle} +

+ + {tabTooltip} + + {layoutTabId !== null ? ( + + ) : null} + + )} + {siblingTabs.after.map((tab) => ( + + ))} + {hiddenTerminal ? ( + // The hidden terminal keeps a tab like any other pane tab. + + + + } + > + + Terminal + + + Show the terminal + {hiddenTerminal.shortcutLabel ? ` (${hiddenTerminal.shortcutLabel})` : ""} + + + + ) : null} +
{activeProject ? ( - - - } - > - - - New thread in {activeProjectName} - + ) : null} {renamingTitle === null && activeThreadLinkOrigin ? ( @@ -306,6 +442,91 @@ export const ChatHeader = memo(function ChatHeader({ ); }); +/** + * The notch's "+": choose a new thread (N) or what to open beside the chat, + * by click or by letter while the menu is open. cmd+T still opens a new + * thread without it. + */ +function NewTabMenu(props: { + projectName: string; + newTabShortcutLabel: string | null | undefined; + besideAvailable: ReadonlySet; + onNewThread: () => void; + onOpenBeside: (kind: LayoutBesideKind) => void; + onContextMenu: (event: ReactMouseEvent) => void; +}) { + const [open, setOpen] = useState(false); + const actions = [ + { + label: "New thread", + icon: SquarePenIcon, + shortcut: "N", + available: true, + onClick: props.onNewThread, + }, + // Then what the launcher offers, as it offers them. + ...SURFACE_LAUNCHER_ENTRIES.map((item) => ({ + label: item.label, + icon: item.icon, + shortcut: item.shortcut, + available: props.besideAvailable.has(item.kind), + onClick: () => props.onOpenBeside(item.kind), + })), + ]; + const handleKeyDown = (event: ReactKeyboardEvent) => { + const action = surfaceShortcutActionForKey(actions, event.nativeEvent); + if (!action) return; + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + action.onClick(); + }; + return ( + + + + } + /> + } + > + + + + New tab in {props.projectName} + {props.newTabShortcutLabel ? ` (${props.newTabShortcutLabel})` : ""} + + + + {actions.map((action) => { + const Icon = action.icon; + return ( + + + {action.label} + {action.shortcut} + + ); + })} + + + ); +} + /** * A subagent thread's way back to its parent: a muted notch control ahead of * the thread's own tab. diff --git a/apps/web/src/components/chat/ChatPaneHeader.tsx b/apps/web/src/components/chat/ChatPaneHeader.tsx new file mode 100644 index 0000000000000..6cd46c9c2b07c --- /dev/null +++ b/apps/web/src/components/chat/ChatPaneHeader.tsx @@ -0,0 +1,142 @@ +import { useRef, useState, type ReactNode, type Ref } from "react"; + +import { isElectron } from "../../env"; +import { useLayoutPane } from "../../layout/layoutPaneContext"; +import { observeResize } from "../../lib/observeResize"; +import { workspaceAreaProps } from "../../lib/workspaceFocus"; +import { cn } from "../../lib/utils"; +import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "../../workspaceTitlebar"; +import { PaneNotch } from "../PaneNotch"; + +/** + * A chat pane's top bar, shared by ChatView and the stand-in a pane shows + * before its chat first renders (layout/ChatPaneStandIn), so the two line up + * to the pixel. Children: the controls cluster and ChatHeader. + */ +export function ChatPaneHeader({ + ref, + reserveNativeControlsInset, + children, +}: { + ref?: Ref; + /** Electron's window controls take the trailing pane's top-right corner. */ + reserveNativeControlsInset: boolean; + children: ReactNode; +}) { + const { leadingEdge, trailingEdge } = useLayoutPane(); + return ( +
+ {children} +
+ ); +} + +/** + * The chat pane header's box, for whatever draws one (ChatPaneHeader, the + * overview's thumbnails), so their notches line up on every surface. + */ +export function chatPaneHeaderClassName({ + leadingEdge, + trailingEdge, + reserveNativeControlsInset, +}: { + leadingEdge: boolean; + trailingEdge: boolean; + reserveNativeControlsInset: boolean; +}): string { + return cn( + // Keep tab menus above the timeline. Ease the collapsed-sidebar inset with the sidebar so the + // tab moves with its pane. + "relative z-10 bg-background md:pl-0 [[data-panel-animations=true]_&]:motion-safe:transition-[padding-left] [[data-panel-animations=true]_&]:motion-safe:duration-(--panel-animation-duration) [[data-panel-animations=true]_&]:motion-safe:ease-out", + isElectron + ? cn( + "drag-region flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center px-3 sm:px-5", + reserveNativeControlsInset && + trailingEdge && + "wco:pr-(--workspace-native-controls-inset)", + ) + : "flex h-[var(--workspace-topbar-height)] min-h-[var(--workspace-topbar-height)] shrink-0 items-center pl-(--workspace-gutter-start) pr-(--workspace-gutter-end)", + // Only the pane in the window's top-left corner shares its row + // with the collapsed sidebar's toggle and the traffic lights. + leadingEdge && COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS, + ); +} + +/** + * The header's top-right controls on the mirrored pane notch. Pass `measureRef` from + * `useHeaderControlsInset` to learn the room it takes from the notch. + */ +export function ChatPaneHeaderControls({ + measureRef, + local = false, + marksHost = true, + children, +}: { + measureRef?: Ref; + /** For controls that stay at this header's edge even in a single pane. */ + local?: boolean; + marksHost?: boolean; + children: ReactNode; +}) { + const { split } = useLayoutPane(); + return ( +
+ +
{children}
+
+
+ ); +} + +/** + * The header's floating controls change width with the pane (labels collapse + * to icons), so the notch learns how much room they take: ChatHeader's + * `trailingInset`, from the cluster `measure` is attached to (as its ref). + */ +export function useHeaderControlsInset(): [ + inset: number | null, + measure: (node: HTMLDivElement | null) => void, +] { + const [inset, setInset] = useState(null); + const stopRef = useRef<(() => void) | null>(null); + const [measure] = useState(() => (node: HTMLDivElement | null) => { + stopRef.current?.(); + stopRef.current = null; + if (!node) { + setInset(null); + return; + } + const update = () => { + const header = node.closest("[data-chat-header]"); + if (!header || node.offsetWidth === 0) { + setInset(null); + return; + } + const headerRect = header.getBoundingClientRect(); + const paddingEnd = Number.parseFloat(getComputedStyle(header).paddingRight) || 0; + const room = headerRect.right - paddingEnd - node.getBoundingClientRect().left + 8; + setInset(Math.max(0, Math.ceil(room))); + }; + // Measured as it mounts too: the observer's first size arrives a frame + // late, and the title would truncate against the fallback room till then. + update(); + stopRef.current = observeResize(node, update); + }); + return [inset, measure]; +} diff --git a/apps/web/src/components/chat/ChatRightPanel.tsx b/apps/web/src/components/chat/ChatRightPanel.tsx deleted file mode 100644 index 6ea7a232f1608..0000000000000 --- a/apps/web/src/components/chat/ChatRightPanel.tsx +++ /dev/null @@ -1,59 +0,0 @@ -import { usePreviewPanelInlineSize } from "../../hooks/usePreviewPanelInlineSize"; -import { RightPanelSheet } from "../RightPanelSheet"; -import { RightPanelTabs, type RightPanelTabsProps } from "../RightPanelTabs"; - -type ChatRightPanelProps = Omit & { - readonly sheet: boolean; - readonly maximized: boolean; - readonly workspaceLayoutElement: HTMLElement | null; - readonly widthStorageKey: string; - readonly sheetAnimationDurationMs: number; - readonly onCloseSheet: () => void; -}; - -/** - * The right panel, inline beside the chat column or as a sheet on narrow - * layouts. Its width follows a resize drag every frame, so the width state - * lives here rather than in ChatView. - */ -export function ChatRightPanel(props: ChatRightPanelProps) { - const { - sheet, - workspaceLayoutElement, - widthStorageKey, - sheetAnimationDurationMs, - onCloseSheet, - maximized, - layoutControls, - ...tabs - } = props; - const inlineSize = usePreviewPanelInlineSize({ - container: workspaceLayoutElement, - widthStorageKey, - }); - if (!sheet) { - return ( - - ); - } - return ( - - - - ); -} diff --git a/apps/web/src/components/chat/ChatTerminalDrawers.tsx b/apps/web/src/components/chat/ChatTerminalDrawers.tsx deleted file mode 100644 index c6e14b90ec185..0000000000000 --- a/apps/web/src/components/chat/ChatTerminalDrawers.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; -import { - parseScopedThreadKey, - scopedThreadKey, - scopeThreadRef, -} from "@t3tools/client-runtime/environment"; -import { useState } from "react"; -import { useShallow } from "zustand/react/shallow"; -import { useComposerDraftStore } from "../../composerDraftStore"; -import type { TerminalContextSelection } from "../../lib/terminalContext"; -import { usePanelAnimationSettings, usePanelPresence } from "../../panelAnimations"; -import { useThreadRefs } from "../../state/entities"; -import { useTerminalUiStateStore } from "../../terminalUiStateStore"; -import { - MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - reconcileMountedTerminalThreadIds, -} from "../ChatView.logic"; -import { - PersistentThreadTerminalDrawer, - type PersistentTerminalLaunchContext, -} from "../PersistentThreadTerminals"; - -interface ChatTerminalDrawersProps { - readonly activeThreadKey: string | null; - readonly activeTerminalOpen: boolean; - readonly composerHeight: number; - readonly launchContext: PersistentTerminalLaunchContext | null; - readonly splitShortcutLabel: string | undefined; - readonly splitVerticalShortcutLabel: string | undefined; - readonly newShortcutLabel: string | undefined; - readonly closeShortcutLabel: string | undefined; - readonly keybindings: ResolvedKeybindingsConfig; - readonly onAddTerminalContext: (selection: TerminalContextSelection) => void; -} - -/** - * Keeps terminal drawers mounted for recently open threads, so switching back - * to one finds its sessions as they were. Every thread's terminal and draft - * state feeds the reconcile, so it subscribes here instead of in ChatView. - */ -export function ChatTerminalDrawers(props: ChatTerminalDrawersProps) { - const { activeThreadKey } = props; - const openTerminalThreadKeys = useTerminalUiStateStore( - useShallow((state) => - Object.entries(state.terminalUiStateByThreadKey).flatMap( - ([nextThreadKey, nextTerminalUiState]) => - nextTerminalUiState.terminalOpen ? [nextThreadKey] : [], - ), - ), - ); - const serverThreadRefs = useThreadRefs(); - const serverThreadKeys = serverThreadRefs.map(scopedThreadKey); - const draftThreadsByThreadKey = useComposerDraftStore((store) => store.draftThreadsByThreadKey); - const draftThreadKeys = Object.values(draftThreadsByThreadKey).map((draftThread) => - scopedThreadKey(scopeThreadRef(draftThread.environmentId, draftThread.threadId)), - ); - const { active: panelAnimationsActive, durationMs: panelAnimationDurationMs } = - usePanelAnimationSettings(); - const activeTerminalDrawerPresence = usePanelPresence( - Boolean(activeThreadKey && props.activeTerminalOpen), - true, - panelAnimationsActive, - activeThreadKey, - panelAnimationDurationMs, - ); - const [mountedTerminalThreadKeys, setMountedTerminalThreadKeys] = useState([]); - const existingOpenTerminalThreadKeys = (() => { - const existingThreadKeys = new Set([...serverThreadKeys, ...draftThreadKeys]); - return openTerminalThreadKeys.filter((nextThreadKey) => existingThreadKeys.has(nextThreadKey)); - })(); - const reconciledMountedTerminalThreadKeys = reconcileMountedTerminalThreadIds({ - currentThreadIds: mountedTerminalThreadKeys, - openThreadIds: existingOpenTerminalThreadKeys, - activeThreadId: activeThreadKey, - activeThreadTerminalOpen: activeTerminalDrawerPresence.present, - maxHiddenThreadCount: MAX_HIDDEN_MOUNTED_TERMINAL_THREADS, - }); - if ( - reconciledMountedTerminalThreadKeys.length !== mountedTerminalThreadKeys.length || - reconciledMountedTerminalThreadKeys.some( - (threadKey, index) => threadKey !== mountedTerminalThreadKeys[index], - ) - ) { - setMountedTerminalThreadKeys(reconciledMountedTerminalThreadKeys); - } - const mountedTerminalThreadRefs = mountedTerminalThreadKeys.flatMap((mountedThreadKey) => { - const mountedThreadRef = parseScopedThreadKey(mountedThreadKey); - return mountedThreadRef ? [{ key: mountedThreadKey, threadRef: mountedThreadRef }] : []; - }); - - return mountedTerminalThreadRefs.map(({ key: mountedThreadKey, threadRef: mountedThreadRef }) => ( - - )); -} diff --git a/apps/web/src/components/chat/ComposerApprovalPanel.tsx b/apps/web/src/components/chat/ComposerApprovalPanel.tsx index 6f60bcef70fab..07fe3eb8c1f2e 100644 --- a/apps/web/src/components/chat/ComposerApprovalPanel.tsx +++ b/apps/web/src/components/chat/ComposerApprovalPanel.tsx @@ -1,6 +1,7 @@ import { ShieldIcon } from "lucide-react"; -import { use, useEffect, useEffectEvent, useState } from "react"; +import { use, useEffect, useEffectEvent, useState, useSyncExternalStore } from "react"; import { BackgroundThreadContext } from "./backgroundThread"; +import { useLayoutPaneFocusedRef } from "../../layout/layoutPaneContext"; import type { ProviderApprovalDecision, RuntimeRequestId } from "@t3tools/contracts"; import type { PendingApproval } from "../../session-logic"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; @@ -12,6 +13,27 @@ import { resolveApprovalPrimaryDecision, } from "./ComposerPendingApprovalActions"; +function keepsApprovalKeys(target: EventTarget | null): boolean { + return ( + target instanceof Element && + target.closest( + 'input, textarea, select, button, a[href], [role="button"], [role="link"], summary, [contenteditable]:not([contenteditable="false"]), [role="dialog"], [role="menu"]', + ) !== null + ); +} + +function subscribeApprovalFocus(listener: () => void) { + const onFocusOut = () => queueMicrotask(listener); + document.addEventListener("focusin", listener); + document.addEventListener("focusout", onFocusOut); + return () => { + document.removeEventListener("focusin", listener); + document.removeEventListener("focusout", onFocusOut); + }; +} + +const approvalKeysAvailable = () => !keepsApprovalKeys(document.activeElement); + export function ComposerApprovalPanel({ approval, count, @@ -26,6 +48,8 @@ export function ComposerApprovalPanel({ onRespond: (requestId: RuntimeRequestId, decision: ProviderApprovalDecision) => Promise; }) { const backgroundThread = use(BackgroundThreadContext); + const paneFocusedRef = useLayoutPaneFocusedRef(); + const showShortcutHints = useSyncExternalStore(subscribeApprovalFocus, approvalKeysAvailable); const [expanded, setExpanded] = useState(true); const [alwaysAllow, setAlwaysAllow] = useState(false); const canRespond = @@ -41,6 +65,7 @@ export function ComposerApprovalPanel({ useEffect(() => { if (!canRespond || backgroundThread) return; const handler = (event: KeyboardEvent) => { + if (!paneFocusedRef.current) return; if ( (event.key !== "Escape" && event.key !== "Enter") || event.defaultPrevented || @@ -51,20 +76,14 @@ export function ComposerApprovalPanel({ event.shiftKey ) return; - if ( - event.target instanceof Element && - event.target.closest( - 'input, textarea, select, button, a[href], [role="button"], [role="link"], summary, [contenteditable]:not([contenteditable="false"]), [role="dialog"], [role="menu"]', - ) - ) - return; + if (keepsApprovalKeys(event.target)) return; if (!(event.key === "Enter" ? primary : decline)) return; event.preventDefault(); respondFromShortcut(event.key); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [canRespond, primary, decline, backgroundThread]); + }, [canRespond, primary, decline, backgroundThread, paneFocusedRef]); const title = approval.requestKind === "command" ? "Command approval" @@ -131,6 +150,7 @@ export function ComposerApprovalPanel({ ) { className={cn( "col-start-2 row-start-1 flex min-w-0 items-center gap-2 *:data-[slot=composer-banner-separator]:mx-0", "@max-[560px]:group-data-[composer-banner-layout=approval]/banner-row:col-end-4", - "group-not-has-[>[data-slot=composer-banner-icon]]/banner-row:col-[1/3]", + // Without an icon before it, the content takes the icon's column too. + "[&:not([data-slot=composer-banner-icon]~*)]:col-[1/3]", className, )} {...props} diff --git a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx index 19c5c1054476d..d2fea0ab2e9a4 100644 --- a/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx +++ b/apps/web/src/components/chat/ComposerPendingUserInputPanel.tsx @@ -14,6 +14,7 @@ import { Button } from "../ui/button"; import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { cn } from "~/lib/utils"; import { isInSideChatPane } from "~/lib/sideChatPane"; +import { useLayoutPaneFocusedRef } from "../../layout/layoutPaneContext"; import { ComposerBanner } from "./ComposerBanner"; import ChatMarkdown from "../ChatMarkdown"; import { ComposerRequestCard, useRequestCardEnterShortcut } from "./ComposerRequestCard"; @@ -210,11 +211,14 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( onWriteAnswer?.(); }); + // Only the layout-focused pane's prompt answers number-key shortcuts. + const paneFocusedRef = useLayoutPaneFocusedRef(); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes a document keydown listener for digit shortcuts while a question is answerable useEffect(() => { if (!activeQuestion || responseDisabled || isCollapsed || backgroundThread) return; const handler = (event: globalThis.KeyboardEvent) => { if (event.metaKey || event.ctrlKey || event.altKey) return; + if (!paneFocusedRef.current) return; const target = event.target; if (isInSideChatPane(target)) return; if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) { @@ -243,7 +247,7 @@ const ComposerPendingUserInputCard = memo(function ComposerPendingUserInputCard( }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [activeQuestion, backgroundThread, isCollapsed, responseDisabled]); + }, [activeQuestion, backgroundThread, isCollapsed, paneFocusedRef, responseDisabled]); if (!activeQuestion) { return null; diff --git a/apps/web/src/components/chat/ComposerRequestCard.tsx b/apps/web/src/components/chat/ComposerRequestCard.tsx index 9d392f6eb555d..d0429cdbdc5fa 100644 --- a/apps/web/src/components/chat/ComposerRequestCard.tsx +++ b/apps/web/src/components/chat/ComposerRequestCard.tsx @@ -3,6 +3,7 @@ import { useRender } from "@base-ui/react/use-render"; import { use, useEffect, useEffectEvent, type ComponentProps, type ReactNode } from "react"; import { cn } from "~/lib/utils"; +import { useLayoutPaneFocusedRef } from "../../layout/layoutPaneContext"; import { Kbd } from "../ui/kbd"; import { BackgroundThreadContext } from "./backgroundThread"; @@ -128,11 +129,13 @@ export const ComposerRequestCard = { }; /** - * Enter runs a card's primary action while nothing else has focus. Focused - * controls keep Enter for themselves, and the composer routes its own Enter. + * Enter runs a card's primary action while nothing else has focus, in the + * layout-focused pane only. Focused controls keep Enter for themselves, and + * the composer routes its own Enter. */ export function useRequestCardEnterShortcut(action: (() => void) | null) { const backgroundThread = use(BackgroundThreadContext); + const paneFocusedRef = useLayoutPaneFocusedRef(); const runAction = useEffectEvent((event: globalThis.KeyboardEvent) => { if (!action) return; event.preventDefault(); @@ -145,10 +148,11 @@ export function useRequestCardEnterShortcut(action: (() => void) | null) { const handler = (event: globalThis.KeyboardEvent) => { if (event.key !== "Enter" || event.defaultPrevented || event.isComposing) return; if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return; + if (!paneFocusedRef.current) return; if (event.target !== document.body && event.target !== document.documentElement) return; runAction(event); }; document.addEventListener("keydown", handler); return () => document.removeEventListener("keydown", handler); - }, [enabled]); + }, [enabled, paneFocusedRef]); } diff --git a/apps/web/src/components/chat/ComposerStashMenu.tsx b/apps/web/src/components/chat/ComposerStashMenu.tsx index a192691c676c1..fa402fdeaceea 100644 --- a/apps/web/src/components/chat/ComposerStashMenu.tsx +++ b/apps/web/src/components/chat/ComposerStashMenu.tsx @@ -4,6 +4,7 @@ import { assistantCitationsToPlainText } from "@t3tools/shared/assistantCitation import { RelativeTime } from "../RelativeTime"; import { cn, isMacPlatform } from "~/lib/utils"; +import { useLayoutPaneFocusedRef } from "../../layout/layoutPaneContext"; import { Button } from "../ui/button"; import { Kbd } from "../ui/kbd"; import { type PromptStashEntry } from "../../promptStashStore"; @@ -34,7 +35,7 @@ export function stashEntrySnippet(entry: PromptStashEntry): string { return `(${attachmentCount} ${label}${attachmentCount === 1 ? "" : "s"})`; } -/** Window capture gives the open menu priority over composer key handlers. */ +/** Window capture gives the focused pane's open menu priority over composer key handlers. */ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { entries: ReadonlyArray; stashShortcutLabel: string | null; @@ -46,6 +47,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { const { entries, stashShortcutLabel, composerFormRef, onRestore, onDelete, onClose } = props; const backgroundThread = use(BackgroundThreadContext); const [highlightedId, setHighlightedId] = useState(entries[0]?.id ?? null); + const paneFocusedRef = useLayoutPaneFocusedRef(); const highlightedEntry = entries.find((entry) => entry.id === highlightedId) ?? entries[0]; const drawerRef = useRef(null); @@ -64,7 +66,7 @@ export const ComposerStashMenu = memo(function ComposerStashMenu(props: { }); const handleKeyDown = useEffectEvent((event: KeyboardEvent) => { - if (backgroundThread) return; + if (backgroundThread || !paneFocusedRef.current) return; const drawer = drawerRef.current; if (!drawer) return; const target = event.target; diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 961587ec858c9..23ddc34cfe434 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -58,40 +58,21 @@ interface DraftHeroHeadlineProps { readonly activeProjectTitle: string | null; } -export function DraftHeroHeadline({ - draftId, - activeProjectRef, - activeProjectTitle, -}: DraftHeroHeadlineProps) { +/** + * The projects a new thread can start in, as the draft hero and the new tab + * launcher list them (pen fsQuY): pinned projects, the most recent others, a + * way to every project, then the no-project and add-project rows. + */ +export function useProjectPickerModel(activeProjectRef: ScopedProjectRef | null) { const projects = useProjects(); const { environments } = useEnvironments(); - const providersByEnvironment = useEnvironmentProviders(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); - const setLogicalProjectDraftThreadId = useComposerDraftStore( - (store) => store.setLogicalProjectDraftThreadId, - ); - const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); - // A boolean, so typing in a draft does not re-render the hero. - const isTriageDraftPrompt = useComposerDraftStore((store) => - draftId ? isTriagePrompt(store.getComposerDraft(draftId)?.prompt ?? "") : false, - ); - const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); - const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); const openAddProject = () => openCommandPalette({ open: "add-project" }); - const openAllProjects = () => - openCommandPalette({ - open: "new-thread-in", - onSelectProject: (project) => - selectProject( - project, - deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings), - ), - }); - const { scratchEnvironmentId, scratchWorkspaceRootFor, openScratchProject } = useScratchProject(); - const openProjectDraft = useNewThreadHandler(); + const openAllProjects = () => openCommandPalette({ open: "new-thread-in" }); + const { scratchEnvironmentId, scratchWorkspaceRootFor, scratchUnavailableReason } = + useScratchProject(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); - const nowMinute = useNowMinute(); const environmentLabelById = new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), @@ -120,14 +101,210 @@ export function DraftHeroHeadline({ ), ) ?? null); const activeProjectKey = activeProjectGroup?.projectKey ?? ""; - const hasResolvedProject = activeProjectTitle !== null; - const canChooseProject = projectPickerEntries.length > 0; // The project that hosts threads without a project appears once, as the // "No project" item, not as a project row. const menuEntries = projectPickerEntries.filter( ({ targetProject }) => !isScratchProject(targetProject, scratchWorkspaceRootFor(targetProject.environmentId)), ); + const scratchTargetEnvironmentId = scratchEnvironmentId( + activeProjectRef?.environmentId ?? primaryEnvironmentId, + ); + const scratchWorkspaceRoot = scratchWorkspaceRootFor(scratchTargetEnvironmentId); + const scratchOffReason = scratchUnavailableReason( + activeProjectRef?.environmentId ?? primaryEnvironmentId, + ); + + const { canPinProjectGroup, toggleProjectGroupPin } = useProjectPin(); + const pinnedKeySet = new Set( + menuEntries.filter(({ group }) => group.pinned).map(({ group }) => group.projectKey), + ); + const pinnedEntries = menuEntries.filter(({ group }) => pinnedKeySet.has(group.projectKey)); + const unpinnedEntries = menuEntries.filter(({ group }) => !pinnedKeySet.has(group.projectKey)); + // The active project always shows, so the selection never hides behind "All projects". + const recentEntries = unpinnedEntries.filter( + ({ group }, index) => index < RECENT_PROJECT_LIMIT || group.projectKey === activeProjectKey, + ); + const hiddenProjectCount = menuEntries.length - pinnedEntries.length - recentEntries.length; + return { + projects, + projectGroupingSettings, + primaryEnvironmentId, + environments, + activeProjectGroup, + activeProjectKey, + canChooseProject: projectPickerEntries.length > 0, + menuEntries, + pinnedEntries, + recentEntries, + hiddenProjectCount, + pinnedKeySet, + canPinProjectGroup, + toggleProjectGroupPin, + showProjectEnvironments, + environmentMachineById, + scratchTargetEnvironmentId, + scratchWorkspaceRoot, + scratchOffReason, + noProjectShortcut: shortcutLabelForCommand(keybindings, "chat.newWithoutProject"), + openAddProject, + openAllProjects, + }; +} + +export type ProjectPickerModel = ReturnType; +export type ProjectPickerEntry = ProjectPickerModel["menuEntries"][number]; + +/** + * The picker's rows. `groupLabels` names the pinned and recent groups, as + * the draft hero does; a list with its own heading leaves them out. + */ +export function ProjectPickerRows({ + model, + groupLabels, + selectedProjectKey, + scratchSelected, + onChooseEntry, + onFocusEntry, + onFocusNoProject, + onNoProject, + onAllProjects, + concise = false, +}: { + readonly model: ProjectPickerModel; + readonly groupLabels: boolean; + readonly selectedProjectKey: string | null; + readonly scratchSelected: boolean; + readonly onChooseEntry: (entry: ProjectPickerEntry) => void; + /** A project row took the keyboard (arrows, Tab). */ + readonly onFocusEntry?: (entry: ProjectPickerEntry) => void; + /** "No project" took the keyboard. */ + readonly onFocusNoProject?: () => void; + readonly onNoProject: () => void; + readonly onAllProjects?: () => void; + /** The launcher offers usable actions without explanatory copy. */ + readonly concise?: boolean; +}) { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const renderProjectRow = (entry: ProjectPickerEntry) => { + const { group, targetProject } = entry; + const pinned = model.pinnedKeySet.has(group.projectKey); + return ( + } + label={targetProject.title} + badge={ + model.showProjectEnvironments ? ( + + ) : null + } + detail={formatProjectRootForDisplay(targetProject.workspaceRoot)} + detailIsPath + onSelect={() => onChooseEntry(entry)} + onKeyDown={(event) => { + if (!model.canPinProjectGroup(group) || !isProjectPinShortcut(event, keybindings)) return; + event.preventDefault(); + event.stopPropagation(); + model.toggleProjectGroupPin(group); + }} + {...(onFocusEntry ? { onFocus: () => onFocusEntry(entry) } : {})} + action={ + model.canPinProjectGroup(group) ? ( + + model.toggleProjectGroupPin(group)} + /> + } + > + {pinned ? : } + + {pinned ? "Unpin" : "Pin"} + + ) : null + } + /> + ); + }; + return ( + <> + {model.pinnedEntries.length > 0 ? ( + <> + {groupLabels ? Pinned : null} + {model.pinnedEntries.map(renderProjectRow)} + + ) : null} + {model.recentEntries.length > 0 ? ( + <> + {groupLabels ? Recent : null} + {model.recentEntries.map(renderProjectRow)} + + ) : null} + {model.menuEntries.length > 0 ? ( + } + label="All projects…" + detail={String(model.menuEntries.length)} + onSelect={onAllProjects ?? model.openAllProjects} + /> + ) : null} + {model.menuEntries.length > 0 ? ( +
+ ) : null} + {model.scratchWorkspaceRoot !== null ? ( + } + label="No project" + detail={concise ? "" : "Its own folder, no git"} + shortcut={model.noProjectShortcut} + onSelect={onNoProject} + {...(onFocusNoProject ? { onFocus: onFocusNoProject } : {})} + /> + ) : null} + } + label="Add project…" + detail={concise ? "" : "Open a folder or clone a repository"} + onSelect={model.openAddProject} + /> + + ); +} + +export function DraftHeroHeadline({ + draftId, + activeProjectRef, + activeProjectTitle, +}: DraftHeroHeadlineProps) { + const model = useProjectPickerModel(activeProjectRef); + const providersByEnvironment = useEnvironmentProviders(); + const nowMinute = useNowMinute(); + const { projects, environments, activeProjectKey, scratchTargetEnvironmentId } = model; + const setLogicalProjectDraftThreadId = useComposerDraftStore( + (store) => store.setLogicalProjectDraftThreadId, + ); + const getComposerDraft = useComposerDraftStore((store) => store.getComposerDraft); + // A boolean, so typing in a draft does not re-render the hero. + const isTriageDraftPrompt = useComposerDraftStore((store) => + draftId ? isTriagePrompt(store.getComposerDraft(draftId)?.prompt ?? "") : false, + ); + const applyStickyState = useComposerDraftStore((store) => store.applyStickyState); + const setModelSelection = useComposerDraftStore((store) => store.setModelSelection); + const { openScratchProject, scratchWorkspaceRootFor } = useScratchProject(); + const openProjectDraft = useNewThreadHandler(); + const hasResolvedProject = activeProjectTitle !== null; const activeProject = activeProjectRef === null ? null @@ -136,8 +313,6 @@ export function DraftHeroHeadline({ project.environmentId === activeProjectRef.environmentId && project.id === activeProjectRef.projectId, ) ?? null); - const scratchTargetEnvironmentId = scratchEnvironmentId(activeProjectRef?.environmentId ?? null); - const scratchWorkspaceRoot = scratchWorkspaceRootFor(scratchTargetEnvironmentId); const isScratchDraft = activeProject !== null && isScratchProject(activeProject, scratchWorkspaceRootFor(activeProject.environmentId)); @@ -217,82 +392,23 @@ export function DraftHeroHeadline({ return false; } pendingScratchRef.current = null; - selectProject(project, deriveLogicalProjectKeyFromSettings(project, projectGroupingSettings)); + selectProject( + project, + deriveLogicalProjectKeyFromSettings(project, model.projectGroupingSettings), + ); return true; }; - - // The pen's draft hero (fsQuY): pinned projects, the most recent others, - // a way to every project, then the no-project and add-project rows. - const { canPinProjectGroup, toggleProjectGroupPin } = useProjectPin(); - const pinnedEntries = menuEntries.filter(({ group }) => group.pinned); - const unpinnedEntries = menuEntries.filter(({ group }) => !group.pinned); - // The active project always shows, so the selection never hides behind "All projects". - const recentEntries = unpinnedEntries.filter( - ({ group }, index) => index < RECENT_PROJECT_LIMIT || group.projectKey === activeProjectKey, - ); - const chooseEntry = (entry: (typeof menuEntries)[number]) => { + const chooseEntry = (entry: ProjectPickerEntry) => { if (entry.group.projectKey === activeProjectKey && !isScratchDraft) return; selectProject(entry.targetProject, entry.group.projectKey); }; - const noProjectShortcut = shortcutLabelForCommand(keybindings, "chat.newWithoutProject"); - - const renderProjectRow = (entry: (typeof menuEntries)[number]) => { - const { group, targetProject } = entry; - const pinned = group.pinned; - return ( - } - label={targetProject.title} - badge={ - showProjectEnvironments ? ( - - ) : null - } - detail={formatProjectRootForDisplay(targetProject.workspaceRoot)} - detailIsPath - onSelect={() => chooseEntry(entry)} - onKeyDown={(event) => { - if (!canPinProjectGroup(group) || !isProjectPinShortcut(event, keybindings)) return; - event.preventDefault(); - event.stopPropagation(); - toggleProjectGroupPin(group); - }} - action={ - canPinProjectGroup(group) ? ( - - toggleProjectGroupPin(group)} - /> - } - > - {pinned ? : } - - {pinned ? "Unpin" : "Pin"} - - ) : null - } - /> - ); - }; // The hero used to be a sentence; screen readers still get one. const headingLabel = isScratchDraft ? "What should we work on?" : hasResolvedProject ? `What should we build in ${activeProjectTitle}?` - : canChooseProject + : model.canChooseProject ? "Choose a project to start" : "Add a project to start"; @@ -316,42 +432,23 @@ export function DraftHeroHeadline({ aria-label="Projects" className="flex min-h-0 flex-col gap-0.5 overflow-y-auto" > - {pinnedEntries.length > 0 ? ( - <> - Pinned - {pinnedEntries.map(renderProjectRow)} - - ) : null} - {recentEntries.length > 0 ? ( - <> - Recent - {recentEntries.map(renderProjectRow)} - - ) : null} - {menuEntries.length > 0 ? ( - } - label="All projects…" - detail={String(menuEntries.length)} - onSelect={openAllProjects} - /> - ) : null} - {menuEntries.length > 0 ?
: null} - {scratchWorkspaceRoot === null ? null : ( - } - label="No project" - detail="Its own folder, no git" - shortcut={noProjectShortcut} - onSelect={() => void startScratch()} - /> - )} - } - label="Add project…" - detail="Open a folder or clone a repository" - onSelect={openAddProject} + + openCommandPalette({ + open: "new-thread-in", + onSelectProject: (project) => + selectProject( + project, + deriveLogicalProjectKeyFromSettings(project, model.projectGroupingSettings), + ), + }) + } + onNoProject={() => void startScratch()} />
@@ -362,7 +459,7 @@ export function DraftHeroHeadline({ const RECENT_PROJECT_LIMIT = 3; /** Home-relative like a shell prompt: /Users/me/src/app reads as ~/src/app. */ -function formatProjectRootForDisplay(workspaceRoot: string): string { +export function formatProjectRootForDisplay(workspaceRoot: string): string { return workspaceRoot.replace(/^(?:\/Users\/[^/]+|\/home\/[^/]+|[A-Za-z]:\\Users\\[^\\]+)/, "~"); } @@ -385,22 +482,28 @@ export function DraftProjectRow(props: { readonly detailIsPath?: boolean; readonly shortcut?: string | null; readonly selected?: boolean; + /** Shown but inert; the detail says why. */ + readonly disabled?: boolean; readonly action?: ReactNode; readonly onSelect: () => void; readonly onKeyDown?: KeyboardEventHandler; + readonly onFocus?: () => void; }) { return (
diff --git a/apps/web/src/components/device/DevicePanel.tsx b/apps/web/src/components/device/DevicePanel.tsx index 01455aad5913e..f3ad6fa99c442 100644 --- a/apps/web/src/components/device/DevicePanel.tsx +++ b/apps/web/src/components/device/DevicePanel.tsx @@ -1,4 +1,4 @@ -import { DeviceHostUpdates } from "./DeviceHostUpdates"; +import { DeviceHostUpdates, hasDeviceHostUpdates } from "./DeviceHostUpdates"; import type { DeviceHostSummary, DevicePlatform, @@ -30,6 +30,9 @@ import { useRightPanelStore, type RightPanelSurface, } from "~/rightPanelStore"; +import { isThreadlessSurfacesRef } from "~/threadlessSurfaces"; +import { useLayoutStore } from "~/layout/layoutStore"; +import { findThreadChat } from "~/layout/surfaceSync"; import { useLocalStorage } from "~/hooks/useLocalStorage"; import { Alert, AlertAction, AlertDescription, AlertTitle } from "~/components/ui/alert"; import { @@ -43,6 +46,7 @@ import { import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { DiscoveryList } from "~/components/ui/discovery-list"; +import { LabeledSection } from "~/components/ui/labeled-section"; import { Select, SelectItem, @@ -112,6 +116,12 @@ export function DevicePanel(props: { const [selectedHostId, setSelectedHostId] = useState(null); // Persistent: when on, a device opened from this list floats over chat // instead of taking the panel. The workspace's own Float action still works. + // Its mini player shows over the thread's chat: with no chat tab open (or no + // thread), the device opens in its tab instead. + const chatShown = useLayoutStore( + (layoutState) => findThreadChat(layoutState.layout, props.threadRef) !== null, + ); + const canFloat = !isThreadlessSurfacesRef(props.threadRef) && chatShown; const [floatOverChat, setFloatOverChat] = useLocalStorage( FLOAT_OVER_CHAT_STORAGE_KEY, false, @@ -191,7 +201,7 @@ export function DevicePanel(props: { platform: device.platform, name: device.name, }; - if (floatOverChat) { + if (floatOverChat && canFloat) { usePreviewMiniPlayerStore.getState().open(props.threadRef, { kind: "device", ...target }); useRightPanelStore.getState().close(props.threadRef); } else { @@ -212,6 +222,7 @@ export function DevicePanel(props: { }); }; + // Its mini player shows over a chat, so a threadless device has nowhere to float. const floatActive = () => { if (!activeDevice) return; usePreviewMiniPlayerStore.getState().open(props.threadRef, { @@ -255,7 +266,6 @@ export function DevicePanel(props: { const bootingDevices = state.bootingDevices?.filter((device) => device.threadId === threadId) ?? []; const { ready: hostReady, busy: hostBusy } = deviceHubReadiness(state); - const loading = !activeDevice && (pendingDevice !== null || hostBusy || !loaded); // Without a first snapshot a failed subscription never settles on its own. const loadError = loaded ? null : stateError; const diagnostics = state.hosts.flatMap((host) => { @@ -301,16 +311,27 @@ export function DevicePanel(props: { ); } + const showsWorkspace = activeDevice !== undefined && activeSession !== undefined; + const showsLoading = !showsWorkspace && (pendingDevice !== null || hostBusy || !loaded); + const hasNoticeAbove = + diagnostics.length > 0 || + hasDeviceHostUpdates(state) || + bootingDevices.length > 0 || + operationError !== null; + // The device list scrolls up under a pane's tab row (--surface-top-inset). The live screen, + // the loading view and any notice above them keep below it. + const listReachesUnderHeader = !showsWorkspace && !showsLoading && !hasNoticeAbove; + return (
-