diff --git a/CHANGELOG.md b/CHANGELOG.md index 30a8f4aa..f5ec3946 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +- **Chat scrolling rebuilt around your message.** Sending a message now parks it near the top of the view and the reply streams into the space below it, so you read from where you asked instead of chasing the bottom. While a reply streams, text arrives a paragraph at a time (code blocks line by line) with a soft fade, and the view glides after it in one continuous motion instead of snapping per line. Scrolling up during a stream immediately hands you the wheel — nothing yanks the view back — and the scroll-to-bottom pill appears on the left, carrying the model's working status while you're away from the live edge. Sending from anywhere mid-conversation jumps you straight to your new message. +- Chat: a new "Follow new content while streaming" checkbox (Settings → Chat → Streaming, on by default) turns the automatic following off entirely — your message still parks at the top on send, but the view never moves on its own afterwards. +- Chat: streamed code blocks are now syntax-highlighted while they stream, and finished messages no longer jump when a code block's line numbers fill in at the end of a reply. +- Chat: finished replies no longer flicker — tool cards stopped re-rendering (and replaying their reveal animation) when they completed, and resizing the window no longer throws the conversation up and down while you're at the bottom. +- Chat: clicking the last item in the prompt rail now always lands on it, and rail jumps teleport instead of a long smooth scroll that could stop halfway. +- Chat: opening a session goes straight to the newest message with no scroll animation, and the first uncached open fades the conversation in instead of popping. +- Mobile: scrolling during a streaming reply works again — a drag immediately takes over, the scroll-to-bottom pill shows up, and the load-older button no longer throws you to the bottom of the chat. +- UI: the chat's top and bottom scroll fades are back. +- Fixed file links in messages being checked twice against the filesystem, and against the wrong project directory on the first pass. - **Chat context attachments:** everything you attach to a message — diff/file/plan comments, terminal selections, browser annotations, PR comments and failed checks, linked issues and PRs — now shows up in the conversation as a compact context card: a header naming the source, the captured content behind an expander, and your comment below it. Previously most of these arrived as a wall of raw text inside your message. - **Chat: comment on a reply.** Select text in a chat message and choose Comment to attach that quote with your note to the next message. The selection stays highlighted while you type, and the selection menu itself was restyled — Add to chat is now Add to input. - **Diff: comment like a review.** Hovering a line shows a + button in the gutter; clicking it, clicking a line, or dragging across lines opens the comment editor for that line or range. The comment editor and saved-comment cards now match the chat's comment style. diff --git a/bun.lock b/bun.lock index 7405486f..3155488f 100644 --- a/bun.lock +++ b/bun.lock @@ -167,6 +167,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.21", "@pierre/diffs": "1.3.0-beta.6", @@ -918,6 +919,8 @@ "@kwsites/promise-deferred": ["@kwsites/promise-deferred@1.1.1", "", {}, "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="], + "@legendapp/list": ["@legendapp/list@3.3.8", "", { "dependencies": { "use-sync-external-store": "^1.5.0" }, "peerDependencies": { "react": "*", "react-dom": "*", "react-native": "*" }, "optionalPeers": ["react-dom", "react-native"] }, "sha512-GM4Hca/6WDvcY33XXCieR9MaG9CoZmACzwqQwRhKFSaNKfQV1lTLiTOpWuA9wnE8n8+6WeA52DwNKC9yrnPPeg=="], + "@levischuck/tiny-cbor": ["@levischuck/tiny-cbor@0.2.11", "", {}, "sha512-llBRm4dT4Z89aRsm6u2oEZ8tfwL/2l6BwpZ7JcyieouniDECM5AqNgr/y08zalEIvW3RSK4upYyybDcmjXqAow=="], "@lezer/common": ["@lezer/common@1.5.1", "", {}, "sha512-6YRVG9vBkaY7p1IVxL4s44n5nUnaNnGM2/AckNgYOnxTG2kWh1vR8BMxPseWPjRNpb5VtXnMpeYAEAADoRV1Iw=="], diff --git a/packages/electron/scripts/electron-dev.mjs b/packages/electron/scripts/electron-dev.mjs index a4ee60e4..c8ef57a6 100644 --- a/packages/electron/scripts/electron-dev.mjs +++ b/packages/electron/scripts/electron-dev.mjs @@ -220,7 +220,7 @@ async function main() { }); } - const electron = spawnProcess('npx', ['electron', './main.mjs'], { + const electron = spawnProcess('bun', ['x', 'electron', './main.mjs'], { cwd: electronDir, env: { ...process.env, diff --git a/packages/ui/package.json b/packages/ui/package.json index 4d52e7cb..c9648636 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -43,6 +43,7 @@ "@dnd-kit/core": "^6.3.1", "@dnd-kit/sortable": "^10.0.0", "@dnd-kit/utilities": "^3.2.2", + "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", "@opencode-ai/sdk": "1.18.21", "@pierre/diffs": "1.3.0-beta.6", diff --git a/packages/ui/src/apps/MobileChangesSurface.tsx b/packages/ui/src/apps/MobileChangesSurface.tsx index 35e84fa6..b8e98b59 100644 --- a/packages/ui/src/apps/MobileChangesSurface.tsx +++ b/packages/ui/src/apps/MobileChangesSurface.tsx @@ -628,7 +628,6 @@ const MobileDiffDetail: React.FC<{ ) : ( = ({ onClose - + {directoryError ? ( ) : query.trim() ? ( diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index 99392c46..a64d94f0 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -1455,7 +1455,7 @@ export const MobileSessionsSheet: React.FC = ({ open, // clipped overflow swallowed the footer. const surfaceContent = (
- + {/* The search bar scrolls WITH the list (iOS-style): the open-time auto-scroll to the current session naturally tucks it away, and scrolling to the very top brings it back. */} diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index e7541c2c..0873eee2 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -18,8 +18,8 @@ import { StatusRowContainer } from './StatusRowContainer'; import { SessionRecapNote } from '@/components/chat/SessionRecapSpacer'; import ScrollToBottomButton from './components/ScrollToBottomButton'; import { PromptNavigatorRail } from './components/PromptNavigatorRail'; -import { ScrollShadow } from '@/components/ui/ScrollShadow'; -import { useChatAutoFollow, type AnimationHandlers, type ContentChangeReason } from '@/hooks/useChatAutoFollow'; +import { useScrollShadow } from '@/components/ui/useScrollShadow'; +import { useChatTimelineScroll, type TimelineListHandle } from '@/hooks/useChatTimelineScroll'; import { useChatTimelineController } from './hooks/useChatTimelineController'; import { TimelineDialog } from './TimelineDialog'; import { useChatTurnNavigation } from './hooks/useChatTurnNavigation'; @@ -151,11 +151,15 @@ type ChatViewportProps = { currentSessionKey: string; isDesktopExpandedInput: boolean; isMobile: boolean; - stickyUserHeader: boolean; directory?: string; scrollRef: React.RefObject; messageListRef: React.RefObject; - pendingRevealWork: boolean; + registerList: (list: TimelineListHandle | null) => void; + anchorMessageId: string | null; + onAnchorReady: (messageId: string, anchorIndex: number) => void; + onAnchorSizeChanged: (messageId: string) => void; + onIsAtEndChange: (isAtEnd: boolean) => void; + onTimelineDataChange: () => void; renderedMessages: SessionMessageRecord[]; isLoadingOlder: boolean; sessionIsWorking: boolean; @@ -167,10 +171,11 @@ type ChatViewportProps = { confirmedAt?: number; fallbackTimestamp?: number; } | null; - handleMessageContentChange: (reason?: ContentChangeReason) => void; - getAnimationHandlers: (messageId: string) => AnimationHandlers; - handleHistoryScroll: () => void; scrollToBottom: () => void; + endPinningReleased: boolean; + // One-shot fade for content that replaced the hydration skeleton; + // cached sessions render instantly without it. + revealContent: boolean; sessionQuestions: QuestionRequest[]; sessionPermissions: PermissionRequest[]; isProgrammaticFollowActive: boolean; @@ -190,21 +195,24 @@ const ChatViewport = React.memo(({ currentSessionKey, isDesktopExpandedInput, isMobile, - stickyUserHeader, directory, scrollRef, messageListRef, - pendingRevealWork, + registerList, + anchorMessageId, + onAnchorReady, + onAnchorSizeChanged, + onIsAtEndChange, + onTimelineDataChange, renderedMessages, isLoadingOlder, sessionIsWorking, streamingMessageId, activeStreamingPhase, retryOverlay, - handleMessageContentChange, - getAnimationHandlers, - handleHistoryScroll, scrollToBottom, + endPinningReleased, + revealContent, sessionQuestions, sessionPermissions, isProgrammaticFollowActive, @@ -315,89 +323,96 @@ const ChatViewport = React.memo(({ scrollRef.current?.focus({ preventScroll: true }); }, [scrollRef]); + // Everything that used to sit beside the list inside the scroll container + // now renders as the list's header/footer, so it keeps scrolling with the + // rows exactly as before. + const listHeader = React.useMemo(() => ( + showLoadOlderButton ? ( +
+ +
+ ) : null + ), [isLoadingOlder, onLoadOlder, showLoadOlderButton, t]); + + const listFooter = React.useMemo(() => ( + <> + {(sessionQuestions.length > 0 || sessionPermissions.length > 0) && ( +
+ {sessionQuestions.map((question) => ( + + ))} + {sessionPermissions.map((permission) => ( + + ))} +
+ )} + + + +