diff --git a/apps/web/src/components/BranchToolbar.logic.ts b/apps/web/src/components/BranchToolbar.logic.ts index 8cdd8e70d9e41..ebce548b72a16 100644 --- a/apps/web/src/components/BranchToolbar.logic.ts +++ b/apps/web/src/components/BranchToolbar.logic.ts @@ -1,5 +1,5 @@ import type { EnvironmentId, VcsRef, ProjectId } from "@t3tools/contracts"; -import { deriveLocalBranchNameFromRemoteRef } from "@t3tools/shared/git"; +import { deriveLocalBranchNameFromRemoteRef, stripRemoteRefPrefix } from "@t3tools/shared/git"; import * as Schema from "effect/Schema"; import { toSortableTimestamp } from "../lib/threadSort"; export { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; @@ -236,7 +236,7 @@ export function resolveBranchSelectionTarget(input: { export function resolveExactBranchWorktreeInput(input: { activeProjectCwd: string; - ref: Pick; + ref: Pick; }): | { kind: "reuse"; branch: string; worktreePath: string | null } | { @@ -263,7 +263,11 @@ export function resolveExactBranchWorktreeInput(input: { kind: "create", cwd: activeProjectCwd, refName: ref.name, - newRefName: deriveLocalBranchNameFromRemoteRef(ref.name), + // The ref's own remote is the only prefix worth stripping; falling back to + // the first segment covers refs listed without a parsed remote name. + newRefName: ref.remoteName + ? stripRemoteRefPrefix(ref.name, ref.remoteName) + : deriveLocalBranchNameFromRemoteRef(ref.name), path: null, }; } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 73d52e0b0d31e..a6298826593ae 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -196,16 +196,16 @@ const MobileRunContextSelector = memo(function MobileRunContextSelector({ className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent" onMouseDown={(event) => event.preventDefault()} onClick={() => { - onCheckoutPullRequest( - ref.remoteName ? `${ref.remoteName}/${ref.name}` : ref.name, - ); + // `name` already carries the remote prefix for a remote ref, + // so re-adding `remoteName` would ask for + // `origin/origin/`: a ref that resolves to nothing + // and never gets its worktree. + onCheckoutPullRequest(ref.name); setCheckoutReference(""); }} > - - {ref.remoteName ? `${ref.remoteName}/${ref.name}` : ref.name} - + {ref.name} ))} diff --git a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx index 74e707fca0a6c..e23c7847a1eb3 100644 --- a/apps/web/src/components/BranchToolbarEnvModeSelector.tsx +++ b/apps/web/src/components/BranchToolbarEnvModeSelector.tsx @@ -152,16 +152,16 @@ export const BranchToolbarEnvModeSelector = memo(function BranchToolbarEnvModeSe className="flex min-w-0 items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm hover:bg-accent" onMouseDown={(event) => event.preventDefault()} onClick={() => { - onCheckoutPullRequest?.( - ref.remoteName ? `${ref.remoteName}/${ref.name}` : ref.name, - ); + // `name` already carries the remote prefix for a remote ref, + // so re-adding `remoteName` would ask for + // `origin/origin/`: a ref that resolves to nothing + // and never gets its worktree. + onCheckoutPullRequest?.(ref.name); setCheckoutReference(""); }} > - - {ref.remoteName ? `${ref.remoteName}/${ref.name}` : ref.name} - + {ref.name} ))} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 1078bf29c6aad..3ade6265cdd7c 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -37,7 +37,6 @@ import { createModelSelection, resolvePromptInjectedEffort, } from "@t3tools/shared/model"; -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"; @@ -87,7 +86,7 @@ import { isLatestTurnSettled, } from "../session-logic"; import { type LegendListRef } from "@legendapp/list/react"; -import { getAnchoredTurnMetrics, type TimelineScrollMode } from "./chat/timelineScrollAnchoring"; +import { timelineContentOverflowsViewport, type TimelineScrollMode } from "./chat/timelineScroll"; import { buildPendingUserInputAnswers, derivePendingUserInputProgress, @@ -1537,14 +1536,6 @@ function ChatViewContent(props: ChatViewProps) { } return labels; }, [activeThreadKnownSessions]); - const [timelineAnchor, setTimelineAnchor] = useState<{ - readonly threadKey: string | null; - readonly messageId: MessageId | null; - }>({ threadKey: activeThreadKey, messageId: null }); - if (timelineAnchor.threadKey !== activeThreadKey) { - setTimelineAnchor({ threadKey: activeThreadKey, messageId: null }); - } - const timelineAnchorMessageId = timelineAnchor.messageId; const activeRightPanelKind = useRightPanelStore((state) => selectActiveRightPanel(state.byThreadKey, workspaceThreadRef), ); @@ -3706,31 +3697,12 @@ function ChatViewContent(props: ChatViewProps) { new Debouncer(() => setShowScrollToBottom(true), { wait: 150 }), ); const timelineScrollModeRef = useRef("following-end"); - const pendingTimelineAnchorRef = useRef(null); - const positionedTimelineAnchorRef = useRef(null); - const settledTimelineAnchorRef = useRef(null); - const activeTimelineAnchorIndexRef = useRef(null); - const anchorUserScrollGenerationRef = useRef(0); + const userScrollGenerationRef = useRef(0); const liveFollowUserScrollGenerationRef = useRef(0); - const pendingAnchorScrollRestoreRef = useRef<{ - readonly messageId: MessageId; - readonly offset: number; - readonly userScrollGeneration: number; - } | null>(null); - const anchorScrollRestoreFrameRef = useRef(null); const cancelTimelineLiveFollowForUserNavigation = useCallback(() => { - anchorUserScrollGenerationRef.current += 1; + userScrollGenerationRef.current += 1; timelineScrollModeRef.current = "free-scrolling"; liveFollowUserScrollGenerationRef.current = null; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; - pendingAnchorScrollRestoreRef.current = null; - if (anchorScrollRestoreFrameRef.current !== null) { - cancelAnimationFrame(anchorScrollRestoreFrameRef.current); - anchorScrollRestoreFrameRef.current = null; - } }, []); const cancelTimelineLiveFollowForUserNavigationRef = useRef( cancelTimelineLiveFollowForUserNavigation, @@ -3739,52 +3711,15 @@ function ChatViewContent(props: ChatViewProps) { cancelTimelineLiveFollowForUserNavigationRef.current = cancelTimelineLiveFollowForUserNavigation; }, [cancelTimelineLiveFollowForUserNavigation]); - const getActiveTimelineTurnMetrics = useCallback( - (list?: LegendListRef | null) => { - const resolvedList = list ?? legendListRef.current; - const anchorIndex = activeTimelineAnchorIndexRef.current; - const state = resolvedList?.getState(); - if (!resolvedList || !state || anchorIndex === null) { - return null; - } - - return getAnchoredTurnMetrics({ - state, - anchorIndex, - composerOverlayHeight: timelineComposerLayout.contentInsetEndAdjustment, - anchorOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - }, - [timelineComposerLayout.contentInsetEndAdjustment], - ); const timelineRealContentOverflowsViewport = useCallback( (list?: LegendListRef | null) => { - const resolvedList = list ?? legendListRef.current; - const state = resolvedList?.getState(); - if (!resolvedList || !state || state.data.length === 0) { - return false; - } - - const lastRowIndex = state.data.length - 1; - const lastRowTop = state.positionAtIndex(lastRowIndex); - const lastRowHeight = state.sizeAtIndex(lastRowIndex); - if ( - typeof lastRowTop !== "number" || - typeof lastRowHeight !== "number" || - !Number.isFinite(lastRowTop) || - !Number.isFinite(lastRowHeight) - ) { - return false; - } - - const realContentBottom = lastRowTop + Math.max(1, lastRowHeight); - const visibleScrollLength = Math.max( - 0, - (state.scrollLength ?? 0) - - timelineComposerLayout.contentInsetEndAdjustment - - CHAT_LIST_ANCHOR_OFFSET, - ); - return realContentBottom > visibleScrollLength; + const state = (list ?? legendListRef.current)?.getState(); + return state + ? timelineContentOverflowsViewport({ + state, + composerOverlayHeight: timelineComposerLayout.contentInsetEndAdjustment, + }) + : false; }, [timelineComposerLayout.contentInsetEndAdjustment], ); @@ -3794,9 +3729,7 @@ function ChatViewContent(props: ChatViewProps) { const scrollToEnd = useCallback((animated = false) => { isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); void legendListRef.current?.scrollToEnd?.({ animated }); @@ -3833,103 +3766,8 @@ function ChatViewContent(props: ChatViewProps) { }; }, [activeThread?.id]); - const onTimelineAnchorReady = useCallback((messageId: MessageId, anchorIndex: number) => { - if (pendingTimelineAnchorRef.current === messageId) { - pendingTimelineAnchorRef.current = null; - } - activeTimelineAnchorIndexRef.current = anchorIndex; - if (positionedTimelineAnchorRef.current === messageId) { - return; - } - positionedTimelineAnchorRef.current = messageId; - settledTimelineAnchorRef.current = null; - const positionAnchor = (remainingAttempts: number) => { - requestAnimationFrame(() => { - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const list = legendListRef.current; - if (!list) { - if (remainingAttempts > 0) { - positionAnchor(remainingAttempts - 1); - } - return; - } - const scrollNode = list.getScrollableNode(); - let finished = false; - const finishAnimatedPositioning = () => { - if (finished) { - return; - } - finished = true; - window.clearTimeout(fallbackTimer); - scrollNode.removeEventListener("scrollend", finishAnimatedPositioning); - if (positionedTimelineAnchorRef.current !== messageId) { - return; - } - const scrollOffset = list.getState().scroll; - void list.scrollToOffset({ offset: scrollOffset, animated: false }); - settledTimelineAnchorRef.current = messageId; - }; - const fallbackTimer = window.setTimeout(finishAnimatedPositioning, 750); - scrollNode.addEventListener("scrollend", finishAnimatedPositioning, { once: true }); - void list.scrollToIndex({ - index: anchorIndex, - animated: true, - viewPosition: 0, - viewOffset: CHAT_LIST_ANCHOR_OFFSET, - }); - }); - }; - requestAnimationFrame(() => positionAnchor(12)); - }, []); - const onTimelineAnchorSizeChanged = useCallback((messageId: MessageId) => { - if (settledTimelineAnchorRef.current !== messageId) { - return; - } - if (liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current) { - return; - } - const scrollOffset = legendListRef.current?.getState().scroll; - if (scrollOffset === undefined) { - return; - } - if (pendingAnchorScrollRestoreRef.current === null) { - pendingAnchorScrollRestoreRef.current = { - messageId, - offset: scrollOffset, - userScrollGeneration: anchorUserScrollGenerationRef.current, - }; - } - if (anchorScrollRestoreFrameRef.current !== null) { - return; - } - anchorScrollRestoreFrameRef.current = requestAnimationFrame(() => { - anchorScrollRestoreFrameRef.current = null; - const pending = pendingAnchorScrollRestoreRef.current; - pendingAnchorScrollRestoreRef.current = null; - if ( - pending && - settledTimelineAnchorRef.current === pending.messageId && - pending.userScrollGeneration === anchorUserScrollGenerationRef.current - ) { - const list = legendListRef.current; - const currentScrollOffset = list?.getState().scroll; - if ( - typeof currentScrollOffset === "number" && - Math.abs(currentScrollOffset - pending.offset) <= 2 - ) { - void list?.scrollToOffset({ offset: pending.offset, animated: false }); - } - } - }); - }, []); - const onIsAtEndChange = useCallback((isAtEnd: boolean) => { - if ( - !isAtEnd && - liveFollowUserScrollGenerationRef.current === anchorUserScrollGenerationRef.current - ) { + if (!isAtEnd && liveFollowUserScrollGenerationRef.current === userScrollGenerationRef.current) { showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); return; @@ -3938,7 +3776,7 @@ function ChatViewContent(props: ChatViewProps) { isAtEndRef.current = isAtEnd; if (isAtEnd) { timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); } else { @@ -3952,23 +3790,14 @@ function ChatViewContent(props: ChatViewProps) { if (!activeThread?.id) { return; } - if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { + if (liveFollowUserScrollGenerationRef.current !== userScrollGenerationRef.current) { return; } let secondFrame: number | null = null; const frame = requestAnimationFrame(() => { secondFrame = requestAnimationFrame(() => { - if (liveFollowUserScrollGenerationRef.current !== anchorUserScrollGenerationRef.current) { - return; - } - if (pendingTimelineAnchorRef.current !== null) { - return; - } - if ( - positionedTimelineAnchorRef.current !== null && - settledTimelineAnchorRef.current !== positionedTimelineAnchorRef.current - ) { + if (liveFollowUserScrollGenerationRef.current !== userScrollGenerationRef.current) { return; } const list = legendListRef.current; @@ -3976,20 +3805,6 @@ function ChatViewContent(props: ChatViewProps) { return; } - if (timelineScrollModeRef.current === "anchoring-new-turn") { - const metrics = getActiveTimelineTurnMetrics(list); - if (!metrics) { - return; - } - if (metrics.scrollDeltaToRevealEnd <= 1) { - return; - } - - const nextOffset = list.getState().scroll + metrics.scrollDeltaToRevealEnd; - void list.scrollToOffset({ offset: nextOffset, animated: false }); - return; - } - if (timelineScrollModeRef.current !== "following-end") { return; } @@ -4007,22 +3822,13 @@ function ChatViewContent(props: ChatViewProps) { cancelAnimationFrame(secondFrame); } }; - }, [ - activeThread?.id, - timelineEntries, - getActiveTimelineTurnMetrics, - timelineRealContentOverflowsViewport, - ]); + }, [activeThread?.id, timelineEntries, timelineRealContentOverflowsViewport]); useEffect(() => { setPullRequestDialogState(null); isAtEndRef.current = true; timelineScrollModeRef.current = "following-end"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = null; - positionedTimelineAnchorRef.current = null; - settledTimelineAnchorRef.current = null; - activeTimelineAnchorIndexRef.current = null; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); if (planSidebarOpenOnNextThreadRef.current) { @@ -5045,20 +4851,13 @@ function ChatViewContent(props: ChatViewProps) { sizeBytes: document.sizeBytes, })), ]; - // Sending always returns to the live edge. The new row becomes the - // anchored end-space target so it lands near the top while the response - // streams into the reserved space below it. + // Sending always returns to the live edge and stays there, so the streaming + // response keeps hugging the composer instead of trailing empty space. isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); setOptimisticUserMessages((existing) => [ ...existing, { @@ -5519,18 +5318,12 @@ function ChatViewContent(props: ChatViewProps) { beginLocalDispatch({ preparingWorktree: false }); setThreadError(threadIdForSend, null); - // Position this sent row once LegendList has measured the anchored tail. + // Follow the live edge from the sent row onward. isAtEndRef.current = true; - timelineScrollModeRef.current = "anchoring-new-turn"; - liveFollowUserScrollGenerationRef.current = anchorUserScrollGenerationRef.current; - pendingTimelineAnchorRef.current = messageIdForSend; - activeTimelineAnchorIndexRef.current = null; + timelineScrollModeRef.current = "following-end"; + liveFollowUserScrollGenerationRef.current = userScrollGenerationRef.current; showScrollDebouncer.current.cancel(); setShowScrollToBottom(false); - setTimelineAnchor({ - threadKey: scopedThreadKey(scopeThreadRef(activeThread.environmentId, threadIdForSend)), - messageId: messageIdForSend, - }); setOptimisticUserMessages((existing) => [ ...existing, @@ -6269,9 +6062,6 @@ function ChatViewContent(props: ChatViewProps) { timestampFormat={timestampFormat} workspaceRoot={activeWorkspaceRoot} skills={activeProviderStatus?.skills ?? EMPTY_PROVIDER_SKILLS} - anchorMessageId={timelineAnchorMessageId} - onAnchorReady={onTimelineAnchorReady} - onAnchorSizeChanged={onTimelineAnchorSizeChanged} contentInsetEndAdjustment={timelineComposerLayout.contentInsetEndAdjustment} onIsAtEndChange={onIsAtEndChange} onManualNavigation={cancelTimelineLiveFollowForUserNavigation} diff --git a/apps/web/src/components/PullRequestThreadDialog.logic.test.ts b/apps/web/src/components/PullRequestThreadDialog.logic.test.ts index c6cdcd1f52219..2f8c8d3180bdf 100644 --- a/apps/web/src/components/PullRequestThreadDialog.logic.test.ts +++ b/apps/web/src/components/PullRequestThreadDialog.logic.test.ts @@ -2,9 +2,45 @@ import { describe, expect, it } from "vite-plus/test"; import { canAutoSubmitResolvedReference, + findBranchRefForReference, resolveBranchWorktreeTarget, } from "./PullRequestThreadDialog.logic"; +describe("findBranchRefForReference", () => { + const remoteOnly = { name: "origin/claude/schedule-limit", remoteName: "origin" }; + const local = { name: "claude/schedule-limit" }; + + it("resolves a remote-only branch from the fully qualified name a suggestion passes", () => { + // `listRefs` reports the ref as `origin/`, which is what the + // workspace picker shows and hands back on click. + expect(findBranchRefForReference([remoteOnly], "origin/claude/schedule-limit")).toBe( + remoteOnly, + ); + }); + + it("resolves a remote-only branch from the plain branch name a user types", () => { + expect(findBranchRefForReference([remoteOnly], "claude/schedule-limit")).toBe(remoteOnly); + }); + + it("prefers the local branch when both a local and a remote ref match", () => { + expect(findBranchRefForReference([remoteOnly, local], "claude/schedule-limit")).toBe(local); + }); + + it("ignores surrounding whitespace", () => { + expect(findBranchRefForReference([local], " claude/schedule-limit ")).toBe(local); + }); + + it("resolves nothing for an empty or unmatched reference", () => { + expect(findBranchRefForReference([remoteOnly, local], "")).toBeUndefined(); + expect(findBranchRefForReference([remoteOnly, local], " ")).toBeUndefined(); + expect(findBranchRefForReference([remoteOnly, local], "claude/other")).toBeUndefined(); + // The doubled prefix a caller must never build. + expect( + findBranchRefForReference([remoteOnly], "origin/origin/claude/schedule-limit"), + ).toBeUndefined(); + }); +}); + describe("resolveBranchWorktreeTarget", () => { it("creates a dedicated worktree for an existing local branch", () => { expect( @@ -28,7 +64,12 @@ describe("resolveBranchWorktreeTarget", () => { expect( resolveBranchWorktreeTarget({ cwd: "/repo", - ref: { name: "origin/feature/review", isRemote: true, worktreePath: null }, + ref: { + name: "origin/feature/review", + isRemote: true, + remoteName: "origin", + worktreePath: null, + }, }), ).toEqual({ branch: "feature/review", diff --git a/apps/web/src/components/PullRequestThreadDialog.logic.ts b/apps/web/src/components/PullRequestThreadDialog.logic.ts index 9d3ea9a792190..4d4a7489c3fa1 100644 --- a/apps/web/src/components/PullRequestThreadDialog.logic.ts +++ b/apps/web/src/components/PullRequestThreadDialog.logic.ts @@ -1,7 +1,33 @@ import type { VcsCreateWorktreeInput, VcsRef } from "@t3tools/contracts"; +import { stripRemoteRefPrefix } from "@t3tools/shared/git"; import { resolveExactBranchWorktreeInput } from "./BranchToolbar.logic"; +/** + * Match a typed or clicked reference against listed refs. + * + * A remote ref carries its remote in `name` (`origin/claude/foo`) and again in + * `remoteName`, so a reference can legitimately arrive in either shape: the + * fully qualified `origin/claude/foo` a suggestion row shows, or the plain + * `claude/foo` a user types. Both must resolve, or the branch never gets its + * worktree. Local refs win over remote ones so an already-fetched branch is + * checked out as-is instead of being recreated from the remote. + */ +export function findBranchRefForReference>( + refs: ReadonlyArray, + reference: string, +): Ref | undefined { + const trimmed = reference.trim(); + if (trimmed.length === 0) { + return undefined; + } + + const matches = refs.filter( + (ref) => ref.name === trimmed || stripRemoteRefPrefix(ref.name, ref.remoteName) === trimmed, + ); + return matches.find((ref) => ref.name === trimmed) ?? matches[0]; +} + export type ResolvedBranchWorktreeTarget = { branch: string; worktreePath: string | null; @@ -24,7 +50,7 @@ export type ResolvedBranchWorktreeTarget = { */ export function resolveBranchWorktreeTarget(input: { readonly cwd: string; - readonly ref: Pick; + readonly ref: Pick; }): ResolvedBranchWorktreeTarget { const target = resolveExactBranchWorktreeInput({ activeProjectCwd: input.cwd, diff --git a/apps/web/src/components/PullRequestThreadDialog.tsx b/apps/web/src/components/PullRequestThreadDialog.tsx index c6e4aadea25e4..3a0a49d361f7f 100644 --- a/apps/web/src/components/PullRequestThreadDialog.tsx +++ b/apps/web/src/components/PullRequestThreadDialog.tsx @@ -19,6 +19,7 @@ import { vcsEnvironment } from "~/state/vcs"; import { useAtomCommand } from "~/state/use-atom-command"; import { canAutoSubmitResolvedReference, + findBranchRefForReference, resolveBranchWorktreeTarget, } from "./PullRequestThreadDialog.logic"; import { Button } from "./ui/button"; @@ -143,9 +144,7 @@ export function PullRequestThreadDialog({ }) : null, ); - const resolvedBranch = branchQuery.data?.refs.find( - (ref) => ref.name === reference.trim() || `${ref.remoteName}/${ref.name}` === reference.trim(), - ); + const resolvedBranch = findBranchRefForReference(branchQuery.data?.refs ?? [], reference); const resolvedBranchTarget = useMemo( () => cwd && resolvedBranch ? resolveBranchWorktreeTarget({ cwd, ref: resolvedBranch }) : null, diff --git a/apps/web/src/components/chat/MessagesTimeline.test.tsx b/apps/web/src/components/chat/MessagesTimeline.test.tsx index 83ca7d3e9527c..85ed6c0184291 100644 --- a/apps/web/src/components/chat/MessagesTimeline.test.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.test.tsx @@ -13,13 +13,6 @@ vi.mock("@legendapp/list/react", async () => { renderItem: (args: { item: { id: string } }) => ReactNode; ListHeaderComponent?: ReactNode; ListFooterComponent?: ReactNode; - anchoredEndSpace?: { - anchorIndex: number; - anchorMaxSize?: number; - anchorOffset?: number; - onReady?: (info: { anchorIndex: number }) => void; - onSizeChanged?: (size: number) => void; - }; contentInsetEndAdjustment?: number; className?: string; maintainScrollAtEnd?: @@ -41,17 +34,9 @@ vi.mock("@legendapp/list/react", async () => { }; ref?: Ref; }) => { - if (props.anchoredEndSpace) { - props.anchoredEndSpace.onSizeChanged?.(240); - props.anchoredEndSpace.onReady?.({ anchorIndex: props.anchoredEndSpace.anchorIndex }); - } return (
{}, - onAnchorSizeChanged: () => {}, contentInsetEndAdjustment: 0, onIsAtEndChange: () => {}, onManualNavigation: () => {}, @@ -357,9 +339,7 @@ describe("MessagesTimeline", () => { expect(resolveTimelineMinimapInteractiveWidth(40, true)).toBe("22rem"); }); - it("anchors a sent attachment message using its measured height", () => { - const onAnchorReady = vi.fn(); - const onAnchorSizeChanged = vi.fn(); + it("keeps a sent attachment message pinned to the live edge", () => { const firstEntry = buildUserTimelineEntry("First prompt."); const secondEntry = { ...buildUserTimelineEntry("Newest prompt."), @@ -382,27 +362,20 @@ describe("MessagesTimeline", () => { const markup = renderToStaticMarkup( , ); - expect(markup).toContain('data-anchor-index="1"'); - expect(markup).toContain('data-anchor-offset="16"'); - expect(markup).toContain('data-anchor-on-ready="true"'); - expect(markup).not.toContain("data-anchor-max-size="); + // No reserved tail space: the streaming response hugs the composer instead + // of leaving a viewport-sized gap below the newest row. expect(markup).toContain('data-content-inset-end="144"'); expect(markup).toContain("[overflow-anchor:none]"); - expect(markup).not.toContain('data-maintain-scroll-at-end="enabled"'); + expect(markup).toContain('data-maintain-scroll-at-end="enabled"'); + expect(markup).toContain('data-maintain-scroll-at-end-animated="false"'); expect(markup).toContain('data-maintain-visible-content-position="object"'); expect(markup).toContain('data-maintain-visible-content-position-data="true"'); expect(markup).toContain('data-maintain-visible-content-position-size="false"'); - expect(onAnchorReady).toHaveBeenCalledOnce(); - expect(onAnchorReady).toHaveBeenCalledWith(secondEntry.message.id, 1); - expect(onAnchorSizeChanged).toHaveBeenCalledWith(secondEntry.message.id, 240); }); it("renders collapse controls for long user messages", () => { diff --git a/apps/web/src/components/chat/MessagesTimeline.tsx b/apps/web/src/components/chat/MessagesTimeline.tsx index 7abeee7ef2aa6..6ae92bc17d8ad 100644 --- a/apps/web/src/components/chat/MessagesTimeline.tsx +++ b/apps/web/src/components/chat/MessagesTimeline.tsx @@ -6,7 +6,6 @@ import { type TurnId, } from "@t3tools/contracts"; import { parseScopedThreadKey } from "@t3tools/client-runtime/environment"; -import { resolveChatListAnchoredEndSpace } from "@t3tools/shared/chatList"; import { createContext, Fragment, @@ -174,9 +173,6 @@ interface MessagesTimelineProps { timestampFormat: TimestampFormat; workspaceRoot: string | undefined; skills?: ReadonlyArray>; - anchorMessageId: MessageId | null; - onAnchorReady: (messageId: MessageId, anchorIndex: number) => void; - onAnchorSizeChanged: (messageId: MessageId, size: number) => void; contentInsetEndAdjustment: number; onIsAtEndChange: (isAtEnd: boolean) => void; onManualNavigation: () => void; @@ -209,9 +205,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ timestampFormat, workspaceRoot, skills = EMPTY_TIMELINE_SKILLS, - anchorMessageId, - onAnchorReady, - onAnchorSizeChanged, contentInsetEndAdjustment, onIsAtEndChange, onManualNavigation, @@ -328,30 +321,6 @@ export const MessagesTimeline = memo(function MessagesTimeline({ ); const [minimapHasPersistentGutter, setMinimapHasPersistentGutter] = useState(false); const [minimapHitStripWidth, setMinimapHitStripWidth] = useState(0); - const handleAnchorReady = useCallback( - (info: { anchorIndex: number | undefined }) => { - if (anchorMessageId !== null && info.anchorIndex !== undefined) { - onAnchorReady(anchorMessageId, info.anchorIndex); - } - }, - [anchorMessageId, onAnchorReady], - ); - const handleAnchorSizeChanged = useCallback( - (size: number) => { - if (anchorMessageId !== null) { - onAnchorSizeChanged(anchorMessageId, size); - } - }, - [anchorMessageId, onAnchorSizeChanged], - ); - const anchoredEndSpace = useMemo(() => { - const config = resolveChatListAnchoredEndSpace(rows, anchorMessageId, (row) => - row.kind === "message" ? row.message.id : null, - ); - return config - ? { ...config, onReady: handleAnchorReady, onSizeChanged: handleAnchorSizeChanged } - : undefined; - }, [anchorMessageId, handleAnchorReady, handleAnchorSizeChanged, rows]); const handleScroll = useCallback(() => { const state = listRef.current?.getState?.(); @@ -490,20 +459,15 @@ export const MessagesTimeline = memo(function MessagesTimeline({ renderItem={renderItem} estimatedItemSize={90} initialScrollAtEnd - {...(anchoredEndSpace ? { anchoredEndSpace } : {})} contentInsetEndAdjustment={contentInsetEndAdjustment} - maintainScrollAtEnd={ - anchoredEndSpace - ? false - : { - animated: false, - on: { - dataChange: true, - itemLayout: true, - layout: true, - }, - } - } + maintainScrollAtEnd={{ + animated: false, + on: { + dataChange: true, + itemLayout: true, + layout: true, + }, + }} maintainVisibleContentPosition={{ data: true, size: false, diff --git a/apps/web/src/components/chat/timelineScroll.test.ts b/apps/web/src/components/chat/timelineScroll.test.ts new file mode 100644 index 0000000000000..82f2ef893f35c --- /dev/null +++ b/apps/web/src/components/chat/timelineScroll.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; +import { getRowBottom, timelineContentOverflowsViewport } from "./timelineScroll"; + +function buildState({ + positions, + sizes, + scroll = 0, + scrollLength = 700, +}: { + readonly positions: readonly number[]; + readonly sizes: readonly number[]; + readonly scroll?: number; + readonly scrollLength?: number; +}) { + return { + data: positions.map((_, index) => index), + scroll, + scrollLength, + positionAtIndex: (index: number) => positions[index], + sizeAtIndex: (index: number) => sizes[index], + }; +} + +describe("timeline scroll", () => { + it("measures row bottoms from LegendList row position and size", () => { + const state = buildState({ + positions: [0, 120], + sizes: [80, 40], + }); + + expect(getRowBottom(state, 1)).toBe(160); + }); + + it("treats content that fits above the composer as non-overflowing", () => { + const state = buildState({ + positions: [0, 300], + sizes: [240, 120], + scrollLength: 760, + }); + + expect(timelineContentOverflowsViewport({ state, composerOverlayHeight: 0 })).toBe(false); + }); + + it("subtracts the composer overlay from the visible viewport", () => { + const state = buildState({ + positions: [0, 300], + sizes: [120, 300], + scrollLength: 700, + }); + + expect(timelineContentOverflowsViewport({ state, composerOverlayHeight: 0 })).toBe(false); + expect(timelineContentOverflowsViewport({ state, composerOverlayHeight: 220 })).toBe(true); + }); + + it("reports no overflow when the list is empty or unmeasured", () => { + expect( + timelineContentOverflowsViewport({ + state: buildState({ positions: [], sizes: [] }), + composerOverlayHeight: 0, + }), + ).toBe(false); + expect( + timelineContentOverflowsViewport({ + state: buildState({ positions: [0], sizes: [Number.NaN] }), + composerOverlayHeight: 0, + }), + ).toBe(false); + }); +}); diff --git a/apps/web/src/components/chat/timelineScroll.ts b/apps/web/src/components/chat/timelineScroll.ts new file mode 100644 index 0000000000000..c6a84e9d19077 --- /dev/null +++ b/apps/web/src/components/chat/timelineScroll.ts @@ -0,0 +1,55 @@ +// The timeline either tracks the live edge (so a streaming response stays +// pinned just above the composer) or is parked wherever the user scrolled to. +export type TimelineScrollMode = "following-end" | "free-scrolling"; + +// Breathing room kept below the last row when deciding whether the real +// content already overflows the visible viewport. +export const TIMELINE_END_SLACK = 16; + +export interface TimelineListMeasurementState { + readonly data: readonly unknown[]; + readonly scroll: number; + readonly scrollLength: number; + readonly positionAtIndex: (index: number) => number | undefined; + readonly sizeAtIndex: (index: number) => number | undefined; +} + +export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null { + const top = state.positionAtIndex(index); + const height = state.sizeAtIndex(index); + if ( + typeof top !== "number" || + typeof height !== "number" || + !Number.isFinite(top) || + !Number.isFinite(height) + ) { + return null; + } + + return top + Math.max(1, height); +} + +// True when the last measured row already reaches past the visible bottom of +// the list, i.e. following the end actually moves the viewport. +export function timelineContentOverflowsViewport({ + state, + composerOverlayHeight, +}: { + readonly state: TimelineListMeasurementState; + readonly composerOverlayHeight: number; +}): boolean { + if (state.data.length === 0) { + return false; + } + + const contentBottom = getRowBottom(state, state.data.length - 1); + if (contentBottom === null) { + return false; + } + + const visibleScrollLength = Math.max( + 0, + (state.scrollLength ?? 0) - composerOverlayHeight - TIMELINE_END_SLACK, + ); + return contentBottom > visibleScrollLength; +} diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx b/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx deleted file mode 100644 index 1bf82c47a614e..0000000000000 --- a/apps/web/src/components/chat/timelineScrollAnchoring.test.tsx +++ /dev/null @@ -1,138 +0,0 @@ -import { describe, expect, it } from "vite-plus/test"; -import { getAnchoredTurnMetrics, getRowBottom } from "./timelineScrollAnchoring"; - -function buildState({ - positions, - sizes, - scroll = 0, - scrollLength = 700, -}: { - readonly positions: readonly number[]; - readonly sizes: readonly number[]; - readonly scroll?: number; - readonly scrollLength?: number; -}) { - return { - data: positions.map((_, index) => index), - scroll, - scrollLength, - positionAtIndex: (index: number) => positions[index], - sizeAtIndex: (index: number) => sizes[index], - }; -} - -describe("timeline scroll anchoring", () => { - it("measures row bottoms from LegendList row position and size", () => { - const state = buildState({ - positions: [0, 120], - sizes: [80, 40], - }); - - expect(getRowBottom(state, 1)).toBe(160); - }); - - it("treats the active turn as fitting when it fits above the composer", () => { - const state = buildState({ - positions: [0, 300, 460], - sizes: [240, 80, 140], - scrollLength: 760, - }); - - const metrics = getAnchoredTurnMetrics({ - state, - anchorIndex: 1, - composerOverlayHeight: 180, - anchorOffset: 16, - }); - - expect(metrics?.turnHeight).toBe(300); - expect(metrics?.usableViewportHeight).toBe(564); - expect(metrics?.overflowsUsableViewport).toBe(false); - expect(metrics?.targetScrollToRevealEnd).toBe(36); - expect(metrics?.scrollDeltaToRevealEnd).toBe(36); - }); - - it("targets the real row end instead of any temporary reserved tail", () => { - const state = buildState({ - positions: [0, 1720, 1880], - sizes: [1600, 80, 120], - scroll: 1900, - scrollLength: 760, - }); - - const metrics = getAnchoredTurnMetrics({ - state, - anchorIndex: 1, - composerOverlayHeight: 180, - anchorOffset: 16, - }); - - expect(metrics?.lastBottom).toBe(2000); - expect(metrics?.targetScrollToRevealEnd).toBe(1436); - expect(metrics?.scrollDeltaToRevealEnd).toBe(0); - }); - - it("reports overflow only for the current anchored turn", () => { - const state = buildState({ - positions: [0, 900, 1180], - sizes: [800, 220, 300], - scroll: 900, - scrollLength: 760, - }); - - const metrics = getAnchoredTurnMetrics({ - state, - anchorIndex: 1, - composerOverlayHeight: 180, - anchorOffset: 16, - }); - - expect(metrics?.turnHeight).toBe(580); - expect(metrics?.usableViewportHeight).toBe(564); - expect(metrics?.overflowsUsableViewport).toBe(true); - }); - - it("returns the minimal positive scroll delta needed to reveal the turn end", () => { - const state = buildState({ - positions: [0, 900, 1180], - sizes: [800, 220, 360], - scroll: 900, - scrollLength: 760, - }); - - const metrics = getAnchoredTurnMetrics({ - state, - anchorIndex: 1, - composerOverlayHeight: 180, - anchorOffset: 16, - }); - - expect(metrics?.lastBottom).toBe(1540); - expect(metrics?.visibleUsableBottom).toBe(1464); - expect(metrics?.scrollDeltaToRevealEnd).toBe(76); - }); - - it("subtracts composer height from usable viewport height", () => { - const state = buildState({ - positions: [0, 300], - sizes: [120, 470], - scrollLength: 700, - }); - - const withoutComposer = getAnchoredTurnMetrics({ - state, - anchorIndex: 1, - composerOverlayHeight: 0, - anchorOffset: 16, - }); - const withComposer = getAnchoredTurnMetrics({ - state, - anchorIndex: 1, - composerOverlayHeight: 220, - anchorOffset: 16, - }); - - expect(withoutComposer?.overflowsUsableViewport).toBe(false); - expect(withComposer?.overflowsUsableViewport).toBe(true); - }); -}); diff --git a/apps/web/src/components/chat/timelineScrollAnchoring.ts b/apps/web/src/components/chat/timelineScrollAnchoring.ts deleted file mode 100644 index 48d3fc7542df0..0000000000000 --- a/apps/web/src/components/chat/timelineScrollAnchoring.ts +++ /dev/null @@ -1,78 +0,0 @@ -export type TimelineScrollMode = "following-end" | "anchoring-new-turn" | "free-scrolling"; - -export interface TimelineListMeasurementState { - readonly data: readonly unknown[]; - readonly scroll: number; - readonly scrollLength: number; - readonly positionAtIndex: (index: number) => number | undefined; - readonly sizeAtIndex: (index: number) => number | undefined; -} - -export interface AnchoredTurnMetrics { - readonly anchorTop: number; - readonly lastBottom: number; - readonly turnHeight: number; - readonly usableViewportHeight: number; - readonly visibleUsableBottom: number; - readonly overflowsUsableViewport: boolean; - readonly targetScrollToRevealEnd: number; - readonly scrollDeltaToRevealEnd: number; -} - -export function getRowBottom(state: TimelineListMeasurementState, index: number): number | null { - const top = state.positionAtIndex(index); - const height = state.sizeAtIndex(index); - if ( - typeof top !== "number" || - typeof height !== "number" || - !Number.isFinite(top) || - !Number.isFinite(height) - ) { - return null; - } - - return top + Math.max(1, height); -} - -export function getAnchoredTurnMetrics({ - state, - anchorIndex, - composerOverlayHeight, - anchorOffset, -}: { - readonly state: TimelineListMeasurementState; - readonly anchorIndex: number; - readonly composerOverlayHeight: number; - readonly anchorOffset: number; -}): AnchoredTurnMetrics | null { - if (state.data.length === 0) { - return null; - } - - const boundedAnchorIndex = Math.max(0, Math.min(anchorIndex, state.data.length - 1)); - const anchorTop = state.positionAtIndex(boundedAnchorIndex); - const lastBottom = getRowBottom(state, state.data.length - 1); - if (typeof anchorTop !== "number" || !Number.isFinite(anchorTop) || lastBottom === null) { - return null; - } - - const usableViewportHeight = Math.max( - 0, - state.scrollLength - composerOverlayHeight - anchorOffset, - ); - const turnHeight = Math.max(0, lastBottom - anchorTop); - const visibleUsableBottom = state.scroll + usableViewportHeight; - const targetScrollToRevealEnd = Math.max(0, lastBottom - usableViewportHeight); - const scrollDeltaToRevealEnd = Math.max(0, targetScrollToRevealEnd - state.scroll); - - return { - anchorTop, - lastBottom, - turnHeight, - usableViewportHeight, - visibleUsableBottom, - overflowsUsableViewport: turnHeight > usableViewportHeight, - targetScrollToRevealEnd, - scrollDeltaToRevealEnd, - }; -} diff --git a/apps/web/src/lib/baseRefChoices.ts b/apps/web/src/lib/baseRefChoices.ts index 2be010040a36c..c0d614f0fdc19 100644 --- a/apps/web/src/lib/baseRefChoices.ts +++ b/apps/web/src/lib/baseRefChoices.ts @@ -1,4 +1,5 @@ import type { VcsRef } from "@t3tools/contracts"; +import { stripRemoteRefPrefix } from "@t3tools/shared/git"; export interface BaseRefChoice { readonly id: string; @@ -7,13 +8,6 @@ export interface BaseRefChoice { readonly remote: VcsRef | null; } -function remoteBranchName(ref: VcsRef): string { - if (ref.remoteName && ref.name.startsWith(`${ref.remoteName}/`)) { - return ref.name.slice(ref.remoteName.length + 1); - } - return ref.name; -} - export function buildBaseRefChoices( localRefs: ReadonlyArray, remoteRefs: ReadonlyArray, @@ -21,7 +15,9 @@ export function buildBaseRefChoices( const unusedRemoteRefs = new Set(remoteRefs); const pairedChoices = localRefs.map((local) => { const matches = remoteRefs.filter( - (remote) => unusedRemoteRefs.has(remote) && remoteBranchName(remote) === local.name, + (remote) => + unusedRemoteRefs.has(remote) && + stripRemoteRefPrefix(remote.name, remote.remoteName) === local.name, ); const remote = matches.find((candidate) => candidate.remoteName === "origin") ?? matches[0] ?? null; diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts index 7fa77ae3c09e8..92acad15e2aa6 100644 --- a/packages/shared/src/git.test.ts +++ b/packages/shared/src/git.test.ts @@ -8,6 +8,7 @@ import { normalizeGitRemoteUrl, parseGitHubRepositoryNameWithOwnerFromRemoteUrl, sanitizeWorktreeBranchPrefix, + stripRemoteRefPrefix, WORKTREE_BRANCH_PREFIX, } from "./git.ts"; @@ -130,6 +131,25 @@ describe("sanitizeWorktreeBranchPrefix", () => { }); }); +describe("stripRemoteRefPrefix", () => { + it("strips only the ref's own remote prefix", () => { + expect(stripRemoteRefPrefix("origin/claude/feature", "origin")).toBe("claude/feature"); + expect(stripRemoteRefPrefix("upstream/main", "upstream")).toBe("main"); + }); + + it("keeps a local branch that merely looks prefixed", () => { + // A local `claude/feature` has no remote, so nothing may be stripped — + // otherwise the branch resolves to `feature`, which need not exist. + expect(stripRemoteRefPrefix("claude/feature")).toBe("claude/feature"); + expect(stripRemoteRefPrefix("claude/feature", null)).toBe("claude/feature"); + expect(stripRemoteRefPrefix("claude/feature", "origin")).toBe("claude/feature"); + }); + + it("keeps a name that is only the remote prefix", () => { + expect(stripRemoteRefPrefix("origin/", "origin")).toBe("origin/"); + }); +}); + describe("applyGitStatusStreamEvent", () => { it("treats a remote-only update as a repository when local state is missing", () => { const remote: VcsStatusRemoteResult = { diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index dd9181ad11023..b047107b9030c 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -94,6 +94,20 @@ export function deriveLocalBranchNameFromRemoteRef(branchName: string): string { return branchName.slice(firstSeparatorIndex + 1); } +/** + * Local branch name for a ref whose `name` already carries its remote prefix. + * + * `listRefs` reports a remote ref as `{ name: "origin/feature/demo", + * remoteName: "origin" }`, so only the ref's own remote prefix is stripped — a + * local branch such as `claude/feature` keeps every segment. + */ +export function stripRemoteRefPrefix(name: string, remoteName?: string | null): string { + const prefix = remoteName ? `${remoteName}/` : null; + return prefix !== null && name.startsWith(prefix) && name.length > prefix.length + ? name.slice(prefix.length) + : name; +} + /** * Normalize a user-provided worktree branch prefix into a single, valid, * lowercase git ref segment (no slashes). Falls back to the default prefix diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4981a25d3d009..7ae7076b4090d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5139,10 +5139,12 @@ packages: '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} + deprecated: this version has critical issues, please update to the latest version '@xmldom/xmldom@0.9.10': resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + deprecated: this version has critical issues, please update to the latest version '@xterm/addon-fit@0.11.0': resolution: {integrity: sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==} @@ -5961,6 +5963,7 @@ packages: crypto-js@4.2.0: resolution: {integrity: sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q==} + deprecated: Active development of CryptoJS has been discontinued. This library is no longer maintained. css-select@5.2.2: resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==}