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,52 +180,90 @@ 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}>
<ScrollView {scrollEnabled ? (
ref={scrollViewRef} <ScrollView
testID={testID} ref={scrollViewRef}
style={style} testID={testID}
contentContainerStyle={contentContainerStyle} style={style}
showsVerticalScrollIndicator={ contentContainerStyle={contentContainerStyle}
showCustomScrollbar ? false : showsVerticalScrollIndicator showsVerticalScrollIndicator={
} showCustomScrollbar ? false : showsVerticalScrollIndicator
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined} }
onContentSizeChange={ onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined onContentSizeChange={
} showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined} }
scrollEventThrottle={showCustomScrollbar ? 16 : undefined} onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
> scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
{ListHeaderComponent}
{items.length === 0 && ListEmptyComponent}
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
> >
<SortableContext items={ids} strategy={verticalListSortingStrategy}> {ListHeaderComponent}
{items.map((item, index) => { {items.length === 0 && ListEmptyComponent}
const id = keyExtractor(item, index); <DndContext
return ( sensors={sensors}
<SortableItem collisionDetection={closestCenter}
key={id} modifiers={[restrictToVerticalAxis]}
id={id} onDragStart={handleDragStart}
item={item} onDragEnd={handleDragEnd}
index={index} >
renderItem={renderItem} <SortableContext items={ids} strategy={verticalListSortingStrategy}>
activeId={activeId} {items.map((item, index) => {
/> const id = keyExtractor(item, index);
); return (
})} <SortableItem
</SortableContext> key={id}
</DndContext> id={id}
{ListFooterComponent} item={item}
</ScrollView> index={index}
renderItem={renderItem}
activeId={activeId}
useDragHandle={useDragHandle}
/>
);
})}
</SortableContext>
</DndContext>
{ListFooterComponent}
</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,21 +116,23 @@ 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, bucket } = input
return bucket === 'needs_input'
? theme.colors.palette.amber[500]
: bucket === 'failed'
? theme.colors.palette.red[500]
: bucket === 'running'
? theme.colors.palette.blue[500]
: bucket === 'attention'
? theme.colors.palette.green[500]
: theme.colors.border
}
function WorkspaceStatusDot({ bucket }: { bucket: SidebarWorkspaceEntry['statusBucket'] }) {
const { theme } = useUnistyles() const { theme } = useUnistyles()
const color = resolveStatusDotColor({ theme, bucket })
const color = return <View style={[styles.workspaceStatusDot, { backgroundColor: color }]} />
bucket === 'needs_input'
? theme.colors.palette.amber[500]
: bucket === 'failed'
? theme.colors.palette.red[500]
: bucket === 'running'
? theme.colors.palette.blue[500]
: bucket === 'attention'
? theme.colors.palette.green[500]
: theme.colors.border
return <View style={[styles.projectStatusDot, { 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}`}
> >
<Text style={styles.workspaceBranchText} numberOfLines={1}> <View style={styles.workspaceRowLeft}>
{resolveWorkspaceBranchLabel(workspace)} <WorkspaceStatusDot bucket={workspace.statusBucket} />
</Text> <Text style={styles.workspaceBranchText} numberOfLines={1}>
{resolveWorkspaceBranchLabel(workspace)}
</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({
queryKey: terminalsQueryKey,
type: "active",
});
}); });
}, [client, isConnected, queryClient, terminalsQueryKey]); }, [client, isConnected]);
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()

File diff suppressed because it is too large Load Diff

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 {