-
Notifications
You must be signed in to change notification settings - Fork 51
feat: Add sidebar navigation controls and auto-follow functionality #113
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
6807904
f59f339
09e9fb5
3b88989
4fef665
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,10 +1,20 @@ | ||
| import React from "react"; | ||
| import { Minimize2, Edit, Terminal, Code2 } from "lucide-react"; | ||
| import { useState } from "react"; | ||
| import { | ||
| Minimize2, | ||
| Edit, | ||
| Terminal, | ||
| Code2, | ||
| Play, | ||
| SkipBack, | ||
| SkipForward, | ||
| } from "lucide-react"; | ||
| import { useState, useEffect, useRef } from "react"; | ||
| import { useGlobalState } from "../contexts/GlobalState"; | ||
| import { ComputerCodeBlock } from "./ComputerCodeBlock"; | ||
| import { TerminalCodeBlock } from "./TerminalCodeBlock"; | ||
| import { DiffView } from "./DiffView"; | ||
| import { CodeActionButtons } from "@/components/ui/code-action-buttons"; | ||
| import { useSidebarNavigation } from "../hooks/useSidebarNavigation"; | ||
| import { | ||
| Tooltip, | ||
| TooltipTrigger, | ||
|
|
@@ -15,20 +25,92 @@ import { | |
| isSidebarTerminal, | ||
| isSidebarPython, | ||
| type SidebarContent, | ||
| type ChatStatus, | ||
| } from "@/types/chat"; | ||
|
|
||
| interface ComputerSidebarProps { | ||
| sidebarOpen: boolean; | ||
| sidebarContent: SidebarContent | null; | ||
| closeSidebar: () => void; | ||
| messages?: any[]; | ||
| onNavigate?: (content: SidebarContent) => void; | ||
| status?: ChatStatus; | ||
| } | ||
|
|
||
| export const ComputerSidebarBase: React.FC<ComputerSidebarProps> = ({ | ||
| sidebarOpen, | ||
| sidebarContent, | ||
| closeSidebar, | ||
| messages = [], | ||
| onNavigate, | ||
| status, | ||
| }) => { | ||
| const [isWrapped, setIsWrapped] = useState(true); | ||
| const previousToolCountRef = useRef<number>(0); | ||
|
|
||
| const { | ||
| toolExecutions, | ||
| currentIndex, | ||
| maxIndex, | ||
| handlePrev, | ||
| handleNext, | ||
| handleJumpToLive, | ||
| handleSliderClick, | ||
| getProgressPercentage, | ||
| isAtLive, | ||
| canGoPrev, | ||
| canGoNext, | ||
| } = useSidebarNavigation({ | ||
| messages, | ||
| sidebarContent, | ||
| onNavigate, | ||
| }); | ||
|
|
||
| // Initialize tool count ref on mount | ||
| useEffect(() => { | ||
| if (sidebarOpen && toolExecutions.length > 0) { | ||
| previousToolCountRef.current = toolExecutions.length; | ||
| } | ||
| }, [sidebarOpen]); // Only run when sidebar opens/closes | ||
|
|
||
| // Auto-follow new tools when at live position during streaming | ||
| useEffect(() => { | ||
| if (!sidebarOpen || !onNavigate || toolExecutions.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| const currentToolCount = toolExecutions.length; | ||
| const previousToolCount = previousToolCountRef.current; | ||
|
|
||
| // Check if new tools arrived (count increased) | ||
| if (currentToolCount > previousToolCount) { | ||
| // Check if we were at the last position before new tools arrived | ||
| const wasAtLive = currentIndex === previousToolCount - 1; | ||
|
|
||
| // Also check if we're currently at live (in case sidebarContent already updated) | ||
| const isCurrentlyAtLive = currentIndex === currentToolCount - 1; | ||
|
|
||
| // Auto-update if we were at live OR currently at live | ||
| if (wasAtLive || isCurrentlyAtLive) { | ||
| // Navigate to the latest tool execution | ||
| // Since we only extract file operations when output is available, | ||
| // content should always be ready | ||
| const latestTool = toolExecutions[toolExecutions.length - 1]; | ||
| if (latestTool) { | ||
| onNavigate(latestTool); | ||
| } | ||
| } | ||
| } | ||
|
|
||
| // Update the ref for next comparison | ||
| previousToolCountRef.current = currentToolCount; | ||
| }, [ | ||
| toolExecutions.length, | ||
| currentIndex, | ||
| sidebarOpen, | ||
| onNavigate, | ||
| toolExecutions, | ||
| ]); | ||
|
|
||
| if (!sidebarOpen || !sidebarContent) { | ||
| return null; | ||
|
|
@@ -255,7 +337,7 @@ export const ComputerSidebarBase: React.FC<ComputerSidebarProps> = ({ | |
| </div> | ||
|
|
||
| {/* Content */} | ||
| <div className="flex-1 min-h-0 w-full overflow-hidden"> | ||
| <div className="flex-1 min-h-0 w-full overflow-hidden bg-background"> | ||
| <div className="flex flex-col min-h-0 h-full relative"> | ||
| <div className="focus-visible:outline-none flex-1 min-h-0 h-full text-sm flex flex-col py-0 outline-none"> | ||
| <div | ||
|
|
@@ -267,16 +349,33 @@ export const ComputerSidebarBase: React.FC<ComputerSidebarProps> = ({ | |
| }} | ||
| > | ||
| {isFile && ( | ||
| <ComputerCodeBlock | ||
| language={ | ||
| sidebarContent.language || | ||
| getLanguageFromPath(sidebarContent.path) | ||
| } | ||
| wrap={isWrapped} | ||
| showButtons={false} | ||
| > | ||
| {sidebarContent.content} | ||
| </ComputerCodeBlock> | ||
| <> | ||
| {/* Show DiffView for editing actions with diff data */} | ||
| {sidebarContent.action === "editing" && | ||
| sidebarContent.originalContent && | ||
| sidebarContent.modifiedContent ? ( | ||
| <DiffView | ||
| originalContent={sidebarContent.originalContent} | ||
| modifiedContent={sidebarContent.modifiedContent} | ||
| language={ | ||
| sidebarContent.language || | ||
| getLanguageFromPath(sidebarContent.path) | ||
| } | ||
| wrap={isWrapped} | ||
| /> | ||
| ) : ( | ||
| <ComputerCodeBlock | ||
| language={ | ||
| sidebarContent.language || | ||
| getLanguageFromPath(sidebarContent.path) | ||
| } | ||
| wrap={isWrapped} | ||
| showButtons={false} | ||
| > | ||
| {sidebarContent.content} | ||
| </ComputerCodeBlock> | ||
| )} | ||
| </> | ||
|
Comment on lines
351
to
+378
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Diff view guard will skip valid diffs when content is an empty string The condition: sidebarContent.action === "editing" &&
sidebarContent.originalContent &&
sidebarContent.modifiedContentrelies on truthiness, so cases where either side of the diff is an empty string (e.g. creating a new file, or clearing a file) will fall back to the plain To treat “empty content” as valid while still requiring the fields to be present, consider tightening the check to explicit null/undefined: - {sidebarContent.action === "editing" &&
- sidebarContent.originalContent &&
- sidebarContent.modifiedContent ? (
+ {sidebarContent.action === "editing" &&
+ sidebarContent.originalContent != null &&
+ sidebarContent.modifiedContent != null ? (This keeps the guard against missing data but allows empty strings to still render through 🤖 Prompt for AI Agents |
||
| )} | ||
| {isTerminal && ( | ||
| <TerminalCodeBlock | ||
|
|
@@ -325,6 +424,112 @@ export const ComputerSidebarBase: React.FC<ComputerSidebarProps> = ({ | |
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Navigation Footer */} | ||
| <div className="mt-auto flex w-full items-center gap-2 px-4 h-[44px] relative bg-background border-t border-border"> | ||
| <div className="flex items-center" dir="ltr"> | ||
| <button | ||
| type="button" | ||
| onClick={handlePrev} | ||
| disabled={!canGoPrev} | ||
| className={`flex items-center justify-center w-[24px] h-[24px] transition-colors cursor-pointer ${ | ||
| !canGoPrev | ||
| ? "text-muted-foreground/30 cursor-not-allowed" | ||
| : "text-muted-foreground hover:text-blue-500" | ||
| }`} | ||
| aria-label="Previous tool execution" | ||
| > | ||
| <SkipBack size={16} /> | ||
| </button> | ||
| <button | ||
| type="button" | ||
| onClick={handleNext} | ||
| disabled={!canGoNext} | ||
| className={`flex items-center justify-center w-[24px] h-[24px] transition-colors cursor-pointer ${ | ||
| !canGoNext | ||
| ? "text-muted-foreground/30 cursor-not-allowed" | ||
| : "text-muted-foreground hover:text-blue-500" | ||
| }`} | ||
| aria-label="Next tool execution" | ||
| > | ||
| <SkipForward size={16} /> | ||
| </button> | ||
| </div> | ||
| <div | ||
| className="group touch-none group relative hover:z-10 flex h-1 flex-1 min-w-0 cursor-pointer select-none items-center" | ||
| onClick={handleSliderClick} | ||
| onKeyDown={(e) => { | ||
| if (e.key === "Enter" || e.key === " ") { | ||
| e.preventDefault(); | ||
| // Focus the slider handle for keyboard navigation | ||
| const handle = e.currentTarget.querySelector( | ||
| '[role="slider"]', | ||
| ) as HTMLElement; | ||
| handle?.focus(); | ||
| } | ||
| }} | ||
| > | ||
| <span className="relative h-full w-full rounded-full bg-muted"> | ||
| <span | ||
| className="absolute h-full rounded-full bg-blue-500" | ||
| style={{ | ||
| left: "0%", | ||
| width: `${getProgressPercentage}%`, | ||
| }} | ||
| ></span> | ||
rossmanko marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| </span> | ||
| {currentIndex >= 0 && ( | ||
| <span | ||
| className="absolute -translate-x-1/2 p-[3px]" | ||
| style={{ | ||
| left: `${getProgressPercentage}%`, | ||
| }} | ||
| > | ||
| <span | ||
| role="slider" | ||
| tabIndex={0} | ||
| aria-valuemin={0} | ||
| aria-valuemax={maxIndex} | ||
| aria-valuenow={currentIndex} | ||
| aria-label={`Tool execution ${currentIndex + 1}`} | ||
| className="relative block h-[14px] w-[14px] rounded-full bg-blue-500 transition-all focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 border-2 border-background drop-shadow-[0px_1px_4px_rgba(0,0,0,0.06)]" | ||
| ></span> | ||
| </span> | ||
| )} | ||
| </div> | ||
| <div className="flex items-center gap-1 text-sm ms-[2px] cursor-default"> | ||
| <div | ||
| className={`h-[8px] w-[8px] rounded-full ${ | ||
| status === "streaming" | ||
| ? "bg-green-500" | ||
| : "bg-muted-foreground" | ||
| }`} | ||
| ></div> | ||
| <span | ||
| className={ | ||
| status === "streaming" | ||
| ? "text-foreground" | ||
| : "text-muted-foreground" | ||
| } | ||
| > | ||
| live | ||
| </span> | ||
| </div> | ||
| {!isAtLive && ( | ||
| <button | ||
| onClick={handleJumpToLive} | ||
| className="h-10 px-4 border border-border flex items-center gap-2 bg-background hover:bg-muted shadow-[0px_5px_16px_0px_rgba(0,0,0,0.1),0px_0px_1.25px_0px_rgba(0,0,0,0.1)] rounded-full cursor-pointer absolute left-[50%] translate-x-[-50%]" | ||
| style={{ bottom: "calc(100% + 10px)" }} | ||
| aria-label="Jump to live" | ||
| > | ||
| <Play size={16} className="text-foreground" /> | ||
| <span className="text-foreground text-sm font-medium"> | ||
| Jump to live | ||
| </span> | ||
| </button> | ||
| )} | ||
| <div></div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
@@ -334,14 +539,21 @@ export const ComputerSidebarBase: React.FC<ComputerSidebarProps> = ({ | |
| }; | ||
|
|
||
| // Wrapper for normal chats using GlobalState | ||
| export const ComputerSidebar: React.FC = () => { | ||
| const { sidebarOpen, sidebarContent, closeSidebar } = useGlobalState(); | ||
| export const ComputerSidebar: React.FC<{ | ||
| messages?: any[]; | ||
| status?: ChatStatus; | ||
| }> = ({ messages, status }) => { | ||
| const { sidebarOpen, sidebarContent, closeSidebar, openSidebar } = | ||
| useGlobalState(); | ||
|
|
||
| return ( | ||
| <ComputerSidebarBase | ||
| sidebarOpen={sidebarOpen} | ||
| sidebarContent={sidebarContent} | ||
| closeSidebar={closeSidebar} | ||
| messages={messages} | ||
| onNavigate={openSidebar} | ||
| status={status} | ||
| /> | ||
| ); | ||
| }; | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Sidebar wrap toggle is currently out of sync with
ComputerCodeBlockrenderingYou manage
isWrappedinComputerSidebarBaseand pass it both toCodeActionButtonsand aswrap={isWrapped}toComputerCodeBlock/TerminalCodeBlock, butComputerCodeBlockstill owns its ownisWrappedstate (initialized fromwraponly once) and ignores subsequent prop changes. Because you setshowButtons={false}here, there’s no way for the child to update its ownisWrapped, so clicking the wrap control in the sidebar header updates only the sidebar’sisWrappedand not the actual code wrapping. The visible wrap toggle in the header will therefore not affect the file/Python/result blocks.To make the new sidebar‑level wrap control actually work and avoid the parent/child conflict called out previously, I’d recommend syncing
ComputerCodeBlock’s internal state from thewrapprop when it’s being controlled externally, e.g. something along these lines inComputerCodeBlock.tsx:This lets standalone uses (with
showButtonsdefaulting totrue) keep their internal toggle, while sidebar uses (withshowButtons={false}and a parentwrapprop) behave as a controlled component and reflect the sidebar’sisWrappedcorrectly. Based on learnings, this aligns with the goal of having the sidebar own wrap state.Also applies to: 230-232, 308-336, 351-378
🤖 Prompt for AI Agents