{/* The desktop window only moves where CSS opts in, so keep a titlebar strip. */} {isElectron ? : null} diff --git a/apps/web/src/components/PersistentThreadTerminals.tsx b/apps/web/src/components/PersistentThreadTerminals.tsx index dab0f16969a56..5e2fe0dbe91f5 100644 --- a/apps/web/src/components/PersistentThreadTerminals.tsx +++ b/apps/web/src/components/PersistentThreadTerminals.tsx @@ -162,6 +162,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 useEffect(() => { if (terminalIdListsEqual(serverOrderedTerminalIds, terminalUiState.terminalIds)) { return; diff --git a/apps/web/src/components/ProjectCloneToastCoordinator.tsx b/apps/web/src/components/ProjectCloneToastCoordinator.tsx index 79c927d6e2d02..8832a444f377c 100644 --- a/apps/web/src/components/ProjectCloneToastCoordinator.tsx +++ b/apps/web/src/components/ProjectCloneToastCoordinator.tsx @@ -88,6 +88,7 @@ function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentI void handleNewThread(scopeProjectRef(environmentId, projectId)); }; + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- opens, updates and closes imperative toastManager toasts to match the current project clones useEffect(() => { const seen = new Set(); for (const clone of clones) { @@ -215,6 +216,7 @@ function EnvironmentCloneToasts({ environmentId }: { environmentId: EnvironmentI retryClone, ]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- closes every tracked clone toast in toastManager when the coordinator unmounts useEffect( () => () => { for (const tracked of toasts.current.values()) toastManager.close(tracked.toastId); diff --git a/apps/web/src/components/PullRequestThreadDialog.test.ts b/apps/web/src/components/PullRequestThreadDialog.test.ts index 1d601205dd72f..e800d588d6ba8 100644 --- a/apps/web/src/components/PullRequestThreadDialog.test.ts +++ b/apps/web/src/components/PullRequestThreadDialog.test.ts @@ -50,13 +50,13 @@ vi.mock("~/state/session", () => ({ ? state.canOperate : scope === AuthSourceControlWriteScope && state.canWriteSourceControl, })); -vi.mock("~/lib/sourceControlActions", async () => { +vi.mock("~/state/sourceControlActions", async () => { const { usePreparePullRequestThreadAction } = await vi.importActual< typeof import("~/state/sourceControlActions") >("~/state/sourceControlActions"); return { readCachedPullRequestResolution: () => null, - usePullRequestResolution: () => ({ + usePullRequestResolutionState: () => ({ data: { pullRequest: { number: 123, title: "Pull request", state: "open" } }, }), usePreparePullRequestThreadAction, diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index 0ceda05792934..4ecce60109961 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -13,8 +13,8 @@ import { useRef, useState } from "react"; import { readCachedPullRequestResolution, usePreparePullRequestThreadAction, - usePullRequestResolution, -} from "~/lib/sourceControlActions"; + usePullRequestResolutionState, +} from "~/state/sourceControlActions"; import { cn } from "~/lib/utils"; import { parsePullRequestReference } from "@t3tools/client-runtime/pull-request-link-input"; import { getSourceControlPresentation } from "~/sourceControlPresentation"; @@ -92,7 +92,7 @@ export function PullRequestThreadDialog({ const parsedReference = parsePullRequestReference(reference); const parsedDebouncedReference = parsePullRequestReference(debouncedReference); - const pullRequestResolution = usePullRequestResolution({ + const pullRequestResolution = usePullRequestResolutionState({ environmentId, cwd, reference: open ? parsedDebouncedReference : null, diff --git a/apps/web/src/components/QuitHoldOverlay.tsx b/apps/web/src/components/QuitHoldOverlay.tsx index 819455b68f3f7..94ecda194fe43 100644 --- a/apps/web/src/components/QuitHoldOverlay.tsx +++ b/apps/web/src/components/QuitHoldOverlay.tsx @@ -22,7 +22,7 @@ function subscribeQuitHint(listener: () => void): () => void { listeners.add(listener); if (listeners.size === 1) { stopBridge = - window.desktopBridge?.onQuitShortcut?.((hint) => { + window.desktopBridge?.onQuitShortcut((hint) => { window.clearTimeout(hideTimer); if (hint.state === "down") { pressedMode = hint.mode; diff --git a/apps/web/src/components/ReopenClosedViewShortcut.tsx b/apps/web/src/components/ReopenClosedViewShortcut.tsx index eb7f9866919d1..f1b5520d4216a 100644 --- a/apps/web/src/components/ReopenClosedViewShortcut.tsx +++ b/apps/web/src/components/ReopenClosedViewShortcut.tsx @@ -44,6 +44,8 @@ import { import { toastManager } from "./ui/toast"; const PREVIEW_FORWARDED_LAYOUT_COMMANDS = [ + "workspace.focusNext", + "workspace.focusPrevious", "sidebar.toggle", "rightPanel.toggle", "rightPanel.toggleMaximized", @@ -144,6 +146,7 @@ export function ReopenClosedViewShortcut() { }); }); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- subscribes to window keydown and the desktop menu action for the reopen closed view shortcut useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if ( @@ -178,9 +181,10 @@ export function ReopenClosedViewShortcut() { }; }, [keybindings, previewOpen, terminalOpen]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- registers forwarded shortcut chords with the desktop preview bridge and clears them on cleanup useEffect(() => { const preview = window.desktopBridge?.preview; - if (!preview?.setForwardedShortcuts) return; + if (!preview) return; const options = { context: { previewFocus: true, @@ -209,7 +213,7 @@ export function ReopenClosedViewShortcut() { ) .catch(() => undefined); return () => { - void preview.setForwardedShortcuts?.([]).catch(() => undefined); + void preview.setForwardedShortcuts([]).catch(() => undefined); }; }, [hasHistory, keybindings, terminalOpen]); diff --git a/apps/web/src/components/RightPanelTabs.tsx b/apps/web/src/components/RightPanelTabs.tsx index 4cab22ef83871..08791b8a5ba65 100644 --- a/apps/web/src/components/RightPanelTabs.tsx +++ b/apps/web/src/components/RightPanelTabs.tsx @@ -73,7 +73,7 @@ import { import { useBrowserDefaults } from "~/browser/browserDefaults"; import { ScrollArea } from "~/components/ui/scroll-area"; import { PanelTabCloseButton } from "~/components/ui/panel-tab-close-button"; -import { faviconUrlForOrigin } from "~/lib/favicon"; +import { faviconUrlForOrigin } from "@t3tools/shared/favicon"; import { useTheme } from "~/hooks/useTheme"; import { useDeviceState } from "~/state/device"; import type { PreviewPanelInlineSize } from "~/hooks/usePreviewPanelInlineSize"; @@ -84,6 +84,7 @@ import { } from "~/state/pullRequests"; import { useEnvironmentQuery } from "~/state/query"; import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; +import * as Lazurio from "~/lazurio/LazurioEnvironmentBrowser"; import { PreviewPanelShell, type PreviewPanelMode } from "./preview/PreviewPanelShell"; import { FaviconImage } from "./preview/PreviewFaviconIcon"; @@ -195,7 +196,8 @@ type TabContextMenuAction = | "close" | "close-others" | "close-to-right" - | "close-all"; + | "close-all" + | Lazurio.TabMenuAction; const TAB_SCROLL_EDGE_TOLERANCE = 1; const TAB_DRAG_MODIFIERS = [restrictToHorizontalAxis, restrictToFirstScrollableAncestor]; @@ -310,6 +312,8 @@ function RightPanelEmptyState(props: { pullRequestAvailable: boolean; pullRequestsAvailable: boolean; deviceAvailable: boolean; + /** Lazurio overlay: why the Environment browser is not here yet, instead of upstream's line. */ + browserNote: string | null; }) { const [highlight, setHighlight] = useState(-1); @@ -319,7 +323,7 @@ function RightPanelEmptyState(props: { icon: Globe2, shortcut: "B", available: props.browserAvailable, - disabledReason: SURFACE_UNAVAILABLE_HINTS.browser, + disabledReason: props.browserNote ?? SURFACE_UNAVAILABLE_HINTS.browser, onClick: props.onAddBrowser, }, { @@ -389,6 +393,7 @@ function RightPanelEmptyState(props: { event.stopPropagation(); action.onClick(); }); + // 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); @@ -435,7 +440,7 @@ function RightPanelEmptyState(props: { return (
+ {props.browserNote === null ? null : ( +

{props.browserNote}

+ )}
); @@ -534,6 +542,7 @@ function surfaceTitle( surface: RightPanelSurface, sessions: Readonly>, terminalLabelsById: ReadonlyMap, + lazurioTitles: Lazurio.FrameTitles, ): string { switch (surface.kind) { case "diff": @@ -555,6 +564,8 @@ function surfaceTitle( return "Pull requests"; case "device": return surface.title ?? surface.target?.name ?? "Device"; + case "environment-browser": + return Lazurio.surfaceTitle(surface, lazurioTitles); case "preview": { const snapshot = surface.resourceId ? sessions[surface.resourceId] : null; if (!snapshot || snapshot.navStatus._tag === "Idle") return "Browser"; @@ -643,6 +654,8 @@ function SurfaceIcon({ ); case "pull-requests": return ; + case "environment-browser": + return ; case "device": return surface.target?.platform === "ios" ? ( @@ -711,17 +724,17 @@ function PullRequestSurfaceIcon({ ? undefined : configs.get(resolvedEnvironmentId)?.environment.capabilities; const linkedSnapshot = - capabilities?.threadPullRequests === true + capabilities !== undefined ? (resolvePullRequestTabLink(threads, resolvedEnvironmentId, host, surface)?.snapshot ?? null) : null; const detail = useEnvironmentQuery( - resolvedEnvironmentId === null || capabilities?.pullRequests !== true || linkedSnapshot !== null + resolvedEnvironmentId === null || capabilities === undefined || linkedSnapshot !== null ? null : pullRequestEnvironment.detail({ environmentId: resolvedEnvironmentId, input: { projectId: surface.projectId as ProjectId, - ...(capabilities?.threadPullRequests === true && surface.host !== undefined + ...(capabilities !== undefined && surface.host !== undefined ? { host: surface.host } : {}), repository: surface.repository, @@ -747,10 +760,6 @@ function PullRequestSurfaceIcon({ return ; } -function focusOnMount(node: HTMLDivElement | null) { - node?.focus(); -} - function SortableTabList(props: { surfaces: readonly RightPanelSurface[]; onMove: ((surfaceId: string, toIndex: number) => void) | undefined; @@ -912,7 +921,7 @@ function RightPanelTab( title={title} /> ) : ( - title + )} @@ -944,6 +953,7 @@ function RightPanelTab( } export function RightPanelTabs(props: RightPanelTabsProps) { + const focusedSurface = props.surfaces.find((surface) => surface.id === props.activeSurfaceId); const ownsDesktopTitleBar = isElectron && props.mode === "inline"; const browserProfiles = useBrowserDefaults().profiles; const { resolvedTheme } = useTheme(); @@ -971,10 +981,12 @@ export function RightPanelTabs(props: RightPanelTabsProps) { event.preventDefault(); event.stopPropagation(); if (!event.repeat) { + if (Lazurio.openNewTab()) return; 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); @@ -1067,6 +1079,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { onClick: props.onAddDevice, }, ] as const; + const lazurio = Lazurio.useRightPanel(addSurfaceActions); const handleAddSurfaceMenuKeyDown = (event: ReactKeyboardEvent) => { const action = surfaceShortcutActionForKey(addSurfaceActions, event.nativeEvent); @@ -1105,6 +1118,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }), }); } + items.push(...Lazurio.tabMenuItems(surface)); items.push( { id: "close", label: "Close" }, { @@ -1158,6 +1172,8 @@ export function RightPanelTabs(props: RightPanelTabsProps) { break; case null: break; + default: + Lazurio.runTabMenuAction(action, surface); } }; const handleTabAuxClick = (event: ReactMouseEvent, surface: RightPanelSurface) => { @@ -1181,6 +1197,7 @@ export function RightPanelTabs(props: RightPanelTabsProps) { }); // 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']"); @@ -1223,6 +1240,13 @@ export function RightPanelTabs(props: RightPanelTabsProps) { return ( - {props.open !== false ? ( + {props.open !== false && lazurio.newTab ? : null} + {props.open !== false && !lazurio.newTab ? (
- {props.activeSurfaceId === null ? ( + {props.activeSurfaceId === null && lazurio.newTab ? ( + + ) : props.activeSurfaceId === null ? ( ) : ( props.children diff --git a/apps/web/src/components/ServerUpdateAction.browser.test.tsx b/apps/web/src/components/ServerUpdateAction.browser.test.tsx index 0062a69c09177..ed3a723652a56 100644 --- a/apps/web/src/components/ServerUpdateAction.browser.test.tsx +++ b/apps/web/src/components/ServerUpdateAction.browser.test.tsx @@ -13,8 +13,8 @@ const testState = vi.hoisted(() => ({ updateServer: vi.fn(), toast: vi.fn(), clipboard: vi.fn(), - continueThreadsAfterServerUpdate: false, sessionAtom: null as unknown, + serverVersions: new Map(), })); vi.mock(import("~/hooks/useCopyToClipboard"), async (original) => ({ @@ -27,16 +27,6 @@ vi.mock(import("~/hooks/useCopyToClipboard"), async (original) => ({ isCopied: false, })) as never, })); -vi.mock(import("~/hooks/useSettings"), async (original) => ({ - ...(await original()), - useEnvironmentSettings: (( - _environmentId: EnvironmentId, - selector: (settings: { continueThreadsAfterServerUpdate: boolean }) => unknown, - ) => - selector({ - continueThreadsAfterServerUpdate: testState.continueThreadsAfterServerUpdate, - })) as never, -})); vi.mock(import("~/state/session"), async (original) => { const actual = await original(); return { @@ -47,6 +37,16 @@ vi.mock(import("~/state/session"), async (original) => { }, }; }); +vi.mock(import("~/state/entities"), async (original) => ({ + ...(await original()), + useServerConfigs: (() => + new Map( + [...testState.serverVersions].map(([id, serverVersion]) => [ + id, + { environment: { serverVersion } }, + ]), + )) as never, +})); vi.mock(import("~/state/use-atom-command"), async (original) => ({ ...(await original()), useAtomCommand: (() => testState.updateServer) as never, @@ -95,7 +95,7 @@ function setup(): Disposable { testState.updateServer.mockReset(); testState.toast.mockReset(); testState.clipboard.mockReset(); - testState.continueThreadsAfterServerUpdate = false; + testState.serverVersions.clear(); sessionAtom = Atom.make(AsyncResult.success(currentSession)); testState.sessionAtom = sessionAtom; resetConfirmDialogForTests(); @@ -188,16 +188,12 @@ describe("ServerUpdatesAction", () => { serverLabel: "Laptop", selfUpdate: "boot-service", targetVersion: "0.0.31", - threadContinuation: true, - continueThreadsAfterServerUpdate: true, }, { environmentId: "batch-b" as EnvironmentId, serverLabel: "Office", selfUpdate: "respawn", targetVersion: "0.0.31", - threadContinuation: true, - continueThreadsAfterServerUpdate: false, }, { environmentId: "batch-c" as EnvironmentId, @@ -228,4 +224,17 @@ describe("ServerUpdatesAction", () => { ); expect(button(screen, "Update all")!.disabled).toBe(false); }); + + it("skips a machine whose update would cross Stable and Nightly", async () => { + using _state = setup(); + testState.serverVersions.set("batch-a", "0.0.30-nightly.20261001.1"); + testState.updateServer.mockResolvedValue(success()); + await using screen = await mount(); + await click(screen, "Update all"); + + expect(testState.updateServer).toHaveBeenCalledTimes(1); + expect(testState.toast).toHaveBeenCalledWith( + expect.objectContaining({ type: "success", title: "Office updated" }), + ); + }); }); diff --git a/apps/web/src/components/ServerUpdateAction.tsx b/apps/web/src/components/ServerUpdateAction.tsx index c659d410b8581..141f139aefd7b 100644 --- a/apps/web/src/components/ServerUpdateAction.tsx +++ b/apps/web/src/components/ServerUpdateAction.tsx @@ -7,6 +7,7 @@ import { import type { AsyncResult } from "effect/reactivity"; import { environmentSession } from "~/state/session"; import type { + DesktopUpdateChannel, EnvironmentId, ServerInstallation, ServerSelfUpdateCapability, @@ -22,11 +23,12 @@ import { type ComponentProps, useRef, useState } from "react"; import { requestConfirmDialog } from "~/confirmDialog"; import { useCopyToClipboard } from "~/hooks/useCopyToClipboard"; -import { useEnvironmentSettings } from "~/hooks/useSettings"; import { serverEnvironment, updateOutdatedServer } from "~/state/server"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { useAtomCommand } from "~/state/use-atom-command"; import { manualServerUpdateCommand } from "~/versionSkew"; +import { useServerConfigs } from "~/state/entities"; +import { crossesReleaseTrack } from "@t3tools/shared/cliRelease"; import { Button } from "./ui/button"; import { toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; @@ -42,6 +44,29 @@ export function serverUpdateStageLabel(stage: ServerUpdateStage): string { return UPDATE_STAGE_LABELS[stage]; } +const CROSS_TRACK_NOTE = "Switch tracks in About → Update track"; + +/** Hosts refuse a Stable/Nightly crossing outside a track switch, which checks the database first. */ +function refusedCrossing( + target: { + readonly selfUpdate: ServerSelfUpdateCapability | null; + readonly targetVersion: string; + readonly channel?: DesktopUpdateChannel | undefined; + }, + serverVersion: string | undefined, +): boolean { + return ( + target.channel === undefined && + target.selfUpdate !== null && + serverVersion !== undefined && + crossesReleaseTrack(serverVersion, target.targetVersion) + ); +} + +function CrossTrackNote() { + return {CROSS_TRACK_NOTE}; +} + function updateFailureMessage(error: unknown): string { return error instanceof Error ? error.message : "Server update failed."; } @@ -52,9 +77,13 @@ export interface ServerUpdateTarget { readonly selfUpdate: ServerSelfUpdateCapability | null; readonly installation?: ServerInstallation | undefined; readonly desktopAppUpdate?: boolean; - readonly threadContinuation?: boolean; readonly targetVersion: string; - readonly continueThreadsAfterServerUpdate?: boolean; + /** Set only by a track switch; the host's desktop app moves to this track. */ + readonly channel?: DesktopUpdateChannel; + /** A track switch's checked release migration; the host re-checks its own database. */ + readonly targetDatabaseMigration?: number; + /** The host already runs its track's newest build and only saves the track. */ + readonly trackOnly?: boolean; } type UpdateButtonProps = Pick, "variant" | "size" | "className"> & { @@ -84,9 +113,10 @@ export function useServerUpdate() { environmentId, input: { targetVersion, - ...(target.threadContinuation && target.continueThreadsAfterServerUpdate - ? { continueRunningThreads: true } - : {}), + ...(target.channel === undefined ? {} : { channel: target.channel }), + ...(target.targetDatabaseMigration === undefined + ? {} + : { targetDatabaseMigration: target.targetDatabaseMigration }), }, }); pendingUpdateEnvironmentIds.delete(environmentId); @@ -126,12 +156,14 @@ export function ServerUpdatesAction({ readonly targets: ReadonlyArray; }) { const update = useServerUpdate(); + const serverConfigs = useServerConfigs(); const pending = useRef(false); const [isPending, setIsPending] = useState(false); const eligible = targets.filter( (target) => target.selfUpdate !== null && - (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate), + (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate) && + !refusedCrossing(target, serverConfigs.get(target.environmentId)?.environment.serverVersion), ); const handleUpdate = async () => { if (pending.current) return; @@ -205,21 +237,18 @@ export function ServerUpdateAction({ selfUpdate, installation, desktopAppUpdate = false, - threadContinuation = false, targetVersion, + channel, label = "Update", variant = "outline", size = "xs", className, appearance = "button", -}: Omit & UpdateButtonProps) { +}: ServerUpdateTarget & UpdateButtonProps) { const isDesktopAppUpdate = selfUpdate === "desktop-managed"; + const serverVersion = useServerConfigs().get(environmentId)?.environment.serverVersion; const sessionStateAtom = environmentSession.sessionStateAtom(environmentId); const canUpdate = canUpdateServer(useAtomValue(sessionStateAtom)); - const continueThreadsAfterServerUpdate = useEnvironmentSettings( - environmentId, - (settings) => settings.continueThreadsAfterServerUpdate, - ); const update = useServerUpdate(); const { copyToClipboard } = useCopyToClipboard<{ command: string }>({ target: installation?.kind === "npm-global" ? "update command" : "relaunch command", @@ -265,9 +294,7 @@ export function ServerUpdateAction({ serverLabel, selfUpdate, desktopAppUpdate, - threadContinuation, targetVersion, - continueThreadsAfterServerUpdate, }); }; @@ -279,6 +306,10 @@ export function ServerUpdateAction({ ); } + if (refusedCrossing({ selfUpdate, targetVersion, channel }, serverVersion)) { + return ; + } + const manualCommand = selfUpdate === null ? manualServerUpdateCommand(targetVersion, installation) : null; const actionLabel = @@ -366,6 +397,9 @@ export function OutdatedServerUpdateAction({ description: `Reconnected on t3@${result.value.targetVersion}.`, }); }; + if (fromVersion !== undefined && crossesReleaseTrack(fromVersion, targetVersion)) { + return ; + } return ( - ) : scopedProjectGroup ? ( - `No threads in ${scopedProjectGroup.displayName} yet` + ) : scopedProjectGroups.length > 1 ? ( + "No threads in these projects yet" + ) : scopedProjectGroups[0] ? ( + `No threads in ${scopedProjectGroups[0].displayName} yet` ) : ( "No threads yet" )} @@ -3252,9 +3525,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve ) : null} - + ); } diff --git a/apps/web/src/components/SidebarStageBackdrop.tsx b/apps/web/src/components/SidebarStageBackdrop.tsx index b272c5724feb7..b5c9d34ac6670 100644 --- a/apps/web/src/components/SidebarStageBackdrop.tsx +++ b/apps/web/src/components/SidebarStageBackdrop.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import type { EnvironmentIdentificationMode } from "@t3tools/contracts"; import { useId } from "react"; import { APP_STAGE_LABEL } from "../branding"; @@ -12,9 +13,11 @@ const STAGE_BACKDROP_VIEW_BOX = "0 0 8192 96"; export function resolveSidebarStageBackdropVariant( stageLabel: string, - enabled = true, + mode: EnvironmentIdentificationMode = "artwork", ): SidebarStageBackdropVariant | null { - if (!enabled) return null; + if (mode === "nightly-artwork") return "nightly"; + if (mode === "dev-artwork") return "dev"; + if (mode !== "artwork") return null; const normalized = stageLabel.trim().toLowerCase(); if (normalized === "nightly") return "nightly"; if (normalized === "dev") return "dev"; @@ -40,8 +43,10 @@ export function useEnvironmentStageLabel(): string { }); } -export function useSidebarStageBackdropVariant(enabled = true): SidebarStageBackdropVariant | null { - return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), enabled); +export function useSidebarStageBackdropVariant( + mode: EnvironmentIdentificationMode, +): SidebarStageBackdropVariant | null { + return resolveSidebarStageBackdropVariant(useEnvironmentStageLabel(), mode); } export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdropVariant }) { @@ -55,7 +60,7 @@ export function SidebarStageBackdrop({ variant }: { variant: SidebarStageBackdro ); } -export function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) { +function StageBackdropArt({ variant }: { variant: SidebarStageBackdropVariant }) { return variant === "nightly" ? : ; } diff --git a/apps/web/src/components/SourceControlAppAccessNotice.tsx b/apps/web/src/components/SourceControlAppAccessNotice.tsx new file mode 100644 index 0000000000000..4267b53150207 --- /dev/null +++ b/apps/web/src/components/SourceControlAppAccessNotice.tsx @@ -0,0 +1,68 @@ +import type { EnvironmentId } from "@t3tools/contracts"; +import { + readHostCredentialSettings, + sourceControlClients, +} from "@t3tools/client-runtime/source-control-clients"; +import { ExternalLinkIcon } from "lucide-react"; + +import { useEnvironmentSettings } from "../hooks/useSettings"; +import { cn } from "../lib/utils"; +import { useEnvironmentQuery } from "../state/query"; +import { sourceControlEnvironment } from "../state/sourceControl"; +import { T3AppIcon } from "./T3AppIcon"; +import { InlineButton } from "./ui/button"; + +/** + * Says when the server's forge app sign-in cannot reach `repository`, so requests there fall back + * to the user's own login. Renders nothing for servers that do not use the app. + */ +export function SourceControlAppAccessNotice(props: { + readonly environmentId: EnvironmentId; + readonly provider: string | undefined; + readonly host: string | undefined; + /** `owner/name`. */ + readonly repository: string; + readonly className?: string; +}) { + const definition = props.provider ? sourceControlClients.find(props.provider) : undefined; + const appSignIn = definition?.credentials?.appSignIn; + const host = (props.host ?? definition?.publicHost ?? "").toLowerCase(); + const signedIn = useEnvironmentSettings(props.environmentId, (settings) => + definition === undefined + ? false + : (readHostCredentialSettings(settings, definition.kind).signIns[host] ?? "").length > 0, + ); + const access = useEnvironmentQuery( + definition !== undefined && appSignIn?.hosts.includes(host) && signedIn + ? sourceControlEnvironment.appAccess({ + environmentId: props.environmentId, + input: { provider: definition.kind, host, repository: props.repository }, + }) + : null, + ); + if (appSignIn === undefined || access.data?.status !== "not-covered") return null; + const owner = props.repository.split("/")[0] ?? props.repository; + + return ( +
+ +

+ {appSignIn.appName} isn't installed on {owner}, so + requests here use your own GitHub login and its lower rate limit. +

+ } + > + Install on {owner} + + +
+ ); +} diff --git a/apps/web/src/components/T3AppIcon.tsx b/apps/web/src/components/T3AppIcon.tsx new file mode 100644 index 0000000000000..7e3ebef5692b1 --- /dev/null +++ b/apps/web/src/components/T3AppIcon.tsx @@ -0,0 +1,6 @@ +// The production icon, as GitHub shows the T3 Code app, whatever this build's channel. +import appIconUrl from "../../../../assets/prod/t3-black-web-apple-touch-180.png"; + +export function T3AppIcon(props: { readonly className?: string }) { + return ; +} diff --git a/apps/web/src/components/ThreadContextChip.tsx b/apps/web/src/components/ThreadContextChip.tsx index 5f1a4202f1d36..715a210f4e917 100644 --- a/apps/web/src/components/ThreadContextChip.tsx +++ b/apps/web/src/components/ThreadContextChip.tsx @@ -30,7 +30,7 @@ export function ThreadContextChip(props: { className="no-underline" > {project ? ( - + ) : ( )} diff --git a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts index f253dd6be22c7..89128ff69ac55 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts +++ b/apps/web/src/components/ThreadNotificationCoordinator.badge.browser.test.ts @@ -45,6 +45,19 @@ vi.mock(import("../state/presentation"), async (original) => { }, }; }); +vi.mock(import("../state/threads"), async (original) => { + const actual = await original(); + return { + ...actual, + environmentThreadShells: { + ...actual.environmentThreadShells, + pendingSubagentRequestsAtom: (() => noRequestsAtom) as never, + get delegatedUnderParentAtom() { + return noDelegatedAtom as never; + }, + }, + }; +}); vi.mock(import("../hooks/useSettings"), async (original) => ({ ...(await original()), subscribeClientSettings: ((listener: () => void) => { @@ -68,6 +81,8 @@ import { startThreadNotifications } from "./ThreadNotificationCoordinator"; let registry: AtomRegistry.AtomRegistry; const shellAtom = Atom.family((_id: string) => Atom.make(null)); const environmentIdsAtom = Atom.make>([]); +const noRequestsAtom = Atom.make(new Map()); +const noDelegatedAtom = Atom.make>(new Set()); state.shells = { set: (id, value) => registry.set(shellAtom(id), value) }; class TestNotification extends EventTarget { diff --git a/apps/web/src/components/ThreadNotificationCoordinator.tsx b/apps/web/src/components/ThreadNotificationCoordinator.tsx index a782e24da6522..fa4fdbb672faa 100644 --- a/apps/web/src/components/ThreadNotificationCoordinator.tsx +++ b/apps/web/src/components/ThreadNotificationCoordinator.tsx @@ -3,7 +3,7 @@ import { type PendingSubagentRequest, presentThreadShell, } from "@t3tools/client-runtime/state/models"; -import { pendingSubagentRequestsByThread } from "@t3tools/client-runtime/state/threads"; +import { isShownUnderParent } from "@t3tools/client-runtime/state/threads"; import type { AnyRouter } from "@tanstack/react-router"; import type { EnvironmentId, OrchestrationV2ThreadShell, ThreadId } from "@t3tools/contracts"; import type { ClientSettings } from "@t3tools/contracts/settings"; @@ -20,6 +20,7 @@ import { getClientSettings, subscribeClientSettings } from "../hooks/useSettings import { isWindowsPlatform } from "../lib/utils"; import { environmentSummaries } from "../state/presentation"; import { environmentShell } from "../state/shell"; +import { environmentThreadShells } from "../state/threads"; import { hasDesktopNotifications, hasNotificationSound, @@ -155,7 +156,7 @@ export function startThreadNotifications( notification.addEventListener("click", () => { notification.close(); window.focus(); - void window.desktopBridge?.revealWindow?.().catch(() => undefined); + void window.desktopBridge?.revealWindow().catch(() => undefined); void router.navigate({ to: "/$environmentId/$threadId", params: { environmentId, threadId: thread.id }, @@ -173,10 +174,22 @@ export function startThreadNotifications( ) => { const previous = entry.previous; const next = new Map(); - const subagentRequests = pendingSubagentRequestsByThread(threads); + // A parent here also hears of what its tasks on linked environments ask. + const subagentRequests = registry.get( + environmentThreadShells.pendingSubagentRequestsAtom(environmentId), + ); + const delegatedUnderParent = registry.get(environmentThreadShells.delegatedUnderParentAtom); const changed: Array<[NotificationState | undefined, NotificationState]> = []; for (const rawThread of threads) { - if (rawThread.lineage.relationshipToParent === "subagent") continue; + if ( + isShownUnderParent( + { environmentId, threadId: rawThread.id }, + rawThread.lineage, + delegatedUnderParent, + ) + ) { + continue; + } const prior = previous.get(rawThread.id); const subagentRequest = subagentRequests.get(rawThread.id) ?? null; if (prior?.raw === rawThread && prior.subagentRequest === subagentRequest) { @@ -227,10 +240,11 @@ export function startThreadNotifications( if (state) present(environmentId, state, kind); }), }; + let threads: ReadonlyArray | null = null; const unsubscribeShell = registry.subscribe( environmentShell.stateValueAtom(environmentId), (shell) => { - const threads = + threads = shell.status === "live" && Option.isSome(shell.snapshot) ? shell.snapshot.value.threads : null; @@ -243,8 +257,21 @@ export function startThreadNotifications( }, { immediate: true }, ); + const reobserve = () => { + if (threads !== null) observeThreads(environmentId, entry, threads); + }; + const unsubscribeRequests = registry.subscribe( + environmentThreadShells.pendingSubagentRequestsAtom(environmentId), + reobserve, + ); + const unsubscribeDelegated = registry.subscribe( + environmentThreadShells.delegatedUnderParentAtom, + reobserve, + ); entry.unsubscribe = () => { unsubscribeShell(); + unsubscribeRequests(); + unsubscribeDelegated(); entry.gate.clear(); }; return entry; @@ -287,7 +314,7 @@ export function startThreadNotifications( clearPending(); const stops: Array<() => void> = []; if (hasDesktopNotifications(nextMode)) { - const unsubscribeBadgeClear = window.desktopBridge?.onNotificationBadgeClear?.(clearPending); + const unsubscribeBadgeClear = window.desktopBridge?.onNotificationBadgeClear(clearPending); window.addEventListener("focus", clearPending); stops.push(() => { unsubscribeBadgeClear?.(); diff --git a/apps/web/src/components/ThreadRouteView.tsx b/apps/web/src/components/ThreadRouteView.tsx index 30ee70d0dd637..79f4633ea5452 100644 --- a/apps/web/src/components/ThreadRouteView.tsx +++ b/apps/web/src/components/ThreadRouteView.tsx @@ -89,6 +89,7 @@ export function ThreadRouteView({ }); const environmentHasAnyThreads = environmentThreadRefs.length > 0 || environmentHasDraftThreads; + // 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) { return; diff --git a/apps/web/src/components/ThreadStatusIndicators.test.ts b/apps/web/src/components/ThreadStatusIndicators.test.ts index 8ef5d75269b13..caba939c26e01 100644 --- a/apps/web/src/components/ThreadStatusIndicators.test.ts +++ b/apps/web/src/components/ThreadStatusIndicators.test.ts @@ -1,17 +1,10 @@ import { SourceControlProviderKind } from "@t3tools/contracts"; import { ProjectId, type PullRequestSummary, type VcsStatusResult } from "@t3tools/contracts"; import { describe, expect, it } from "@effect/vitest"; -import * as Effect from "effect/Effect"; -import { AtomRegistry } from "effect/reactivity"; import { - nextThreadChangeRequestSnapshot, resolveDisplayedThreadPr, resolveDisplayedThreadPrProvider, - resolveThreadPr, - threadChangeRequestSnapshotsEqual, - threadChangeRequestSnapshotsAtom, - type ThreadChangeRequestSnapshot, } from "./ThreadStatusIndicators"; import { newestPullRequestSummary } from "../state/pullRequests"; @@ -49,14 +42,6 @@ function mergedFeaturePr(): NonNullable { }; } -function snapshotFor( - branch: string, - pr: NonNullable, - sourceControlProvider?: VcsStatusResult["sourceControlProvider"], -): ThreadChangeRequestSnapshot { - return { branch, pr, sourceControlProvider }; -} - function pullRequestSummary( state: PullRequestSummary["state"], updatedAt: string, @@ -91,18 +76,7 @@ describe("shared pull request state", () => { }); }); -describe("resolveThreadPr", () => { - it("hides PR indicators when a dedicated worktree has switched away from the thread branch", () => { - expect( - resolveThreadPr({ - threadBranch: "stack/base", - gitStatus: status(), - }), - ).toBeNull(); - }); -}); - -describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { +describe("resolveDisplayedThreadPr", () => { const featureBranch = "feature/current"; const mergedPr = mergedFeaturePr(); const linkedPullRequest = { @@ -118,162 +92,15 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { }; it("shows a linked pull request when the checkout has a different branch", () => { - const linkedPullRequestStatus = { - pr: mergedPr, - sourceControlProvider: provider, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual(provider); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "feature/other", - gitStatus: status({ refName: "feature/other", pr: null }), - snapshot: undefined, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus, - }), - ).toEqual({ - branch: "feature/other", - pr: mergedPr, - sourceControlProvider: provider, - linkedPullRequest, - }); - }); - - it("keeps a matching linked pull request snapshot while its status reloads", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest, - }; - - expect( - resolveDisplayedThreadPr({ - threadBranch: null, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toEqual(mergedPr); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: null, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: false, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toBeUndefined(); - }); - - it("clears an old snapshot when a different pull request is linked", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), - linkedPullRequest: { ...linkedPullRequest, number: 41 }, - }; - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: true, - linkedPullRequest, - linkedPullRequestStatus: null, - }), - ).toBeNull(); - }); - - it("removes a linked pull request snapshot after the link is cleared", () => { - const snapshot = { - ...snapshotFor(featureBranch, mergedPr, provider), + const input = { + threadBranch: "feature/other", + gitStatus: status({ refName: "feature/other", pr: null }), linkedPullRequest, + linkedPullRequestStatus: { pr: mergedPr, sourceControlProvider: provider }, }; - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("after caching a merged PR, resolves main status back to the cached feature PR", () => { - const matchingStatus = status({ - refName: featureBranch, - pr: mergedPr, - sourceControlProvider: provider, - }); - const cached = nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: matchingStatus, - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }); - expect(cached).toEqual(snapshotFor(featureBranch, mergedPr, provider)); - - const mainStatus = status({ - refName: "main", - isDefaultRef: true, - pr: { - number: 99, - title: "Unrelated main PR", - url: "https://github.com/pingdotgg/t3code/pull/99", - baseRef: "main", - headRef: "main", - state: "open", - }, - sourceControlProvider: provider, - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus: mainStatus, - snapshot: cached as ThreadChangeRequestSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(provider); + expect(resolveDisplayedThreadPr(input)).toEqual(mergedPr); + expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider); }); it("never attaches a PR reported by main to the feature thread", () => { @@ -289,215 +116,7 @@ describe("resolveDisplayedThreadPr + nextThreadChangeRequestSnapshot", () => { resolveDisplayedThreadPr({ threadBranch: featureBranch, gitStatus: status({ refName: "main", pr: mainPr }), - snapshot: undefined, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: mainPr }), - snapshot: undefined, - retainTerminalOnBranchMismatch: true, }), ).toBeNull(); }); - - it("does not show a cached open PR across a branch mismatch", () => { - const openSnapshot = snapshotFor(featureBranch, { - ...mergedPr, - state: "open", - title: "Still open", - }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("retains a cached closed PR across a branch mismatch", () => { - const closedPr = { ...mergedPr, state: "closed" as const, title: "Closed feature" }; - const closedSnapshot = snapshotFor(featureBranch, closedPr, provider); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: status({ refName: "main", pr: null }), - snapshot: closedSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(closedPr); - }); - - it("does not retain or display a terminal PR when a worktree switches branches", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - const mismatchedStatus = status({ refName: "feature/other", pr: null }); - - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeUndefined(); - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: mismatchedStatus, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: false, - }), - ).toBeNull(); - }); - - it("retains a terminal snapshot when a local thread and status move to a branch with no PR", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - expect( - resolveDisplayedThreadPr({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("clears an open snapshot when a local thread moves to a branch with no PR", () => { - const openSnapshot = snapshotFor(featureBranch, { ...mergedPr, state: "open" }); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: "main", - gitStatus: status({ refName: "main", pr: null }), - snapshot: openSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - }); - - it("clears a retained snapshot when the thread branch is cleared", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPr({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeNull(); - expect( - resolveDisplayedThreadPrProvider({ - threadBranch: null, - gitStatus: status({ refName: "main", pr: null }), - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - }); - - it("does not erase a terminal snapshot when VCS data is missing", () => { - const terminalSnapshot = snapshotFor(featureBranch, mergedPr, provider); - - expect( - nextThreadChangeRequestSnapshot({ - threadBranch: featureBranch, - gitStatus: null, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toBeUndefined(); - expect( - resolveDisplayedThreadPr({ - threadBranch: featureBranch, - gitStatus: null, - snapshot: terminalSnapshot, - retainTerminalOnBranchMismatch: true, - }), - ).toEqual(mergedPr); - }); - - it("shows the last matching PR snapshot while its live status lease is released", () => { - const openPr = { ...mergedPr, state: "open" as const }; - const input = { - threadBranch: featureBranch, - gitStatus: null, - snapshot: snapshotFor(featureBranch, openPr, provider), - retainTerminalOnBranchMismatch: false, - }; - expect(resolveDisplayedThreadPr(input)).toEqual(openPr); - expect(resolveDisplayedThreadPrProvider(input)).toEqual(provider); - expect(resolveDisplayedThreadPr({ ...input, threadBranch: "feature/other" })).toBeNull(); - expect( - resolveDisplayedThreadPr({ - ...input, - snapshot: { ...input.snapshot, linkedPullRequest }, - }), - ).toBeNull(); - }); - - it("refreshes a cached snapshot when a pull request becomes ready", () => { - const readyPr = { ...mergedPr, state: "open" as const }; - const draftPr = { ...readyPr, isDraft: true }; - - expect( - threadChangeRequestSnapshotsEqual( - snapshotFor(featureBranch, draftPr), - snapshotFor(featureBranch, readyPr), - ), - ).toBe(false); - }); -}); - -describe("threadChangeRequestSnapshotsAtom", () => { - it.effect("retains snapshots while sidebar and chat consumers are unmounted", () => - Effect.gen(function* () { - const registry = AtomRegistry.make(); - const threadKey = "environment-1:thread-1"; - const snapshot = snapshotFor("feature/current", mergedFeaturePr()); - - const unmount = registry.mount(threadChangeRequestSnapshotsAtom); - registry.set(threadChangeRequestSnapshotsAtom, new Map([[threadKey, snapshot]])); - unmount(); - - yield* Effect.yieldNow; - - const remount = registry.mount(threadChangeRequestSnapshotsAtom); - expect(registry.get(threadChangeRequestSnapshotsAtom).get(threadKey)).toEqual(snapshot); - - remount(); - registry.dispose(); - }), - ); }); diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 15fe062a960f9..0af0e063bdea3 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -1,3 +1,4 @@ +import { pipe } from "effect/Function"; import { scopeProjectRef, scopedThreadKey, @@ -15,7 +16,6 @@ import { type ThreadPullRequestLink, type VcsStatusResult, } from "@t3tools/contracts"; -import { Atom } from "effect/reactivity"; import { FolderGit2Icon, TerminalIcon } from "lucide-react"; import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { EnvironmentMachineIcon } from "./EnvironmentMachineIcon"; @@ -287,7 +287,7 @@ function PullRequestBadge({ className={cn("contents font-normal text-xs tabular-nums", presentation.toneClassName)} > - {presentation.text} + {presentation.text} ; onOpenPullRequest?: (event: MouseEvent, url: string) => void; }) { - const lines = pullRequestListLines( - resolveThreadPullRequestChains(visibleThreadPullRequests(pullRequests)), + const lines = pipe( + visibleThreadPullRequests(pullRequests), + resolveThreadPullRequestChains, + pullRequestListLines, ); if (lines.length === 0) return null; return ( @@ -441,7 +443,7 @@ export function ChangeRequestStatusIcon({ return ; } -export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { +function PrStatusTooltipContent({ status }: { status: PrStatusIndicator }) { return ( {status.tooltipLead} @@ -451,165 +453,15 @@ export function PrStatusTooltipContent({ status }: { status: PrStatusIndicator } ); } -export function resolveThreadPr(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; -}): ThreadPr | null { - const { threadBranch, gitStatus } = input; - if (gitStatus === null) { - return null; - } - - if (threadBranch === null || gitStatus.refName !== threadBranch) { - return null; - } - - return gitStatus.pr ?? null; -} - -/** Hold terminal PR metadata above rows because settlement moves remount them. */ -export interface ThreadChangeRequestSnapshot { - readonly branch: string; - readonly pr: NonNullable; - readonly sourceControlProvider: VcsStatusResult["sourceControlProvider"] | undefined; - readonly linkedPullRequest?: ThreadLinkedPullRequest; -} - -export const threadChangeRequestSnapshotsAtom = Atom.make< - ReadonlyMap ->(new Map()).pipe(Atom.keepAlive, Atom.withLabel("sidebar:thread-change-request-snapshots")); - -function isTerminalChangeRequestState( - state: NonNullable["state"], -): state is "merged" | "closed" { - return state === "merged" || state === "closed"; -} - -function sourceControlProvidersEqual( - left: VcsStatusResult["sourceControlProvider"] | undefined, - right: VcsStatusResult["sourceControlProvider"] | undefined, -): boolean { - if (left === right) return true; - if (left == null || right == null) return left == null && right == null; - return left.kind === right.kind && left.name === right.name && left.baseUrl === right.baseUrl; -} - -function linkedPullRequestsEqual( - left: ThreadLinkedPullRequest | null | undefined, - right: ThreadLinkedPullRequest | null | undefined, -): boolean { - if (left == null || right == null) return left == null && right == null; - return ( - left.projectId === right.projectId && - left.repository === right.repository && - left.number === right.number && - left.url === right.url - ); -} - -export function threadChangeRequestSnapshotsEqual( - left: ThreadChangeRequestSnapshot, - right: ThreadChangeRequestSnapshot, -): boolean { - return ( - left.branch === right.branch && - left.pr.number === right.pr.number && - left.pr.title === right.pr.title && - left.pr.url === right.pr.url && - left.pr.baseRef === right.pr.baseRef && - left.pr.headRef === right.pr.headRef && - left.pr.state === right.pr.state && - left.pr.isDraft === right.pr.isDraft && - (left.pr.updatedAt ?? null) === (right.pr.updatedAt ?? null) && - sourceControlProvidersEqual(left.sourceControlProvider, right.sourceControlProvider) && - linkedPullRequestsEqual(left.linkedPullRequest, right.linkedPullRequest) - ); -} - -export function nextThreadChangeRequestSnapshot(input: { - threadBranch: string | null; - gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; - linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; - linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; -}): ThreadChangeRequestSnapshot | null | undefined { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; - if (linkedPullRequest != null) { - if (linkedPullRequestStatus === null || linkedPullRequestStatus === undefined) { - return linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? undefined - : null; - } - return { - branch: threadBranch ?? linkedPullRequestStatus.pr.headRef, - pr: linkedPullRequestStatus.pr, - sourceControlProvider: linkedPullRequestStatus.sourceControlProvider, - linkedPullRequest, - }; - } - if (gitStatus === null) { - return snapshot?.linkedPullRequest === undefined ? undefined : null; - } - if (threadBranch === null) { - return null; - } - if (gitStatus.refName !== threadBranch) { - return retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ? undefined - : null; - } - if (gitStatus.pr == null) { - if ( - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return undefined; - } - return null; - } - return { - branch: threadBranch, - pr: gitStatus.pr, - sourceControlProvider: gitStatus.sourceControlProvider, - }; -} - export function resolveDisplayedThreadPr(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): ThreadPr | null { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; + const { threadBranch, gitStatus, linkedPullRequest, linkedPullRequestStatus } = input; if (linkedPullRequest != null) { - return ( - linkedPullRequestStatus?.pr ?? - (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? (snapshot?.pr ?? null) - : null) - ); + return linkedPullRequestStatus?.pr ?? null; } if ( threadBranch !== null && @@ -620,51 +472,18 @@ export function resolveDisplayedThreadPr(input: { return gitStatus.pr; } - if ( - gitStatus === null && - threadBranch !== null && - snapshot?.branch === threadBranch && - snapshot.linkedPullRequest === undefined - ) { - return snapshot.pr; - } - - if ( - threadBranch !== null && - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return snapshot.pr; - } - return null; } export function resolveDisplayedThreadPrProvider(input: { threadBranch: string | null; gitStatus: VcsStatusResult | null; - snapshot: ThreadChangeRequestSnapshot | null | undefined; - retainTerminalOnBranchMismatch: boolean; linkedPullRequest?: ThreadLinkedPullRequest | null | undefined; linkedPullRequestStatus?: LinkedThreadPullRequestStatus | null | undefined; }): VcsStatusResult["sourceControlProvider"] | undefined { - const { - threadBranch, - gitStatus, - snapshot, - retainTerminalOnBranchMismatch, - linkedPullRequest, - linkedPullRequestStatus, - } = input; + const { threadBranch, gitStatus, linkedPullRequest, linkedPullRequestStatus } = input; if (linkedPullRequest != null) { - return ( - linkedPullRequestStatus?.sourceControlProvider ?? - (linkedPullRequestsEqual(snapshot?.linkedPullRequest, linkedPullRequest) - ? snapshot?.sourceControlProvider - : undefined) - ); + return linkedPullRequestStatus?.sourceControlProvider; } if ( threadBranch !== null && @@ -675,25 +494,6 @@ export function resolveDisplayedThreadPrProvider(input: { return gitStatus.sourceControlProvider; } - if ( - gitStatus === null && - threadBranch !== null && - snapshot?.branch === threadBranch && - snapshot.linkedPullRequest === undefined - ) { - return snapshot.sourceControlProvider; - } - - if ( - threadBranch !== null && - retainTerminalOnBranchMismatch && - snapshot != null && - snapshot.linkedPullRequest === undefined && - isTerminalChangeRequestState(snapshot.pr.state) - ) { - return snapshot.sourceControlProvider; - } - return undefined; } @@ -803,7 +603,7 @@ export function ThreadWorktreeIndicator({ ); } -export function ThreadStatusLabel({ +function ThreadStatusLabel({ status, compact = false, }: { @@ -856,13 +656,7 @@ export function ThreadStatusLabel({ ); } -export function ThreadRowLeadingStatus({ - thread, - snapshot, -}: { - thread: SidebarThreadSummary; - snapshot?: ThreadChangeRequestSnapshot | undefined; -}) { +export function ThreadRowLeadingStatus({ thread }: { thread: SidebarThreadSummary }) { const { leaseLiveStatus, rowRef } = useSidebarRowSubscriptionLease(false); // A new ref callback would detach and reattach the row, and each reattach sets lease state. const statusRef = useStableCallback((node: HTMLSpanElement | null) => @@ -899,8 +693,6 @@ export function ThreadRowLeadingStatus({ const displayedPrInput = { threadBranch: thread.branch, gitStatus: visibleGitStatus, - snapshot, - retainTerminalOnBranchMismatch: thread.worktreePath === null, linkedPullRequest: thread.linkedPullRequest, linkedPullRequestStatus: linkedPullRequest, }; diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index 301255c7d516a..295c553827f7c 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -1,3 +1,4 @@ +import { workspaceAreaProps } from "../lib/workspaceFocus"; import { useAtomMount, useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, @@ -616,6 +617,7 @@ function TerminalSessionViewport({ openTerminalLinkInPreview({ url, threadRef, openPreview, fallbackToBrowser, forceBrowser }), ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- sets the terminal input's readOnly from the operate grant and consumes a pending focus request useLayoutEffect(() => { if (terminalRef.current) terminalRef.current.input.readOnly = !canOperateTerminal; if (canOperateTerminal && visibleRef.current) @@ -624,12 +626,14 @@ function TerminalSessionViewport({ // Links re-read the grant on activation; only the hovered link's styling needs a nudge. const linkActivationScopeRef = useRef(canOpenHostEditor); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- refreshes link activation styling on the imperative terminal surface when the editor grant changes useEffect(() => { if (linkActivationScopeRef.current === canOpenHostEditor) return; linkActivationScopeRef.current = canOpenHostEditor; terminalRef.current?.refreshLinkActivation(); }, [canOpenHostEditor]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- pushes visibility into the terminal surface before paint and consumes focus requests made while hidden useLayoutEffect(() => { visibleRef.current = visible; terminalRef.current?.setVisible(visible); @@ -637,6 +641,7 @@ function TerminalSessionViewport({ if (visible) consumePendingTerminalFocus(threadRef, terminalId); }, [terminalId, threadRef, visible]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- applies font family and size changes to the live Ghostty terminal surface useEffect(() => { const current = terminalFontRef.current; if (current.family === terminalFontFamily && current.size === terminalFontSize) return; @@ -644,6 +649,7 @@ function TerminalSessionViewport({ void terminalRef.current?.setFont(terminalFontOptions(terminalFontFamily, terminalFontSize)); }, [terminalFontFamily, terminalFontSize]); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- creates the Ghostty terminal in the mount node and subscribes it to the terminal session useEffect(() => { const mount = containerRef.current; if (!mount) return; @@ -681,6 +687,7 @@ function TerminalSessionViewport({ terminal.setTheme(terminalThemeFromApp(mount)); setupTerminal = terminal; terminalRef.current = terminal; + terminal.input.setAttribute("data-workspace-focus-default", ""); terminal.input.readOnly = !hasTerminalWriteAccess(); // Reapply font settings hydrated during loading, when terminalRef was null. const currentFont = terminalFontRef.current; @@ -688,6 +695,7 @@ function TerminalSessionViewport({ void terminal.setFont(terminalFontOptions(currentFont.family, currentFont.size)); } const latestSession = readTerminalSession(); + terminal.setSharedGrid(latestSession.grid); const initialOutput = readTerminalOutputUpdate( latestSession.output, INITIAL_TERMINAL_OUTPUT_CURSOR, @@ -713,6 +721,7 @@ function TerminalSessionViewport({ } const previous = previousSession; previousSession = session; + terminal.setSharedGrid(session.grid); synchronizeTerminalStatus(terminal, session.status, session.version); outputCursorRef.current = synchronizeTerminalSession( terminal, @@ -1433,6 +1442,7 @@ export default function ThreadTerminalDrawer({ terminalLaunchLocationsById, }: ThreadTerminalDrawerProps) { const canOperateTerminal = useEnvironmentScope(threadRef.environmentId, AuthTerminalOperateScope); + const terminalArea = workspaceAreaProps("terminal", 5, "Terminal"); const isPanel = mode === "panel"; const [advancedTypography] = useLocalStorage( TYPOGRAPHY_ADVANCED_STORAGE_KEY, @@ -1690,6 +1700,7 @@ export default function ThreadTerminalDrawer({ if (heightBudgetChanged) setDrawerLayout(layout); }, ); + // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- measures the drawer before visible paint and observes window and chat column resizes while visible useLayoutEffect(() => { if (!visible) { return; @@ -1716,6 +1727,7 @@ export default function ThreadTerminalDrawer({ return (