From 0508792c572abd39b1afd6f68cd420f2ca00a484 Mon Sep 17 00:00:00 2001 From: maria Date: Wed, 19 Aug 2026 17:46:57 -0400 Subject: [PATCH 001/141] feat(web): confirm before closing a terminal (#7592) --- apps/web/src/components/ChatView.tsx | 75 +++++++++++++++--- .../src/components/ThreadTerminalDrawer.tsx | 16 +++- apps/web/src/lib/terminalCloseConfirm.test.ts | 78 +++++++++++++++++++ apps/web/src/lib/terminalCloseConfirm.ts | 42 ++++++++++ 4 files changed, 198 insertions(+), 13 deletions(-) create mode 100644 apps/web/src/lib/terminalCloseConfirm.test.ts create mode 100644 apps/web/src/lib/terminalCloseConfirm.ts diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 8044db1338a0..c7df43240568 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -46,7 +46,11 @@ import { import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; import { truncate } from "@t3tools/shared/String"; -import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels"; +import { + getTerminalLabel, + nextTerminalId, + resolveTerminalSessionLabel, +} from "@t3tools/shared/terminalLabels"; import { Debouncer } from "@tanstack/react-pacer"; import { useAtomValue } from "@effect/atom-react"; import { @@ -192,8 +196,12 @@ import { import { useNowMinute } from "../hooks/useNowMinute"; import { useNewThreadHandler } from "../hooks/useHandleNewThread"; import { resolveAppModelSelectionForInstance } from "../modelSelection"; +import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; import { getTerminalFocusOwner } from "../lib/terminalFocus"; -import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; +import { + preventRepeatedTerminalCloseShortcut, + preventTerminalCloseShortcut, +} from "../lib/terminalCloseShortcut"; import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; import { derivePhysicalProjectKey, @@ -3453,6 +3461,24 @@ function ChatViewContent(props: ChatViewProps) { }, [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal], ); + const requestCloseTerminal = useCallback( + (terminalId: string) => { + const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); + void confirmTerminalClose([label]).then((confirmed) => { + if (confirmed) closeTerminal(terminalId); + }); + }, + [activeTerminalLabelsById, closeTerminal], + ); + const requestClosePanelTerminal = useCallback( + (terminalId: string) => { + const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId); + void confirmTerminalClose([label]).then((confirmed) => { + if (confirmed) closePanelTerminal(terminalId); + }); + }, + [activeTerminalLabelsById, closePanelTerminal], + ); const activateRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; @@ -3527,11 +3553,33 @@ function ChatViewContent(props: ChatViewProps) { const closeRightPanelSurface = useCallback( (surface: RightPanelSurface) => { if (!activeThreadRef) return; - cleanupRightPanelSurfaces([surface]); - useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); - syncActivePreviewSurface(); + const finishClose = () => { + cleanupRightPanelSurfaces([surface]); + useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id); + syncActivePreviewSurface(); + }; + if (surface.kind !== "terminal") { + finishClose(); + return; + } + const activeLabel = + activeTerminalLabelsById.get(surface.activeTerminalId) ?? + getTerminalLabel(surface.activeTerminalId); + const otherLabels = surface.terminalIds + .filter((terminalId) => terminalId !== surface.activeTerminalId) + .map( + (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId), + ); + void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => { + if (confirmed) finishClose(); + }); }, - [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface], + [ + activeThreadRef, + activeTerminalLabelsById, + cleanupRightPanelSurfaces, + syncActivePreviewSurface, + ], ); const closeOtherRightPanelSurfaces = useCallback( (surface: RightPanelSurface) => { @@ -4724,6 +4772,13 @@ function ChatViewContent(props: ChatViewProps) { event.stopPropagation(); return; } + // While a close confirmation is open, terminal focus has moved to the + // dialog, so a deliberate second close shortcut would otherwise fall + // through to the native window/tab close accelerator. + if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) { + event.stopPropagation(); + return; + } if (!activeThreadId || isCommandPaletteOpen()) { return; } @@ -4807,11 +4862,11 @@ function ChatViewContent(props: ChatViewProps) { event.preventDefault(); event.stopPropagation(); if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") { - closePanelTerminal(activeRightPanelSurface.activeTerminalId); + requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId); return; } if (!terminalUiState.terminalOpen) return; - closeTerminal(terminalUiState.activeTerminalId); + requestCloseTerminal(terminalUiState.activeTerminalId); return; } @@ -4860,8 +4915,8 @@ function ChatViewContent(props: ChatViewProps) { terminalUiState.terminalOpen, terminalUiState.activeTerminalId, activeThreadId, - closeTerminal, - closePanelTerminal, + requestCloseTerminal, + requestClosePanelTerminal, createNewTerminal, setTerminalOpen, runProjectScript, diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index cf2adaca2cf4..d9fb8973b0da 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -59,6 +59,7 @@ import { type ThreadTerminalGroup, } from "../types"; import { readLocalApi } from "~/localApi"; +import { confirmTerminalClose } from "~/lib/terminalCloseConfirm"; import { useClientSettings } from "../hooks/useSettings"; import { useLocalStorage } from "../hooks/useLocalStorage"; import { useAttachedTerminalSession } from "../state/terminalSessions"; @@ -1273,6 +1274,15 @@ export default function ThreadTerminalDrawer({ const onNewTerminalAction = useCallback(() => { onNewTerminal(); }, [onNewTerminal]); + const confirmCloseTerminal = useCallback( + (terminalId: string) => { + const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId); + void confirmTerminalClose([label]).then((confirmed) => { + if (confirmed) onCloseTerminal(terminalId); + }); + }, + [onCloseTerminal, terminalLabelById], + ); useEffect(() => { onHeightChangeRef.current = onHeightChange; @@ -1463,7 +1473,7 @@ export default function ThreadTerminalDrawer({
onCloseTerminal(resolvedActiveTerminalId)} + onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)} label={closeTerminalActionLabel} > @@ -1598,7 +1608,7 @@ export default function ThreadTerminalDrawer({ onCloseTerminal(resolvedActiveTerminalId)} + onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)} label={closeTerminalActionLabel} > @@ -1668,7 +1678,7 @@ export default function ThreadTerminalDrawer({
diff --git a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx index 4e1118611d85..731a3acecef4 100644 --- a/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx +++ b/apps/web/src/components/pullRequest/PullRequestDetailPanel.tsx @@ -25,6 +25,7 @@ import { GitPullRequestDraftIcon, GitPullRequestIcon, HammerIcon, + LayersIcon, MessageCircleQuestionIcon, MessageSquareIcon, LinkIcon, @@ -50,6 +51,7 @@ import { import { type DraftId, useComposerDraftStore } from "~/composerDraftStore"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; import { useCopyToClipboard, writeTextToClipboard } from "~/hooks/useCopyToClipboard"; +import { changeRequestRepositoryUrl } from "~/lib/openPullRequestLink"; import { usePreparePullRequestThreadAction } from "~/lib/sourceControlActions"; import { cn } from "~/lib/utils"; import { readLocalApi } from "~/localApi"; @@ -60,6 +62,7 @@ import { useEnvironmentQuery } from "~/state/query"; import { useLiveRefresh } from "~/hooks/useLiveRefresh"; import { pullRequestEnvironment } from "~/state/pullRequests"; import { useAtomCommand } from "~/state/use-atom-command"; +import { vcsEnvironment } from "~/state/vcs"; import { formatRelativeTimeLabel } from "~/timestampFormat"; import { @@ -74,6 +77,7 @@ import { import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; +import { Toggle, ToggleGroup } from "../ui/toggle-group"; import { Menu, MenuItem, @@ -104,6 +108,7 @@ import { handoffPrompt, handoffReviewComments, latestPullRequestReviewOutcomes, + isStackedPullRequestBase, pullRequestActionMenuHasGroup, pullRequestActionNeedsHostRefresh, pullRequestComposerTarget, @@ -121,6 +126,7 @@ import { } from "./pullRequestProjectAssignment.logic"; import { PullRequestChecksPopover } from "./PullRequestChecksPopover"; import { + PullRequestActorAvatar, PullRequestActorLabel, PullRequestDiffStat, PullRequestMetaLine, @@ -362,7 +368,6 @@ export function PullRequestDetailPanel({ onClose, onStateChange, context = "page", - chromeVariant = "full", composerDraftTarget, }: { environmentId: EnvironmentId; @@ -394,12 +399,6 @@ export function PullRequestDetailPanel({ * again is at best a no-op and at worst git refusing a branch two checkouts. */ context?: "page" | "thread"; - /** - * How the metadata above the content behaves: `full` keeps every row pinned; `collapse` - * folds the whole of it into the top row once the active tab scrolls, and unfolds at the - * top — the chrome spends its height on what is being read. - */ - chromeVariant?: "full" | "collapse"; /** * The open thread's composer. Beside the thread whose own pull request this is, hand-offs * land here instead of opening a new thread — the branch is already under the reader's feet. @@ -436,27 +435,16 @@ export function PullRequestDetailPanel({ ); }, [tab]); const [chromeCondensed, setChromeCondensed] = useState(false); - // Each tab remembers whether its chrome was condensed. Only the active tab can emit scroll - // events, so the capture handler always writes the active tab's entry — and a tab switch - // reads the destination's memory instead of inheriting the tab being left. A tab too short - // to scroll remembers "expanded", which is what keeps it from being stranded under a chrome - // it has no scrollbar to reopen. + // Each mounted tab remembers its own scroll chrome; short tabs cannot scroll to reopen it. const chromeStateByTab = useRef>>({}); useEffect(() => { setChromeCondensed(chromeStateByTab.current[tab] ?? false); }, [tab]); - const condensed = chromeVariant === "collapse" && chromeCondensed; - // Collapsing removes the fold's height from the chrome, which would otherwise hand that - // height to the scrollport and leap the content up by it mid-scroll. The cure is exact - // compensation: collapse only once the reader has scrolled at least the fold's height, - // then give that height back to `scrollTop` before the next paint — the content under - // their eyes does not move, and the collapse itself is the only thing that changes. + const condensed = chromeCondensed; const scrollerRef = useRef(null); const foldRef = useRef(null); - // The condensed chrome's second row opens as the fold closes, so the height the scrollport - // gains is the fold's minus this row's. Measured the same way the fold is: `scrollHeight` - // through a zero track reads its natural height in either state. const condensedRowRef = useRef(null); + // Refund after the fold commits so the content under the reader does not jump with its height. const compensationRef = useRef(null); useLayoutEffect(() => { if (compensationRef.current === null) return; @@ -478,7 +466,6 @@ export function PullRequestDetailPanel({ target: "branch name", timeout: 1600, }); - // The chunk is fetched as soon as the panel exists rather than waiting for the Code tab to be // clicked, so a reader who does click it lands on a chunk already in the module cache. useEffect(() => { @@ -517,6 +504,23 @@ export function PullRequestDetailPanel({ }, [activity, coreDetail], ); + const repositoryUrl = detail === null ? null : changeRequestRepositoryUrl(detail.url); + const branchRefsQuery = useEnvironmentQuery( + detail === null + ? null + : vcsEnvironment.listRefs({ + environmentId, + input: { + cwd: detail.workspaceRoot, + includeMatchingRemoteRefs: true, + // listRefs keeps the current ref first and a known default second. + limit: 2, + }, + }), + ); + const isStackedPullRequest = + detail !== null && + isStackedPullRequestBase(detail.baseBranch, branchRefsQuery.data?.refs ?? []); const activityPending = activityQuery.isPending && activity === null; const activityError = activity === null ? activityQuery.error : null; const refreshDetail = useCallback(() => { @@ -1046,17 +1050,17 @@ export function PullRequestDetailPanel({ const can = (action: PullRequestAction) => detail?.capabilities.actions.includes(action) === true && detail.viewerPermissions.actions.includes(action); - // One live action holds the slot. A conflicting change cannot be merged now, so the slot goes - // to the thing that would help instead of a Merge button that only ever says no. + // One live action holds the slot. Conflicts take priority because every other completion action + // depends on resolving them first, even for a reader who cannot merge on the host themselves. const primaryAction = detail === null || detail.state !== "open" ? null - : detail.isDraft && can("ready") - ? "ready" - : !can("merge") - ? null - : conflicting - ? "resolve" + : conflicting + ? "resolve" + : detail.isDraft && can("ready") + ? "ready" + : !can("merge") + ? null : allowedMergeMethods.length > 0 ? "merge" : null; @@ -1081,7 +1085,7 @@ export function PullRequestDetailPanel({ !conflicting && allowedMergeMethods.length > 1; // The pull request number carries this state in the overview and the right-panel tab mirrors - // it. Conflicts keep their own row below: an open pull request remains green there. + // it. The conflict action is separate from this state: an open pull request remains green. const statePresentation = detail ? resolvePullRequestState({ state: detail.state, isDraft: detail.isDraft }) : null; @@ -1101,34 +1105,47 @@ export function PullRequestDetailPanel({ ).length : 0; + if (detailQuery.isPending && !detail) { + return ; + } + return (
- {/* The top row's geometry never changes: both of its states occupy the same stacked - cell and crossfade, so the actions on the right have one home whatever the chrome - is doing below. The fold and this fade share one 200ms clock. */}
- {/* The fixed height lives on the two top-row cells — not the grid, whose later rows - are the fold — so the actions have one immovable home in both states. */} -
+
{detail && statePresentation ? ( <> {detail.repository}} + render={ + repositoryUrl ? ( + + ) : ( + + {detail.repository} + + ) + } /> - {detail.repository} + + {repositoryUrl ? `Open ${detail.repository} repository` : detail.repository} + {detail && statePresentation ? ( @@ -1194,29 +1211,103 @@ export function PullRequestDetailPanel({ /> {detail.title} - {conflicting ? ( - - - Conflicts - - ) : checksSummary ? ( - - {detail && checksState !== null ? ( - - ) : null} - {checksSummary} - - ) : null} ) : null}
-
+
{detail ? ( <> + {/* Checking a pull request out is the reason to open one here at all, so it is a + button of its own rather than a side effect of asking an agent for something. + It asks where, because the two answers are not interchangeable: one leaves your + work where it is, the other moves the repository you are standing in. Only on + the page: beside a thread the branch is already checked out right there. */} + {context === "page" ? ( + + + + {handoff?.startsWith("checkout") ? "Checking out..." : "Check out"} + + + } + /> + + startCheckout("worktree")}> + + + In a separate worktree + + Its own folder and thread. Nothing you have open moves. + + + + startCheckout("local")}> + + + In this repository + + Switches the branch you are working in, like `gh pr checkout`. + + + + {pickableEnvironments.length > 0 ? ( + setActingScope({ pullRequestKey, environmentId: next })} + disabled={handoff !== null} + /> + ) : null} + + + ) : null} + {/* Said where the Merge button is, because it is the answer to why nobody has + pressed it: the merge is already asked for, and the host is holding it. */} + {autoMergeArmed ? ( + + + + Auto-merge + + } + /> + + The host will merge this on its own once its requirements are met + + + ) : null} + {primaryAction === "resolve" ? ( + + ) : primaryAction === "ready" ? ( + + ) : primaryAction === "merge" ? ( + + ) : null} Copy link - {/* Only where the button row could not take it, so it is never offered twice. */} - {conflicting && primaryAction !== "resolve" ? ( - - - {handoff === "conflicts" ? "Preparing..." : handoffLabels.resolveConflicts} - - ) : null} {detail.state === "open" && can("close") ? ( <> @@ -1391,92 +1475,6 @@ export function PullRequestDetailPanel({ ) : null} - {/* Checking a pull request out is the reason to open one here at all, so it is a - button of its own rather than a side effect of asking an agent for something. - It asks where, because the two answers are not interchangeable: one leaves your - work where it is, the other moves the repository you are standing in. Only on - the page: beside a thread the branch is already checked out right there. */} - {context === "page" ? ( - - - {handoff?.startsWith("checkout") ? ( - "Checking out..." - ) : ( - <> - - Check out - - - )} - - } - /> - - startCheckout("worktree")}> - - - In a separate worktree - - Its own folder and thread. Nothing you have open moves. - - - - startCheckout("local")}> - - - In this repository - - Switches the branch you are working in, like `gh pr checkout`. - - - - {pickableEnvironments.length > 0 ? ( - setActingScope({ pullRequestKey, environmentId: next })} - disabled={handoff !== null} - /> - ) : null} - - - ) : null} - {/* Said where the Merge button is, because it is the answer to why nobody has - pressed it: the merge is already asked for, and the host is holding it. */} - {autoMergeArmed ? ( - - - - Auto-merge - - } - /> - - The host will merge this on its own once its requirements are met - - - ) : null} - {primaryAction === "ready" ? ( - - ) : primaryAction === "merge" ? ( - - ) : null} ) : null} {onClose ? ( @@ -1491,14 +1489,9 @@ export function PullRequestDetailPanel({ ) : null}
- {/* The condensed chrome's second row: the tabs that the closing fold takes with it, - and compact copies of the branch pair and diff stat so they stay in sight while - the full rows are folded away. Same zero-track mechanism as the fold, inverted. */}
{detail ? ( -
- - - - }> - {detail.baseBranch} - - {`${detail.baseBranch} ← ${detail.headBranch}`} - - {freshness ? ( - void perform("update-branch", undefined, method)} - iconClassName="size-3" + + {detail.changedFiles.toLocaleString()} + + - ) : null} - - - }> - {detail.headBranch} - - {`${detail.baseBranch} ← ${detail.headBranch}`} - - - - - - {detail.changedFiles.toLocaleString()} - - +
) : null}
- {/* Folding is a grid track going to zero: the rows below stay mounted, the track - animates closed over them, and `inert` takes the hidden controls out of the tab - order for as long as the chrome is condensed. */}
{detail ? ( -
+
{titleDraft === null ? (

@@ -1671,60 +1680,75 @@ export function PullRequestDetailPanel({
- - - {detail.baseBranch} - - } - /> - {detail.baseBranch} - - {freshness ? ( - void perform("update-branch", undefined, method)} + + + + {isStackedPullRequest ? ( + + ) : null} + {detail.baseBranch} + + } + /> + + {isStackedPullRequest + ? `Stacked on ${detail.baseBranch}` + : detail.baseBranch} + + + {freshness ? ( + void perform("update-branch", undefined, method)} + /> + ) : null} + - ) : null} - - - copyBranchToClipboard(detail.headBranch)} - /> - } - > - - {detail.headBranch} - - - - - {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`} - - + + {detail.headBranch} + + + + + {`${isBranchCopied ? "Copied" : "Copy pull request branch"}: ${detail.headBranch}`} + + + @@ -1740,165 +1764,130 @@ export function PullRequestDetailPanel({

) : null} +
+
- {detail && conflicting ? ( -
- - - Merge conflicts - - -
- ) : null} - - {detail ? ( - + + ) : null} + + +
) : null} -
-
+ + ) : null}
{ - if (chromeVariant !== "collapse") return; const scroller = event.target as HTMLElement; scrollerRef.current = scroller; const top = scroller.scrollTop; setChromeCondensed((previous) => { let next = previous; - // `scrollHeight` reads the fold's natural height whichever state the track is in. const foldHeight = foldRef.current?.scrollHeight ?? 0; - // The chrome trades the fold for the condensed second row, so the height the - // scrollport actually gains is the difference between the two. + // The condensed row remains mounted, so refund only the height that actually leaves. const chromeDelta = foldHeight - (condensedRowRef.current?.scrollHeight ?? 0); if (previous) { // The hard top reopens the chrome with no refund: the reader asked for the top, @@ -1916,17 +1905,7 @@ export function PullRequestDetailPanel({ }); }} > - {detailQuery.isPending && !detail ? ( - // The ghost wears the shape of the tab being waited on, so switching tabs mid-load - // does not flash a summary outline under a timeline heading. - tab === "timeline" ? ( - - ) : tab === "code" ? ( - - ) : ( - - ) - ) : detailQuery.error && !detail ? ( + {detailQuery.error && !detail ? ( ) : detail ? ( <> diff --git a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx index 09b79cf340e6..38a3ab70d642 100644 --- a/apps/web/src/components/pullRequest/PullRequestGhosts.tsx +++ b/apps/web/src/components/pullRequest/PullRequestGhosts.tsx @@ -45,13 +45,11 @@ export function PullRequestListGhost({
- +
- +
))} @@ -59,32 +57,101 @@ export function PullRequestListGhost({ ); } -/** The summary's own shape: a title, a byline, the facts rows, the description. */ +/** + * The detail panel's current expanded shape. Keeping the chrome, summary facts, and description + * boundaries in the ghost prevents the loaded pull request from replacing one layout with + * another a moment later. + */ export function PullRequestDetailGhost() { return (
-
- - +
+
+
+ + +
+
+ + +
+
+ +
+ +
+ + +
+
+ + + +
+ + +
+
+
+ +
+
+ + + +
+ +
-
- {Array.from({ length: 4 }, (_, index) => ( -
+ +
+
+
+
+ + +
+
+ + + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+ +
+
+ +
+
+ - -
- ))} -
-
- - - - +
+ + + + +
+
); @@ -113,7 +180,7 @@ export function PullRequestTimelineGhost({ rows = 6 }: { rows?: number }) {
- +
))}
@@ -134,8 +201,8 @@ export function PullRequestConversationGhost({ rows = 3 }: { rows?: number }) {
- - + +
))} diff --git a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx index d0654324d1f1..67d2d77e4c94 100644 --- a/apps/web/src/components/pullRequest/PullRequestListFilters.tsx +++ b/apps/web/src/components/pullRequest/PullRequestListFilters.tsx @@ -25,6 +25,7 @@ import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; import { ProjectFavicon } from "../ProjectFavicon"; import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { Button } from "../ui/button"; import { Menu, @@ -273,12 +274,14 @@ export function PullRequestFiltersMenu({ return ( + } > {filtered ? ( diff --git a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx index bb79cfe52d8d..3594e71b26ea 100644 --- a/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx +++ b/apps/web/src/components/pullRequest/PullRequestSummaryTab.tsx @@ -220,12 +220,12 @@ function MetaRow({ children: ReactNode; }) { return ( -
- +
+ {icon} {label} - {children} + {children}
); } diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts index c2108835a615..9b9ef610752b 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.test.ts @@ -17,6 +17,7 @@ import { handoffPrompt, handoffReviewComments, isPullRequestVerdictStale, + isStackedPullRequestBase, isThreadOwnPullRequest, latestPullRequestReviewOutcomes, newestPullRequestCommitAt, @@ -142,8 +143,6 @@ describe("pull request handoff labels", () => { fixFinding: "Fix in this thread", fixCheck: "Fix in this thread", fixFindings: "Fix findings in this thread", - resolve: "Resolve in this thread", - resolveConflicts: "Resolve conflicts in this thread", }); }); @@ -152,8 +151,6 @@ describe("pull request handoff labels", () => { fixFinding: "Fix in a thread", fixCheck: "Fix", fixFindings: "Fix findings in a thread", - resolve: "Resolve in a new thread", - resolveConflicts: "Resolve conflicts in a thread", }); }); }); @@ -167,6 +164,47 @@ describe("pull request composer target", () => { }); }); +describe("stacked pull request classification", () => { + it("requires a known default branch", () => { + expect(isStackedPullRequestBase("main", [{ name: "main", isDefault: false }])).toBe(false); + }); + + it("recognizes local and remote forms of the default branch", () => { + expect( + isStackedPullRequestBase("main", [{ name: "main", isDefault: true, isRemote: false }]), + ).toBe(false); + expect( + isStackedPullRequestBase("main", [ + { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" }, + ]), + ).toBe(false); + }); + + it("classifies a non-default base as stacked once the default is known", () => { + expect( + isStackedPullRequestBase("feature-base", [ + { name: "origin/main", isDefault: true, isRemote: true, remoteName: "origin" }, + ]), + ).toBe(true); + }); + + it("does not mistake a nested branch suffix for the default branch", () => { + expect( + isStackedPullRequestBase("main", [ + { + name: "origin/feature/main", + isDefault: true, + isRemote: true, + remoteName: "origin", + }, + ]), + ).toBe(true); + expect( + isStackedPullRequestBase("1.0", [{ name: "release/1.0", isDefault: true, isRemote: false }]), + ).toBe(true); + }); +}); + describe("ordering comments", () => { it("reverses the chronological list for newest first, and leaves oldest first alone", () => { const comments = [{ createdAt: "a" }, { createdAt: "b" }, { createdAt: "c" }]; diff --git a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts index 6b83681e17c8..a616a8395239 100644 --- a/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts +++ b/apps/web/src/components/pullRequest/pullRequestDetail.logic.ts @@ -11,6 +11,7 @@ import type { PullRequestReviewThread, PullRequestState, PullRequestUpdateMethod, + VcsRef, } from "@t3tools/contracts"; import { inferReviewCommentFenceLanguage, type ReviewCommentContext } from "~/reviewCommentContext"; @@ -75,15 +76,11 @@ export function pullRequestHandoffLabels(inThisThread: boolean) { fixFinding: "Fix in this thread", fixCheck: "Fix in this thread", fixFindings: "Fix findings in this thread", - resolve: "Resolve in this thread", - resolveConflicts: "Resolve conflicts in this thread", } : { fixFinding: "Fix in a thread", fixCheck: "Fix", fixFindings: "Fix findings in a thread", - resolve: "Resolve in a new thread", - resolveConflicts: "Resolve conflicts in a thread", }; } @@ -103,6 +100,20 @@ export function pullRequestActionMenuHasGroup( return showsDraftToggle || showsAutoMerge || showsMergeMethods; } +export function isStackedPullRequestBase( + baseBranch: string, + refs: ReadonlyArray>, +): boolean { + const defaultRef = refs.find((refName) => refName.isDefault); + if (!defaultRef) return false; + if (defaultRef.isRemote !== true) return defaultRef.name !== baseBranch; + const remotePrefix = `${defaultRef.remoteName ?? defaultRef.name.split("/")[0]}/`; + const defaultBranch = defaultRef.name.startsWith(remotePrefix) + ? defaultRef.name.slice(remotePrefix.length) + : defaultRef.name; + return defaultBranch !== baseBranch; +} + /** Plain-language state, shown beside the author. Conflicts are a merge signal, not a state. */ export function describePullRequestState(state: PullRequestState, isDraft: boolean): string { if (state === "merged") return "Merged"; diff --git a/apps/web/src/lib/openPullRequestLink.test.ts b/apps/web/src/lib/openPullRequestLink.test.ts index 9d26fa292124..edba97fa7d3d 100644 --- a/apps/web/src/lib/openPullRequestLink.test.ts +++ b/apps/web/src/lib/openPullRequestLink.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import { + changeRequestRepositoryUrl, findProjectForChangeRequest, openPullRequestLink, parseChangeRequestUrl, @@ -8,6 +9,24 @@ import { shouldOpenPullRequestExternally, } from "./openPullRequestLink"; +describe("changeRequestRepositoryUrl", () => { + it("preserves repository path casing", () => { + expect( + changeRequestRepositoryUrl( + "https://gitlab.example.test/Team/Platform/Repo/-/merge_requests/42/diffs#note_1", + ), + ).toBe("https://gitlab.example.test/Team/Platform/Repo"); + }); + + it("keeps pull-like segments inside nested GitLab repository paths", () => { + expect( + changeRequestRepositoryUrl( + "https://gitlab.example.test/group/pull/123/repo/-/merge_requests/42", + ), + ).toBe("https://gitlab.example.test/group/pull/123/repo"); + }); +}); + describe("openPullRequestLink", () => { it("opens the requested pull request URL", async () => { const openExternal = vi.fn(async () => undefined); diff --git a/apps/web/src/lib/openPullRequestLink.ts b/apps/web/src/lib/openPullRequestLink.ts index 0b7e6bf0f970..c8ec1b7a628c 100644 --- a/apps/web/src/lib/openPullRequestLink.ts +++ b/apps/web/src/lib/openPullRequestLink.ts @@ -118,6 +118,23 @@ export function parseChangeRequestUrl(targetUrl: string): ChangeRequestLink | nu return null; } +/** The repository root behind a recognised change-request URL, without PR-specific state. */ +export function changeRequestRepositoryUrl(targetUrl: string): string | null { + const changeRequest = parseChangeRequestUrl(targetUrl); + if (changeRequest === null) return null; + const url = new URL(targetUrl); + const repositoryPath = + /^(.*?)\/-\/merge_requests\/\d+(?:\/|$)/iu.exec(url.pathname)?.[1] ?? + /^(.*?)(?:\/pull\/\d+|\/-\/merge_requests\/\d+|\/pull-requests\/\d+|\/pullrequest\/\d+)(?:\/|$)/iu.exec( + url.pathname, + )?.[1]; + if (!repositoryPath) return null; + url.pathname = repositoryPath; + url.search = ""; + url.hash = ""; + return url.toString(); +} + function claim(host: string, match: RegExpExecArray | null): ChangeRequestLink | null { const repository = match?.[1]; const number = Number(match?.[2]); diff --git a/apps/web/src/routes/_chat.pull-requests.tsx b/apps/web/src/routes/_chat.pull-requests.tsx index 2ee7eb93fa38..91fc4f7789de 100644 --- a/apps/web/src/routes/_chat.pull-requests.tsx +++ b/apps/web/src/routes/_chat.pull-requests.tsx @@ -74,6 +74,9 @@ import { WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../components/WorkspaceBreadcrumb"; +import { WorkspacePageContainer } from "../components/WorkspacePageContainer"; +import { WorkspacePageHeader } from "../components/WorkspacePageHeader"; +import { isElectron } from "../env"; import { PanelLayoutControls } from "../components/chat/PanelLayoutControls"; import { Button } from "../components/ui/button"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "../components/ui/menu"; @@ -100,7 +103,6 @@ import { import { useAtomCommand } from "../state/use-atom-command"; import { cn } from "~/lib/utils"; import { getSourceControlPresentationForKind } from "~/sourceControlPresentation"; -import { COLLAPSED_SIDEBAR_TITLEBAR_INSET_CLASS } from "~/workspaceTitlebar"; export interface PullRequestsSearch { readonly involvement: PullRequestInvolvement; @@ -1181,6 +1183,7 @@ function PullRequestsRouteView() { : null, [search.number, search.repository, selectedProject], ); + const rightPanelAvailable = selectedPullRequestSurface !== null; useEffect(() => { if (!pullRequestsSupported || rightPanelRef === null || linkedSelection === null) return; useRightPanelStore.getState().openPullRequest(rightPanelRef, linkedSelection); @@ -1294,9 +1297,10 @@ function PullRequestsRouteView() { terminalAvailable={false} terminalOpen={false} terminalShortcutLabel={null} - rightPanelAvailable={rightPanelState.surfaces.length > 0} + rightPanelAvailable={rightPanelAvailable} rightPanelOpen={rightPanelState.isOpen} rightPanelShortcutLabel={null} + rightPanelUnavailableLabel="Select a pull request first" liveAgentCount={0} onToggleTerminal={() => undefined} onToggleRightPanel={toggleRightPanel} @@ -1603,7 +1607,6 @@ function PullRequestsRouteView() { reviewingQuery.refresh(); }} onStateChange={handlePullRequestTabStatusChange} - chromeVariant="collapse" /> ) : null} @@ -1612,10 +1615,7 @@ function PullRequestsRouteView() { ); } -/** - * A compact stand-in for one pill group: the trigger wears the current choice, the choices - * live in a menu. Same options, same handler — only the footprint changes. - */ +/** A compact stand-in for one pill group when the header is narrow. */ function CompactFilterMenu({ label, value, @@ -1627,7 +1627,8 @@ function CompactFilterMenu({ options: ReadonlyArray>; onChange: (value: Value) => void; }) { - const current = options.find((option) => option.value === value) ?? options[0]!; + const current = options.find((option) => option.value === value) ?? options[0]; + if (!current) return null; return ( ({ onChange(next as Value)}> {options.map((option) => { - // A host the server has already said it cannot read is not a choice here either. - // The pills disable it; a menu that offers it would answer the press by replacing - // a working list with the same failure the pill row exists to explain. const item = ( @@ -1656,11 +1654,12 @@ function CompactFilterMenu({ ); - if (!option.unavailable) return item; - return ( + return option.unavailable === undefined ? ( + item + ) : ( - + {option.unavailable} @@ -1838,18 +1837,10 @@ function PullRequestsColumn({ // Painted flat like the chat column: the inset underneath carries the chrome grain, and a // content surface that lets it show reads as a different background than every thread.
-
+ {/* A closed right panel leaves this column full-width, so the shared header + reserves native window controls. While the panel is open, the column ends + at the panel and the absolute controls strip owns the top-right corner. */} + {condensed ? ( {/* The page name remains the foreground anchor in both states; the live filters are @@ -1891,27 +1882,22 @@ function PullRequestsColumn({ )}
{condensed ? ( - { - topbarSearchFocusedRef.current = focused; - }} - /> +
+ { + topbarSearchFocusedRef.current = focused; + }} + /> + +
) : null} - {rightPanelControl} -
+
+
{searchInput} {filtersMenu} + {!condensed ? ( + + ) : null}
{/* Scrolled past this marker, the controls are gone and the title takes over. */}
{listBody} -
+
); } + +function PullRequestRefreshControl({ + compact = false, + refreshing, + onRefresh, +}: { + compact?: boolean; + refreshing: boolean; + onRefresh: () => void; +}) { + return ( + + ); +} From 62654d279fcdd910bb755e106dfd51bc8940c263 Mon Sep 17 00:00:00 2001 From: Luis Gustavo Couto Wacker Date: Wed, 19 Aug 2026 19:26:35 -0300 Subject: [PATCH 003/141] fix(web): usage hourly breakdown lists every hour chronologically (#7595) --- apps/web/src/components/usage/UsagePage.tsx | 26 ++++++++++++++++----- 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/apps/web/src/components/usage/UsagePage.tsx b/apps/web/src/components/usage/UsagePage.tsx index 3c99271c1b2b..cf98be1b0e78 100644 --- a/apps/web/src/components/usage/UsagePage.tsx +++ b/apps/web/src/components/usage/UsagePage.tsx @@ -68,12 +68,15 @@ export function UsagePage() { : enumerateHourStarts(window.sinceTime, window.untilTime), [window.sinceTime, window.untilTime], ); - // Newest first: the window can run 90 periods, so the interesting end - // belongs at the top of the table. - const breakdownPeriods = useMemo( - () => (isPast24Hours ? merged.hourly : merged.daily).toReversed(), - [isPast24Hours, merged.daily, merged.hourly], - ); + // The hourly window is small enough to render every period: the table then + // reads chronologically like the chart, instead of jumping between the hours + // that happened to have activity. Daily windows can run 90 periods, so those + // stay newest-first with the interesting end on top. + const breakdownPeriods = useMemo(() => { + if (!isPast24Hours) return merged.daily.toReversed(); + const byHour = new Map(merged.hourly.map((entry) => [entry.hourStart, entry])); + return hours.map((hourStart) => byHour.get(hourStart) ?? zeroHour(hourStart)); + }, [isPast24Hours, merged.daily, merged.hourly, hours]); const selectWindow = (days: number) => { setWindowSelection({ @@ -435,6 +438,17 @@ export function UsagePage() { ); } +/** A zero-filled hourly period so the breakdown lists every hour in the window. */ +function zeroHour(hourStart: string): HourlyTotals { + return { + day: "", + hourStart, + costUsd: 0, + totalTokens: 0, + byProvider: new Map(), + }; +} + /** Brand mark for the harness a row belongs to. */ function ProviderMark({ provider, From 105cd5e0c57ab8b6df3bd8af5b534a5bc682fdce Mon Sep 17 00:00:00 2001 From: Jake Leventhal Date: Wed, 19 Aug 2026 18:26:45 -0400 Subject: [PATCH 004/141] fix(web): remove the terminal pane's app-canvas gutter (#6222) Co-authored-by: Claude Opus 5 (1M context) --- apps/web/src/components/ThreadTerminalDrawer.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/ThreadTerminalDrawer.tsx b/apps/web/src/components/ThreadTerminalDrawer.tsx index d9fb8973b0da..ec2e63d4146d 100644 --- a/apps/web/src/components/ThreadTerminalDrawer.tsx +++ b/apps/web/src/components/ThreadTerminalDrawer.tsx @@ -978,7 +978,7 @@ export function TerminalViewport({ return (
); } @@ -1483,7 +1483,12 @@ export default function ThreadTerminalDrawer({ )}
-
+
{isSplitView ? (
-
+
) : ( -
+
Date: Wed, 19 Aug 2026 20:20:34 -0400 Subject: [PATCH 005/141] feat(web): attach composer state drawers (#7150) --- apps/mobile/src/components/AppSymbol.tsx | 4 + .../threads/ComposerCommandPopover.tsx | 23 +- apps/web/src/components/ChatView.tsx | 23 +- .../src/components/ComposerPromptEditor.tsx | 6 +- apps/web/src/components/chat/ChatComposer.tsx | 1084 ++++++++++------- .../chat/ComposerBannerStack.test.tsx | 10 +- .../components/chat/ComposerBannerStack.tsx | 22 +- .../chat/ComposerCommandMenu.test.tsx | 89 ++ .../components/chat/ComposerCommandMenu.tsx | 211 ++-- .../ComposerPendingApprovalActions.test.tsx | 24 + .../chat/ComposerPendingApprovalActions.tsx | 26 +- .../ComposerPendingApprovalPanel.test.tsx | 28 +- .../chat/ComposerPendingApprovalPanel.tsx | 49 +- .../chat/ComposerPendingUserInputPanel.tsx | 27 +- .../chat/ComposerPlanFollowUpBanner.tsx | 13 +- .../chat/ComposerStashBadge.test.tsx | 68 ++ .../components/chat/ComposerStashBadge.tsx | 68 +- .../chat/ComposerStashMenu.test.tsx | 74 ++ .../src/components/chat/ComposerStashMenu.tsx | 86 +- .../chat/ComposerTasksBadge.test.tsx | 130 ++ .../components/chat/ComposerTasksBadge.tsx | 246 ++++ .../src/components/chat/SkillInlineText.tsx | 2 +- .../chat/ThreadSyncStatusPill.test.tsx | 5 + .../components/chat/ThreadSyncStatusPill.tsx | 3 +- apps/web/src/components/composerInlineChip.ts | 19 +- apps/web/src/components/ui/button.test.tsx | 8 + apps/web/src/components/ui/button.tsx | 2 + apps/web/src/index.css | 323 ++++- apps/web/src/providerSkillSearch.ts | 3 +- apps/web/src/session-logic.test.ts | 162 ++- apps/web/src/session-logic.ts | 93 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/providerSkills.test.ts | 48 +- .../client-runtime/src/providerSkills.ts | 43 +- 34 files changed, 2233 insertions(+), 793 deletions(-) create mode 100644 apps/web/src/components/chat/ComposerCommandMenu.test.tsx create mode 100644 apps/web/src/components/chat/ComposerPendingApprovalActions.test.tsx create mode 100644 apps/web/src/components/chat/ComposerStashBadge.test.tsx create mode 100644 apps/web/src/components/chat/ComposerStashMenu.test.tsx create mode 100644 apps/web/src/components/chat/ComposerTasksBadge.test.tsx create mode 100644 apps/web/src/components/chat/ComposerTasksBadge.tsx rename apps/web/src/providerSkillPresentation.test.ts => packages/client-runtime/src/providerSkills.test.ts (54%) rename apps/web/src/providerSkillPresentation.ts => packages/client-runtime/src/providerSkills.ts (63%) diff --git a/apps/mobile/src/components/AppSymbol.tsx b/apps/mobile/src/components/AppSymbol.tsx index 74308467a052..32f915e7af5c 100644 --- a/apps/mobile/src/components/AppSymbol.tsx +++ b/apps/mobile/src/components/AppSymbol.tsx @@ -2,6 +2,7 @@ import { IconAdjustmentsHorizontal, IconAlertCircle, IconAlertTriangle, + IconApps, IconArchive, IconArrowBackUp, IconArrowDownCircle, @@ -13,6 +14,7 @@ import { IconArrowsMaximize, IconBellRinging, IconBolt, + IconBox, IconCamera, IconChartBar, IconCheck, @@ -104,6 +106,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { checkmark: IconCheck, "checkmark.circle": IconCircleCheck, clock: IconClock, + cube: IconBox, "chevron.down": IconChevronDown, "chevron.left": IconChevronLeft, "chevron.left.forwardslash.chevron.right": IconCode, @@ -141,6 +144,7 @@ const ANDROID_ICON_BY_SF_SYMBOL: Partial> = { "sidebar.right": IconLayoutSidebarRight, "slider.horizontal.3": IconAdjustmentsHorizontal, "square.and.pencil": IconEdit, + "square.grid.2x2": IconApps, "square.split.2x1": IconLayoutColumns, "sun.max": IconSun, "stop.fill": IconPlayerStopFilled, diff --git a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx index 0eea51719521..68ee71d883f3 100644 --- a/apps/mobile/src/features/threads/ComposerCommandPopover.tsx +++ b/apps/mobile/src/features/threads/ComposerCommandPopover.tsx @@ -1,9 +1,13 @@ -import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; +import { + resolveProviderSkillSourceKind, + type ProviderSkillSourceKind, +} from "@t3tools/client-runtime/providerSkills"; import type { ServerProviderSkill, ServerProviderSlashCommand } from "@t3tools/contracts"; -import { SymbolView } from "../../components/AppSymbol"; +import type { ComposerTriggerKind } from "@t3tools/shared/composerTrigger"; import { memo } from "react"; import { Pressable, ScrollView, View, type ViewStyle } from "react-native"; +import { SymbolView, type AppSymbolName } from "../../components/AppSymbol"; import { AppText as Text } from "../../components/AppText"; import { GlassSurface } from "../../components/GlassSurface"; import { PierreEntryIcon } from "../../components/PierreEntryIcon"; @@ -61,13 +65,22 @@ function PopoverSurface(props: { readonly children: React.ReactNode; readonly st ); } -function itemIcon(item: ComposerCommandItem) { +const SKILL_SOURCE_SYMBOL_BY_KIND: Record = { + app: "square.grid.2x2", + repo: "folder", + project: "folder", + personal: "person.crop.circle", + system: "gearshape", + other: "cube", +}; + +function itemIcon(item: ComposerCommandItem): AppSymbolName | null { switch (item.type) { case "slash-command": case "provider-slash-command": - return "terminal" as const; + return "terminal"; case "skill": - return "cube" as const; + return SKILL_SOURCE_SYMBOL_BY_KIND[resolveProviderSkillSourceKind(item.skill)]; case "path": return null; } diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index b0a75b7bea1b..336f7ed828c1 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -4183,6 +4183,14 @@ function ChatViewContent(props: ChatViewProps) { // partition (same shell, same capability gate, same PR auto-settle input) // so the banner and the sidebar row never disagree. const activeThreadShell = useThreadShell(isServerThread ? activeThreadRef : null); + const activeComposerTasksProgress = + activeLatestTurn !== null && !latestTurnSettled + ? (activeThreadShell?.planProgress ?? null) + : null; + const activeComposerTaskSteps = + activeComposerTasksProgress && activePlan && activePlan.turnId === activeLatestTurn?.turnId + ? activePlan.steps + : null; const autoSettleAfterDays = useClientSettings((settings) => settings.sidebarAutoSettleAfterDays); const autoSettleOnMerge = useClientSettings((settings) => settings.sidebarAutoSettleOnMerge); const activeThreadPr = resolveDisplayedThreadPr({ @@ -4519,13 +4527,13 @@ function ChatViewContent(props: ChatViewProps) { ), title: working ? liveCount > 0 - ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working in the background` - : "Background work running" - : "Monitoring in the background", + ? `${liveCount} ${liveCount === 1 ? "agent" : "agents"} working` + : "Background work" + : "Monitoring", actions: ( + {inlineTasksBadge} + {inlineStashBadge} {activePendingProgress?.activeQuestion?.multiSelect ? (
) : null} - - {showCollapsedMobilePromptRow ? ( -
- - -
- ) : null} - +
+ ) : null} + {isTasksDrawerOpen && + !hasBlockingComposerTopDrawer && + visibleTasksProgress && + visibleTaskSteps ? ( + + ) : null} +
+ {visibleTasksProgress && + visibleTaskSteps && + !isTasksDrawerOpen && + !props.externalDrawerAttached && + !showComposerTopDrawer && + !isComposerCollapsedMobile ? ( + 0} + onDismiss={dismissTasks} + onToggle={toggleTasksDrawer} + progress={visibleTasksProgress} + steps={visibleTaskSteps} + /> + ) : null} + {!props.externalDrawerAttached && + !showComposerTopDrawer && + !isTasksDrawerOpen && + !isComposerCollapsedMobile ? ( + + ) : null} +
- - - {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( - - setIsStashMenuOpen(false)} - /> - - )} - - {composerMenuOpen && !isComposerApprovalState && ( - - - - )} - - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerPreviewAnnotations.length > 0 && ( - - removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) - } - onExpandImage={(imageId) => { - const preview = buildExpandedImagePreview(composerImages, imageId); - if (preview) onExpandImage(preview); + {showCollapsedMobilePromptRow ? ( +
+ + {inlineTasksBadge} + {inlineStashBadge} + +
+ ) : null} + +
+ {isStashMenuOpen && !composerMenuOpen && !isComposerApprovalState && ( + + setIsStashMenuOpen(false)} + /> + )} - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerReviewComments.length > 0 && ( - - removeComposerDraftReviewComment(composerDraftTarget, commentId) - } - className="mb-3" - /> + {composerMenuOpen && !isComposerApprovalState && ( + + + )} - {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerElementContexts.length > 0 && ( - - removeComposerDraftElementContext(composerDraftTarget, contextId) + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerPreviewAnnotations.length > 0 && ( + + removeComposerDraftPreviewAnnotation(composerDraftTarget, annotationId) + } + onExpandImage={(imageId) => { + const preview = buildExpandedImagePreview(composerImages, imageId); + if (preview) onExpandImage(preview); + }} + className="mb-3" + /> + )} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerReviewComments.length > 0 && ( + + removeComposerDraftReviewComment(composerDraftTarget, commentId) + } + className="mb-3" + /> + )} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerElementContexts.length > 0 && ( + + removeComposerDraftElementContext(composerDraftTarget, contextId) + } + className="mb-3" + /> + )} + + {!isComposerCollapsedMobile && + !isComposerApprovalState && + pendingUserInputs.length === 0 && + composerImages.some( + (image) => + !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), + ) && ( +
+ {composerImages + .filter( + (image) => + !composerPreviewAnnotations.some( + (annotation) => annotation.id === image.id, + ), + ) + .map((image) => ( +
+ {image.previewUrl ? ( + + ) : ( +
+ {image.name} +
+ )} + {nonPersistedComposerImageIdSet.has(image.id) && ( + + + + + } + /> + + Draft attachment could not be saved locally and may be lost on + navigation. + + + )} + +
+ ))} +
+ )} + +
+ - )} + {showMobilePendingAnswerActions ? ( +
+ {inlineTasksBadge} + {inlineStashBadge} + +
+ ) : null} +
+
- {!isComposerCollapsedMobile && - !isComposerApprovalState && - pendingUserInputs.length === 0 && - composerImages.some( - (image) => - !composerPreviewAnnotations.some((annotation) => annotation.id === image.id), - ) && ( -
- {composerImages - .filter( - (image) => - !composerPreviewAnnotations.some( - (annotation) => annotation.id === image.id, - ), - ) - .map((image) => ( -
- {image.previewUrl ? ( - - ) : ( -
- {image.name} -
- )} - {nonPersistedComposerImageIdSet.has(image.id) && ( - - - - - } - /> - - Draft attachment could not be saved locally and may be lost on - navigation. - - - )} - -
- ))} + + + {/* Bottom toolbar */} + {isComposerCollapsedMobile || isComposerApprovalState ? null : ( +
0 && "pt-2", + isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", + showMobilePendingAnswerActions && "hidden sm:flex", + )} + > +
+ {noProviderAvailable ? ( + + ) : ( + { + setIsComposerModelPickerOpen(open); + }} + getModelDisabledReason={getModelDisabledReason} + onInstanceModelChange={onProviderModelSelect} + /> + )} + + {isComposerFooterCompact ? ( + + ) : ( + <> + {providerTraitsPicker ? ( + <> + + {providerTraitsPicker} + + ) : null} + + + )}
- )} -
- - {showMobilePendingAnswerActions ? ( + {/* Right side: send / stop button */}
- 0} isSendBusy={isSendBusy} sendDisabledReason={sendDisabledReason} isConnecting={isConnecting} @@ -3104,145 +3377,18 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) noProviderAvailable || projectSelectionRequired } - isPreparingWorktree={false} - hasSendableContent={false} - preserveComposerFocusOnPointerDown + isPreparingWorktree={isPreparingWorktree} + hasSendableContent={composerSendState.hasSendableContent} + preserveComposerFocusOnPointerDown={isMobileViewport} + showSendWhileRunning={isMobileViewport} onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} onInterrupt={handleInterruptPrimaryAction} onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} />
- ) : null} -
-
- - - - {/* Bottom toolbar */} - {isComposerCollapsedMobile ? null : activePendingApproval ? ( -
- -
- ) : ( -
0 && "pt-2", - isComposerFooterCompact ? "gap-1.5" : "gap-2 sm:gap-0", - showMobilePendingAnswerActions && "hidden sm:flex", - )} - > -
- {noProviderAvailable ? ( - - ) : ( - { - setIsComposerModelPickerOpen(open); - }} - getModelDisabledReason={getModelDisabledReason} - onInstanceModelChange={onProviderModelSelect} - /> - )} - - {isComposerFooterCompact ? ( - - ) : ( - <> - {providerTraitsPicker ? ( - <> - - {providerTraitsPicker} - - ) : null} - - - )} -
- - {/* Right side: send / stop button */} -
- 0} - isSendBusy={isSendBusy} - sendDisabledReason={sendDisabledReason} - isConnecting={isConnecting} - isEnvironmentUnavailable={ - environmentUnavailable !== null || - noProviderAvailable || - projectSelectionRequired - } - isPreparingWorktree={isPreparingWorktree} - hasSendableContent={composerSendState.hasSendableContent} - preserveComposerFocusOnPointerDown={isMobileViewport} - showSendWhileRunning={isMobileViewport} - onPreviousPendingQuestion={onPreviousActivePendingUserInputQuestion} - onInterrupt={handleInterruptPrimaryAction} - onImplementPlanInNewThread={handleImplementPlanInNewThreadPrimaryAction} - />
-
- )} + )} +
diff --git a/apps/web/src/components/chat/ComposerBannerStack.test.tsx b/apps/web/src/components/chat/ComposerBannerStack.test.tsx index 6eed4fb05315..f07836ab32a6 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.test.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.test.tsx @@ -36,7 +36,9 @@ describe("ComposerBannerStack", () => { const neutralBehind = renderToStaticMarkup( , ); - expect(neutralBehind).toContain("border-border"); + expect(neutralBehind).toContain("chat-composer-banner-stack-cap"); + expect(neutralBehind).toContain("border-[var(--chat-composer-attached-outline)]"); + expect(neutralBehind).not.toContain("border-border"); expect(neutralBehind).not.toContain("border-warning/24"); const warningBehind = renderToStaticMarkup( @@ -49,12 +51,14 @@ describe("ComposerBannerStack", () => { const markup = renderToStaticMarkup(); expect(markup).not.toContain("data-composer-banner-stack-expanded-items"); - expect(markup).toContain("alert-glass"); + expect(markup).toContain("chat-composer-drawer-surface"); + expect(markup).toContain("chat-composer-drawer-attached"); + expect(markup).toContain("text-xs"); + expect(markup).toContain('data-composer-banner-drawer="true"'); expect(markup).toContain('data-variant="warning"'); expect(markup).toContain("transform:none"); expect(markup).not.toContain("will-change:transform"); }); - it("applies item-specific surface and action layout classes", () => { const markup = renderToStaticMarkup(