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/`), {
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 */
|
||||||
|
|||||||
@@ -41,6 +41,7 @@ interface SortableItemProps<T> {
|
|||||||
index: number;
|
index: number;
|
||||||
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
renderItem: (info: DraggableRenderItemInfo<T>) => ReactElement;
|
||||||
activeId: string | null;
|
activeId: string | null;
|
||||||
|
useDragHandle: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SortableItem<T>({
|
function SortableItem<T>({
|
||||||
@@ -49,11 +50,13 @@ function SortableItem<T>({
|
|||||||
index,
|
index,
|
||||||
renderItem,
|
renderItem,
|
||||||
activeId,
|
activeId,
|
||||||
|
useDragHandle,
|
||||||
}: SortableItemProps<T>) {
|
}: SortableItemProps<T>) {
|
||||||
const {
|
const {
|
||||||
attributes,
|
attributes,
|
||||||
listeners,
|
listeners,
|
||||||
setNodeRef,
|
setNodeRef,
|
||||||
|
setActivatorNodeRef,
|
||||||
transform,
|
transform,
|
||||||
transition,
|
transition,
|
||||||
isDragging,
|
isDragging,
|
||||||
@@ -87,10 +90,23 @@ function SortableItem<T>({
|
|||||||
index,
|
index,
|
||||||
drag,
|
drag,
|
||||||
isActive: activeId === id,
|
isActive: activeId === id,
|
||||||
|
dragHandleProps: useDragHandle
|
||||||
|
? {
|
||||||
|
attributes: attributes as unknown as Record<string, unknown>,
|
||||||
|
listeners: listeners as unknown as Record<string, unknown>,
|
||||||
|
setActivatorNodeRef: setActivatorNodeRef as unknown as (
|
||||||
|
node: unknown
|
||||||
|
) => void,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const wrapperProps = useDragHandle
|
||||||
|
? { ref: setNodeRef }
|
||||||
|
: { ref: setNodeRef, ...attributes, ...listeners };
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes} {...listeners}>
|
<div {...wrapperProps} style={style}>
|
||||||
{renderItem(info)}
|
{renderItem(info)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -102,6 +118,7 @@ export function DraggableList<T>({
|
|||||||
renderItem,
|
renderItem,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
style,
|
style,
|
||||||
|
containerStyle,
|
||||||
contentContainerStyle,
|
contentContainerStyle,
|
||||||
testID,
|
testID,
|
||||||
ListFooterComponent,
|
ListFooterComponent,
|
||||||
@@ -109,6 +126,8 @@ export function DraggableList<T>({
|
|||||||
ListEmptyComponent,
|
ListEmptyComponent,
|
||||||
showsVerticalScrollIndicator = true,
|
showsVerticalScrollIndicator = true,
|
||||||
enableDesktopWebScrollbar = false,
|
enableDesktopWebScrollbar = false,
|
||||||
|
scrollEnabled = true,
|
||||||
|
useDragHandle = false,
|
||||||
// simultaneousGestureRef is native-only, ignored on web
|
// simultaneousGestureRef is native-only, ignored on web
|
||||||
onDragBegin,
|
onDragBegin,
|
||||||
}: DraggableListProps<T>) {
|
}: DraggableListProps<T>) {
|
||||||
@@ -161,10 +180,16 @@ export function DraggableList<T>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const ids = items.map((item, index) => keyExtractor(item, index));
|
const ids = items.map((item, index) => keyExtractor(item, index));
|
||||||
const showCustomScrollbar = enableDesktopWebScrollbar;
|
const showCustomScrollbar = enableDesktopWebScrollbar && scrollEnabled;
|
||||||
|
const wrapperStyle = [
|
||||||
|
{ position: "relative" as const },
|
||||||
|
scrollEnabled ? { flex: 1, minHeight: 0 } : null,
|
||||||
|
containerStyle,
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={{ flex: 1, minHeight: 0, position: "relative" }}>
|
<View style={wrapperStyle}>
|
||||||
|
{scrollEnabled ? (
|
||||||
<ScrollView
|
<ScrollView
|
||||||
ref={scrollViewRef}
|
ref={scrollViewRef}
|
||||||
testID={testID}
|
testID={testID}
|
||||||
@@ -200,6 +225,7 @@ export function DraggableList<T>({
|
|||||||
index={index}
|
index={index}
|
||||||
renderItem={renderItem}
|
renderItem={renderItem}
|
||||||
activeId={activeId}
|
activeId={activeId}
|
||||||
|
useDragHandle={useDragHandle}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -207,6 +233,37 @@ export function DraggableList<T>({
|
|||||||
</DndContext>
|
</DndContext>
|
||||||
{ListFooterComponent}
|
{ListFooterComponent}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{ListHeaderComponent}
|
||||||
|
{items.length === 0 && ListEmptyComponent}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
modifiers={[restrictToVerticalAxis]}
|
||||||
|
onDragStart={handleDragStart}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext items={ids} strategy={verticalListSortingStrategy}>
|
||||||
|
{items.map((item, index) => {
|
||||||
|
const id = keyExtractor(item, index);
|
||||||
|
return (
|
||||||
|
<SortableItem
|
||||||
|
key={id}
|
||||||
|
id={id}
|
||||||
|
item={item}
|
||||||
|
index={index}
|
||||||
|
renderItem={renderItem}
|
||||||
|
activeId={activeId}
|
||||||
|
useDragHandle={useDragHandle}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
{ListFooterComponent}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<WebDesktopScrollbarOverlay
|
<WebDesktopScrollbarOverlay
|
||||||
enabled={showCustomScrollbar}
|
enabled={showCustomScrollbar}
|
||||||
metrics={scrollbarMetrics}
|
metrics={scrollbarMetrics}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -9,20 +9,21 @@ import {
|
|||||||
type ReactElement,
|
type ReactElement,
|
||||||
type MutableRefObject,
|
type MutableRefObject,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { router, usePathname } from 'expo-router'
|
import { router, useSegments } from 'expo-router'
|
||||||
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
import { StyleSheet, UnistylesRuntime, useUnistyles } from 'react-native-unistyles'
|
||||||
import { type GestureType } from 'react-native-gesture-handler'
|
import { type GestureType } from 'react-native-gesture-handler'
|
||||||
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
import { ChevronDown, ChevronRight } from 'lucide-react-native'
|
||||||
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
import { DraggableList, type DraggableRenderItemInfo } from './draggable-list'
|
||||||
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
import { getHostRuntimeStore, isHostRuntimeConnected } from '@/runtime/host-runtime'
|
||||||
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
import { projectIconQueryKey } from '@/hooks/use-project-icon-query'
|
||||||
import { buildHostWorkspaceRoute, parseHostWorkspaceRouteFromPathname } from '@/utils/host-routes'
|
import { buildHostWorkspaceRoute } from '@/utils/host-routes'
|
||||||
import {
|
import {
|
||||||
type SidebarProjectEntry,
|
type SidebarProjectEntry,
|
||||||
type SidebarWorkspaceEntry,
|
type SidebarWorkspaceEntry,
|
||||||
} from '@/hooks/use-sidebar-agents-list'
|
} from '@/hooks/use-sidebar-agents-list'
|
||||||
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
import { useSidebarOrderStore } from '@/stores/sidebar-order-store'
|
||||||
import { formatTimeAgo } from '@/utils/time'
|
import { formatTimeAgo } from '@/utils/time'
|
||||||
|
import type { SidebarStateBucket } from '@/utils/sidebar-agent-state'
|
||||||
|
|
||||||
type SidebarTreeRow =
|
type SidebarTreeRow =
|
||||||
| {
|
| {
|
||||||
@@ -68,7 +69,6 @@ interface ProjectRowProps {
|
|||||||
|
|
||||||
interface WorkspaceRowProps {
|
interface WorkspaceRowProps {
|
||||||
workspace: SidebarWorkspaceEntry
|
workspace: SidebarWorkspaceEntry
|
||||||
compact?: boolean
|
|
||||||
onPress: () => void
|
onPress: () => void
|
||||||
onLongPress: () => void
|
onLongPress: () => void
|
||||||
}
|
}
|
||||||
@@ -116,11 +116,9 @@ function resolveWorkspaceCreatedAtLabel(workspace: SidebarWorkspaceEntry): strin
|
|||||||
return formatTimeAgo(workspace.createdAt)
|
return formatTimeAgo(workspace.createdAt)
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectStatusDot({ bucket }: { bucket: SidebarProjectEntry['statusBucket'] }) {
|
function resolveStatusDotColor(input: { theme: ReturnType<typeof useUnistyles>['theme']; bucket: SidebarStateBucket }) {
|
||||||
const { theme } = useUnistyles()
|
const { theme, bucket } = input
|
||||||
|
return bucket === 'needs_input'
|
||||||
const color =
|
|
||||||
bucket === 'needs_input'
|
|
||||||
? theme.colors.palette.amber[500]
|
? theme.colors.palette.amber[500]
|
||||||
: bucket === 'failed'
|
: bucket === 'failed'
|
||||||
? theme.colors.palette.red[500]
|
? theme.colors.palette.red[500]
|
||||||
@@ -129,8 +127,12 @@ function ProjectStatusDot({ bucket }: { bucket: SidebarProjectEntry['statusBucke
|
|||||||
: bucket === 'attention'
|
: bucket === 'attention'
|
||||||
? theme.colors.palette.green[500]
|
? theme.colors.palette.green[500]
|
||||||
: theme.colors.border
|
: theme.colors.border
|
||||||
|
}
|
||||||
|
|
||||||
return <View style={[styles.projectStatusDot, { backgroundColor: color }]} />
|
function WorkspaceStatusDot({ bucket }: { bucket: SidebarWorkspaceEntry['statusBucket'] }) {
|
||||||
|
const { theme } = useUnistyles()
|
||||||
|
const color = resolveStatusDotColor({ theme, bucket })
|
||||||
|
return <View style={[styles.workspaceStatusDot, { backgroundColor: color }]} />
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProjectRow({
|
function ProjectRow({
|
||||||
@@ -185,19 +187,15 @@ function ProjectRow({
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ProjectStatusDot bucket={project.statusBucket} />
|
|
||||||
|
|
||||||
<Text style={styles.projectTitle} numberOfLines={1}>
|
<Text style={styles.projectTitle} numberOfLines={1}>
|
||||||
{displayName}
|
{displayName}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<Text style={styles.projectCountText}>{project.workspaces.length}</Text>
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: WorkspaceRowProps) {
|
function WorkspaceRow({ workspace, onPress, onLongPress }: WorkspaceRowProps) {
|
||||||
const didLongPressRef = useRef(false)
|
const didLongPressRef = useRef(false)
|
||||||
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
const createdAtLabel = resolveWorkspaceCreatedAtLabel(workspace)
|
||||||
|
|
||||||
@@ -218,7 +216,6 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
|
|||||||
<Pressable
|
<Pressable
|
||||||
style={({ pressed, hovered = false }) => [
|
style={({ pressed, hovered = false }) => [
|
||||||
styles.workspaceRow,
|
styles.workspaceRow,
|
||||||
compact && styles.workspaceRowCompact,
|
|
||||||
hovered && styles.workspaceRowHovered,
|
hovered && styles.workspaceRowHovered,
|
||||||
pressed && styles.workspaceRowPressed,
|
pressed && styles.workspaceRowPressed,
|
||||||
]}
|
]}
|
||||||
@@ -227,9 +224,12 @@ function WorkspaceRow({ workspace, compact = false, onPress, onLongPress }: Work
|
|||||||
delayLongPress={200}
|
delayLongPress={200}
|
||||||
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
testID={`sidebar-workspace-row-${workspace.workspaceKey}`}
|
||||||
>
|
>
|
||||||
|
<View style={styles.workspaceRowLeft}>
|
||||||
|
<WorkspaceStatusDot bucket={workspace.statusBucket} />
|
||||||
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
<Text style={styles.workspaceBranchText} numberOfLines={1}>
|
||||||
{resolveWorkspaceBranchLabel(workspace)}
|
{resolveWorkspaceBranchLabel(workspace)}
|
||||||
</Text>
|
</Text>
|
||||||
|
</View>
|
||||||
{createdAtLabel ? (
|
{createdAtLabel ? (
|
||||||
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
|
<Text style={styles.workspaceCreatedAtText} numberOfLines={1}>
|
||||||
{createdAtLabel}
|
{createdAtLabel}
|
||||||
@@ -273,7 +273,8 @@ export function SidebarAgentList({
|
|||||||
}: SidebarAgentListProps) {
|
}: SidebarAgentListProps) {
|
||||||
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
const isMobile = UnistylesRuntime.breakpoint === 'xs' || UnistylesRuntime.breakpoint === 'sm'
|
||||||
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
|
const showDesktopWebScrollbar = Platform.OS === 'web' && !isMobile
|
||||||
const pathname = usePathname()
|
const segments = useSegments()
|
||||||
|
const shouldReplaceWorkspaceNavigation = segments[0] === 'h'
|
||||||
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
const [collapsedProjectKeys, setCollapsedProjectKeys] = useState<Set<string>>(new Set())
|
||||||
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
|
const [canonicalResyncNonce, setCanonicalResyncNonce] = useState(0)
|
||||||
|
|
||||||
@@ -419,13 +420,11 @@ export function SidebarAgentList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
|
const workspaceRoute = buildHostWorkspaceRoute(serverId ?? '', item.workspace.cwd)
|
||||||
const shouldReplace = Boolean(parseHostWorkspaceRouteFromPathname(pathname))
|
const navigate = shouldReplaceWorkspaceNavigation ? router.replace : router.push
|
||||||
const navigate = shouldReplace ? router.replace : router.push
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<WorkspaceRow
|
<WorkspaceRow
|
||||||
workspace={item.workspace}
|
workspace={item.workspace}
|
||||||
compact={isMobile}
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (!serverId) {
|
if (!serverId) {
|
||||||
return
|
return
|
||||||
@@ -441,9 +440,9 @@ export function SidebarAgentList({
|
|||||||
collapsedProjectKeys,
|
collapsedProjectKeys,
|
||||||
isMobile,
|
isMobile,
|
||||||
onWorkspacePress,
|
onWorkspacePress,
|
||||||
pathname,
|
|
||||||
projectIconByProjectKey,
|
projectIconByProjectKey,
|
||||||
serverId,
|
serverId,
|
||||||
|
shouldReplaceWorkspaceNavigation,
|
||||||
toggleProjectCollapsed,
|
toggleProjectCollapsed,
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
@@ -603,27 +602,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foregroundMuted,
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
},
|
},
|
||||||
projectStatusDot: {
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
borderRadius: theme.borderRadius.full,
|
|
||||||
},
|
|
||||||
projectTitle: {
|
projectTitle: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
projectCountText: {
|
|
||||||
color: theme.colors.foregroundMuted,
|
|
||||||
fontSize: theme.fontSize.xs,
|
|
||||||
flexShrink: 0,
|
|
||||||
},
|
|
||||||
workspaceRow: {
|
workspaceRow: {
|
||||||
minHeight: 34,
|
minHeight: 36,
|
||||||
marginBottom: theme.spacing[1],
|
marginBottom: theme.spacing[1],
|
||||||
marginLeft: theme.spacing[4],
|
paddingVertical: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
flexDirection: 'row',
|
flexDirection: 'row',
|
||||||
@@ -631,9 +619,12 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between',
|
||||||
gap: theme.spacing[2],
|
gap: theme.spacing[2],
|
||||||
},
|
},
|
||||||
workspaceRowCompact: {
|
workspaceRowLeft: {
|
||||||
marginLeft: theme.spacing[3],
|
flexDirection: 'row',
|
||||||
paddingHorizontal: theme.spacing[1],
|
alignItems: 'center',
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
workspaceRowHovered: {
|
workspaceRowHovered: {
|
||||||
backgroundColor: theme.colors.surface1,
|
backgroundColor: theme.colors.surface1,
|
||||||
@@ -641,9 +632,15 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
workspaceRowPressed: {
|
workspaceRowPressed: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
workspaceStatusDot: {
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
workspaceBranchText: {
|
workspaceBranchText: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.xs,
|
fontSize: theme.fontSize.sm,
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
View,
|
View,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { Plus, X } from "lucide-react-native";
|
import { Plus, X } from "lucide-react-native";
|
||||||
|
import Animated, { runOnJS, useAnimatedReaction } from "react-native-reanimated";
|
||||||
import Svg, {
|
import Svg, {
|
||||||
Defs,
|
Defs,
|
||||||
LinearGradient as SvgLinearGradient,
|
LinearGradient as SvgLinearGradient,
|
||||||
@@ -19,6 +20,7 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
|
|||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
import { encodeTerminalKeyInput } from "@server/shared/terminal-key-input";
|
||||||
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
import { useHostRuntimeSession } from "@/runtime/host-runtime";
|
||||||
|
import { useKeyboardShiftStyle } from "@/hooks/use-keyboard-shift-style";
|
||||||
import {
|
import {
|
||||||
hasPendingTerminalModifiers,
|
hasPendingTerminalModifiers,
|
||||||
normalizeTerminalTransportKey,
|
normalizeTerminalTransportKey,
|
||||||
@@ -145,6 +147,10 @@ export function TerminalPane({
|
|||||||
const { theme } = useUnistyles();
|
const { theme } = useUnistyles();
|
||||||
const isMobile =
|
const isMobile =
|
||||||
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
|
||||||
|
const { shift: keyboardShift, style: keyboardPaddingStyle } = useKeyboardShiftStyle({
|
||||||
|
mode: "padding",
|
||||||
|
enabled: isMobile,
|
||||||
|
});
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const { client, isConnected } = useHostRuntimeSession(serverId);
|
const { client, isConnected } = useHostRuntimeSession(serverId);
|
||||||
@@ -185,6 +191,7 @@ export function TerminalPane({
|
|||||||
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const hoverOutTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
const selectedTerminalIdRef = useRef<string | null>(selectedTerminalId);
|
||||||
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
const pendingTerminalInputRef = useRef<PendingTerminalInput[]>([]);
|
||||||
|
const keyboardRefitTimeoutsRef = useRef<Array<ReturnType<typeof setTimeout>>>([]);
|
||||||
|
|
||||||
const updateSelectedTerminalId = useCallback(
|
const updateSelectedTerminalId = useCallback(
|
||||||
(
|
(
|
||||||
@@ -298,6 +305,41 @@ export function TerminalPane({
|
|||||||
setResizeRequestToken((current) => current + 1);
|
setResizeRequestToken((current) => current + 1);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const clearKeyboardRefitTimeouts = useCallback(() => {
|
||||||
|
if (keyboardRefitTimeoutsRef.current.length === 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const handle of keyboardRefitTimeoutsRef.current) {
|
||||||
|
clearTimeout(handle);
|
||||||
|
}
|
||||||
|
keyboardRefitTimeoutsRef.current = [];
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const pulseKeyboardRefits = useCallback(() => {
|
||||||
|
clearKeyboardRefitTimeouts();
|
||||||
|
requestTerminalReflow();
|
||||||
|
keyboardRefitTimeoutsRef.current = TERMINAL_REFIT_DELAYS_MS.map((delayMs) =>
|
||||||
|
setTimeout(() => {
|
||||||
|
requestTerminalReflow();
|
||||||
|
}, delayMs)
|
||||||
|
);
|
||||||
|
}, [clearKeyboardRefitTimeouts, requestTerminalReflow]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => clearKeyboardRefitTimeouts();
|
||||||
|
}, [clearKeyboardRefitTimeouts]);
|
||||||
|
|
||||||
|
useAnimatedReaction(
|
||||||
|
() => keyboardShift.value > 0,
|
||||||
|
(next, prev) => {
|
||||||
|
if (next === prev) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
runOnJS(pulseKeyboardRefits)();
|
||||||
|
},
|
||||||
|
[pulseKeyboardRefits]
|
||||||
|
);
|
||||||
|
|
||||||
useFocusEffect(
|
useFocusEffect(
|
||||||
useCallback(() => {
|
useCallback(() => {
|
||||||
if (!selectedTerminalId) {
|
if (!selectedTerminalId) {
|
||||||
@@ -353,16 +395,8 @@ export function TerminalPane({
|
|||||||
streamId: message.payload.streamId,
|
streamId: message.payload.streamId,
|
||||||
});
|
});
|
||||||
setModifiers({ ...EMPTY_MODIFIERS });
|
setModifiers({ ...EMPTY_MODIFIERS });
|
||||||
|
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: terminalsQueryKey,
|
|
||||||
});
|
});
|
||||||
void queryClient.refetchQueries({
|
}, [client, isConnected]);
|
||||||
queryKey: terminalsQueryKey,
|
|
||||||
type: "active",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}, [client, isConnected, queryClient, terminalsQueryKey]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@@ -381,13 +415,12 @@ export function TerminalPane({
|
|||||||
if (message.payload.cwd !== cwd) {
|
if (message.payload.cwd !== cwd) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({
|
|
||||||
queryKey: terminalsQueryKey,
|
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
|
||||||
});
|
cwd: message.payload.cwd,
|
||||||
void queryClient.refetchQueries({
|
terminals: message.payload.terminals,
|
||||||
queryKey: terminalsQueryKey,
|
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||||
type: "active",
|
}));
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.subscribeTerminals({ cwd });
|
client.subscribeTerminals({ cwd });
|
||||||
@@ -960,7 +993,7 @@ export function TerminalPane({
|
|||||||
const combinedError = streamError ?? closeError ?? createError ?? queryError;
|
const combinedError = streamError ?? closeError ?? createError ?? queryError;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<Animated.View style={[styles.container, keyboardPaddingStyle]}>
|
||||||
{!hideHeader ? (
|
{!hideHeader ? (
|
||||||
<View style={styles.header} testID="terminals-header">
|
<View style={styles.header} testID="terminals-header">
|
||||||
<ScrollView
|
<ScrollView
|
||||||
@@ -1161,7 +1194,7 @@ export function TerminalPane({
|
|||||||
</ScrollView>
|
</ScrollView>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
</View>
|
</Animated.View>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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
|
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,
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||||
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
import { ExplorerSidebar } from "@/components/explorer-sidebar";
|
||||||
import { TerminalPane } from "@/components/terminal-pane";
|
import { TerminalPane } from "@/components/terminal-pane";
|
||||||
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
import { ExplorerSidebarAnimationProvider } from "@/contexts/explorer-sidebar-animation-context";
|
||||||
@@ -62,6 +63,9 @@ import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
|
|||||||
import type { ListTerminalsResponse } from "@server/shared/messages";
|
import type { ListTerminalsResponse } from "@server/shared/messages";
|
||||||
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
import { upsertTerminalListEntry } from "@/utils/terminal-list";
|
||||||
import { confirmDialog } from "@/utils/confirm-dialog";
|
import { confirmDialog } from "@/utils/confirm-dialog";
|
||||||
|
import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state";
|
||||||
|
import { getStatusDotColor } from "@/utils/status-dot-color";
|
||||||
|
import { useArchiveAgent } from "@/hooks/use-archive-agent";
|
||||||
|
|
||||||
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
const TERMINALS_QUERY_STALE_TIME = 5_000;
|
||||||
const DROPDOWN_WIDTH = 220;
|
const DROPDOWN_WIDTH = 220;
|
||||||
@@ -358,6 +362,7 @@ function WorkspaceScreenContent({
|
|||||||
return payload;
|
return payload;
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const { archiveAgent, isArchivingAgent } = useArchiveAgent();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
|
if (!client || !isConnected || !normalizedWorkspaceId.startsWith("/")) {
|
||||||
@@ -371,16 +376,18 @@ function WorkspaceScreenContent({
|
|||||||
if (message.payload.cwd !== normalizedWorkspaceId) {
|
if (message.payload.cwd !== normalizedWorkspaceId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
|
||||||
void queryClient.refetchQueries({ queryKey: terminalsQueryKey, type: "active" });
|
queryClient.setQueryData<ListTerminalsPayload>(terminalsQueryKey, (current) => ({
|
||||||
|
cwd: message.payload.cwd,
|
||||||
|
terminals: message.payload.terminals,
|
||||||
|
requestId: current?.requestId ?? `terminals-changed-${Date.now()}`,
|
||||||
|
}));
|
||||||
});
|
});
|
||||||
|
|
||||||
const unsubscribeStreamExit = client.on("terminal_stream_exit", (message) => {
|
const unsubscribeStreamExit = client.on("terminal_stream_exit", (message) => {
|
||||||
if (message.type !== "terminal_stream_exit") {
|
if (message.type !== "terminal_stream_exit") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
|
||||||
void queryClient.refetchQueries({ queryKey: terminalsQueryKey, type: "active" });
|
|
||||||
});
|
});
|
||||||
|
|
||||||
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
|
client.subscribeTerminals({ cwd: normalizedWorkspaceId });
|
||||||
@@ -819,14 +826,10 @@ function WorkspaceScreenContent({
|
|||||||
[handleCreateAgent, handleCreateTerminal]
|
[handleCreateAgent, handleCreateTerminal]
|
||||||
);
|
);
|
||||||
|
|
||||||
const getTabAfterClosingTerminal = useCallback(
|
const getTabAfterClosing = useCallback(
|
||||||
(terminalId: string): WorkspaceTabTarget | null => {
|
(tabKey: string): WorkspaceTabTarget | null => {
|
||||||
const currentIndex = tabs.findIndex(
|
const currentIndex = tabs.findIndex((tab) => tab.key === tabKey);
|
||||||
(tab) => tab.kind === "terminal" && tab.terminalId === terminalId
|
const nextTabs = tabs.filter((tab) => tab.key !== tabKey);
|
||||||
);
|
|
||||||
const nextTabs = tabs.filter(
|
|
||||||
(tab) => !(tab.kind === "terminal" && tab.terminalId === terminalId)
|
|
||||||
);
|
|
||||||
if (nextTabs.length === 0) {
|
if (nextTabs.length === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -870,11 +873,21 @@ function WorkspaceScreenContent({
|
|||||||
current === tabKey ? null : current
|
current === tabKey ? null : current
|
||||||
);
|
);
|
||||||
|
|
||||||
if (
|
queryClient.setQueryData<ListTerminalsPayload>(
|
||||||
resolvedTab?.kind === "terminal" &&
|
terminalsQueryKey,
|
||||||
resolvedTab.terminalId === terminalId
|
(current) => {
|
||||||
) {
|
if (!current) {
|
||||||
const nextTab = getTabAfterClosingTerminal(terminalId);
|
return current;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...current,
|
||||||
|
terminals: current.terminals.filter((terminal) => terminal.id !== terminalId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (resolvedTab?.kind === "terminal" && resolvedTab.terminalId === terminalId) {
|
||||||
|
const nextTab = getTabAfterClosing(`terminal:${terminalId}`);
|
||||||
if (nextTab) {
|
if (nextTab) {
|
||||||
navigateToTab(nextTab);
|
navigateToTab(nextTab);
|
||||||
} else {
|
} else {
|
||||||
@@ -886,17 +899,11 @@ function WorkspaceScreenContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void queryClient.invalidateQueries({ queryKey: terminalsQueryKey });
|
|
||||||
void queryClient.refetchQueries({
|
|
||||||
queryKey: terminalsQueryKey,
|
|
||||||
type: "active",
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[
|
[
|
||||||
getTabAfterClosingTerminal,
|
getTabAfterClosing,
|
||||||
killTerminalMutation,
|
killTerminalMutation,
|
||||||
navigateToTab,
|
navigateToTab,
|
||||||
normalizedServerId,
|
normalizedServerId,
|
||||||
@@ -908,6 +915,55 @@ function WorkspaceScreenContent({
|
|||||||
]
|
]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleCloseAgentTab = useCallback(
|
||||||
|
async (agentId: string) => {
|
||||||
|
if (!normalizedServerId || isArchivingAgent({ serverId: normalizedServerId, agentId })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmed = await confirmDialog({
|
||||||
|
title: "Archive agent?",
|
||||||
|
message: "This closes the tab and archives the agent.",
|
||||||
|
confirmLabel: "Archive",
|
||||||
|
cancelLabel: "Cancel",
|
||||||
|
destructive: true,
|
||||||
|
});
|
||||||
|
if (!confirmed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await archiveAgent({ serverId: normalizedServerId, agentId });
|
||||||
|
|
||||||
|
const tabKey = `agent:${agentId}`;
|
||||||
|
setHoveredTabKey((current) => (current === tabKey ? null : current));
|
||||||
|
setHoveredCloseTabKey((current) => (current === tabKey ? null : current));
|
||||||
|
|
||||||
|
if (resolvedTab?.kind === "agent" && resolvedTab.agentId === agentId) {
|
||||||
|
const nextTab = getTabAfterClosing(tabKey);
|
||||||
|
if (nextTab) {
|
||||||
|
navigateToTab(nextTab);
|
||||||
|
} else {
|
||||||
|
router.replace(
|
||||||
|
buildHostWorkspaceRoute(
|
||||||
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId
|
||||||
|
) as any
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
archiveAgent,
|
||||||
|
getTabAfterClosing,
|
||||||
|
isArchivingAgent,
|
||||||
|
navigateToTab,
|
||||||
|
normalizedServerId,
|
||||||
|
normalizedWorkspaceId,
|
||||||
|
resolvedTab,
|
||||||
|
router,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
const handleOpenAgentChatView = useCallback(() => {
|
const handleOpenAgentChatView = useCallback(() => {
|
||||||
if (!activeAgent) {
|
if (!activeAgent) {
|
||||||
return;
|
return;
|
||||||
@@ -959,6 +1015,8 @@ function WorkspaceScreenContent({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
|
<View style={styles.threePaneRow}>
|
||||||
|
<View style={styles.centerColumn}>
|
||||||
<ScreenHeader
|
<ScreenHeader
|
||||||
left={
|
left={
|
||||||
<>
|
<>
|
||||||
@@ -970,22 +1028,6 @@ function WorkspaceScreenContent({
|
|||||||
}
|
}
|
||||||
right={
|
right={
|
||||||
<View style={styles.headerRight}>
|
<View style={styles.headerRight}>
|
||||||
{isMobile ? (
|
|
||||||
<Pressable
|
|
||||||
ref={tabSwitcherAnchorRef}
|
|
||||||
style={({ hovered, pressed }) => [
|
|
||||||
styles.switcherTrigger,
|
|
||||||
(hovered || pressed) && styles.switcherTriggerActive,
|
|
||||||
]}
|
|
||||||
onPress={() => setIsTabSwitcherOpen(true)}
|
|
||||||
>
|
|
||||||
<Text style={styles.switcherTriggerText} numberOfLines={1}>
|
|
||||||
{activeTabLabel}
|
|
||||||
</Text>
|
|
||||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
</Pressable>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<HeaderToggleButton
|
<HeaderToggleButton
|
||||||
testID="workspace-explorer-toggle"
|
testID="workspace-explorer-toggle"
|
||||||
onPress={handleToggleExplorer}
|
onPress={handleToggleExplorer}
|
||||||
@@ -1030,44 +1072,6 @@ function WorkspaceScreenContent({
|
|||||||
)}
|
)}
|
||||||
</HeaderToggleButton>
|
</HeaderToggleButton>
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger
|
|
||||||
testID="workspace-new-tab"
|
|
||||||
style={({ hovered, pressed, open }) => [
|
|
||||||
styles.newTabButton,
|
|
||||||
(hovered || pressed || open) && styles.newTabButtonActive,
|
|
||||||
]}
|
|
||||||
>
|
|
||||||
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
<Text style={styles.newTabButtonText}>New tab</Text>
|
|
||||||
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
align="end"
|
|
||||||
width={DROPDOWN_WIDTH}
|
|
||||||
testID="workspace-new-tab-content"
|
|
||||||
>
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID="workspace-new-tab-agent-option"
|
|
||||||
description="Open the draft agent flow for this workspace"
|
|
||||||
onSelect={() => {
|
|
||||||
handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Agent tab
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
testID="workspace-new-tab-terminal-option"
|
|
||||||
description="Create a new terminal in this workspace"
|
|
||||||
onSelect={() => {
|
|
||||||
handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Terminal tab
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{activeAgent ? (
|
{activeAgent ? (
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger
|
<DropdownMenuTrigger
|
||||||
@@ -1094,8 +1098,140 @@ function WorkspaceScreenContent({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
) : null}
|
) : null}
|
||||||
|
</View>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
|
<View style={styles.mobileTabsRow} testID="workspace-tabs-row">
|
||||||
|
<Pressable
|
||||||
|
ref={tabSwitcherAnchorRef}
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.switcherTrigger,
|
||||||
|
(hovered || pressed || isTabSwitcherOpen) && styles.switcherTriggerActive,
|
||||||
|
{ borderWidth: 0, borderColor: "transparent" },
|
||||||
|
Platform.OS === "web"
|
||||||
|
? {
|
||||||
|
outlineStyle: "solid",
|
||||||
|
outlineWidth: 0,
|
||||||
|
outlineColor: "transparent",
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
]}
|
||||||
|
onPress={() => setIsTabSwitcherOpen(true)}
|
||||||
|
>
|
||||||
|
<View style={styles.switcherTriggerLeft}>
|
||||||
|
<View style={styles.switcherTriggerIcon} testID="workspace-active-tab-icon">
|
||||||
|
{(() => {
|
||||||
|
const activeDescriptor = tabs.find((tab) => tab.key === activeTabKey) ?? null;
|
||||||
|
if (!activeDescriptor) {
|
||||||
|
return <View style={styles.tabIcon}><Bot size={14} color={theme.colors.foregroundMuted} /></View>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (activeDescriptor.kind === "terminal") {
|
||||||
|
return <Terminal size={14} color={theme.colors.foreground} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tabAgent = agentsById.get(activeDescriptor.agentId) ?? null;
|
||||||
|
const tabAgentStatusBucket = tabAgent
|
||||||
|
? deriveSidebarStateBucket({
|
||||||
|
status: tabAgent.status,
|
||||||
|
pendingPermissionCount: tabAgent.pendingPermissions.length,
|
||||||
|
requiresAttention: tabAgent.requiresAttention,
|
||||||
|
attentionReason: tabAgent.attentionReason,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const tabAgentStatusColor =
|
||||||
|
tabAgentStatusBucket === null
|
||||||
|
? null
|
||||||
|
: getStatusDotColor({
|
||||||
|
theme,
|
||||||
|
bucket: tabAgentStatusBucket,
|
||||||
|
showDoneAsInactive: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={styles.tabAgentIconWrapper}>
|
||||||
|
{activeDescriptor.provider === "claude" ? (
|
||||||
|
<ClaudeIcon size={14} color={theme.colors.foreground} />
|
||||||
|
) : activeDescriptor.provider === "codex" ? (
|
||||||
|
<CodexIcon size={14} color={theme.colors.foreground} />
|
||||||
|
) : (
|
||||||
|
<Bot size={14} color={theme.colors.foreground} />
|
||||||
|
)}
|
||||||
|
{tabAgentStatusColor ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.tabStatusDot,
|
||||||
|
{
|
||||||
|
backgroundColor: tabAgentStatusColor,
|
||||||
|
borderColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Text style={styles.switcherTriggerText} numberOfLines={1}>
|
||||||
|
{activeTabLabel}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ChevronDown size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</Pressable>
|
||||||
|
|
||||||
|
<View style={styles.mobileTabsActions}>
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger
|
||||||
|
testID="workspace-new-agent-tab"
|
||||||
|
onPress={() => handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="New agent tab"
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.newTabActionButton,
|
||||||
|
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="end" offset={8}>
|
||||||
|
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger
|
||||||
|
testID="workspace-new-terminal-tab"
|
||||||
|
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
||||||
|
disabled={createTerminalMutation.isPending}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="New terminal tab"
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.newTabActionButton,
|
||||||
|
createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
|
||||||
|
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{createTerminalMutation.isPending ? (
|
||||||
|
<ActivityIndicator size="small" color={theme.colors.foregroundMuted} />
|
||||||
|
) : (
|
||||||
|
<View style={styles.terminalPlusIcon}>
|
||||||
|
<Terminal size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
<View style={styles.terminalPlusBadge}>
|
||||||
|
<Plus size={10} color={theme.colors.foregroundMuted} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="end" offset={8}>
|
||||||
|
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</View>
|
||||||
|
|
||||||
<Combobox
|
<Combobox
|
||||||
options={tabSwitcherOptions}
|
options={tabSwitcherOptions}
|
||||||
value={activeTabKey}
|
value={activeTabKey}
|
||||||
@@ -1107,42 +1243,74 @@ function WorkspaceScreenContent({
|
|||||||
onOpenChange={setIsTabSwitcherOpen}
|
onOpenChange={setIsTabSwitcherOpen}
|
||||||
anchorRef={tabSwitcherAnchorRef}
|
anchorRef={tabSwitcherAnchorRef}
|
||||||
/>
|
/>
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
}
|
) : (
|
||||||
/>
|
<View style={styles.tabsContainer} testID="workspace-tabs-row">
|
||||||
|
|
||||||
{!isMobile ? (
|
|
||||||
<View style={styles.tabsContainer}>
|
|
||||||
<ScrollView
|
<ScrollView
|
||||||
horizontal
|
horizontal
|
||||||
|
testID="workspace-tabs-scroll"
|
||||||
style={styles.tabsScroll}
|
style={styles.tabsScroll}
|
||||||
contentContainerStyle={styles.tabsContent}
|
contentContainerStyle={styles.tabsContent}
|
||||||
showsHorizontalScrollIndicator={false}
|
showsHorizontalScrollIndicator={false}
|
||||||
>
|
>
|
||||||
{tabs.map((tab) => {
|
{tabs.map((tab) => {
|
||||||
const isActive = tab.key === activeTabKey;
|
const isActive = tab.key === activeTabKey;
|
||||||
|
const tabAgent = tab.kind === "agent" ? agentsById.get(tab.agentId) ?? null : null;
|
||||||
const isTabHovered = hoveredTabKey === tab.key;
|
const isTabHovered = hoveredTabKey === tab.key;
|
||||||
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
const isCloseHovered = hoveredCloseTabKey === tab.key;
|
||||||
|
const isClosingAgent =
|
||||||
|
tab.kind === "agent" &&
|
||||||
|
isArchivingAgent({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
agentId: tab.agentId,
|
||||||
|
});
|
||||||
const isClosingTerminal =
|
const isClosingTerminal =
|
||||||
tab.kind === "terminal" &&
|
tab.kind === "terminal" &&
|
||||||
killTerminalMutation.isPending &&
|
killTerminalMutation.isPending &&
|
||||||
killTerminalMutation.variables === tab.terminalId;
|
killTerminalMutation.variables === tab.terminalId;
|
||||||
const shouldShowCloseButton =
|
const isClosingTab = isClosingAgent || isClosingTerminal;
|
||||||
tab.kind === "terminal" &&
|
const shouldShowCloseButton = true;
|
||||||
(isTabHovered || isCloseHovered || isClosingTerminal);
|
|
||||||
const iconColor = isActive
|
const iconColor = isActive
|
||||||
? theme.colors.foreground
|
? theme.colors.foreground
|
||||||
: theme.colors.foregroundMuted;
|
: theme.colors.foregroundMuted;
|
||||||
|
const tabAgentStatusBucket = tabAgent
|
||||||
|
? deriveSidebarStateBucket({
|
||||||
|
status: tabAgent.status,
|
||||||
|
pendingPermissionCount: tabAgent.pendingPermissions.length,
|
||||||
|
requiresAttention: tabAgent.requiresAttention,
|
||||||
|
attentionReason: tabAgent.attentionReason,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const tabAgentStatusColor =
|
||||||
|
tabAgentStatusBucket === null
|
||||||
|
? null
|
||||||
|
: getStatusDotColor({
|
||||||
|
theme,
|
||||||
|
bucket: tabAgentStatusBucket,
|
||||||
|
showDoneAsInactive: false,
|
||||||
|
});
|
||||||
const icon =
|
const icon =
|
||||||
tab.kind === "agent" ? (
|
tab.kind === "agent" ? (
|
||||||
tab.provider === "claude" ? (
|
<View style={styles.tabAgentIconWrapper}>
|
||||||
|
{tab.provider === "claude" ? (
|
||||||
<ClaudeIcon size={14} color={iconColor} />
|
<ClaudeIcon size={14} color={iconColor} />
|
||||||
) : tab.provider === "codex" ? (
|
) : tab.provider === "codex" ? (
|
||||||
<CodexIcon size={14} color={iconColor} />
|
<CodexIcon size={14} color={iconColor} />
|
||||||
) : (
|
) : (
|
||||||
<Bot size={14} color={iconColor} />
|
<Bot size={14} color={iconColor} />
|
||||||
)
|
)}
|
||||||
|
{tabAgentStatusColor ? (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.tabStatusDot,
|
||||||
|
{
|
||||||
|
backgroundColor: tabAgentStatusColor,
|
||||||
|
borderColor: theme.colors.surface0,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
) : (
|
) : (
|
||||||
<Terminal size={14} color={iconColor} />
|
<Terminal size={14} color={iconColor} />
|
||||||
);
|
);
|
||||||
@@ -1154,7 +1322,7 @@ function WorkspaceScreenContent({
|
|||||||
style={({ hovered, pressed }) => [
|
style={({ hovered, pressed }) => [
|
||||||
styles.tab,
|
styles.tab,
|
||||||
isActive && styles.tabActive,
|
isActive && styles.tabActive,
|
||||||
(hovered || pressed) && styles.tabHovered,
|
(hovered || pressed || isCloseHovered) && styles.tabHovered,
|
||||||
]}
|
]}
|
||||||
onHoverIn={() => {
|
onHoverIn={() => {
|
||||||
setHoveredTabKey(tab.key);
|
setHoveredTabKey(tab.key);
|
||||||
@@ -1186,21 +1354,32 @@ function WorkspaceScreenContent({
|
|||||||
>
|
>
|
||||||
{tab.label}
|
{tab.label}
|
||||||
</Text>
|
</Text>
|
||||||
{tab.kind === "terminal" ? (
|
|
||||||
<Pressable
|
<Pressable
|
||||||
testID={`workspace-terminal-close-${tab.terminalId}`}
|
testID={
|
||||||
|
tab.kind === "agent"
|
||||||
|
? `workspace-agent-close-${tab.agentId}`
|
||||||
|
: `workspace-terminal-close-${tab.terminalId}`
|
||||||
|
}
|
||||||
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
pointerEvents={shouldShowCloseButton ? "auto" : "none"}
|
||||||
disabled={!shouldShowCloseButton || isClosingTerminal}
|
disabled={!shouldShowCloseButton || isClosingTab}
|
||||||
onHoverIn={() => {
|
onHoverIn={() => {
|
||||||
|
setHoveredTabKey(tab.key);
|
||||||
setHoveredCloseTabKey(tab.key);
|
setHoveredCloseTabKey(tab.key);
|
||||||
}}
|
}}
|
||||||
onHoverOut={() => {
|
onHoverOut={() => {
|
||||||
|
setHoveredTabKey((current) =>
|
||||||
|
current === tab.key ? null : current
|
||||||
|
);
|
||||||
setHoveredCloseTabKey((current) =>
|
setHoveredCloseTabKey((current) =>
|
||||||
current === tab.key ? null : current
|
current === tab.key ? null : current
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
onPress={(event) => {
|
onPress={(event) => {
|
||||||
event.stopPropagation();
|
event.stopPropagation?.();
|
||||||
|
if (tab.kind === "agent") {
|
||||||
|
void handleCloseAgentTab(tab.agentId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
void handleCloseTerminalTab(tab.terminalId);
|
void handleCloseTerminalTab(tab.terminalId);
|
||||||
}}
|
}}
|
||||||
style={({ hovered, pressed }) => [
|
style={({ hovered, pressed }) => [
|
||||||
@@ -1211,21 +1390,76 @@ function WorkspaceScreenContent({
|
|||||||
(hovered || pressed) && styles.tabCloseButtonActive,
|
(hovered || pressed) && styles.tabCloseButtonActive,
|
||||||
]}
|
]}
|
||||||
>
|
>
|
||||||
{isClosingTerminal ? (
|
{isClosingTab ? (
|
||||||
<ActivityIndicator size={12} color={theme.colors.foregroundMuted} />
|
<ActivityIndicator
|
||||||
|
size={12}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<X size={12} color={theme.colors.foregroundMuted} />
|
<X size={12} color={theme.colors.foregroundMuted} />
|
||||||
)}
|
)}
|
||||||
</Pressable>
|
</Pressable>
|
||||||
) : null}
|
|
||||||
</Pressable>
|
</Pressable>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
|
<View style={styles.tabsActions}>
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger
|
||||||
|
testID="workspace-new-agent-tab"
|
||||||
|
onPress={() => handleSelectNewTabOption(NEW_TAB_AGENT_OPTION_ID)}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="New agent tab"
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.newTabActionButton,
|
||||||
|
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<Plus size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="end" offset={8}>
|
||||||
|
<Text style={styles.newTabTooltipText}>New agent tab</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
||||||
|
<TooltipTrigger
|
||||||
|
testID="workspace-new-terminal-tab"
|
||||||
|
onPress={() => handleSelectNewTabOption(NEW_TAB_TERMINAL_OPTION_ID)}
|
||||||
|
disabled={createTerminalMutation.isPending}
|
||||||
|
accessibilityRole="button"
|
||||||
|
accessibilityLabel="New terminal tab"
|
||||||
|
style={({ hovered, pressed }) => [
|
||||||
|
styles.newTabActionButton,
|
||||||
|
createTerminalMutation.isPending && styles.newTabActionButtonDisabled,
|
||||||
|
(hovered || pressed) && styles.newTabActionButtonHovered,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{createTerminalMutation.isPending ? (
|
||||||
|
<ActivityIndicator
|
||||||
|
size="small"
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<View style={styles.terminalPlusIcon}>
|
||||||
|
<Terminal
|
||||||
|
size={theme.iconSize.sm}
|
||||||
|
color={theme.colors.foregroundMuted}
|
||||||
|
/>
|
||||||
|
<View style={styles.terminalPlusBadge}>
|
||||||
|
<Plus size={10} color={theme.colors.foregroundMuted} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
</View>
|
||||||
|
)}
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom" align="end" offset={8}>
|
||||||
|
<Text style={styles.newTabTooltipText}>New terminal tab</Text>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
<View style={styles.mainRow}>
|
<View style={styles.centerContent}>
|
||||||
{isMobile ? (
|
{isMobile ? (
|
||||||
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
|
<GestureDetector gesture={explorerOpenGesture} touchAction="pan-y">
|
||||||
<View style={styles.content}>{renderContent()}</View>
|
<View style={styles.content}>{renderContent()}</View>
|
||||||
@@ -1233,6 +1467,8 @@ function WorkspaceScreenContent({
|
|||||||
) : (
|
) : (
|
||||||
<View style={styles.content}>{renderContent()}</View>
|
<View style={styles.content}>{renderContent()}</View>
|
||||||
)}
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<ExplorerSidebar
|
<ExplorerSidebar
|
||||||
serverId={normalizedServerId}
|
serverId={normalizedServerId}
|
||||||
@@ -1250,6 +1486,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
flex: 1,
|
flex: 1,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
},
|
},
|
||||||
|
threePaneRow: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "stretch",
|
||||||
|
},
|
||||||
|
centerColumn: {
|
||||||
|
flex: 1,
|
||||||
|
minHeight: 0,
|
||||||
|
},
|
||||||
headerTitle: {
|
headerTitle: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
fontSize: theme.fontSize.base,
|
fontSize: theme.fontSize.base,
|
||||||
@@ -1268,50 +1514,108 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
padding: theme.spacing[3],
|
padding: theme.spacing[3],
|
||||||
borderRadius: theme.borderRadius.lg,
|
borderRadius: theme.borderRadius.lg,
|
||||||
},
|
},
|
||||||
newTabButton: {
|
newTabActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
paddingHorizontal: theme.spacing[2],
|
},
|
||||||
paddingVertical: theme.spacing[1],
|
newTabActionButton: {
|
||||||
|
width: 30,
|
||||||
|
height: 30,
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
},
|
},
|
||||||
newTabButtonActive: {
|
newTabActionButtonHovered: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
newTabButtonText: {
|
newTabActionButtonDisabled: {
|
||||||
color: theme.colors.foregroundMuted,
|
opacity: 0.6,
|
||||||
fontSize: theme.fontSize.sm,
|
|
||||||
},
|
},
|
||||||
switcherTrigger: {
|
newTabTooltipText: {
|
||||||
maxWidth: 220,
|
fontSize: theme.fontSize.sm,
|
||||||
|
color: theme.colors.popoverForeground,
|
||||||
|
},
|
||||||
|
terminalPlusIcon: {
|
||||||
|
position: "relative",
|
||||||
|
width: theme.iconSize.sm,
|
||||||
|
height: theme.iconSize.sm,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
terminalPlusBadge: {
|
||||||
|
position: "absolute",
|
||||||
|
right: -6,
|
||||||
|
bottom: -6,
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
borderRadius: 7,
|
||||||
|
backgroundColor: theme.colors.surface1,
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: theme.colors.border,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
mobileTabsRow: {
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
borderBottomColor: theme.colors.border,
|
||||||
|
backgroundColor: theme.colors.surface0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[2],
|
||||||
|
paddingHorizontal: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
},
|
||||||
|
mobileTabsActions: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
gap: theme.spacing[1],
|
gap: theme.spacing[1],
|
||||||
|
},
|
||||||
|
switcherTrigger: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
borderRadius: theme.borderRadius.md,
|
borderRadius: theme.borderRadius.md,
|
||||||
borderWidth: theme.borderWidth[1],
|
borderWidth: theme.borderWidth[1],
|
||||||
borderColor: theme.colors.border,
|
borderColor: theme.colors.border,
|
||||||
|
justifyContent: "space-between",
|
||||||
},
|
},
|
||||||
switcherTriggerActive: {
|
switcherTriggerActive: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
|
switcherTriggerLeft: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
|
},
|
||||||
|
switcherTriggerIcon: {
|
||||||
|
flexShrink: 0,
|
||||||
|
},
|
||||||
switcherTriggerText: {
|
switcherTriggerText: {
|
||||||
minWidth: 0,
|
minWidth: 0,
|
||||||
flexShrink: 1,
|
flex: 1,
|
||||||
color: theme.colors.foregroundMuted,
|
color: theme.colors.foreground,
|
||||||
fontSize: theme.fontSize.sm,
|
fontSize: theme.fontSize.sm,
|
||||||
},
|
},
|
||||||
tabsContainer: {
|
tabsContainer: {
|
||||||
borderBottomWidth: 1,
|
borderBottomWidth: 1,
|
||||||
borderBottomColor: theme.colors.border,
|
borderBottomColor: theme.colors.border,
|
||||||
backgroundColor: theme.colors.surface0,
|
backgroundColor: theme.colors.surface0,
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
},
|
},
|
||||||
tabsScroll: {
|
tabsScroll: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
|
minWidth: 0,
|
||||||
},
|
},
|
||||||
tabsContent: {
|
tabsContent: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
@@ -1320,10 +1624,16 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
paddingHorizontal: theme.spacing[2],
|
paddingHorizontal: theme.spacing[2],
|
||||||
paddingVertical: theme.spacing[1],
|
paddingVertical: theme.spacing[1],
|
||||||
},
|
},
|
||||||
mainRow: {
|
tabsActions: {
|
||||||
|
flexDirection: "row",
|
||||||
|
alignItems: "center",
|
||||||
|
gap: theme.spacing[1],
|
||||||
|
paddingRight: theme.spacing[2],
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
},
|
||||||
|
centerContent: {
|
||||||
flex: 1,
|
flex: 1,
|
||||||
minHeight: 0,
|
minHeight: 0,
|
||||||
flexDirection: "row",
|
|
||||||
},
|
},
|
||||||
tab: {
|
tab: {
|
||||||
paddingHorizontal: theme.spacing[3],
|
paddingHorizontal: theme.spacing[3],
|
||||||
@@ -1337,6 +1647,22 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
tabIcon: {
|
tabIcon: {
|
||||||
flexShrink: 0,
|
flexShrink: 0,
|
||||||
},
|
},
|
||||||
|
tabAgentIconWrapper: {
|
||||||
|
position: "relative",
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
alignItems: "center",
|
||||||
|
justifyContent: "center",
|
||||||
|
},
|
||||||
|
tabStatusDot: {
|
||||||
|
position: "absolute",
|
||||||
|
right: -2,
|
||||||
|
bottom: -2,
|
||||||
|
width: 7,
|
||||||
|
height: 7,
|
||||||
|
borderRadius: theme.borderRadius.full,
|
||||||
|
borderWidth: 1,
|
||||||
|
},
|
||||||
tabActive: {
|
tabActive: {
|
||||||
backgroundColor: theme.colors.surface2,
|
backgroundColor: theme.colors.surface2,
|
||||||
},
|
},
|
||||||
@@ -1351,7 +1677,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
fontWeight: theme.fontWeight.normal,
|
fontWeight: theme.fontWeight.normal,
|
||||||
},
|
},
|
||||||
tabLabelWithCloseButton: {
|
tabLabelWithCloseButton: {
|
||||||
paddingRight: theme.spacing[1],
|
paddingRight: 0,
|
||||||
},
|
},
|
||||||
tabLabelActive: {
|
tabLabelActive: {
|
||||||
color: theme.colors.foreground,
|
color: theme.colors.foreground,
|
||||||
@@ -1359,7 +1685,7 @@ const styles = StyleSheet.create((theme) => ({
|
|||||||
tabCloseButton: {
|
tabCloseButton: {
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
marginLeft: theme.spacing[1],
|
marginLeft: 0,
|
||||||
borderRadius: theme.borderRadius.sm,
|
borderRadius: theme.borderRadius.sm,
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
justifyContent: "center",
|
justifyContent: "center",
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
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);
|
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 });
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user