Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 13 additions & 13 deletions app/components/ComputerCodeBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -150,28 +150,28 @@ export const ComputerCodeBlock = ({
)}

{/* Code content - takes full available space */}
<div className="h-full w-full overflow-auto">
<div className={`h-full w-full overflow-auto bg-background`}>
{shouldUsePlainText ? (
<pre
className={`shiki not-prose relative bg-transparent text-sm font-[450] text-card-foreground h-full w-full px-[0.5em] py-[0.5em] rounded-none m-0 min-h-full min-w-0 ${
wrap
? "whitespace-pre-wrap break-words overflow-visible word-break-break-word"
: "overflow-x-auto max-w-full"
isWrapped
? "whitespace-pre-wrap break-words word-break-break-word"
: "whitespace-pre overflow-x-auto"
}`}
>
<code>{codeContent}</code>
<code className="bg-transparent">{codeContent}</code>
</pre>
) : (
<ShikiErrorBoundary
fallback={
<pre
className={`shiki not-prose relative bg-transparent text-sm font-[450] text-card-foreground h-full w-full px-[0.5em] py-[0.5em] rounded-none m-0 min-h-full min-w-0 ${
wrap
? "whitespace-pre-wrap break-words overflow-visible word-break-break-word"
: "overflow-x-auto max-w-full"
isWrapped
? "whitespace-pre-wrap break-words word-break-break-word"
: "whitespace-pre overflow-x-auto"
}`}
>
<code>{codeContent}</code>
<code className="bg-transparent">{codeContent}</code>
</pre>
}
>
Expand All @@ -181,10 +181,10 @@ export const ComputerCodeBlock = ({
delay={150}
addDefaultStyles={false}
showLanguage={false}
className={`shiki not-prose relative bg-transparent text-sm font-[450] text-card-foreground h-full w-full [&_pre]:!bg-transparent [&_pre]:px-[0.5em] [&_pre]:py-[0.5em] [&_pre]:rounded-none [&_pre]:m-0 [&_pre]:h-full [&_pre]:w-full [&_pre]:min-h-full [&_pre]:min-w-0 ${
wrap
? "[&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:overflow-visible [&_pre]:word-break-break-word"
: "[&_pre]:overflow-x-auto [&_pre]:max-w-full"
className={`shiki not-prose relative bg-transparent text-sm font-[450] text-card-foreground h-full w-full [&_pre]:!bg-transparent [&_pre]:px-[0.5em] [&_pre]:py-[0.5em] [&_pre]:rounded-none [&_pre]:m-0 [&_pre]:h-full [&_pre]:w-full [&_pre]:min-h-full [&_pre]:min-w-0 [&_code]:bg-transparent [&_span]:bg-transparent ${
isWrapped
? "[&_pre]:whitespace-pre-wrap [&_pre]:break-words [&_pre]:word-break-break-word"
: "[&_pre]:whitespace-pre [&_pre]:overflow-x-auto"
}`}
>
{codeContent}
Expand Down
242 changes: 227 additions & 15 deletions app/components/ComputerSidebar.tsx
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,
Expand All @@ -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);

Comment on lines 48 to +50
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sidebar wrap toggle is currently out of sync with ComputerCodeBlock rendering

You manage isWrapped in ComputerSidebarBase and pass it both to CodeActionButtons and as wrap={isWrapped} to ComputerCodeBlock/TerminalCodeBlock, but ComputerCodeBlock still owns its own isWrapped state (initialized from wrap only once) and ignores subsequent prop changes. Because you set showButtons={false} here, there’s no way for the child to update its own isWrapped, so clicking the wrap control in the sidebar header updates only the sidebar’s isWrapped and 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 the wrap prop when it’s being controlled externally, e.g. something along these lines in ComputerCodeBlock.tsx:

// inside ComputerCodeBlock
const [isWrapped, setIsWrapped] = useState(wrap);

// When used in controlled mode (sidebar passes wrap and hides local buttons),
// keep internal state in sync with the prop.
useEffect(() => {
  if (!showButtons) {
    setIsWrapped(wrap);
  }
}, [wrap, showButtons]);

This lets standalone uses (with showButtons defaulting to true) keep their internal toggle, while sidebar uses (with showButtons={false} and a parent wrap prop) behave as a controlled component and reflect the sidebar’s isWrapped correctly. 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
In app/components/ComputerSidebar.tsx around lines 48-50 (and similarly at lines
230-232, 308-336, 351-378), the sidebar manages a wrap toggle but child
ComputerCodeBlock/TerminalCodeBlock components initialize their own isWrapped
state from the wrap prop only once and ignore subsequent prop changes; update
the child components so they initialize isWrapped from the wrap prop and add an
effect that, when the component is being externally controlled (showButtons is
false), synchronizes internal isWrapped with the incoming wrap prop (i.e., call
setIsWrapped(wrap) inside a useEffect that depends on [wrap, showButtons]); this
preserves standalone toggle behavior when showButtons is true and makes the
sidebar-controlled wrap toggle actually affect the rendered blocks when
showButtons is false.

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;
Expand Down Expand Up @@ -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
Expand All @@ -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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Diff view guard will skip valid diffs when content is an empty string

The condition:

sidebarContent.action === "editing" &&
sidebarContent.originalContent &&
sidebarContent.modifiedContent

relies 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 ComputerCodeBlock path instead of showing a proper diff.

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 DiffView.

🤖 Prompt for AI Agents
In app/components/ComputerSidebar.tsx around lines 351 to 378, the DiffView
guard uses truthiness for sidebarContent.originalContent and
sidebarContent.modifiedContent which wrongly rejects empty-string content;
change the condition to explicitly check for null/undefined (e.g.
sidebarContent.originalContent != null && sidebarContent.modifiedContent !=
null) while keeping sidebarContent.action === "editing", so empty strings are
treated as valid diff content but missing fields still block DiffView.

)}
{isTerminal && (
<TerminalCodeBlock
Expand Down Expand Up @@ -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>
</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>
Expand All @@ -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}
/>
);
};
Loading