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
90 changes: 51 additions & 39 deletions apps/web/src/components/app/center-pane-tab-bar.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { memo, useCallback } from "react";

import type { DiffStats } from "@/components/app/types";
import { TipSpot } from "@/components/tips/tip-spot";
import { type CenterTab, type SplitPaneState } from "@/lib/store";
import { cn } from "@/lib/utils";

Expand Down Expand Up @@ -54,46 +55,57 @@ export const CenterPaneTabBar = memo(function CenterPaneTabBar({

return (
<div role="tablist" className="pointer-events-auto flex items-center">
{visibleTabs.map((tab) => (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
data-testid={`center-tab-${tab.id}`}
draggable={!isMobile && activeTab !== tab.id}
onDragStart={(e) => handleDragStart(e, tab.id)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
activeTab === tab.id
? "text-foreground"
: "cursor-grab text-muted-foreground hover:text-foreground/80 active:cursor-grabbing"
)}
onClick={() => {
if (isSplit) {
// Clicking a tab in center bar while split exits split mode
// and shows that tab full-width (per spec)
}
onTabChange(tab.id);
}}
>
<span className="relative pb-1.5 -mb-1.5">
{tab.label}
{activeTab === tab.id && !isSplit ? (
<span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-foreground" />
) : null}
</span>
{tab.id === "changes" && hasChanges ? (
<span className="inline-flex items-center gap-1 rounded-full border border-border/50 bg-muted/30 px-1.5 py-0 font-mono text-[10px] font-normal normal-case tracking-normal">
<span className="text-status-working">+{diffStats.added}</span>
<span className="text-status-blocked">
{"−"}
{diffStats.deleted}
</span>
{visibleTabs.map((tab) => {
const button = (
<button
key={tab.id}
type="button"
role="tab"
aria-selected={activeTab === tab.id}
data-testid={`center-tab-${tab.id}`}
draggable={!isMobile && activeTab !== tab.id}
onDragStart={(e) => handleDragStart(e, tab.id)}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 text-xs font-semibold uppercase tracking-wide transition-colors",
activeTab === tab.id
? "text-foreground"
: "cursor-grab text-muted-foreground hover:text-foreground/80 active:cursor-grabbing"
)}
onClick={() => {
if (isSplit) {
// Clicking a tab in center bar while split exits split mode
// and shows that tab full-width (per spec)
}
onTabChange(tab.id);
}}
>
<span className="relative pb-1.5 -mb-1.5">
{tab.label}
{activeTab === tab.id && !isSplit ? (
<span className="absolute bottom-0 left-0 right-0 h-0.5 rounded-full bg-foreground" />
) : null}
</span>
) : null}
</button>
))}
{tab.id === "changes" && hasChanges ? (
<span className="inline-flex items-center gap-1 rounded-full border border-border/50 bg-muted/30 px-1.5 py-0 font-mono text-[10px] font-normal normal-case tracking-normal">
<span className="text-status-working">+{diffStats.added}</span>
<span className="text-status-blocked">
{"−"}
{diffStats.deleted}
</span>
</span>
) : null}
</button>
);

if (tab.id !== "changes" || activeTab === tab.id || isMobile || isSplit)
return button;

return (
<TipSpot key={tab.id} tipId="split-tabs" side="bottom" align="center">
{button}
</TipSpot>
);
})}
</div>
);
});
9 changes: 8 additions & 1 deletion apps/web/src/components/app/docs-sections/agents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,7 @@ export function AgentsContent() {
</Section>

<Section>
<H3>Changes tab</H3>
<H3 id="split-tabs">Changes tab</H3>
<P>
The <strong>Changes</strong> tab next to <strong>Terminal</strong> in
the center pane shows a diff of the agent's uncommitted work against
Expand All @@ -184,6 +184,13 @@ export function AgentsContent() {
edits. These settings persist across sessions. On mobile, the diff
always renders in unified mode.
</P>
<P>
On desktop, drag the <strong>Terminal</strong> or{" "}
<strong>Changes</strong> tab onto the left or right side of the center
pane to split the workspace. Split panes let you keep the terminal and
diff visible together, resize them with the center handle, and return
to a single pane with the unsplit control.
</P>
<P>
Select one or more lines in a diff, then click the comment icon to
leave a note for the agent. The comment is injected into the agent's
Expand Down
35 changes: 32 additions & 3 deletions apps/web/src/components/tips/ambient-tip-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,16 @@ const IDLE_DELAY_MS = 2.5 * 60 * 1000; // 2.5 minutes
const SHOW_CHANCE = 0.4;
const AUTO_HIDE_MS = 30_000;
const ALL_SEEN_MS = 5_000;
const DESKTOP_MEDIA_QUERY = "(min-width: 768px)";

function isTipEligibleForViewport(tip: Tip, isDesktop: boolean): boolean {
return !tip.desktopOnly || isDesktop;
}

function getIsDesktop(): boolean {
if (typeof window === "undefined") return true;
return window.matchMedia(DESKTOP_MEDIA_QUERY).matches;
}

export function AmbientTipBar() {
const navigate = useNavigate();
Expand All @@ -21,6 +31,7 @@ export function AmbientTipBar() {
const setEnabled = useSetAtom(tipsEnabledAtom);

const [visibleTip, setVisibleTip] = useState<Tip | null>(null);
const [isDesktop, setIsDesktop] = useState(getIsDesktop);
const [allSeenMessage, setAllSeenMessage] = useState(false);
const shownThisSessionRef = useRef(new Set<string>());
const hoveredRef = useRef(false);
Expand All @@ -32,12 +43,13 @@ export function AmbientTipBar() {
const eligible = tips.filter(
(t) =>
t.surfaces.includes("ambient") &&
isTipEligibleForViewport(t, isDesktop) &&
!dismissed.includes(t.id) &&
!shownThisSessionRef.current.has(t.id)
);
if (eligible.length === 0) return null;
return eligible[Math.floor(Math.random() * eligible.length)]!;
}, [dismissed]);
}, [dismissed, isDesktop]);

const startAutoHide = useCallback(() => {
clearTimeout(autoHideTimerRef.current);
Expand Down Expand Up @@ -68,6 +80,20 @@ export function AmbientTipBar() {

const lastResetRef = useRef(0);

useEffect(() => {
const media = window.matchMedia(DESKTOP_MEDIA_QUERY);
const onChange = () => setIsDesktop(media.matches);
onChange();
media.addEventListener("change", onChange);
return () => media.removeEventListener("change", onChange);
}, []);

useEffect(() => {
if (visibleTip?.desktopOnly && !isDesktop) {
setVisibleTip(null);
}
}, [isDesktop, visibleTip]);

useEffect(() => {
if (!enabled) {
setVisibleTip(null);
Expand Down Expand Up @@ -126,7 +152,10 @@ export function AmbientTipBar() {
const handleRequestTip = useCallback(() => {
if (visibleTip || allSeenMessage) return;
const eligible = tips.filter(
(t) => t.surfaces.includes("ambient") && !dismissed.includes(t.id)
(t) =>
t.surfaces.includes("ambient") &&
isTipEligibleForViewport(t, isDesktop) &&
!dismissed.includes(t.id)
);
if (eligible.length === 0) {
setAllSeenMessage(true);
Expand All @@ -141,7 +170,7 @@ export function AmbientTipBar() {
shownThisSessionRef.current.add(tip.id);
setVisibleTip(tip);
startAutoHide();
}, [visibleTip, allSeenMessage, dismissed, startAutoHide]);
}, [visibleTip, allSeenMessage, dismissed, isDesktop, startAutoHide]);

useEffect(() => {
return () => clearTimeout(allSeenTimerRef.current);
Expand Down
10 changes: 10 additions & 0 deletions apps/web/src/lib/tips/tips.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type Tip = {
docsSection?: string;
since: string;
surfaces: Surface[];
desktopOnly?: boolean;
};

export const tips: Tip[] = [
Expand Down Expand Up @@ -74,6 +75,15 @@ export const tips: Tip[] = [
since: "0.23.9",
surfaces: ["ambient"],
},
{
id: "split-tabs",
title: "Split Tabs",
body: "Drag the Changes tab to the left or right side of the center pane to compare the diff beside the live terminal. Use the split handle to resize or unsplit.",
docsSection: "agents#split-tabs",
since: "0.27.4",
surfaces: ["inline", "ambient"],
desktopOnly: true,
},
{
id: "session-rename",
title: "Rename Sessions",
Expand Down
Loading