feat: stream worktree setup progress and terminal subscriptions

This commit is contained in:
Mohamed Boudra
2026-02-16 08:09:27 +07:00
parent b980abb7af
commit 037a6a15a4
29 changed files with 1449 additions and 102 deletions
@@ -11,7 +11,11 @@ interface BackHeaderProps {
onBack?: () => void;
}
export function BackHeader({ title, rightContent, onBack }: BackHeaderProps) {
export function BackHeader({
title,
rightContent,
onBack,
}: BackHeaderProps) {
const { theme } = useUnistyles();
return (
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { Text } from "react-native";
import { Text, View, type StyleProp, type ViewStyle } from "react-native";
import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyles";
import { Menu, PanelLeft } from "lucide-react-native";
import { PanelLeft } from "lucide-react-native";
import { ScreenHeader } from "./screen-header";
import { HeaderToggleButton } from "./header-toggle-button";
import { usePanelStore } from "@/stores/panel-store";
@@ -12,7 +12,39 @@ interface MenuHeaderProps {
rightContent?: ReactNode;
}
export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
interface SidebarMenuToggleProps {
style?: StyleProp<ViewStyle>;
tooltipSide?: "left" | "right" | "top" | "bottom";
testID?: string;
nativeID?: string;
}
const MOBILE_MENU_LINE_WIDTH = 16;
const MOBILE_MENU_LINE_SHORT_WIDTH = 8;
const MOBILE_MENU_LINE_HEIGHT = 2;
function MobileMenuIcon({ color }: { color: string }) {
return (
<View style={styles.mobileMenuIcon} pointerEvents="none">
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View style={[styles.mobileMenuLine, { backgroundColor: color }]} />
<View
style={[
styles.mobileMenuLine,
styles.mobileMenuLineShort,
{ backgroundColor: color },
]}
/>
</View>
);
}
export function SidebarMenuToggle({
style,
tooltipSide = "right",
testID = "menu-button",
nativeID = "menu-button",
}: SidebarMenuToggleProps = {}) {
const { theme } = useUnistyles();
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
@@ -22,29 +54,42 @@ export function MenuHeader({ title, rightContent }: MenuHeaderProps) {
const toggleShortcutKeys = getShortcutOs() === "mac" ? ["mod", "B"] : ["mod", "."];
const isOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const MenuIcon = isMobile ? Menu : PanelLeft;
const menuIconColor = !isMobile && isOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
return (
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide={tooltipSide}
testID={testID}
nativeID={nativeID}
style={style}
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
{isMobile ? (
<MobileMenuIcon color={menuIconColor} />
) : (
<PanelLeft size={16} color={menuIconColor} />
)}
</HeaderToggleButton>
);
}
export function MenuHeader({
title,
rightContent,
}: MenuHeaderProps) {
return (
<ScreenHeader
left={
<>
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={toggleShortcutKeys}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
accessible
accessibilityRole="button"
accessibilityLabel={isOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isOpen }}
>
<MenuIcon size={isMobile ? 20 : 16} color={menuIconColor} />
</HeaderToggleButton>
<SidebarMenuToggle />
{title && (
<Text style={styles.title} numberOfLines={1}>
{title}
@@ -71,4 +116,18 @@ const styles = StyleSheet.create((theme) => ({
},
color: theme.colors.foreground,
},
mobileMenuIcon: {
width: MOBILE_MENU_LINE_WIDTH,
height: 12,
justifyContent: "space-between",
alignItems: "flex-start",
},
mobileMenuLine: {
width: MOBILE_MENU_LINE_WIDTH,
height: MOBILE_MENU_LINE_HEIGHT,
borderRadius: theme.borderRadius.full,
},
mobileMenuLineShort: {
width: MOBILE_MENU_LINE_SHORT_WIDTH,
},
}));
@@ -16,7 +16,12 @@ interface ScreenHeaderProps {
* Shared frame for the home/back headers so we only maintain padding, border,
* and safe-area logic in one place.
*/
export function ScreenHeader({ left, right, leftStyle, rightStyle }: ScreenHeaderProps) {
export function ScreenHeader({
left,
right,
leftStyle,
rightStyle,
}: ScreenHeaderProps) {
const insets = useSafeAreaInsets();
const isMobile = UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
// Only add extra padding on mobile for better touch targets; on desktop, only use safe area insets
@@ -1089,8 +1089,7 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
opacity: 0.78,
fontSize: theme.fontSize.xs,
flexShrink: 1,
minWidth: 0,
flexShrink: 0,
},
agentMetaBranchBadge: {
borderRadius: theme.borderRadius.full,
@@ -1098,7 +1097,8 @@ const styles = StyleSheet.create((theme) => ({
borderColor: theme.colors.border,
paddingHorizontal: theme.spacing[1],
paddingVertical: 1,
flexShrink: 0,
flexShrink: 1,
minWidth: 0,
},
agentMetaBranchBadgeSelected: {
borderColor: theme.colors.borderAccent,
@@ -1107,6 +1107,8 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foreground,
opacity: 0.76,
fontSize: theme.fontSize.xs,
flexShrink: 1,
minWidth: 0,
},
selectionBar: {
position: "absolute",
@@ -290,6 +290,35 @@ export function TerminalPane({ serverId, cwd }: TerminalPaneProps) {
});
}, [client, cwd, isConnected, queryClient, serverId]);
useEffect(() => {
if (!client || !isConnected || !cwd.startsWith("/")) {
return;
}
const unsubscribe = client.on("terminals_changed", (message) => {
if (message.type !== "terminals_changed") {
return;
}
if (message.payload.cwd !== cwd) {
return;
}
void queryClient.invalidateQueries({
queryKey: ["terminals", serverId, cwd],
});
void queryClient.refetchQueries({
queryKey: ["terminals", serverId, cwd],
type: "active",
});
});
client.subscribeTerminals({ cwd });
return () => {
unsubscribe();
client.unsubscribeTerminals({ cwd });
};
}, [client, cwd, isConnected, queryClient, serverId]);
const createTerminalMutation = useMutation({
mutationFn: async () => {
if (!client) {
@@ -89,6 +89,43 @@ export function ToolCallDetailsContent({
</View>
</View>
);
} else if (detail?.type === "worktree_setup") {
const setupLog = detail.log.replace(/^\n+/, "");
const hasLog = setupLog.length > 0;
sections.push(
<View
key="worktree-setup"
style={[styles.section, shouldFill && styles.fillHeight]}
>
<View style={[codeBlockStyle, shouldFill && styles.fillHeight]}>
<ScrollView
style={[
styles.codeVerticalScroll,
resolvedMaxHeight !== undefined && { maxHeight: resolvedMaxHeight },
shouldFill && styles.fillHeight,
]}
contentContainerStyle={styles.codeVerticalContent}
nestedScrollEnabled
showsVerticalScrollIndicator
>
<ScrollView
horizontal
nestedScrollEnabled
showsHorizontalScrollIndicator
contentContainerStyle={styles.codeHorizontalContent}
>
<View style={styles.codeLine}>
<Text selectable style={styles.scrollText}>
{hasLog
? setupLog
: `Preparing worktree ${detail.branchName} at ${detail.worktreePath}`}
</Text>
</View>
</ScrollView>
</ScrollView>
</View>
</View>
);
} else if (detail?.type === "edit") {
sections.push(
<View
@@ -14,8 +14,8 @@ import { StyleSheet, UnistylesRuntime, useUnistyles } from "react-native-unistyl
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useReanimatedKeyboardAnimation } from "react-native-keyboard-controller";
import Animated, { useAnimatedStyle, useSharedValue } from "react-native-reanimated";
import { Folder, GitBranch, Menu, PanelLeft } from "lucide-react-native";
import { HeaderToggleButton } from "@/components/headers/header-toggle-button";
import { Folder, GitBranch } from "lucide-react-native";
import { SidebarMenuToggle } from "@/components/headers/menu-header";
import { AgentInputArea } from "@/components/agent-input-area";
import { AgentStreamView } from "@/components/agent-stream-view";
import { AgentConfigRow, FormSelectTrigger } from "@/components/agent-form/agent-form-dropdowns";
@@ -31,7 +31,6 @@ import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { buildBranchComboOptions, normalizeBranchOptionName } from "@/utils/branch-suggestions";
import { shortenPath } from "@/utils/shorten-path";
import { usePanelStore } from "@/stores/panel-store";
import { useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
@@ -125,9 +124,6 @@ export function DraftAgentScreen({
const insets = useSafeAreaInsets();
const { connectionStates } = useDaemonConnections();
const { daemons } = useDaemonRegistry();
const mobileView = usePanelStore((state) => state.mobileView);
const desktopAgentListOpen = usePanelStore((state) => state.desktop.agentListOpen);
const toggleAgentList = usePanelStore((state) => state.toggleAgentList);
const params = useLocalSearchParams<DraftAgentParams>();
const { height: keyboardHeight } = useReanimatedKeyboardAnimation();
@@ -226,11 +222,6 @@ export function DraftAgentScreen({
: undefined;
const isMobile =
UnistylesRuntime.breakpoint === "xs" || UnistylesRuntime.breakpoint === "sm";
const isSidebarOpen = isMobile ? mobileView === "agent-list" : desktopAgentListOpen;
const SidebarIcon = isMobile ? Menu : PanelLeft;
const sidebarIconColor = !isMobile && isSidebarOpen
? theme.colors.foreground
: theme.colors.foregroundMuted;
const [worktreeMode, setWorktreeMode] = useState<"none" | "create" | "attach">("none");
const [baseBranch, setBaseBranch] = useState("");
@@ -977,20 +968,7 @@ export function DraftAgentScreen({
isMobile ? { paddingTop: insets.top + theme.spacing[2] } : null,
]}
>
<HeaderToggleButton
onPress={toggleAgentList}
tooltipLabel="Toggle sidebar"
tooltipKeys={["mod", "B"]}
tooltipSide="right"
testID="menu-button"
nativeID="menu-button"
accessible
accessibilityRole="button"
accessibilityLabel={isSidebarOpen ? "Close menu" : "Open menu"}
accessibilityState={{ expanded: isSidebarOpen }}
>
<SidebarIcon size={isMobile ? 20 : 16} color={sidebarIconColor} />
</HeaderToggleButton>
<SidebarMenuToggle />
</View>
<Animated.View style={[styles.contentContainer, animatedKeyboardStyle]}>
@@ -76,6 +76,34 @@ describe("tool-call-display", () => {
});
});
it("builds display model from worktree setup detail", () => {
const display = buildToolCallDisplayModel({
name: "paseo_worktree_setup",
status: "running",
error: null,
detail: {
type: "worktree_setup",
worktreePath: "/tmp/repo/.paseo/worktrees/repo/branch",
branchName: "feature-branch",
log: "==> [1/1] Running: npm install\n",
commands: [
{
index: 1,
command: "npm install",
cwd: "/tmp/repo/.paseo/worktrees/repo/branch",
status: "running",
exitCode: null,
},
],
},
});
expect(display).toEqual({
displayName: "Worktree Setup",
summary: "feature-branch",
});
});
it("does not derive command summary from unknown raw detail", () => {
const display = buildToolCallDisplayModel({
name: "exec_command",
+1
View File
@@ -19,6 +19,7 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
edit: Pencil,
write: Pencil,
search: Search,
worktree_setup: SquareTerminal,
unknown: Wrench,
};