mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Restore workspace header controls and polish tab management
This commit is contained in:
@@ -26,38 +26,87 @@ async function openWorkspaceWithAgent(page: Page, workspacePath: string): Promis
|
||||
await expect(page).toHaveURL(new RegExp(`/h/${encodeURIComponent(serverId)}/workspace/`), {
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
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-");
|
||||
|
||||
try {
|
||||
await openWorkspaceWithAgent(page, repo.path);
|
||||
|
||||
const trigger = page.getByTestId("workspace-new-tab").first();
|
||||
await expect(trigger).toBeVisible({ timeout: 30000 });
|
||||
await trigger.click();
|
||||
const agentButton = page.getByTestId("workspace-new-agent-tab").first();
|
||||
const terminalButton = page.getByTestId("workspace-new-terminal-tab").first();
|
||||
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();
|
||||
await expect(menu).toBeVisible({ timeout: 10000 });
|
||||
// Create enough terminal tabs to ensure the tabs row has overflow to scroll.
|
||||
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();
|
||||
|
||||
expect(menuBounds).not.toBeNull();
|
||||
expect(agentBoundsBefore).not.toBeNull();
|
||||
expect(terminalBoundsBefore).not.toBeNull();
|
||||
expect(viewport).not.toBeNull();
|
||||
|
||||
if (!menuBounds || !viewport) {
|
||||
if (!agentBoundsBefore || !terminalBoundsBefore || !viewport) {
|
||||
return;
|
||||
}
|
||||
|
||||
expect(menuBounds.x).toBeGreaterThanOrEqual(0);
|
||||
expect(menuBounds.y).toBeGreaterThanOrEqual(0);
|
||||
expect(menuBounds.x + menuBounds.width).toBeLessThanOrEqual(viewport.width);
|
||||
expect(menuBounds.y + menuBounds.height).toBeLessThanOrEqual(viewport.height);
|
||||
expect(agentBoundsBefore.x).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.y).toBeGreaterThanOrEqual(0);
|
||||
expect(agentBoundsBefore.x + agentBoundsBefore.width).toBeLessThanOrEqual(viewport.width);
|
||||
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 {
|
||||
await repo.cleanup();
|
||||
}
|
||||
|
||||
@@ -2,8 +2,7 @@ import { View, Pressable, Text, ActivityIndicator, Platform } from 'react-native
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { StyleSheet, useUnistyles } from 'react-native-unistyles'
|
||||
import { ArrowUp, Square, Pencil, AudioLines } from 'lucide-react-native'
|
||||
import Animated, { useAnimatedStyle } from 'react-native-reanimated'
|
||||
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
|
||||
import Animated from 'react-native-reanimated'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useIsFocused } from '@react-navigation/native'
|
||||
import { FOOTER_HEIGHT, MAX_CONTENT_WIDTH } from '@/constants/layout'
|
||||
@@ -37,6 +36,7 @@ import {
|
||||
} from '@/attachments/service'
|
||||
import { shouldSkipDraftPersist } from '@/components/agent-input-area.draft-persist-guard'
|
||||
import { markScrollInvestigationRender } from '@/utils/scroll-jank-investigation'
|
||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||
|
||||
type QueuedMessage = {
|
||||
id: string
|
||||
@@ -84,7 +84,6 @@ export function AgentInputArea({
|
||||
markScrollInvestigationRender(`AgentInputArea:${serverId}:${agentId}`)
|
||||
const { theme } = useUnistyles()
|
||||
const insets = useSafeAreaInsets()
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
|
||||
const isScreenFocused = useIsFocused()
|
||||
const messageInputActionRequest = useKeyboardShortcutsStore((s) => s.messageInputActionRequest)
|
||||
const clearMessageInputActionRequest = useKeyboardShortcutsStore(
|
||||
@@ -551,13 +550,8 @@ export function AgentInputArea({
|
||||
serverId,
|
||||
])
|
||||
|
||||
const keyboardAnimatedStyle = useAnimatedStyle(() => {
|
||||
'worklet'
|
||||
const absoluteHeight = Math.abs(keyboardHeight.value)
|
||||
const shift = Math.max(0, absoluteHeight - insets.bottom)
|
||||
return {
|
||||
transform: [{ translateY: -shift }],
|
||||
}
|
||||
const { style: keyboardAnimatedStyle } = useKeyboardShiftStyle({
|
||||
mode: 'translate',
|
||||
})
|
||||
|
||||
function handleCancelAgent() {
|
||||
|
||||
@@ -1,25 +1,41 @@
|
||||
import { View } from "react-native";
|
||||
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({
|
||||
status,
|
||||
requiresAttention,
|
||||
attentionReason,
|
||||
pendingPermissionCount,
|
||||
showInactive = false,
|
||||
}: {
|
||||
status: string | null | undefined;
|
||||
requiresAttention: boolean | null | undefined;
|
||||
attentionReason?: "finished" | "error" | "permission" | null;
|
||||
pendingPermissionCount?: number;
|
||||
showInactive?: boolean;
|
||||
}) {
|
||||
const { theme } = useUnistyles();
|
||||
|
||||
const isRunning = status === "running";
|
||||
const color = isRunning
|
||||
? theme.colors.palette.blue[500]
|
||||
: requiresAttention
|
||||
? theme.colors.success
|
||||
: showInactive
|
||||
? theme.colors.border
|
||||
: null;
|
||||
if (!status) {
|
||||
return null;
|
||||
}
|
||||
if (!isAgentLifecycleStatus(status)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bucket = deriveSidebarStateBucket({
|
||||
status,
|
||||
requiresAttention: Boolean(requiresAttention),
|
||||
attentionReason: attentionReason ?? null,
|
||||
pendingPermissionCount: pendingPermissionCount ?? 0,
|
||||
});
|
||||
const color = getStatusDotColor({ theme, bucket, showDoneAsInactive: showInactive });
|
||||
|
||||
if (!color) {
|
||||
return null;
|
||||
@@ -28,6 +44,10 @@ export function AgentStatusDot({
|
||||
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) => ({
|
||||
dot: {
|
||||
width: 8,
|
||||
|
||||
@@ -17,6 +17,7 @@ export function DraggableList<T>({
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
style,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
ListFooterComponent,
|
||||
@@ -24,6 +25,8 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar: _enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
useDragHandle: _useDragHandle = false,
|
||||
refreshing,
|
||||
onRefresh,
|
||||
simultaneousGestureRef,
|
||||
@@ -70,6 +73,8 @@ export function DraggableList<T>({
|
||||
}, []);
|
||||
|
||||
const showRefreshControl = Boolean(onRefresh) && (!isDragging || Boolean(refreshing));
|
||||
const resolvedContainerStyle =
|
||||
containerStyle ?? (scrollEnabled ? { flex: 1 } : undefined);
|
||||
|
||||
return (
|
||||
<DraggableFlatList
|
||||
@@ -79,12 +84,13 @@ export function DraggableList<T>({
|
||||
renderItem={handleRenderItem}
|
||||
onDragEnd={handleDragEnd}
|
||||
style={style}
|
||||
containerStyle={{ flex: 1 }}
|
||||
containerStyle={resolvedContainerStyle}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
ListFooterComponent={ListFooterComponent}
|
||||
ListHeaderComponent={ListHeaderComponent}
|
||||
ListEmptyComponent={ListEmptyComponent}
|
||||
showsVerticalScrollIndicator={showsVerticalScrollIndicator}
|
||||
scrollEnabled={scrollEnabled}
|
||||
simultaneousHandlers={simultaneousHandlers}
|
||||
// Higher activationDistance prevents drag from interfering with nested onLongPress handlers
|
||||
activationDistance={20}
|
||||
|
||||
@@ -2,11 +2,22 @@ import type { ReactElement, MutableRefObject } from "react";
|
||||
import type { StyleProp, ViewStyle } from "react-native";
|
||||
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> {
|
||||
item: T;
|
||||
index: number;
|
||||
drag: () => void;
|
||||
isActive: boolean;
|
||||
dragHandleProps?: DraggableListDragHandleProps;
|
||||
}
|
||||
|
||||
export interface DraggableListProps<T> {
|
||||
@@ -15,6 +26,8 @@ export interface DraggableListProps<T> {
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
onDragEnd: (data: T[]) => void;
|
||||
style?: StyleProp<ViewStyle>;
|
||||
/** Outer container style (useful for nested, non-scrolling lists). */
|
||||
containerStyle?: StyleProp<ViewStyle>;
|
||||
contentContainerStyle?: StyleProp<ViewStyle>;
|
||||
testID?: string;
|
||||
ListFooterComponent?: ReactElement | null;
|
||||
@@ -22,6 +35,13 @@ export interface DraggableListProps<T> {
|
||||
ListEmptyComponent?: ReactElement | null;
|
||||
showsVerticalScrollIndicator?: 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;
|
||||
onRefresh?: () => void;
|
||||
/** Fill remaining space when content is smaller than container */
|
||||
|
||||
@@ -41,6 +41,7 @@ interface SortableItemProps<T> {
|
||||
index: number;
|
||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||
activeId: string | null;
|
||||
useDragHandle: boolean;
|
||||
}
|
||||
|
||||
function SortableItem<T>({
|
||||
@@ -49,11 +50,13 @@ function SortableItem<T>({
|
||||
index,
|
||||
renderItem,
|
||||
activeId,
|
||||
useDragHandle,
|
||||
}: SortableItemProps<T>) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
@@ -87,10 +90,23 @@ function SortableItem<T>({
|
||||
index,
|
||||
drag,
|
||||
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 (
|
||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
||||
<div {...wrapperProps} style={style}>
|
||||
{renderItem(info)}
|
||||
</div>
|
||||
);
|
||||
@@ -102,6 +118,7 @@ export function DraggableList<T>({
|
||||
renderItem,
|
||||
onDragEnd,
|
||||
style,
|
||||
containerStyle,
|
||||
contentContainerStyle,
|
||||
testID,
|
||||
ListFooterComponent,
|
||||
@@ -109,6 +126,8 @@ export function DraggableList<T>({
|
||||
ListEmptyComponent,
|
||||
showsVerticalScrollIndicator = true,
|
||||
enableDesktopWebScrollbar = false,
|
||||
scrollEnabled = true,
|
||||
useDragHandle = false,
|
||||
// simultaneousGestureRef is native-only, ignored on web
|
||||
onDragBegin,
|
||||
}: DraggableListProps<T>) {
|
||||
@@ -161,52 +180,90 @@ export function DraggableList<T>({
|
||||
);
|
||||
|
||||
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 (
|
||||
<View style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID={testID}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={
|
||||
showCustomScrollbar ? false : showsVerticalScrollIndicator
|
||||
}
|
||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
||||
onContentSizeChange={
|
||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : 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}
|
||||
<View style={wrapperStyle}>
|
||||
{scrollEnabled ? (
|
||||
<ScrollView
|
||||
ref={scrollViewRef}
|
||||
testID={testID}
|
||||
style={style}
|
||||
contentContainerStyle={contentContainerStyle}
|
||||
showsVerticalScrollIndicator={
|
||||
showCustomScrollbar ? false : showsVerticalScrollIndicator
|
||||
}
|
||||
onLayout={showCustomScrollbar ? scrollbarMetrics.onLayout : undefined}
|
||||
onContentSizeChange={
|
||||
showCustomScrollbar ? scrollbarMetrics.onContentSizeChange : undefined
|
||||
}
|
||||
onScroll={showCustomScrollbar ? scrollbarMetrics.onScroll : undefined}
|
||||
scrollEventThrottle={showCustomScrollbar ? 16 : undefined}
|
||||
>
|
||||
<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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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}
|
||||
</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
|
||||
enabled={showCustomScrollbar}
|
||||
metrics={scrollbarMetrics}
|
||||
|
||||
@@ -9,7 +9,6 @@ import Animated, {
|
||||
} from "react-native-reanimated";
|
||||
import { Gesture, GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { X } from "lucide-react-native";
|
||||
import {
|
||||
usePanelStore,
|
||||
@@ -21,9 +20,9 @@ import { useExplorerSidebarAnimation } from "@/contexts/explorer-sidebar-animati
|
||||
import { HEADER_INNER_HEIGHT } from "@/constants/layout";
|
||||
import { GitDiffPane } from "./git-diff-pane";
|
||||
import { FileExplorerPane } from "./file-explorer-pane";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
|
||||
const MIN_CHAT_WIDTH = 400;
|
||||
const IOS_KEYBOARD_INSET_MIN_HEIGHT = 120;
|
||||
const IS_DEV = Boolean((globalThis as { __DEV__?: boolean }).__DEV__);
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
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 {
|
||||
serverId: string;
|
||||
workspaceId?: string | null;
|
||||
@@ -69,14 +58,13 @@ export function ExplorerSidebar({
|
||||
const setExplorerTabForCheckout = usePanelStore((state) => state.setExplorerTabForCheckout);
|
||||
const setExplorerWidth = usePanelStore((state) => state.setExplorerWidth);
|
||||
const { width: viewportWidth } = useWindowDimensions();
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
const closeTouchStartX = useSharedValue(0);
|
||||
const closeTouchStartY = useSharedValue(0);
|
||||
|
||||
useEffect(() => {
|
||||
bottomInset.value = insets.bottom;
|
||||
}, [bottomInset, insets.bottom]);
|
||||
const { style: mobileKeyboardInsetStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: isMobile,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) {
|
||||
@@ -257,14 +245,6 @@ export function ExplorerSidebar({
|
||||
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(() => ({
|
||||
width: resizeWidth.value,
|
||||
}));
|
||||
@@ -322,7 +302,9 @@ export function ExplorerSidebar({
|
||||
}
|
||||
|
||||
return (
|
||||
<Animated.View style={[styles.desktopSidebar, resizeAnimatedStyle]}>
|
||||
<Animated.View
|
||||
style={[styles.desktopSidebar, resizeAnimatedStyle, { paddingTop: insets.top }]}
|
||||
>
|
||||
{/* Resize handle - absolutely positioned over left border */}
|
||||
<GestureDetector gesture={resizeGesture}>
|
||||
<View
|
||||
|
||||
@@ -6,6 +6,7 @@ import Animated, {
|
||||
interpolate,
|
||||
Extrapolation,
|
||||
runOnJS,
|
||||
useSharedValue,
|
||||
} from 'react-native-reanimated'
|
||||
import { Gesture, GestureDetector } from 'react-native-gesture-handler'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
@@ -127,6 +128,8 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
} = useSidebarAnimation()
|
||||
const dragHandlers = useTauriDragHandlers()
|
||||
const trafficLightPadding = useTrafficLightPadding()
|
||||
const closeTouchStartX = useSharedValue(0)
|
||||
const closeTouchStartY = useSharedValue(0)
|
||||
|
||||
// Track user-initiated refresh to avoid showing spinner on background revalidation
|
||||
const [isManualRefresh, setIsManualRefresh] = useState(false)
|
||||
@@ -236,17 +239,47 @@ export function LeftSidebar({ selectedAgentId }: LeftSidebarProps) {
|
||||
)
|
||||
|
||||
// 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()
|
||||
.withRef(closeGestureRef)
|
||||
.enabled(isOpen)
|
||||
// Only activate on leftward swipe (negative X)
|
||||
.activeOffsetX(-15)
|
||||
// Fail on rightward movement (allow internal list scrolling)
|
||||
.failOffsetX(10)
|
||||
// Fail if vertical movement happens first (allow vertical scroll)
|
||||
.failOffsetY([-10, 10])
|
||||
// Use manual activation so child views keep touch streams unless we detect
|
||||
// an intentional left-swipe close (mirrors explorer-sidebar pattern).
|
||||
.manualActivation(true)
|
||||
.onTouchesDown((event) => {
|
||||
const touch = event.changedTouches[0]
|
||||
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(() => {
|
||||
isGesturing.value = true
|
||||
})
|
||||
|
||||
@@ -9,20 +9,21 @@ import {
|
||||
type ReactElement,
|
||||
type MutableRefObject,
|
||||
} from 'react'
|
||||
import { router, usePathname } from 'expo-router'
|
||||
import { router, useSegments } from 'expo-router'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { type GestureType } from 'react-native-gesture-handler'
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from '@/utils/host-routes'
|
||||
import { buildHostWorkspaceRoute } from '@/utils/host-routes'
|
||||
import {
|
||||
type SidebarProjectEntry,
|
||||
type SidebarWorkspaceEntry,
|
||||
} from '@/hooks/use-sidebar-agents-list'
|
||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||
import { formatTimeAgo } from '@/utils/time'
|
||||
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
|
||||
|
||||
type SidebarTreeRow =
|
||||
| {
|
||||
@@ -68,7 +69,6 @@ interface ProjectRowProps {
|
||||
|
||||
interface WorkspaceRowProps {
|
||||
workspace: SidebarWorkspaceEntry
|
||||
compact?: boolean
|
||||
onPress: () => void
|
||||
onLongPress: () => void
|
||||
}
|
||||
@@ -116,21 +116,23 @@ function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): strin
|
||||
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 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 }]} />
|
||||
const color = resolveStatusDotColor({ theme, bucket })
|
||||
return <View style={[styles.workspaceStatusDot, { backgroundColor: color }]} />
|
||||
}
|
||||
|
||||
function ProjectRow({
|
||||
@@ -185,19 +187,15 @@ function ProjectRow({
|
||||
</View>
|
||||
)}
|
||||
|
||||
<ProjectStatusDot bucket={project.statusBucket} />
|
||||
|
||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||
{displayName}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<Text style={styles.projectCountText}>{project.workspaces.length}</Text>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: WorkspaceRowProps) {
|
||||
function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
|
||||
const didLongPressRef = useRef(false)
|
||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||
|
||||
@@ -218,7 +216,6 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
|
||||
<Pressable
|
||||
style={({ pressed, hovered = false }) => [
|
||||
styles.workspaceRow,
|
||||
compact && styles.workspaceRowCompact,
|
||||
hovered && styles.workspaceRowHovered,
|
||||
pressed && styles.workspaceRowPressed,
|
||||
]}
|
||||
@@ -227,9 +224,12 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
|
||||
delayLongPress={200}
|
||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||
>
|
||||
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
||||
{resolveWorkspaceBranchLabel(workspace)}
|
||||
</Text>
|
||||
<View style={styles.workspaceRowLeft}>
|
||||
<WorkspaceStatusDot bucket={workspace.statusBucket} />
|
||||
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
||||
{resolveWorkspaceBranchLabel(workspace)}
|
||||
</Text>
|
||||
</View>
|
||||
{createdAtLabel ? (
|
||||
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
|
||||
{createdAtLabel}
|
||||
@@ -273,7 +273,8 @@ export function SidebarAgentList({
|
||||
}: SidebarAgentListProps) {
|
||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||
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 [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
|
||||
|
||||
@@ -419,13 +420,11 @@ export function SidebarAgentList({
|
||||
}
|
||||
|
||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
|
||||
const shouldReplace = Boolean(parseHostWorkspaceRouteFromPathname(pathname))
|
||||
const navigate = shouldReplace ? router.replace : router.push
|
||||
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
||||
|
||||
return (
|
||||
<WorkspaceRow
|
||||
workspace={item.workspace}
|
||||
compact={isMobile}
|
||||
onPress={() => {
|
||||
if (!serverId) {
|
||||
return
|
||||
@@ -441,9 +440,9 @@ export function SidebarAgentList({
|
||||
collapsedProjectKeys,
|
||||
isMobile,
|
||||
onWorkspacePress,
|
||||
pathname,
|
||||
projectIconByProjectKey,
|
||||
serverId,
|
||||
shouldReplaceWorkspaceNavigation,
|
||||
toggleProjectCollapsed,
|
||||
]
|
||||
)
|
||||
@@ -603,27 +602,16 @@ const styles = StyleSheet.create((theme) => ({
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: 9,
|
||||
},
|
||||
projectStatusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
},
|
||||
projectTitle: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
projectCountText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.xs,
|
||||
flexShrink: 0,
|
||||
},
|
||||
workspaceRow: {
|
||||
minHeight: 34,
|
||||
minHeight: 36,
|
||||
marginBottom: theme.spacing[1],
|
||||
marginLeft: theme.spacing[4],
|
||||
paddingVertical: theme.spacing[1],
|
||||
paddingVertical: theme.spacing[2],
|
||||
paddingHorizontal: theme.spacing[2],
|
||||
borderRadius: theme.borderRadius.lg,
|
||||
flexDirection: 'row',
|
||||
@@ -631,9 +619,12 @@ const styles = StyleSheet.create((theme) => ({
|
||||
justifyContent: 'space-between',
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
workspaceRowCompact: {
|
||||
marginLeft: theme.spacing[3],
|
||||
paddingHorizontal: theme.spacing[1],
|
||||
workspaceRowLeft: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: theme.spacing[2],
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
workspaceRowHovered: {
|
||||
backgroundColor: theme.colors.surface1,
|
||||
@@ -641,9 +632,15 @@ const styles = StyleSheet.create((theme) => ({
|
||||
workspaceRowPressed: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
workspaceStatusDot: {
|
||||
width: 8,
|
||||
height: 8,
|
||||
borderRadius: theme.borderRadius.full,
|
||||
flexShrink: 0,
|
||||
},
|
||||
workspaceBranchText: {
|
||||
color: theme.colors.foreground,
|
||||
fontSize: theme.fontSize.xs,
|
||||
fontSize: theme.fontSize.sm,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
},
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
View,
|
||||
} from "react-native";
|
||||
import { Plus, X } from "lucide-react-native";
|
||||
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||
import Svg, {
|
||||
Defs,
|
||||
LinearGradient as SvgLinearGradient,
|
||||
@@ -19,6 +20,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import {
|
||||
hasPendingTerminalModifiers,
|
||||
normalizeTerminalTransportKey,
|
||||
@@ -145,6 +147,10 @@ export function TerminalPane({
|
||||
const { theme } = useUnistyles();
|
||||
const isMobile =
|
||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
||||
mode: "padding",
|
||||
enabled: isMobile,
|
||||
});
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
||||
@@ -185,6 +191,7 @@ export function TerminalPane({
|
||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||
|
||||
const updateSelectedTerminalId = useCallback(
|
||||
(
|
||||
@@ -298,6 +305,41 @@ export function TerminalPane({
|
||||
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(
|
||||
useCallback(() => {
|
||||
if (!selectedTerminalId) {
|
||||
@@ -353,16 +395,8 @@ export function TerminalPane({
|
||||
streamId: message.payload.streamId,
|
||||
});
|
||||
setModifiers({ ...EMPTY_MODIFIERS });
|
||||
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: terminalsQueryKey,
|
||||
});
|
||||
void queryClient.refetchQueries({
|
||||
queryKey: terminalsQueryKey,
|
||||
type: "active",
|
||||
});
|
||||
});
|
||||
}, [client, isConnected, queryClient, terminalsQueryKey]);
|
||||
}, [client, isConnected]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
@@ -381,13 +415,12 @@ export function TerminalPane({
|
||||
if (message.payload.cwd !== cwd) {
|
||||
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()}`,
|
||||
}));
|
||||
});
|
||||
|
||||
client.subscribeTerminals({ cwd });
|
||||
@@ -960,7 +993,7 @@ export function TerminalPane({
|
||||
const combinedError = streamError ?? closeError ?? createError ?? queryError;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
||||
{!hideHeader ? (
|
||||
<View style={styles.header} testID="terminals-header">
|
||||
<ScrollView
|
||||
@@ -1161,7 +1194,7 @@ export function TerminalPane({
|
||||
</ScrollView>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
84
packages/app/src/hooks/use-keyboard-shift-style.ts
Normal file
84
packages/app/src/hooks/use-keyboard-shift-style.ts
Normal 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 };
|
||||
}
|
||||
@@ -25,6 +25,7 @@ export interface SidebarWorkspaceEntry {
|
||||
createdAt: Date | null
|
||||
isMainCheckout: boolean
|
||||
isPaseoOwnedWorktree: boolean
|
||||
statusBucket: SidebarStateBucket
|
||||
}
|
||||
|
||||
export interface SidebarProjectEntry {
|
||||
@@ -54,6 +55,7 @@ interface MutableWorkspaceEntry {
|
||||
createdAt: Date | null
|
||||
isMainCheckout: boolean
|
||||
isPaseoOwnedWorktree: boolean
|
||||
statusBucket: SidebarStateBucket
|
||||
}
|
||||
|
||||
interface MutableProjectEntry {
|
||||
@@ -256,6 +258,7 @@ function ensureWorkspace(
|
||||
createdAt: input.createdAt,
|
||||
isMainCheckout: input.isMainCheckout,
|
||||
isPaseoOwnedWorktree: input.isPaseoOwnedWorktree,
|
||||
statusBucket: 'done',
|
||||
}
|
||||
project.workspacesByKey.set(workspaceKey, workspace)
|
||||
return workspace
|
||||
@@ -394,6 +397,7 @@ export function useSidebarAgentsList(options?: {
|
||||
isMainCheckout,
|
||||
isPaseoOwnedWorktree: placement.checkout.isPaseoOwnedWorktree,
|
||||
})
|
||||
workspace.statusBucket = aggregateBucket(workspace.statusBucket, bucket)
|
||||
|
||||
const explicitMainRepoRoot = normalizePath(placement.checkout.mainRepoRoot)
|
||||
if (placement.checkout.isPaseoOwnedWorktree && explicitMainRepoRoot) {
|
||||
@@ -573,6 +577,7 @@ export function useSidebarAgentsList(options?: {
|
||||
createdAt: workspace.createdAt,
|
||||
isMainCheckout: workspace.isMainCheckout,
|
||||
isPaseoOwnedWorktree: workspace.isPaseoOwnedWorktree,
|
||||
statusBucket: workspace.statusBucket,
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -12,12 +12,8 @@ import { useRouter } from "expo-router";
|
||||
import * as Clipboard from "expo-clipboard";
|
||||
import { useFocusEffect } from "@react-navigation/native";
|
||||
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
||||
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import ReanimatedAnimated, {
|
||||
useAnimatedStyle,
|
||||
useSharedValue,
|
||||
} from "react-native-reanimated";
|
||||
import ReanimatedAnimated from "react-native-reanimated";
|
||||
import { GestureDetector } from "react-native-gesture-handler";
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
|
||||
import {
|
||||
@@ -78,6 +74,7 @@ import {
|
||||
normalizeAgentSnapshot,
|
||||
} from "@/utils/agent-snapshots";
|
||||
import { mergePendingCreateImages } from "@/utils/pending-create-images";
|
||||
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||
import { shouldClearAgentAttentionOnView } from "@/utils/agent-attention";
|
||||
import type { DaemonClient } from "@server/client/daemon-client";
|
||||
import { useExplorerOpenGesture } from "@/hooks/use-explorer-open-gesture";
|
||||
@@ -508,20 +505,8 @@ function AgentScreenContent({
|
||||
[serverId]
|
||||
);
|
||||
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
|
||||
const bottomInset = useSharedValue(insets.bottom);
|
||||
|
||||
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 { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: "translate",
|
||||
});
|
||||
|
||||
const handleHistorySyncFailure = useCallback(
|
||||
|
||||
@@ -6,9 +6,8 @@ import { useLocalSearchParams, useRouter } from 'expo-router'
|
||||
import { useIsFocused } from '@react-navigation/native'
|
||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useReanimatedKeyboardAnimation } from 'react-native-keyboard-controller'
|
||||
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 { SidebarMenuToggle } from '@/components/headers/menu-header'
|
||||
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 { buildHostAgentDetailRoute } from '@/utils/host-routes'
|
||||
import { useTauriDragHandlers } from '@/utils/tauri-window'
|
||||
import { useKeyboardShiftStyle } from '@/hooks/use-keyboard-shift-style'
|
||||
|
||||
const DRAFT_AGENT_ID = '__new_agent__'
|
||||
const EMPTY_PENDING_PERMISSIONS = new Map()
|
||||
@@ -148,20 +148,8 @@ function DraftAgentScreenContent({
|
||||
)
|
||||
const params = useLocalSearchParams<DraftAgentParams>()
|
||||
|
||||
const { height: keyboardHeight } = useReanimatedKeyboardAnimation()
|
||||
const bottomInset = useSharedValue(insets.bottom)
|
||||
|
||||
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 { style: animatedKeyboardStyle } = useKeyboardShiftStyle({
|
||||
mode: 'translate',
|
||||
})
|
||||
|
||||
const forcedServerIdParam = forcedServerId?.trim()
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -34,4 +34,15 @@ describe("deriveSidebarStateBucket", () => {
|
||||
})
|
||||
).toBe("attention");
|
||||
});
|
||||
|
||||
it("treats initializing agents as running", () => {
|
||||
expect(
|
||||
deriveSidebarStateBucket({
|
||||
status: "initializing",
|
||||
pendingPermissionCount: 0,
|
||||
requiresAttention: false,
|
||||
attentionReason: null,
|
||||
})
|
||||
).toBe("running");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -31,7 +31,7 @@ export function deriveSidebarStateBucket(input: {
|
||||
if (input.status === "error" || input.attentionReason === "error") {
|
||||
return "failed";
|
||||
}
|
||||
if (input.status === "running") {
|
||||
if (input.status === "running" || input.status === "initializing") {
|
||||
return "running";
|
||||
}
|
||||
if (input.requiresAttention) {
|
||||
|
||||
27
packages/app/src/utils/status-dot-color.ts
Normal file
27
packages/app/src/utils/status-dot-color.ts
Normal 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;
|
||||
}
|
||||
1
packages/cli/cli-client-id
Normal file
1
packages/cli/cli-client-id
Normal file
@@ -0,0 +1 @@
|
||||
cid_48610cdfae94492497dcf8d77214267c
|
||||
@@ -383,8 +383,7 @@ const shouldRun = !process.env.CI;
|
||||
await waitForCondition(() => sawExit, 10000);
|
||||
|
||||
const next = await ctx.client.listTerminals(cwd);
|
||||
expect(next.terminals).toHaveLength(1);
|
||||
expect(next.terminals[0].id).not.toBe(terminalId);
|
||||
expect(next.terminals).toHaveLength(0);
|
||||
|
||||
unsubscribeExit();
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
|
||||
@@ -200,13 +200,15 @@ describe("TerminalManager", () => {
|
||||
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();
|
||||
const terminals = await manager.getTerminals("/tmp");
|
||||
|
||||
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 () => {
|
||||
@@ -239,8 +241,7 @@ describe("TerminalManager", () => {
|
||||
expect(manager.getTerminal(exitedId)).toBeUndefined();
|
||||
|
||||
const remaining = await manager.getTerminals("/tmp");
|
||||
expect(remaining).toHaveLength(1);
|
||||
expect(remaining[0].id).not.toBe(exitedId);
|
||||
expect(remaining).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -250,7 +251,7 @@ describe("TerminalManager", () => {
|
||||
expect(manager.listDirectories()).toEqual([]);
|
||||
});
|
||||
|
||||
it("returns all cwds with active terminals", async () => {
|
||||
it("returns all cwds that have ever had terminals", async () => {
|
||||
manager = createTerminalManager();
|
||||
await manager.getTerminals("/tmp");
|
||||
await manager.getTerminals("/home");
|
||||
|
||||
@@ -35,6 +35,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
const terminalExitUnsubscribeById = new Map<string, () => void>();
|
||||
const terminalsChangedListeners = new Set<TerminalsChangedListener>();
|
||||
const defaultEnvByRootCwd = new Map<string, Record<string, string>>();
|
||||
const knownDirectories = new Set<string>();
|
||||
|
||||
function assertAbsolutePath(cwd: string): void {
|
||||
if (!cwd.startsWith("/")) {
|
||||
@@ -134,8 +135,12 @@ export function createTerminalManager(): TerminalManager {
|
||||
async getTerminals(cwd: string): Promise<TerminalSession[]> {
|
||||
assertAbsolutePath(cwd);
|
||||
|
||||
let terminals = terminalsByCwd.get(cwd);
|
||||
if (!terminals || terminals.length === 0) {
|
||||
const terminals = terminalsByCwd.get(cwd);
|
||||
if (terminals && terminals.length > 0) {
|
||||
return terminals;
|
||||
}
|
||||
|
||||
if (!knownDirectories.has(cwd)) {
|
||||
const inheritedEnv = resolveDefaultEnvForCwd(cwd);
|
||||
const session = registerSession(
|
||||
await createTerminal({
|
||||
@@ -144,11 +149,14 @@ export function createTerminalManager(): TerminalManager {
|
||||
...(inheritedEnv ? { env: inheritedEnv } : {}),
|
||||
})
|
||||
);
|
||||
terminals = [session];
|
||||
terminalsByCwd.set(cwd, terminals);
|
||||
const created = [session];
|
||||
terminalsByCwd.set(cwd, created);
|
||||
knownDirectories.add(cwd);
|
||||
emitTerminalsChanged({ cwd });
|
||||
return created;
|
||||
}
|
||||
return terminals;
|
||||
|
||||
return [];
|
||||
},
|
||||
|
||||
async createTerminal(options: {
|
||||
@@ -158,6 +166,7 @@ export function createTerminalManager(): TerminalManager {
|
||||
}): Promise<TerminalSession> {
|
||||
assertAbsolutePath(options.cwd);
|
||||
|
||||
knownDirectories.add(options.cwd);
|
||||
const terminals = terminalsByCwd.get(options.cwd) ?? [];
|
||||
const defaultName = `Terminal ${terminals.length + 1}`;
|
||||
const inheritedEnv = resolveDefaultEnvForCwd(options.cwd);
|
||||
@@ -194,13 +203,14 @@ export function createTerminalManager(): TerminalManager {
|
||||
},
|
||||
|
||||
listDirectories(): string[] {
|
||||
return Array.from(terminalsByCwd.keys());
|
||||
return Array.from(knownDirectories);
|
||||
},
|
||||
|
||||
killAll(): void {
|
||||
for (const id of Array.from(terminalsById.keys())) {
|
||||
removeSessionById(id, { kill: true });
|
||||
}
|
||||
knownDirectories.clear();
|
||||
},
|
||||
|
||||
subscribeTerminalsChanged(listener: TerminalsChangedListener): () => void {
|
||||
|
||||
Reference in New Issue
Block a user