Restore workspace header controls and polish tab management

This commit is contained in:
Mohamed Boudra
2026-03-02 12:23:04 +07:00
parent 18d4df339f
commit c8cef9bec8
22 changed files with 1132 additions and 504 deletions

View File

@@ -26,38 +26,87 @@ async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promis
await expect(page).toHaveURL(new RegExp(`/h/${encodeURIComponent(serverId)}/workspace/`), { await expect(page).toHaveURL(new RegExp(`/h/${encodeURIComponent(serverId)}/workspace/`), {
timeout: 30000, timeout: 30000,
}); });
await expect(page.getByTestId("workspace-new-tab").first()).toBeVisible({ await expect(page.getByTestId("workspace-new-agent-tab").first()).toBeVisible({
timeout: 30000,
});
await expect(page.getByTestId("workspace-new-terminal-tab").first()).toBeVisible({
timeout: 30000, timeout: 30000,
}); });
} }
test("workspace new-tab menu opens on-screen", async ({ page }) => { test("workspace new-tab buttons stay on-screen during horizontal scroll", async ({ page }) => {
const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-"); const repo = await createTempGitRepo("paseo-e2e-workspace-new-tab-");
try { try {
await openWorkspaceWithAgent(page, repo.path); await openWorkspaceWithAgent(page, repo.path);
const trigger = page.getByTestId("workspace-new-tab").first(); const agentButton = page.getByTestId("workspace-new-agent-tab").first();
await expect(trigger).toBeVisible({ timeout: 30000 }); const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
await trigger.click(); const tabsScroll = page.getByTestId("workspace-tabs-scroll").first();
await expect(agentButton).toBeVisible({ timeout: 30000 });
await expect(terminalButton).toBeVisible({ timeout: 30000 });
await expect(tabsScroll).toBeVisible({ timeout: 30000 });
const menu = page.getByTestId("workspace-new-tab-content").first(); // Create enough terminal tabs to ensure the tabs row has overflow to scroll.
await expect(menu).toBeVisible({ timeout: 10000 }); const terminalTabs = page.locator('[data-testid^="workspace-tab-terminal:"]');
const initialTerminalCount = await terminalTabs.count();
const targetTerminalCount = initialTerminalCount + 8;
const menuBounds = await menu.boundingBox(); for (let attempt = initialTerminalCount; attempt < targetTerminalCount; attempt += 1) {
await expect(terminalButton).toBeEnabled({ timeout: 30000 });
await terminalButton.click();
await expect
.poll(async () => await terminalTabs.count(), { timeout: 30000 })
.toBeGreaterThanOrEqual(attempt + 1);
}
const agentBoundsBefore = await agentButton.boundingBox();
const terminalBoundsBefore = await terminalButton.boundingBox();
const viewport = page.viewportSize(); const viewport = page.viewportSize();
expect(menuBounds).not.toBeNull(); expect(agentBoundsBefore).not.toBeNull();
expect(terminalBoundsBefore).not.toBeNull();
expect(viewport).not.toBeNull(); expect(viewport).not.toBeNull();
if (!menuBounds || !viewport) { if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
return; return;
} }
expect(menuBounds.x).toBeGreaterThanOrEqual(0); expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
expect(menuBounds.y).toBeGreaterThanOrEqual(0); expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
expect(menuBounds.x + menuBounds.width).toBeLessThanOrEqual(viewport.width); expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
expect(menuBounds.y + menuBounds.height).toBeLessThanOrEqual(viewport.height); expect(agentBoundsBefore.y + agentBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
expect(terminalBoundsBefore.x).toBeGreaterThanOrEqual(0);
expect(terminalBoundsBefore.y).toBeGreaterThanOrEqual(0);
expect(terminalBoundsBefore.x + terminalBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
expect(terminalBoundsBefore.y + terminalBoundsBefore.height).toBeLessThanOrEqual(viewport.height);
// Scroll tabs horizontally; the new-tab buttons should remain fixed on the right edge.
await tabsScroll.evaluate((el) => {
(el as HTMLElement).scrollLeft = (el as HTMLElement).scrollWidth;
});
await page.waitForTimeout(200);
const agentBoundsAfter = await agentButton.boundingBox();
const terminalBoundsAfter = await terminalButton.boundingBox();
expect(agentBoundsAfter).not.toBeNull();
expect(terminalBoundsAfter).not.toBeNull();
if (!agentBoundsAfter || !terminalBoundsAfter) {
return;
}
expect(agentBoundsAfter.x).toBeGreaterThanOrEqual(0);
expect(agentBoundsAfter.y).toBeGreaterThanOrEqual(0);
expect(agentBoundsAfter.x + agentBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
expect(agentBoundsAfter.y + agentBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
expect(terminalBoundsAfter.x).toBeGreaterThanOrEqual(0);
expect(terminalBoundsAfter.y).toBeGreaterThanOrEqual(0);
expect(terminalBoundsAfter.x + terminalBoundsAfter.width).toBeLessThanOrEqual(viewport.width);
expect(terminalBoundsAfter.y + terminalBoundsAfter.height).toBeLessThanOrEqual(viewport.height);
} finally { } finally {
await repo.cleanup(); await repo.cleanup();
} }

View File

@@ -2,8 +2,7 @@ import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { StyleSheet, useUnistyles } from 'react-native-unistyles' import { StyleSheet, useUnistyles } from 'react-native-unistyles'
import { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native' import { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native'
import Animated, { useAnimatedStyle } from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useIsFocused } from '@react-navigation/native' import { useIsFocused } from '@react-navigation/native'
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout' import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
@@ -37,6 +36,7 @@ import {
} from '@/attachments/service' } from '@/attachments/service'
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard' import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation' import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
type QueuedMessage = { type QueuedMessage = {
id: string id: string
@@ -84,7 +84,6 @@ export function AgentInputArea({
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`) markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
const { theme } = useUnistyles() const { theme } = useUnistyles()
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
const isScreenFocused = useIsFocused() const isScreenFocused = useIsFocused()
const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest) const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest)
const clearMessageInputActionRequest = useKeyboardShortcutsStore( const clearMessageInputActionRequest = useKeyboardShortcutsStore(
@@ -551,13 +550,8 @@ export function AgentInputArea({
serverId, serverId,
]) ])
const keyboardAnimatedStyle = useAnimatedStyle(() => { const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
'worklet' mode: 'translate',
const absoluteHeight = Math.abs(keyboardHeight.value)
const shift = Math.max(0, absoluteHeight - insets.bottom)
return {
transform: [{ translateY: -shift }],
}
}) })
function handleCancelAgent() { function handleCancelAgent() {

View File

@@ -1,25 +1,41 @@
import { View } from "react-native"; import { View } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { StyleSheet, useUnistyles } from "react-native-unistyles";
import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "@server/shared/agent-lifecycle";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
export function AgentStatusDot({ export function AgentStatusDot({
status, status,
requiresAttention, requiresAttention,
attentionReason,
pendingPermissionCount,
showInactive = false, showInactive = false,
}: { }: {
status: string | null | undefined; status: string | null | undefined;
requiresAttention: boolean | null | undefined; requiresAttention: boolean | null | undefined;
attentionReason?: "finished" | "error" | "permission" | null;
pendingPermissionCount?: number;
showInactive?: boolean; showInactive?: boolean;
}) { }) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isRunning = status === "running"; if (!status) {
const color = isRunning return null;
? theme.colors.palette.blue[500] }
: requiresAttention if (!isAgentLifecycleStatus(status)) {
? theme.colors.success return null;
: showInactive }
? theme.colors.border
: null; const bucket = deriveSidebarStateBucket({
status,
requiresAttention: Boolean(requiresAttention),
attentionReason: attentionReason ?? null,
pendingPermissionCount: pendingPermissionCount ?? 0,
});
const color = getStatusDotColor({ theme, bucket, showDoneAsInactive: showInactive });
if (!color) { if (!color) {
return null; return null;
@@ -28,6 +44,10 @@ export function AgentStatusDot({
return <View style={[styles.dot, { backgroundColor: color }]} />; return <View style={[styles.dot, { backgroundColor: color }]} />;
} }
function isAgentLifecycleStatus(value: string): value is AgentLifecycleStatus {
return AGENT_LIFECYCLE_STATUSES.some((status) => status === value);
}
const styles = StyleSheet.create((theme) => ({ const styles = StyleSheet.create((theme) => ({
dot: { dot: {
width: 8, width: 8,

View File

@@ -17,6 +17,7 @@ export function DraggableList<T>({
renderItem, renderItem,
onDragEnd, onDragEnd,
style, style,
containerStyle,
contentContainerStyle, contentContainerStyle,
testID, testID,
ListFooterComponent, ListFooterComponent,
@@ -24,6 +25,8 @@ export function DraggableList<T>({
ListEmptyComponent, ListEmptyComponent,
showsVerticalScrollIndicator = true, showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false, enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
scrollEnabled = true,
useDragHandle: _useDragHandle = false,
refreshing, refreshing,
onRefresh, onRefresh,
simultaneousGestureRef, simultaneousGestureRef,
@@ -70,6 +73,8 @@ export function DraggableList<T>({
}, []); }, []);
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing)); const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
const resolvedContainerStyle =
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
return ( return (
<DraggableFlatList <DraggableFlatList
@@ -79,12 +84,13 @@ export function DraggableList<T>({
renderItem={handleRenderItem} renderItem={handleRenderItem}
onDragEnd={handleDragEnd} onDragEnd={handleDragEnd}
style={style} style={style}
containerStyle={{ flex: 1 }} containerStyle={resolvedContainerStyle}
contentContainerStyle={contentContainerStyle} contentContainerStyle={contentContainerStyle}
ListFooterComponent={ListFooterComponent} ListFooterComponent={ListFooterComponent}
ListHeaderComponent={ListHeaderComponent} ListHeaderComponent={ListHeaderComponent}
ListEmptyComponent={ListEmptyComponent} ListEmptyComponent={ListEmptyComponent}
showsVerticalScrollIndicator={showsVerticalScrollIndicator} showsVerticalScrollIndicator={showsVerticalScrollIndicator}
scrollEnabled={scrollEnabled}
simultaneousHandlers={simultaneousHandlers} simultaneousHandlers={simultaneousHandlers}
// Higher activationDistance prevents drag from interfering with nested onLongPress handlers // Higher activationDistance prevents drag from interfering with nested onLongPress handlers
activationDistance={20} activationDistance={20}

View File

@@ -2,11 +2,22 @@ import type { ReactElement, MutableRefObject } from "react";
import type { StyleProp, ViewStyle } from "react-native"; import type { StyleProp, ViewStyle } from "react-native";
import type { GestureType } from "react-native-gesture-handler"; import type { GestureType } from "react-native-gesture-handler";
export interface DraggableListDragHandleProps {
/**
* Web-only drag handle props (from dnd-kit). Spread these onto the element
* that should initiate the drag. Native uses the `drag()` callback instead.
*/
attributes?: Record<string, unknown>;
listeners?: Record<string, unknown>;
setActivatorNodeRef?: (node: unknown) => void;
}
export interface DraggableRenderItemInfo<T> { export interface DraggableRenderItemInfo<T> {
item: T; item: T;
index: number; index: number;
drag: () => void; drag: () => void;
isActive: boolean; isActive: boolean;
dragHandleProps?: DraggableListDragHandleProps;
} }
export interface DraggableListProps<T> { export interface DraggableListProps<T> {
@@ -15,6 +26,8 @@ export interface DraggableListProps<T> {
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement; renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
onDragEnd: (data: T[]) => void; onDragEnd: (data: T[]) => void;
style?: StyleProp<ViewStyle>; style?: StyleProp<ViewStyle>;
/** Outer container style (useful for nested, non-scrolling lists). */
containerStyle?: StyleProp<ViewStyle>;
contentContainerStyle?: StyleProp<ViewStyle>; contentContainerStyle?: StyleProp<ViewStyle>;
testID?: string; testID?: string;
ListFooterComponent?: ReactElement | null; ListFooterComponent?: ReactElement | null;
@@ -22,6 +35,13 @@ export interface DraggableListProps<T> {
ListEmptyComponent?: ReactElement | null; ListEmptyComponent?: ReactElement | null;
showsVerticalScrollIndicator?: boolean; showsVerticalScrollIndicator?: boolean;
enableDesktopWebScrollbar?: boolean; enableDesktopWebScrollbar?: boolean;
/** When false, disables internal scrolling (use outer list to scroll). */
scrollEnabled?: boolean;
/**
* Web-only: when true, the drag can only be initiated from the handle props
* passed to `renderItem` (prevents nested lists from fighting).
*/
useDragHandle?: boolean;
refreshing?: boolean; refreshing?: boolean;
onRefresh?: () => void; onRefresh?: () => void;
/** Fill remaining space when content is smaller than container */ /** Fill remaining space when content is smaller than container */

View File

@@ -41,6 +41,7 @@ interface SortableItemProps<T> {
index: number; index: number;
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement; renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
activeId: string | null; activeId: string | null;
useDragHandle: boolean;
} }
function SortableItem<T>({ function SortableItem<T>({
@@ -49,11 +50,13 @@ function SortableItem<T>({
index, index,
renderItem, renderItem,
activeId, activeId,
useDragHandle,
}: SortableItemProps<T>) { }: SortableItemProps<T>) {
const { const {
attributes, attributes,
listeners, listeners,
setNodeRef, setNodeRef,
setActivatorNodeRef,
transform, transform,
transition, transition,
isDragging, isDragging,
@@ -87,10 +90,23 @@ function SortableItem<T>({
index, index,
drag, drag,
isActive: activeId === id, isActive: activeId === id,
dragHandleProps: useDragHandle
? {
attributes: attributes as unknown as Record<string, unknown>,
listeners: listeners as unknown as Record<string, unknown>,
setActivatorNodeRef: setActivatorNodeRef as unknown as (
node: unknown
) => void,
}
: undefined,
}; };
const wrapperProps = useDragHandle
? { ref: setNodeRef }
: { ref: setNodeRef, ...attributes, ...listeners };
return ( return (
<div ref={setNodeRef} style={style} {...attributes} {...listeners}> <div {...wrapperProps} style={style}>
{renderItem(info)} {renderItem(info)}
</div> </div>
); );
@@ -102,6 +118,7 @@ export function DraggableList<T>({
renderItem, renderItem,
onDragEnd, onDragEnd,
style, style,
containerStyle,
contentContainerStyle, contentContainerStyle,
testID, testID,
ListFooterComponent, ListFooterComponent,
@@ -109,6 +126,8 @@ export function DraggableList<T>({
ListEmptyComponent, ListEmptyComponent,
showsVerticalScrollIndicator = true, showsVerticalScrollIndicator = true,
enableDesktopWebScrollbar = false, enableDesktopWebScrollbar = false,
scrollEnabled = true,
useDragHandle = false,
// simultaneousGestureRef is native-only, ignored on web // simultaneousGestureRef is native-only, ignored on web
onDragBegin, onDragBegin,
}: DraggableListProps<T>) { }: DraggableListProps<T>) {
@@ -161,10 +180,16 @@ export function DraggableList<T>({
); );
const ids = items.map((item, index) => keyExtractor(item, index)); const ids = items.map((item, index) => keyExtractor(item, index));
const showCustomScrollbar = enableDesktopWebScrollbar; const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
const wrapperStyle = [
{ position: "relative" as const },
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
containerStyle,
];
return ( return (
<View style={{ flex: 1, minHeight: 0, position: "relative" }}> <View style={wrapperStyle}>
{scrollEnabled ? (
<ScrollView <ScrollView
ref={scrollViewRef} ref={scrollViewRef}
testID={testID} testID={testID}
@@ -200,6 +225,7 @@ export function DraggableList<T>({
index={index} index={index}
renderItem={renderItem} renderItem={renderItem}
activeId={activeId} activeId={activeId}
useDragHandle={useDragHandle}
/> />
); );
})} })}
@@ -207,6 +233,37 @@ export function DraggableList<T>({
</DndContext> </DndContext>
{ListFooterComponent} {ListFooterComponent}
</ScrollView> </ScrollView>
) : (
<>
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
{items.map((item, index) => {
const id = keyExtractor(item, index);
return (
<SortableItem
key={id}
id={id}
item={item}
index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</>
)}
<WebDesktopScrollbarOverlay <WebDesktopScrollbarOverlay
enabled={showCustomScrollbar} enabled={showCustomScrollbar}
metrics={scrollbarMetrics} metrics={scrollbarMetrics}

View File

@@ -9,7 +9,6 @@ import Animated, {
} from "react-native-reanimated"; } from "react-native-reanimated";
import { Gesture, GestureDetector } from "react-native-gesture-handler"; import { Gesture, GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { X } from "lucide-react-native"; import { X } from "lucide-react-native";
import { import {
usePanelStore, usePanelStore,
@@ -21,9 +20,9 @@ import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animati
import { HEADER_INNER_HEIGHT } from "@/constants/layout"; import { HEADER_INNER_HEIGHT } from "@/constants/layout";
import { GitDiffPane } from "./git-diff-pane"; import { GitDiffPane } from "./git-diff-pane";
import { FileExplorerPane } from "./file-explorer-pane"; import { FileExplorerPane } from "./file-explorer-pane";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
const MIN_CHAT_WIDTH = 400; const MIN_CHAT_WIDTH = 400;
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__); const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
function logExplorerSidebar(event: string, details: Record<string, unknown>): void { function logExplorerSidebar(event: string, details: Record<string, unknown>): void {
@@ -33,16 +32,6 @@ function logExplorerSidebar(event: string, details: Record<string, unknown>): vo
console.log(`[ExplorerSidebar] ${event}`, details); console.log(`[ExplorerSidebar] ${event}`, details);
} }
function resolveKeyboardShift(rawHeight: number, inset: number): number {
"worklet";
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so terminal scroll gestures don't "bounce" the layout.
if (Platform.OS === "ios" && rawHeight < IOS_KEYBOARD_INSET_MIN_HEIGHT) {
return 0;
}
return Math.max(0, rawHeight - inset);
}
interface ExplorerSidebarProps { interface ExplorerSidebarProps {
serverId: string; serverId: string;
workspaceId?: string | null; workspaceId?: string | null;
@@ -69,14 +58,13 @@ export function ExplorerSidebar({
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout); const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth); const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
const { width: viewportWidth } = useWindowDimensions(); const { width: viewportWidth } = useWindowDimensions();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
const closeTouchStartX = useSharedValue(0); const closeTouchStartX = useSharedValue(0);
const closeTouchStartY = useSharedValue(0); const closeTouchStartY = useSharedValue(0);
useEffect(() => { const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
bottomInset.value = insets.bottom; mode: "padding",
}, [bottomInset, insets.bottom]); enabled: isMobile,
});
useEffect(() => { useEffect(() => {
if (isMobile) { if (isMobile) {
@@ -257,14 +245,6 @@ export function ExplorerSidebar({
pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none", pointerEvents: backdropOpacity.value > 0.01 ? "auto" : "none",
})); }));
const mobileKeyboardInsetStyle = useAnimatedStyle(() => {
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = resolveKeyboardShift(absoluteHeight, bottomInset.value);
return {
paddingBottom: bottomInset.value + shift,
};
});
const resizeAnimatedStyle = useAnimatedStyle(() => ({ const resizeAnimatedStyle = useAnimatedStyle(() => ({
width: resizeWidth.value, width: resizeWidth.value,
})); }));
@@ -322,7 +302,9 @@ export function ExplorerSidebar({
} }
return ( return (
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}> <Animated.View
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
>
{/* Resize handle - absolutely positioned over left border */} {/* Resize handle - absolutely positioned over left border */}
<GestureDetector gesture={resizeGesture}> <GestureDetector gesture={resizeGesture}>
<View <View

View File

@@ -6,6 +6,7 @@ import Animated, {
interpolate, interpolate,
Extrapolation, Extrapolation,
runOnJS, runOnJS,
useSharedValue,
} from 'react-native-reanimated' } from 'react-native-reanimated'
import { Gesture, GestureDetector } from 'react-native-gesture-handler' import { Gesture, GestureDetector } from 'react-native-gesture-handler'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
@@ -127,6 +128,8 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
} = useSidebarAnimation() } = useSidebarAnimation()
const dragHandlers = useTauriDragHandlers() const dragHandlers = useTauriDragHandlers()
const trafficLightPadding = useTrafficLightPadding() const trafficLightPadding = useTrafficLightPadding()
const closeTouchStartX = useSharedValue(0)
const closeTouchStartY = useSharedValue(0)
// Track user-initiated refresh to avoid showing spinner on background revalidation // Track user-initiated refresh to avoid showing spinner on background revalidation
const [isManualRefresh, setIsManualRefresh] = useState(false) const [isManualRefresh, setIsManualRefresh] = useState(false)
@@ -236,17 +239,47 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
) )
// Close gesture (swipe left to close when sidebar is open) // Close gesture (swipe left to close when sidebar is open)
// Only activates on leftward swipe, fails on rightward or vertical movement
// This mirrors the explorer-sidebar pattern for the right sidebar
const closeGesture = Gesture.Pan() const closeGesture = Gesture.Pan()
.withRef(closeGestureRef) .withRef(closeGestureRef)
.enabled(isOpen) .enabled(isOpen)
// Only activate on leftward swipe (negative X) // Use manual activation so child views keep touch streams unless we detect
.activeOffsetX(-15) // an intentional left-swipe close (mirrors explorer-sidebar pattern).
// Fail on rightward movement (allow internal list scrolling) .manualActivation(true)
.failOffsetX(10) .onTouchesDown((event) => {
// Fail if vertical movement happens first (allow vertical scroll) const touch = event.changedTouches[0]
.failOffsetY([-10, 10]) if (!touch) {
return
}
closeTouchStartX.value = touch.absoluteX
closeTouchStartY.value = touch.absoluteY
})
.onTouchesMove((event, stateManager) => {
const touch = event.changedTouches[0]
if (!touch || event.numberOfTouches !== 1) {
stateManager.fail()
return
}
const deltaX = touch.absoluteX - closeTouchStartX.value
const deltaY = touch.absoluteY - closeTouchStartY.value
const absDeltaX = Math.abs(deltaX)
const absDeltaY = Math.abs(deltaY)
// Fail quickly on clear rightward or vertical intent so child views keep control.
if (deltaX >= 10) {
stateManager.fail()
return
}
if (absDeltaY > 10 && absDeltaY > absDeltaX) {
stateManager.fail()
return
}
// Activate only on intentional leftward movement.
if (deltaX <= -15 && absDeltaX > absDeltaY) {
stateManager.activate()
}
})
.onStart(() => { .onStart(() => {
isGesturing.value = true isGesturing.value = true
}) })

View File

@@ -9,20 +9,21 @@ import {
type ReactElement, type ReactElement,
type MutableRefObject, type MutableRefObject,
} from 'react' } from 'react'
import { router, usePathname } from 'expo-router' import { router, useSegments } from 'expo-router'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { type GestureType } from 'react-native-gesture-handler' import { type GestureType } from 'react-native-gesture-handler'
import { ChevronDown, ChevronRight } from 'lucide-react-native' import { ChevronDown, ChevronRight } from 'lucide-react-native'
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list' import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime' import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
import { projectIconQueryKey } from '@/hooks/use-project-icon-query' import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from '@/utils/host-routes' import { buildHostWorkspaceRoute } from '@/utils/host-routes'
import { import {
type SidebarProjectEntry, type SidebarProjectEntry,
type SidebarWorkspaceEntry, type SidebarWorkspaceEntry,
} from '@/hooks/use-sidebar-agents-list' } from '@/hooks/use-sidebar-agents-list'
import { useSidebarOrderStore } from '@/stores/sidebar-order-store' import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
import { formatTimeAgo } from '@/utils/time' import { formatTimeAgo } from '@/utils/time'
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
type SidebarTreeRow = type SidebarTreeRow =
| { | {
@@ -68,7 +69,6 @@ interface ProjectRowProps {
interface WorkspaceRowProps { interface WorkspaceRowProps {
workspace: SidebarWorkspaceEntry workspace: SidebarWorkspaceEntry
compact?: boolean
onPress: () => void onPress: () => void
onLongPress: () => void onLongPress: () => void
} }
@@ -116,11 +116,9 @@ function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): strin
return formatTimeAgo(workspace.createdAt) return formatTimeAgo(workspace.createdAt)
} }
function ProjectStatusDot({ bucket }: { bucket: SidebarProjectEntry['statusBucket'] }) { function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['theme']; bucket: SidebarStateBucket }) {
const { theme } = useUnistyles() const { theme, bucket } = input
return bucket === 'needs_input'
const color =
bucket === 'needs_input'
? theme.colors.palette.amber[500] ? theme.colors.palette.amber[500]
: bucket === 'failed' : bucket === 'failed'
? theme.colors.palette.red[500] ? theme.colors.palette.red[500]
@@ -129,8 +127,12 @@ function ProjectStatusDot({ bucket }: { bucket: SidebarProjectEntry['statusBucke
: bucket === 'attention' : bucket === 'attention'
? theme.colors.palette.green[500] ? theme.colors.palette.green[500]
: theme.colors.border : theme.colors.border
}
return <View style={[styles.projectStatusDot, { backgroundColor: color }]} /> function WorkspaceStatusDot({ bucket }: { bucket: SidebarWorkspaceEntry['statusBucket'] }) {
const { theme } = useUnistyles()
const color = resolveStatusDotColor({ theme, bucket })
return <View style={[styles.workspaceStatusDot, { backgroundColor: color }]} />
} }
function ProjectRow({ function ProjectRow({
@@ -185,19 +187,15 @@ function ProjectRow({
</View> </View>
)} )}
<ProjectStatusDot bucket={project.statusBucket} />
<Text style={styles.projectTitle} numberOfLines={1}> <Text style={styles.projectTitle} numberOfLines={1}>
{displayName} {displayName}
</Text> </Text>
</View> </View>
<Text style={styles.projectCountText}>{project.workspaces.length}</Text>
</Pressable> </Pressable>
) )
} }
function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: WorkspaceRowProps) { function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
const didLongPressRef = useRef(false) const didLongPressRef = useRef(false)
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace) const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
@@ -218,7 +216,6 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
<Pressable <Pressable
style={({ pressed, hovered = false }) => [ style={({ pressed, hovered = false }) => [
styles.workspaceRow, styles.workspaceRow,
compact && styles.workspaceRowCompact,
hovered && styles.workspaceRowHovered, hovered && styles.workspaceRowHovered,
pressed && styles.workspaceRowPressed, pressed && styles.workspaceRowPressed,
]} ]}
@@ -227,9 +224,12 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
delayLongPress={200} delayLongPress={200}
testID={`sidebar-workspace-row-${workspace.workspaceKey}`} testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
> >
<View style={styles.workspaceRowLeft}>
<WorkspaceStatusDot bucket={workspace.statusBucket} />
<Text style={styles.workspaceBranchText} numberOfLines={1}> <Text style={styles.workspaceBranchText} numberOfLines={1}>
{resolveWorkspaceBranchLabel(workspace)} {resolveWorkspaceBranchLabel(workspace)}
</Text> </Text>
</View>
{createdAtLabel ? ( {createdAtLabel ? (
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}> <Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
{createdAtLabel} {createdAtLabel}
@@ -273,7 +273,8 @@ export function SidebarAgentList({
}: SidebarAgentListProps) { }: SidebarAgentListProps) {
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm' const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
const pathname = usePathname() const segments = useSegments()
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set()) const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0) const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
@@ -419,13 +420,11 @@ export function SidebarAgentList({
} }
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd) const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
const shouldReplace = Boolean(parseHostWorkspaceRouteFromPathname(pathname)) const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
const navigate = shouldReplace ? router.replace : router.push
return ( return (
<WorkspaceRow <WorkspaceRow
workspace={item.workspace} workspace={item.workspace}
compact={isMobile}
onPress={() => { onPress={() => {
if (!serverId) { if (!serverId) {
return return
@@ -441,9 +440,9 @@ export function SidebarAgentList({
collapsedProjectKeys, collapsedProjectKeys,
isMobile, isMobile,
onWorkspacePress, onWorkspacePress,
pathname,
projectIconByProjectKey, projectIconByProjectKey,
serverId, serverId,
shouldReplaceWorkspaceNavigation,
toggleProjectCollapsed, toggleProjectCollapsed,
] ]
) )
@@ -603,27 +602,16 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
fontSize: 9, fontSize: 9,
}, },
projectStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
projectTitle: { projectTitle: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },
projectCountText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
flexShrink: 0,
},
workspaceRow: { workspaceRow: {
minHeight: 34, minHeight: 36,
marginBottom: theme.spacing[1], marginBottom: theme.spacing[1],
marginLeft: theme.spacing[4], paddingVertical: theme.spacing[2],
paddingVertical: theme.spacing[1],
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
flexDirection: 'row', flexDirection: 'row',
@@ -631,9 +619,12 @@ const styles = StyleSheet.create((theme) => ({
justifyContent: 'space-between', justifyContent: 'space-between',
gap: theme.spacing[2], gap: theme.spacing[2],
}, },
workspaceRowCompact: { workspaceRowLeft: {
marginLeft: theme.spacing[3], flexDirection: 'row',
paddingHorizontal: theme.spacing[1], alignItems: 'center',
gap: theme.spacing[2],
flex: 1,
minWidth: 0,
}, },
workspaceRowHovered: { workspaceRowHovered: {
backgroundColor: theme.colors.surface1, backgroundColor: theme.colors.surface1,
@@ -641,9 +632,15 @@ const styles = StyleSheet.create((theme) => ({
workspaceRowPressed: { workspaceRowPressed: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
workspaceStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
flexShrink: 0,
},
workspaceBranchText: { workspaceBranchText: {
color: theme.colors.foreground, color: theme.colors.foreground,
fontSize: theme.fontSize.xs, fontSize: theme.fontSize.sm,
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
}, },

View File

@@ -9,6 +9,7 @@ import {
View, View,
} from "react-native"; } from "react-native";
import { Plus, X } from "lucide-react-native"; import { Plus, X } from "lucide-react-native";
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
import Svg, { import Svg, {
Defs, Defs,
LinearGradient as SvgLinearGradient, LinearGradient as SvgLinearGradient,
@@ -19,6 +20,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import type { ListTerminalsResponse } from "@server/shared/messages"; import type { ListTerminalsResponse } from "@server/shared/messages";
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input"; import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
import { useHostRuntimeSession } from "@/runtime/host-runtime"; import { useHostRuntimeSession } from "@/runtime/host-runtime";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { import {
hasPendingTerminalModifiers, hasPendingTerminalModifiers,
normalizeTerminalTransportKey, normalizeTerminalTransportKey,
@@ -145,6 +147,10 @@ export function TerminalPane({
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const isMobile = const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm"; UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
mode: "padding",
enabled: isMobile,
});
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { client, isConnected } = useHostRuntimeSession(serverId); const { client, isConnected } = useHostRuntimeSession(serverId);
@@ -185,6 +191,7 @@ export function TerminalPane({
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId); const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]); const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
const updateSelectedTerminalId = useCallback( const updateSelectedTerminalId = useCallback(
( (
@@ -298,6 +305,41 @@ export function TerminalPane({
setResizeRequestToken((current) => current + 1); setResizeRequestToken((current) => current + 1);
}, []); }, []);
const clearKeyboardRefitTimeouts = useCallback(() => {
if (keyboardRefitTimeoutsRef.current.length === 0) {
return;
}
for (const handle of keyboardRefitTimeoutsRef.current) {
clearTimeout(handle);
}
keyboardRefitTimeoutsRef.current = [];
}, []);
const pulseKeyboardRefits = useCallback(() => {
clearKeyboardRefitTimeouts();
requestTerminalReflow();
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
setTimeout(() => {
requestTerminalReflow();
}, delayMs)
);
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
useEffect(() => {
return () => clearKeyboardRefitTimeouts();
}, [clearKeyboardRefitTimeouts]);
useAnimatedReaction(
() => keyboardShift.value > 0,
(next, prev) => {
if (next === prev) {
return;
}
runOnJS(pulseKeyboardRefits)();
},
[pulseKeyboardRefits]
);
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
if (!selectedTerminalId) { if (!selectedTerminalId) {
@@ -353,16 +395,8 @@ export function TerminalPane({
streamId: message.payload.streamId, streamId: message.payload.streamId,
}); });
setModifiers({ ...EMPTY_MODIFIERS }); setModifiers({ ...EMPTY_MODIFIERS });
void queryClient.invalidateQueries({
queryKey: terminalsQueryKey,
}); });
void queryClient.refetchQueries({ }, [client, isConnected]);
queryKey: terminalsQueryKey,
type: "active",
});
});
}, [client, isConnected, queryClient, terminalsQueryKey]);
useEffect(() => { useEffect(() => {
if ( if (
@@ -381,13 +415,12 @@ export function TerminalPane({
if (message.payload.cwd !== cwd) { if (message.payload.cwd !== cwd) {
return; return;
} }
void queryClient.invalidateQueries({
queryKey: terminalsQueryKey, queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
}); cwd: message.payload.cwd,
void queryClient.refetchQueries({ terminals: message.payload.terminals,
queryKey: terminalsQueryKey, requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
type: "active", }));
});
}); });
client.subscribeTerminals({ cwd }); client.subscribeTerminals({ cwd });
@@ -960,7 +993,7 @@ export function TerminalPane({
const combinedError = streamError ?? closeError ?? createError ?? queryError; const combinedError = streamError ?? closeError ?? createError ?? queryError;
return ( return (
<View style={styles.container}> <Animated.View style={[styles.container, keyboardPaddingStyle]}>
{!hideHeader ? ( {!hideHeader ? (
<View style={styles.header} testID="terminals-header"> <View style={styles.header} testID="terminals-header">
<ScrollView <ScrollView
@@ -1161,7 +1194,7 @@ export function TerminalPane({
</ScrollView> </ScrollView>
</View> </View>
) : null} ) : null}
</View> </Animated.View>
); );
} }

View File

@@ -0,0 +1,84 @@
import { useEffect } from "react";
import { Platform } from "react-native";
import type { ViewStyle } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import {
useAnimatedStyle,
useDerivedValue,
useSharedValue,
type SharedValue,
} from "react-native-reanimated";
const DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
function resolveKeyboardShift(input: {
rawKeyboardHeight: number;
bottomInset: number;
isIos: boolean;
iosMinHeight: number;
enabled: boolean;
}): number {
"worklet";
if (!input.enabled) {
return 0;
}
// iOS can report a small accessory/prediction bar height during touch focus.
// Treat that as non-keyboard so layouts don't "bounce" while interacting.
if (input.isIos && input.rawKeyboardHeight < input.iosMinHeight) {
return 0;
}
return Math.max(0, input.rawKeyboardHeight - input.bottomInset);
}
type KeyboardShiftMode = "translate" | "padding";
export function useKeyboardShiftStyle(input: {
mode: KeyboardShiftMode;
enabled?: boolean;
iosMinHeight?: number;
}): {
shift: SharedValue<number>;
style: ReturnType<typeof useAnimatedStyle<ViewStyle>>;
} {
const insets = useSafeAreaInsets();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
const bottomInset = useSharedValue(insets.bottom);
const enabled = input.enabled ?? true;
const isIos = Platform.OS === "ios";
const iosMinHeight = input.iosMinHeight ?? DEFAULT_IOS_KEYBOARD_INSET_MIN_HEIGHT;
useEffect(() => {
bottomInset.value = insets.bottom;
}, [bottomInset, insets.bottom]);
const shift = useDerivedValue(() => {
"worklet";
const rawKeyboardHeight = Math.abs(keyboardHeight.value);
return resolveKeyboardShift({
rawKeyboardHeight,
bottomInset: bottomInset.value,
isIos,
iosMinHeight,
enabled,
});
});
const style = useAnimatedStyle<ViewStyle>(() => {
"worklet";
if (input.mode === "padding") {
if (!enabled) {
return { paddingBottom: 0 };
}
// Include safe-area bottom inset so content clears the home indicator even without a keyboard.
return { paddingBottom: bottomInset.value + shift.value };
}
return { transform: [{ translateY: -shift.value }] };
}, [input.mode]);
return { shift, style };
}

View File

@@ -25,6 +25,7 @@ export interface SidebarWorkspaceEntry {
createdAt: Date | null createdAt: Date | null
isMainCheckout: boolean isMainCheckout: boolean
isPaseoOwnedWorktree: boolean isPaseoOwnedWorktree: boolean
statusBucket: SidebarStateBucket
} }
export interface SidebarProjectEntry { export interface SidebarProjectEntry {
@@ -54,6 +55,7 @@ interface MutableWorkspaceEntry {
createdAt: Date | null createdAt: Date | null
isMainCheckout: boolean isMainCheckout: boolean
isPaseoOwnedWorktree: boolean isPaseoOwnedWorktree: boolean
statusBucket: SidebarStateBucket
} }
interface MutableProjectEntry { interface MutableProjectEntry {
@@ -256,6 +258,7 @@ function ensureWorkspace(
createdAt: input.createdAt, createdAt: input.createdAt,
isMainCheckout: input.isMainCheckout, isMainCheckout: input.isMainCheckout,
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree, isPaseoOwnedWorktree: input.isPaseoOwnedWorktree,
statusBucket: 'done',
} }
project.workspacesByKey.set(workspaceKey, workspace) project.workspacesByKey.set(workspaceKey, workspace)
return workspace return workspace
@@ -394,6 +397,7 @@ export function useSidebarAgentsList(options?: {
isMainCheckout, isMainCheckout,
isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree, isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree,
}) })
workspace.statusBucket = aggregateBucket(workspace.statusBucket, bucket)
const explicitMainRepoRoot = normalizePath(placement.checkout.mainRepoRoot) const explicitMainRepoRoot = normalizePath(placement.checkout.mainRepoRoot)
if (placement.checkout.isPaseoOwnedWorktree && explicitMainRepoRoot) { if (placement.checkout.isPaseoOwnedWorktree && explicitMainRepoRoot) {
@@ -573,6 +577,7 @@ export function useSidebarAgentsList(options?: {
createdAt: workspace.createdAt, createdAt: workspace.createdAt,
isMainCheckout: workspace.isMainCheckout, isMainCheckout: workspace.isMainCheckout,
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree, isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
statusBucket: workspace.statusBucket,
}) })
) )

View File

@@ -12,12 +12,8 @@ import { useRouter } from "expo-router";
import * as Clipboard from "expo-clipboard"; import * as Clipboard from "expo-clipboard";
import { useFocusEffect } from "@react-navigation/native"; import { useFocusEffect } from "@react-navigation/native";
import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import ReanimatedAnimated, { import ReanimatedAnimated from "react-native-reanimated";
useAnimatedStyle,
useSharedValue,
} from "react-native-reanimated";
import { GestureDetector } from "react-native-gesture-handler"; import { GestureDetector } from "react-native-gesture-handler";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles"; import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { import {
@@ -78,6 +74,7 @@ import {
normalizeAgentSnapshot, normalizeAgentSnapshot,
} from "@/utils/agent-snapshots"; } from "@/utils/agent-snapshots";
import { mergePendingCreateImages } from "@/utils/pending-create-images"; import { mergePendingCreateImages } from "@/utils/pending-create-images";
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention"; import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
import type { DaemonClient } from "@server/client/daemon-client"; import type { DaemonClient } from "@server/client/daemon-client";
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture"; import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
@@ -508,20 +505,8 @@ function AgentScreenContent({
[serverId] [serverId]
); );
const { height: keyboardHeight } = useReanimatedKeyboardAnimation(); const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
const bottomInset = useSharedValue(insets.bottom); mode: "translate",
useEffect(() => {
bottomInset.value = insets.bottom;
}, [insets.bottom, bottomInset]);
const animatedKeyboardStyle = useAnimatedStyle(() => {
"worklet";
const absoluteHeight = Math.abs(keyboardHeight.value);
const shift = Math.max(0, absoluteHeight - bottomInset.value);
return {
transform: [{ translateY: -shift }],
};
}); });
const handleHistorySyncFailure = useCallback( const handleHistorySyncFailure = useCallback(

View File

@@ -6,9 +6,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
import { useIsFocused } from '@react-navigation/native' import { useIsFocused } from '@react-navigation/native'
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles' import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
import { useSafeAreaInsets } from 'react-native-safe-area-context' import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
import { GestureDetector } from 'react-native-gesture-handler' import { GestureDetector } from 'react-native-gesture-handler'
import Animated, { useAnimatedStyle, useSharedValue } from 'react-native-reanimated' import Animated from 'react-native-reanimated'
import { Folder, GitBranch, PanelRight } from 'lucide-react-native' import { Folder, GitBranch, PanelRight } from 'lucide-react-native'
import { SidebarMenuToggle } from '@/components/headers/menu-header' import { SidebarMenuToggle } from '@/components/headers/menu-header'
import { HeaderToggleButton } from '@/components/headers/header-toggle-button' import { HeaderToggleButton } from '@/components/headers/header-toggle-button'
@@ -51,6 +50,7 @@ import type {
import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest' import { AGENT_PROVIDER_DEFINITIONS } from '@server/server/agent/provider-manifest'
import { buildHostAgentDetailRoute } from '@/utils/host-routes' import { buildHostAgentDetailRoute } from '@/utils/host-routes'
import { useTauriDragHandlers } from '@/utils/tauri-window' import { useTauriDragHandlers } from '@/utils/tauri-window'
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
const DRAFT_AGENT_ID = '__new_agent__' const DRAFT_AGENT_ID = '__new_agent__'
const EMPTY_PENDING_PERMISSIONS = new Map() const EMPTY_PENDING_PERMISSIONS = new Map()
@@ -148,20 +148,8 @@ function DraftAgentScreenContent({
) )
const params = useLocalSearchParams<DraftAgentParams>() const params = useLocalSearchParams<DraftAgentParams>()
const { height: keyboardHeight } = useReanimatedKeyboardAnimation() const { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
const bottomInset = useSharedValue(insets.bottom) mode: 'translate',
useEffect(() => {
bottomInset.value = insets.bottom
}, [insets.bottom, bottomInset])
const animatedKeyboardStyle = useAnimatedStyle(() => {
'worklet'
const absoluteHeight = Math.abs(keyboardHeight.value)
const shift = Math.max(0, absoluteHeight - bottomInset.value)
return {
transform: [{ translateY: -shift }],
}
}) })
const forcedServerIdParam = forcedServerId?.trim() const forcedServerIdParam = forcedServerId?.trim()

View File

@@ -35,6 +35,7 @@ import {
DropdownMenuItem, DropdownMenuItem,
DropdownMenuTrigger, DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"; } from "@/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { ExplorerSidebar } from "@/components/explorer-sidebar"; import { ExplorerSidebar } from "@/components/explorer-sidebar";
import { TerminalPane } from "@/components/terminal-pane"; import { TerminalPane } from "@/components/terminal-pane";
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context"; import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
@@ -62,6 +63,9 @@ import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
import type { ListTerminalsResponse } from "@server/shared/messages"; import type { ListTerminalsResponse } from "@server/shared/messages";
import { upsertTerminalListEntry } from "@/utils/terminal-list"; import { upsertTerminalListEntry } from "@/utils/terminal-list";
import { confirmDialog } from "@/utils/confirm-dialog"; import { confirmDialog } from "@/utils/confirm-dialog";
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
import { getStatusDotColor } from "@/utils/status-dot-color";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
const TERMINALS_QUERY_STALE_TIME = 5_000; const TERMINALS_QUERY_STALE_TIME = 5_000;
const DROPDOWN_WIDTH = 220; const DROPDOWN_WIDTH = 220;
@@ -358,6 +362,7 @@ function WorkspaceScreenContent({
return payload; return payload;
}, },
}); });
const { archiveAgent, isArchivingAgent } = useArchiveAgent();
useEffect(() => { useEffect(() => {
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) { if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
@@ -371,16 +376,18 @@ function WorkspaceScreenContent({
if (message.payload.cwd !== normalizedWorkspaceId) { if (message.payload.cwd !== normalizedWorkspaceId) {
return; return;
} }
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
void queryClient.refetchQueries({ queryKey: terminalsQueryKey, type: "active" }); queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
cwd: message.payload.cwd,
terminals: message.payload.terminals,
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
}));
}); });
const unsubscribeStreamExit = client.on("terminal_stream_exit", (message) => { const unsubscribeStreamExit = client.on("terminal_stream_exit", (message) => {
if (message.type !== "terminal_stream_exit") { if (message.type !== "terminal_stream_exit") {
return; return;
} }
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
void queryClient.refetchQueries({ queryKey: terminalsQueryKey, type: "active" });
}); });
client.subscribeTerminals({ cwd: normalizedWorkspaceId }); client.subscribeTerminals({ cwd: normalizedWorkspaceId });
@@ -819,14 +826,10 @@ function WorkspaceScreenContent({
[handleCreateAgent, handleCreateTerminal] [handleCreateAgent, handleCreateTerminal]
); );
const getTabAfterClosingTerminal = useCallback( const getTabAfterClosing = useCallback(
(terminalId: string): WorkspaceTabTarget | null => { (tabKey: string): WorkspaceTabTarget | null => {
const currentIndex = tabs.findIndex( const currentIndex = tabs.findIndex((tab) => tab.key === tabKey);
(tab) => tab.kind === "terminal" && tab.terminalId === terminalId const nextTabs = tabs.filter((tab) => tab.key !== tabKey);
);
const nextTabs = tabs.filter(
(tab) => !(tab.kind === "terminal" && tab.terminalId === terminalId)
);
if (nextTabs.length === 0) { if (nextTabs.length === 0) {
return null; return null;
} }
@@ -870,11 +873,21 @@ function WorkspaceScreenContent({
current === tabKey ? null : current current === tabKey ? null : current
); );
if ( queryClient.setQueryData<ListTerminalsPayload>(
resolvedTab?.kind === "terminal" && terminalsQueryKey,
resolvedTab.terminalId === terminalId (current) => {
) { if (!current) {
const nextTab = getTabAfterClosingTerminal(terminalId); return current;
}
return {
...current,
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
};
}
);
if (resolvedTab?.kind === "terminal" && resolvedTab.terminalId === terminalId) {
const nextTab = getTabAfterClosing(`terminal:${terminalId}`);
if (nextTab) { if (nextTab) {
navigateToTab(nextTab); navigateToTab(nextTab);
} else { } else {
@@ -886,17 +899,11 @@ function WorkspaceScreenContent({
); );
} }
} }
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
void queryClient.refetchQueries({
queryKey: terminalsQueryKey,
type: "active",
});
}, },
}); });
}, },
[ [
getTabAfterClosingTerminal, getTabAfterClosing,
killTerminalMutation, killTerminalMutation,
navigateToTab, navigateToTab,
normalizedServerId, normalizedServerId,
@@ -908,6 +915,55 @@ function WorkspaceScreenContent({
] ]
); );
const handleCloseAgentTab = useCallback(
async (agentId: string) => {
if (!normalizedServerId || isArchivingAgent({ serverId: normalizedServerId, agentId })) {
return;
}
const confirmed = await confirmDialog({
title: "Archive agent?",
message: "This closes the tab and archives the agent.",
confirmLabel: "Archive",
cancelLabel: "Cancel",
destructive: true,
});
if (!confirmed) {
return;
}
await archiveAgent({ serverId: normalizedServerId, agentId });
const tabKey = `agent:${agentId}`;
setHoveredTabKey((current) => (current === tabKey ? null : current));
setHoveredCloseTabKey((current) => (current === tabKey ? null : current));
if (resolvedTab?.kind === "agent" && resolvedTab.agentId === agentId) {
const nextTab = getTabAfterClosing(tabKey);
if (nextTab) {
navigateToTab(nextTab);
} else {
router.replace(
buildHostWorkspaceRoute(
normalizedServerId,
normalizedWorkspaceId
) as any
);
}
}
},
[
archiveAgent,
getTabAfterClosing,
isArchivingAgent,
navigateToTab,
normalizedServerId,
normalizedWorkspaceId,
resolvedTab,
router,
]
);
const handleOpenAgentChatView = useCallback(() => { const handleOpenAgentChatView = useCallback(() => {
if (!activeAgent) { if (!activeAgent) {
return; return;
@@ -959,6 +1015,8 @@ function WorkspaceScreenContent({
return ( return (
<View style={styles.container}> <View style={styles.container}>
<View style={styles.threePaneRow}>
<View style={styles.centerColumn}>
<ScreenHeader <ScreenHeader
left={ left={
<> <>
@@ -970,22 +1028,6 @@ function WorkspaceScreenContent({
} }
right={ right={
<View style={styles.headerRight}> <View style={styles.headerRight}>
{isMobile ? (
<Pressable
ref={tabSwitcherAnchorRef}
style={({ hovered, pressed }) => [
styles.switcherTrigger,
(hovered || pressed) && styles.switcherTriggerActive,
]}
onPress={() => setIsTabSwitcherOpen(true)}
>
<Text style={styles.switcherTriggerText} numberOfLines={1}>
{activeTabLabel}
</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
) : null}
<HeaderToggleButton <HeaderToggleButton
testID="workspace-explorer-toggle" testID="workspace-explorer-toggle"
onPress={handleToggleExplorer} onPress={handleToggleExplorer}
@@ -1030,44 +1072,6 @@ function WorkspaceScreenContent({
)} )}
</HeaderToggleButton> </HeaderToggleButton>
<DropdownMenu>
<DropdownMenuTrigger
testID="workspace-new-tab"
style={({ hovered, pressed, open }) => [
styles.newTabButton,
(hovered || pressed || open) && styles.newTabButtonActive,
]}
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<Text style={styles.newTabButtonText}>New tab</Text>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
width={DROPDOWN_WIDTH}
testID="workspace-new-tab-content"
>
<DropdownMenuItem
testID="workspace-new-tab-agent-option"
description="Open the draft agent flow for this workspace"
onSelect={() => {
handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID);
}}
>
Agent tab
</DropdownMenuItem>
<DropdownMenuItem
testID="workspace-new-tab-terminal-option"
description="Create a new terminal in this workspace"
onSelect={() => {
handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID);
}}
>
Terminal tab
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{activeAgent ? ( {activeAgent ? (
<DropdownMenu> <DropdownMenu>
<DropdownMenuTrigger <DropdownMenuTrigger
@@ -1094,8 +1098,140 @@ function WorkspaceScreenContent({
</DropdownMenuContent> </DropdownMenuContent>
</DropdownMenu> </DropdownMenu>
) : null} ) : null}
</View>
}
/>
{isMobile ? ( {isMobile ? (
<View style={styles.mobileTabsRow} testID="workspace-tabs-row">
<Pressable
ref={tabSwitcherAnchorRef}
style={({ hovered, pressed }) => [
styles.switcherTrigger,
(hovered || pressed || isTabSwitcherOpen) && styles.switcherTriggerActive,
{ borderWidth: 0, borderColor: "transparent" },
Platform.OS === "web"
? {
outlineStyle: "solid",
outlineWidth: 0,
outlineColor: "transparent",
}
: null,
]}
onPress={() => setIsTabSwitcherOpen(true)}
>
<View style={styles.switcherTriggerLeft}>
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
{(() => {
const activeDescriptor = tabs.find((tab) => tab.key === activeTabKey) ?? null;
if (!activeDescriptor) {
return <View style={styles.tabIcon}><Bot size={14} color={theme.colors.foregroundMuted} /></View>;
}
if (activeDescriptor.kind === "terminal") {
return <Terminal size={14} color={theme.colors.foreground} />;
}
const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
const tabAgentStatusBucket = tabAgent
? deriveSidebarStateBucket({
status: tabAgent.status,
pendingPermissionCount: tabAgent.pendingPermissions.length,
requiresAttention: tabAgent.requiresAttention,
attentionReason: tabAgent.attentionReason,
})
: null;
const tabAgentStatusColor =
tabAgentStatusBucket === null
? null
: getStatusDotColor({
theme,
bucket: tabAgentStatusBucket,
showDoneAsInactive: false,
});
return (
<View style={styles.tabAgentIconWrapper}>
{activeDescriptor.provider === "claude" ? (
<ClaudeIcon size={14} color={theme.colors.foreground} />
) : activeDescriptor.provider === "codex" ? (
<CodexIcon size={14} color={theme.colors.foreground} />
) : (
<Bot size={14} color={theme.colors.foreground} />
)}
{tabAgentStatusColor ? (
<View
style={[
styles.tabStatusDot,
{
backgroundColor: tabAgentStatusColor,
borderColor: theme.colors.surface0,
},
]}
/>
) : null}
</View>
);
})()}
</View>
<Text style={styles.switcherTriggerText} numberOfLines={1}>
{activeTabLabel}
</Text>
</View>
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</Pressable>
<View style={styles.mobileTabsActions}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab"
onPress={() => handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
accessibilityRole="button"
accessibilityLabel="New agent tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-terminal-tab"
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
disabled={createTerminalMutation.isPending}
accessibilityRole="button"
accessibilityLabel="New terminal tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
{createTerminalMutation.isPending ? (
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
) : (
<View style={styles.terminalPlusIcon}>
<Terminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
<View style={styles.terminalPlusBadge}>
<Plus size={10} color={theme.colors.foregroundMuted} />
</View>
</View>
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
</TooltipContent>
</Tooltip>
</View>
<Combobox <Combobox
options={tabSwitcherOptions} options={tabSwitcherOptions}
value={activeTabKey} value={activeTabKey}
@@ -1107,42 +1243,74 @@ function WorkspaceScreenContent({
onOpenChange={setIsTabSwitcherOpen} onOpenChange={setIsTabSwitcherOpen}
anchorRef={tabSwitcherAnchorRef} anchorRef={tabSwitcherAnchorRef}
/> />
) : null}
</View> </View>
} ) : (
/> <View style={styles.tabsContainer} testID="workspace-tabs-row">
{!isMobile ? (
<View style={styles.tabsContainer}>
<ScrollView <ScrollView
horizontal horizontal
testID="workspace-tabs-scroll"
style={styles.tabsScroll} style={styles.tabsScroll}
contentContainerStyle={styles.tabsContent} contentContainerStyle={styles.tabsContent}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
> >
{tabs.map((tab) => { {tabs.map((tab) => {
const isActive = tab.key === activeTabKey; const isActive = tab.key === activeTabKey;
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
const isTabHovered = hoveredTabKey === tab.key; const isTabHovered = hoveredTabKey === tab.key;
const isCloseHovered = hoveredCloseTabKey === tab.key; const isCloseHovered = hoveredCloseTabKey === tab.key;
const isClosingAgent =
tab.kind === "agent" &&
isArchivingAgent({
serverId: normalizedServerId,
agentId: tab.agentId,
});
const isClosingTerminal = const isClosingTerminal =
tab.kind === "terminal" && tab.kind === "terminal" &&
killTerminalMutation.isPending && killTerminalMutation.isPending &&
killTerminalMutation.variables === tab.terminalId; killTerminalMutation.variables === tab.terminalId;
const shouldShowCloseButton = const isClosingTab = isClosingAgent || isClosingTerminal;
tab.kind === "terminal" && const shouldShowCloseButton = true;
(isTabHovered || isCloseHovered || isClosingTerminal);
const iconColor = isActive const iconColor = isActive
? theme.colors.foreground ? theme.colors.foreground
: theme.colors.foregroundMuted; : theme.colors.foregroundMuted;
const tabAgentStatusBucket = tabAgent
? deriveSidebarStateBucket({
status: tabAgent.status,
pendingPermissionCount: tabAgent.pendingPermissions.length,
requiresAttention: tabAgent.requiresAttention,
attentionReason: tabAgent.attentionReason,
})
: null;
const tabAgentStatusColor =
tabAgentStatusBucket === null
? null
: getStatusDotColor({
theme,
bucket: tabAgentStatusBucket,
showDoneAsInactive: false,
});
const icon = const icon =
tab.kind === "agent" ? ( tab.kind === "agent" ? (
tab.provider === "claude" ? ( <View style={styles.tabAgentIconWrapper}>
{tab.provider === "claude" ? (
<ClaudeIcon size={14} color={iconColor} /> <ClaudeIcon size={14} color={iconColor} />
) : tab.provider === "codex" ? ( ) : tab.provider === "codex" ? (
<CodexIcon size={14} color={iconColor} /> <CodexIcon size={14} color={iconColor} />
) : ( ) : (
<Bot size={14} color={iconColor} /> <Bot size={14} color={iconColor} />
) )}
{tabAgentStatusColor ? (
<View
style={[
styles.tabStatusDot,
{
backgroundColor: tabAgentStatusColor,
borderColor: theme.colors.surface0,
},
]}
/>
) : null}
</View>
) : ( ) : (
<Terminal size={14} color={iconColor} /> <Terminal size={14} color={iconColor} />
); );
@@ -1154,7 +1322,7 @@ function WorkspaceScreenContent({
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
styles.tab, styles.tab,
isActive && styles.tabActive, isActive && styles.tabActive,
(hovered || pressed) && styles.tabHovered, (hovered || pressed || isCloseHovered) && styles.tabHovered,
]} ]}
onHoverIn={() => { onHoverIn={() => {
setHoveredTabKey(tab.key); setHoveredTabKey(tab.key);
@@ -1186,21 +1354,32 @@ function WorkspaceScreenContent({
> >
{tab.label} {tab.label}
</Text> </Text>
{tab.kind === "terminal" ? (
<Pressable <Pressable
testID={`workspace-terminal-close-${tab.terminalId}`} testID={
tab.kind === "agent"
? `workspace-agent-close-${tab.agentId}`
: `workspace-terminal-close-${tab.terminalId}`
}
pointerEvents={shouldShowCloseButton ? "auto" : "none"} pointerEvents={shouldShowCloseButton ? "auto" : "none"}
disabled={!shouldShowCloseButton || isClosingTerminal} disabled={!shouldShowCloseButton || isClosingTab}
onHoverIn={() => { onHoverIn={() => {
setHoveredTabKey(tab.key);
setHoveredCloseTabKey(tab.key); setHoveredCloseTabKey(tab.key);
}} }}
onHoverOut={() => { onHoverOut={() => {
setHoveredTabKey((current) =>
current === tab.key ? null : current
);
setHoveredCloseTabKey((current) => setHoveredCloseTabKey((current) =>
current === tab.key ? null : current current === tab.key ? null : current
); );
}} }}
onPress={(event) => { onPress={(event) => {
event.stopPropagation(); event.stopPropagation?.();
if (tab.kind === "agent") {
void handleCloseAgentTab(tab.agentId);
return;
}
void handleCloseTerminalTab(tab.terminalId); void handleCloseTerminalTab(tab.terminalId);
}} }}
style={({ hovered, pressed }) => [ style={({ hovered, pressed }) => [
@@ -1211,21 +1390,76 @@ function WorkspaceScreenContent({
(hovered || pressed) && styles.tabCloseButtonActive, (hovered || pressed) && styles.tabCloseButtonActive,
]} ]}
> >
{isClosingTerminal ? ( {isClosingTab ? (
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} /> <ActivityIndicator
size={12}
color={theme.colors.foregroundMuted}
/>
) : ( ) : (
<X size={12} color={theme.colors.foregroundMuted} /> <X size={12} color={theme.colors.foregroundMuted} />
)} )}
</Pressable> </Pressable>
) : null}
</Pressable> </Pressable>
); );
})} })}
</ScrollView> </ScrollView>
<View style={styles.tabsActions}>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-agent-tab"
onPress={() => handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
accessibilityRole="button"
accessibilityLabel="New agent tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New agent tab</Text>
</TooltipContent>
</Tooltip>
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
<TooltipTrigger
testID="workspace-new-terminal-tab"
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
disabled={createTerminalMutation.isPending}
accessibilityRole="button"
accessibilityLabel="New terminal tab"
style={({ hovered, pressed }) => [
styles.newTabActionButton,
createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
(hovered || pressed) && styles.newTabActionButtonHovered,
]}
>
{createTerminalMutation.isPending ? (
<ActivityIndicator
size="small"
color={theme.colors.foregroundMuted}
/>
) : (
<View style={styles.terminalPlusIcon}>
<Terminal
size={theme.iconSize.sm}
color={theme.colors.foregroundMuted}
/>
<View style={styles.terminalPlusBadge}>
<Plus size={10} color={theme.colors.foregroundMuted} />
</View> </View>
) : null} </View>
)}
</TooltipTrigger>
<TooltipContent side="bottom" align="end" offset={8}>
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
</TooltipContent>
</Tooltip>
</View>
</View>
)}
<View style={styles.mainRow}> <View style={styles.centerContent}>
{isMobile ? ( {isMobile ? (
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y"> <GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
<View style={styles.content}>{renderContent()}</View> <View style={styles.content}>{renderContent()}</View>
@@ -1233,6 +1467,8 @@ function WorkspaceScreenContent({
) : ( ) : (
<View style={styles.content}>{renderContent()}</View> <View style={styles.content}>{renderContent()}</View>
)} )}
</View>
</View>
<ExplorerSidebar <ExplorerSidebar
serverId={normalizedServerId} serverId={normalizedServerId}
@@ -1250,6 +1486,16 @@ const styles = StyleSheet.create((theme) => ({
flex: 1, flex: 1,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
}, },
threePaneRow: {
flex: 1,
minHeight: 0,
flexDirection: "row",
alignItems: "stretch",
},
centerColumn: {
flex: 1,
minHeight: 0,
},
headerTitle: { headerTitle: {
flex: 1, flex: 1,
fontSize: theme.fontSize.base, fontSize: theme.fontSize.base,
@@ -1268,50 +1514,108 @@ const styles = StyleSheet.create((theme) => ({
padding: theme.spacing[3], padding: theme.spacing[3],
borderRadius: theme.borderRadius.lg, borderRadius: theme.borderRadius.lg,
}, },
newTabButton: { newTabActions: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
paddingHorizontal: theme.spacing[2], },
paddingVertical: theme.spacing[1], newTabActionButton: {
width: 30,
height: 30,
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
backgroundColor: theme.colors.surface1,
alignItems: "center",
justifyContent: "center",
}, },
newTabButtonActive: { newTabActionButtonHovered: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
newTabButtonText: { newTabActionButtonDisabled: {
color: theme.colors.foregroundMuted, opacity: 0.6,
fontSize: theme.fontSize.sm,
}, },
switcherTrigger: { newTabTooltipText: {
maxWidth: 220, fontSize: theme.fontSize.sm,
color: theme.colors.popoverForeground,
},
terminalPlusIcon: {
position: "relative",
width: theme.iconSize.sm,
height: theme.iconSize.sm,
alignItems: "center",
justifyContent: "center",
},
terminalPlusBadge: {
position: "absolute",
right: -6,
bottom: -6,
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: theme.colors.surface1,
borderWidth: 1,
borderColor: theme.colors.border,
alignItems: "center",
justifyContent: "center",
},
mobileTabsRow: {
borderBottomWidth: 1,
borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface0,
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
mobileTabsActions: {
flexDirection: "row", flexDirection: "row",
alignItems: "center", alignItems: "center",
gap: theme.spacing[1], gap: theme.spacing[1],
},
switcherTrigger: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.md, borderRadius: theme.borderRadius.md,
borderWidth: theme.borderWidth[1], borderWidth: theme.borderWidth[1],
borderColor: theme.colors.border, borderColor: theme.colors.border,
justifyContent: "space-between",
}, },
switcherTriggerActive: { switcherTriggerActive: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
switcherTriggerLeft: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
flex: 1,
minWidth: 0,
},
switcherTriggerIcon: {
flexShrink: 0,
},
switcherTriggerText: { switcherTriggerText: {
minWidth: 0, minWidth: 0,
flexShrink: 1, flex: 1,
color: theme.colors.foregroundMuted, color: theme.colors.foreground,
fontSize: theme.fontSize.sm, fontSize: theme.fontSize.sm,
}, },
tabsContainer: { tabsContainer: {
borderBottomWidth: 1, borderBottomWidth: 1,
borderBottomColor: theme.colors.border, borderBottomColor: theme.colors.border,
backgroundColor: theme.colors.surface0, backgroundColor: theme.colors.surface0,
flexDirection: "row",
alignItems: "center",
}, },
tabsScroll: { tabsScroll: {
flex: 1, flex: 1,
minWidth: 0,
}, },
tabsContent: { tabsContent: {
flexDirection: "row", flexDirection: "row",
@@ -1320,10 +1624,16 @@ const styles = StyleSheet.create((theme) => ({
paddingHorizontal: theme.spacing[2], paddingHorizontal: theme.spacing[2],
paddingVertical: theme.spacing[1], paddingVertical: theme.spacing[1],
}, },
mainRow: { tabsActions: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
paddingRight: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
centerContent: {
flex: 1, flex: 1,
minHeight: 0, minHeight: 0,
flexDirection: "row",
}, },
tab: { tab: {
paddingHorizontal: theme.spacing[3], paddingHorizontal: theme.spacing[3],
@@ -1337,6 +1647,22 @@ const styles = StyleSheet.create((theme) => ({
tabIcon: { tabIcon: {
flexShrink: 0, flexShrink: 0,
}, },
tabAgentIconWrapper: {
position: "relative",
width: 14,
height: 14,
alignItems: "center",
justifyContent: "center",
},
tabStatusDot: {
position: "absolute",
right: -2,
bottom: -2,
width: 7,
height: 7,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
},
tabActive: { tabActive: {
backgroundColor: theme.colors.surface2, backgroundColor: theme.colors.surface2,
}, },
@@ -1351,7 +1677,7 @@ const styles = StyleSheet.create((theme) => ({
fontWeight: theme.fontWeight.normal, fontWeight: theme.fontWeight.normal,
}, },
tabLabelWithCloseButton: { tabLabelWithCloseButton: {
paddingRight: theme.spacing[1], paddingRight: 0,
}, },
tabLabelActive: { tabLabelActive: {
color: theme.colors.foreground, color: theme.colors.foreground,
@@ -1359,7 +1685,7 @@ const styles = StyleSheet.create((theme) => ({
tabCloseButton: { tabCloseButton: {
width: 18, width: 18,
height: 18, height: 18,
marginLeft: theme.spacing[1], marginLeft: 0,
borderRadius: theme.borderRadius.sm, borderRadius: theme.borderRadius.sm,
alignItems: "center", alignItems: "center",
justifyContent: "center", justifyContent: "center",

View File

@@ -34,4 +34,15 @@ describe("deriveSidebarStateBucket", () => {
}) })
).toBe("attention"); ).toBe("attention");
}); });
it("treats initializing agents as running", () => {
expect(
deriveSidebarStateBucket({
status: "initializing",
pendingPermissionCount: 0,
requiresAttention: false,
attentionReason: null,
})
).toBe("running");
});
}); });

View File

@@ -31,7 +31,7 @@ export function deriveSidebarStateBucket(input: {
if (input.status === "error" || input.attentionReason === "error") { if (input.status === "error" || input.attentionReason === "error") {
return "failed"; return "failed";
} }
if (input.status === "running") { if (input.status === "running" || input.status === "initializing") {
return "running"; return "running";
} }
if (input.requiresAttention) { if (input.requiresAttention) {

View File

@@ -0,0 +1,27 @@
import type { Theme } from "@/styles/theme";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
export function getStatusDotColor(input: {
theme: Theme;
bucket: SidebarStateBucket;
showDoneAsInactive?: boolean;
}): string | null {
const { theme, bucket, showDoneAsInactive = false } = input;
if (bucket === "needs_input") {
return theme.colors.palette.amber[500];
}
if (bucket === "failed") {
return theme.colors.palette.red[500];
}
if (bucket === "running") {
return theme.colors.palette.blue[500];
}
if (bucket === "attention") {
return theme.colors.palette.green[500];
}
if (bucket === "done") {
return showDoneAsInactive ? theme.colors.border : null;
}
return null;
}

View File

@@ -0,0 +1 @@
cid_48610cdfae94492497dcf8d77214267c

View File

@@ -383,8 +383,7 @@ const shouldRun = !process.env.CI;
await waitForCondition(() => sawExit, 10000); await waitForCondition(() => sawExit, 10000);
const next = await ctx.client.listTerminals(cwd); const next = await ctx.client.listTerminals(cwd);
expect(next.terminals).toHaveLength(1); expect(next.terminals).toHaveLength(0);
expect(next.terminals[0].id).not.toBe(terminalId);
unsubscribeExit(); unsubscribeExit();
rmSync(cwd, { recursive: true, force: true }); rmSync(cwd, { recursive: true, force: true });

View File

@@ -200,13 +200,15 @@ describe("TerminalManager", () => {
expect(manager.getTerminal(id)).toBeUndefined(); expect(manager.getTerminal(id)).toBeUndefined();
}); });
it("removes cwd entry when last terminal is killed", async () => { it("keeps cwd entry when last terminal is killed (but does not auto-recreate)", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
const terminals = await manager.getTerminals("/tmp"); const terminals = await manager.getTerminals("/tmp");
manager.killTerminal(terminals[0].id); manager.killTerminal(terminals[0].id);
expect(manager.listDirectories()).not.toContain("/tmp"); expect(manager.listDirectories()).toContain("/tmp");
const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(0);
}); });
it("keeps cwd entry when other terminals remain", async () => { it("keeps cwd entry when other terminals remain", async () => {
@@ -239,8 +241,7 @@ describe("TerminalManager", () => {
expect(manager.getTerminal(exitedId)).toBeUndefined(); expect(manager.getTerminal(exitedId)).toBeUndefined();
const remaining = await manager.getTerminals("/tmp"); const remaining = await manager.getTerminals("/tmp");
expect(remaining).toHaveLength(1); expect(remaining).toHaveLength(0);
expect(remaining[0].id).not.toBe(exitedId);
}); });
}); });
@@ -250,7 +251,7 @@ describe("TerminalManager", () => {
expect(manager.listDirectories()).toEqual([]); expect(manager.listDirectories()).toEqual([]);
}); });
it("returns all cwds with active terminals", async () => { it("returns all cwds that have ever had terminals", async () => {
manager = createTerminalManager(); manager = createTerminalManager();
await manager.getTerminals("/tmp"); await manager.getTerminals("/tmp");
await manager.getTerminals("/home"); await manager.getTerminals("/home");

View File

@@ -35,6 +35,7 @@ export function createTerminalManager(): TerminalManager {
const terminalExitUnsubscribeById = new Map<string, () => void>(); const terminalExitUnsubscribeById = new Map<string, () => void>();
const terminalsChangedListeners = new Set<TerminalsChangedListener>(); const terminalsChangedListeners = new Set<TerminalsChangedListener>();
const defaultEnvByRootCwd = new Map<string, Record<string, string>>(); const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
const knownDirectories = new Set<string>();
function assertAbsolutePath(cwd: string): void { function assertAbsolutePath(cwd: string): void {
if (!cwd.startsWith("/")) { if (!cwd.startsWith("/")) {
@@ -134,8 +135,12 @@ export function createTerminalManager(): TerminalManager {
async getTerminals(cwd: string): Promise<TerminalSession[]> { async getTerminals(cwd: string): Promise<TerminalSession[]> {
assertAbsolutePath(cwd); assertAbsolutePath(cwd);
let terminals = terminalsByCwd.get(cwd); const terminals = terminalsByCwd.get(cwd);
if (!terminals || terminals.length === 0) { if (terminals && terminals.length > 0) {
return terminals;
}
if (!knownDirectories.has(cwd)) {
const inheritedEnv = resolveDefaultEnvForCwd(cwd); const inheritedEnv = resolveDefaultEnvForCwd(cwd);
const session = registerSession( const session = registerSession(
await createTerminal({ await createTerminal({
@@ -144,11 +149,14 @@ export function createTerminalManager(): TerminalManager {
...(inheritedEnv ? { env: inheritedEnv } : {}), ...(inheritedEnv ? { env: inheritedEnv } : {}),
}) })
); );
terminals = [session]; const created = [session];
terminalsByCwd.set(cwd, terminals); terminalsByCwd.set(cwd, created);
knownDirectories.add(cwd);
emitTerminalsChanged({ cwd }); emitTerminalsChanged({ cwd });
return created;
} }
return terminals;
return [];
}, },
async createTerminal(options: { async createTerminal(options: {
@@ -158,6 +166,7 @@ export function createTerminalManager(): TerminalManager {
}): Promise<TerminalSession> { }): Promise<TerminalSession> {
assertAbsolutePath(options.cwd); assertAbsolutePath(options.cwd);
knownDirectories.add(options.cwd);
const terminals = terminalsByCwd.get(options.cwd) ?? []; const terminals = terminalsByCwd.get(options.cwd) ?? [];
const defaultName = `Terminal ${terminals.length + 1}`; const defaultName = `Terminal ${terminals.length + 1}`;
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd); const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
@@ -194,13 +203,14 @@ export function createTerminalManager(): TerminalManager {
}, },
listDirectories(): string[] { listDirectories(): string[] {
return Array.from(terminalsByCwd.keys()); return Array.from(knownDirectories);
}, },
killAll(): void { killAll(): void {
for (const id of Array.from(terminalsById.keys())) { for (const id of Array.from(terminalsById.keys())) {
removeSessionById(id, { kill: true }); removeSessionById(id, { kill: true });
} }
knownDirectories.clear();
}, },
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void { subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {