From b763d4358e1a9c98f701a61435c038e9e7cf91b6 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 18 Mar 2026 19:55:04 +0700 Subject: [PATCH] feat: add terminal tabs and split pane controls to workspace UI --- packages/app/src/components/message.tsx | 56 ++++++++++++++--- .../app/src/components/split-container.tsx | 57 ++++++++++++++++- .../workspace/workspace-desktop-tabs-row.tsx | 63 +++++++++++++++++-- .../screens/workspace/workspace-screen.tsx | 5 ++ .../src/stores/workspace-layout-actions.ts | 62 ++++++++++++++++++ .../app/src/stores/workspace-layout-store.ts | 41 ++++++++++++ 6 files changed, 267 insertions(+), 17 deletions(-) diff --git a/packages/app/src/components/message.tsx b/packages/app/src/components/message.tsx index 4203bc7e9..90d5a3590 100644 --- a/packages/app/src/components/message.tsx +++ b/packages/app/src/components/message.tsx @@ -541,6 +541,7 @@ const turnCopyButtonStylesheet = StyleSheet.create((theme) => ({ alignSelf: "flex-start", padding: theme.spacing[2], paddingTop: 0, + marginTop: theme.spacing[2], }, iconColor: { color: theme.colors.foregroundMuted, @@ -624,7 +625,7 @@ export const TurnCopyButton = memo(function TurnCopyButton({ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ container: { - marginHorizontal: theme.spacing[2], + marginHorizontal: -6, }, containerSpacing: { marginBottom: theme.spacing[1], @@ -635,8 +636,7 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ pressable: { borderRadius: theme.borderRadius.lg, borderWidth: theme.borderWidth[1], - borderColor: theme.colors.border, - backgroundColor: theme.colors.surface1, + borderColor: "transparent", paddingHorizontal: theme.spacing[2], paddingVertical: theme.spacing[1], overflow: "hidden", @@ -660,16 +660,18 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ borderRadius: 11, alignItems: "center", justifyContent: "center", - marginRight: theme.spacing[2], + marginRight: theme.spacing[1], backgroundColor: "transparent", }, label: { - color: theme.colors.foreground, - opacity: 0.88, + color: theme.colors.foregroundMuted, fontSize: theme.fontSize.base, fontWeight: theme.fontWeight.normal, flexShrink: 0, }, + labelActive: { + color: theme.colors.foreground, + }, labelLoading: { color: theme.colors.foreground, opacity: 0.72, @@ -681,6 +683,9 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ fontWeight: theme.fontWeight.normal, marginLeft: theme.spacing[2], }, + secondaryLabelActive: { + color: theme.colors.foreground, + }, shimmerText: { color: "transparent", fontSize: theme.fontSize.base, @@ -710,6 +715,8 @@ const expandableBadgeStylesheet = StyleSheet.create((theme) => ({ overflow: "hidden", }, pressableExpanded: { + borderColor: theme.colors.border, + backgroundColor: theme.colors.surface1, borderBottomLeftRadius: 0, borderBottomRightRadius: 0, }, @@ -941,6 +948,22 @@ export const AssistantMessage = memo(function AssistantMessage({ ); }, + paragraph: ( + node: any, + children: ReactNode[], + parent: any, + styles: any, + ) => { + const isLastChild = parent[0]?.children?.at(-1)?.key === node.key; + return ( + + {children} + + ); + }, link: ( node: any, children: ReactNode[], @@ -1594,12 +1617,23 @@ const ExpandableBadge = memo(function ExpandableBadge({ [isExpanded, isInteractive] ); + const isActive = isHovered || isExpanded; + const labelStyle = useMemo( () => [ expandableBadgeStylesheet.label, + isActive && expandableBadgeStylesheet.labelActive, isLoading && expandableBadgeStylesheet.labelLoading, ], - [isLoading] + [isActive, isLoading] + ); + + const secondaryLabelStyle = useMemo( + () => [ + expandableBadgeStylesheet.secondaryLabel, + isActive && expandableBadgeStylesheet.secondaryLabelActive, + ], + [isActive] ); const shimmerLabelTextStyle = useMemo( @@ -1670,7 +1704,9 @@ const ExpandableBadge = memo(function ExpandableBadge({ const IconComponent = icon; const iconColor = isError ? theme.colors.destructive - : theme.colors.mutedForeground; + : isActive + ? theme.colors.foreground + : theme.colors.mutedForeground; let iconNode: ReactNode = null; if (isError) { @@ -1717,7 +1753,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ {secondaryLabel ? ( @@ -1812,7 +1848,7 @@ const ExpandableBadge = memo(function ExpandableBadge({ {isInteractive && isHovered ? ( ) : null} diff --git a/packages/app/src/components/split-container.tsx b/packages/app/src/components/split-container.tsx index 4352f7961..d4c271e1e 100644 --- a/packages/app/src/components/split-container.tsx +++ b/packages/app/src/components/split-container.tsx @@ -1,4 +1,4 @@ -import { Fragment, useCallback, useMemo, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; +import { Fragment, useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type ReactNode, type SetStateAction } from "react"; import { DndContext, DragOverlay, @@ -15,7 +15,7 @@ import { type DragStartEvent, } from "@dnd-kit/core"; import { arrayMove, sortableKeyboardCoordinates } from "@dnd-kit/sortable"; -import { View, Text } from "react-native"; +import { Platform, View, Text } from "react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { ResizeHandle } from "@/components/resize-handle"; import { @@ -63,6 +63,7 @@ interface SplitContainerProps { onCloseTabsToRight: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onCloseOtherTabs: (tabId: string, paneTabs: WorkspaceTabDescriptor[]) => Promise | void; onSelectNewTabOption: (selection: { optionId: "__new_tab_agent__"; paneId?: string }) => void; + onNewTerminalTab: () => void; newTabAgentOptionId?: "__new_tab_agent__"; buildPaneContentModel: (input: { paneId: string; @@ -74,6 +75,10 @@ interface SplitContainerProps { targetPaneId: string; position: "left" | "right" | "top" | "bottom"; }) => void; + onSplitPaneEmpty: (input: { + targetPaneId: string; + position: "left" | "right" | "top" | "bottom"; + }) => void; onMoveTabToPane: (tabId: string, toPaneId: string) => void; onResizeSplit: (groupId: string, sizes: number[]) => void; onReorderTabsInPane: (paneId: string, tabIds: string[]) => void; @@ -110,7 +115,6 @@ interface SplitPaneViewProps | "activeDragTabId" | "showDropZones" | "dropPreview" - | "onSplitPane" | "onMoveTabToPane" | "onResizeSplit" > { @@ -161,10 +165,12 @@ export function SplitContainer({ onCloseTabsToRight, onCloseOtherTabs, onSelectNewTabOption, + onNewTerminalTab, newTabAgentOptionId = "__new_tab_agent__", buildPaneContentModel, onFocusPane, onSplitPane, + onSplitPaneEmpty, onMoveTabToPane, onResizeSplit, onReorderTabsInPane, @@ -360,10 +366,12 @@ export function SplitContainer({ onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} onSelectNewTabOption={onSelectNewTabOption} + onNewTerminalTab={onNewTerminalTab} newTabAgentOptionId={newTabAgentOptionId} buildPaneContentModel={buildPaneContentModel} onFocusPane={onFocusPane} onSplitPane={onSplitPane} + onSplitPaneEmpty={onSplitPaneEmpty} onMoveTabToPane={onMoveTabToPane} onResizeSplit={onResizeSplit} onReorderTabsInPane={onReorderTabsInPane} @@ -479,10 +487,12 @@ function SplitNodeView({ onCloseTabsToRight, onCloseOtherTabs, onSelectNewTabOption, + onNewTerminalTab, newTabAgentOptionId, buildPaneContentModel, onFocusPane, onSplitPane, + onSplitPaneEmpty, onMoveTabToPane, onResizeSplit, onReorderTabsInPane, @@ -513,9 +523,12 @@ function SplitNodeView({ onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} onSelectNewTabOption={onSelectNewTabOption} + onNewTerminalTab={onNewTerminalTab} newTabAgentOptionId={newTabAgentOptionId} buildPaneContentModel={buildPaneContentModel} onFocusPane={onFocusPane} + onSplitPane={onSplitPane} + onSplitPaneEmpty={onSplitPaneEmpty} onReorderTabsInPane={onReorderTabsInPane} renderPaneEmptyState={renderPaneEmptyState} activeDragTabId={activeDragTabId} @@ -560,10 +573,12 @@ function SplitNodeView({ onCloseTabsToRight={onCloseTabsToRight} onCloseOtherTabs={onCloseOtherTabs} onSelectNewTabOption={onSelectNewTabOption} + onNewTerminalTab={onNewTerminalTab} newTabAgentOptionId={newTabAgentOptionId} buildPaneContentModel={buildPaneContentModel} onFocusPane={onFocusPane} onSplitPane={onSplitPane} + onSplitPaneEmpty={onSplitPaneEmpty} onMoveTabToPane={onMoveTabToPane} onResizeSplit={onResizeSplit} onReorderTabsInPane={onReorderTabsInPane} @@ -608,9 +623,12 @@ function SplitPaneView({ onCloseTabsToRight, onCloseOtherTabs, onSelectNewTabOption, + onNewTerminalTab, newTabAgentOptionId, buildPaneContentModel, onFocusPane, + onSplitPane, + onSplitPaneEmpty, onReorderTabsInPane, renderPaneEmptyState, activeDragTabId, @@ -618,6 +636,7 @@ function SplitPaneView({ dropPreview, }: SplitPaneViewProps) { const { theme } = useUnistyles(); + const paneRef = useRef(null); const paneState = useMemo( () => deriveWorkspacePaneState({ @@ -677,8 +696,37 @@ function SplitPaneView({ ] ); + useEffect(() => { + if (Platform.OS !== "web") { + return; + } + + const paneElement = paneRef.current as unknown as HTMLElement | null; + if ( + !paneElement || + typeof paneElement.addEventListener !== "function" || + typeof paneElement.removeEventListener !== "function" + ) { + return; + } + + const handlePaneInteraction = () => { + onFocusPane(pane.id); + }; + + paneElement.addEventListener("pointerdown", handlePaneInteraction, true); + paneElement.addEventListener("focusin", handlePaneInteraction, true); + + return () => { + paneElement.removeEventListener("pointerdown", handlePaneInteraction, true); + paneElement.removeEventListener("focusin", handlePaneInteraction, true); + }; + }, [onFocusPane, pane.id]); + return ( { onFocusPane(pane.id); @@ -704,10 +752,13 @@ function SplitPaneView({ onCloseTabsToRight={(tabId) => onCloseTabsToRight(tabId, paneTabs)} onCloseOtherTabs={(tabId) => onCloseOtherTabs(tabId, paneTabs)} onSelectNewTabOption={onSelectNewTabOption} + onNewTerminalTab={onNewTerminalTab} newTabAgentOptionId={newTabAgentOptionId ?? "__new_tab_agent__"} onReorderTabs={(nextTabs) => { onReorderTabsInPane(pane.id, nextTabs.map((tab) => tab.tabId)); }} + onSplitRight={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "right" })} + onSplitDown={() => onSplitPaneEmpty({ targetPaneId: pane.id, position: "bottom" })} externalDndContext activeDragTabId={activeDragTabId} /> diff --git a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx index 70ce09272..ffda56982 100644 --- a/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx +++ b/packages/app/src/screens/workspace/workspace-desktop-tabs-row.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo, useState, type Dispatch, type SetStateAction } from "react"; import { ActivityIndicator, Pressable, ScrollView, Text, View, type LayoutChangeEvent } from "react-native"; -import { Plus, X } from "lucide-react-native"; +import { Columns2, Plus, Rows2, SquareTerminal, X } from "lucide-react-native"; import { StyleSheet, useUnistyles } from "react-native-unistyles"; import { SortableInlineList } from "@/components/sortable-inline-list"; import { @@ -57,6 +57,9 @@ type WorkspaceDesktopTabsRowProps = { onSelectNewTabOption: (selection: NewTabSelection) => void; newTabAgentOptionId: NewTabOptionId; onReorderTabs: (nextTabs: WorkspaceTabDescriptor[]) => void; + onNewTerminalTab: () => void; + onSplitRight: () => void; + onSplitDown: () => void; externalDndContext?: boolean; activeDragTabId?: string | null; }; @@ -270,6 +273,9 @@ export function WorkspaceDesktopTabsRow({ onSelectNewTabOption, newTabAgentOptionId, onReorderTabs, + onNewTerminalTab, + onSplitRight, + onSplitDown, externalDndContext = false, activeDragTabId = null, }: WorkspaceDesktopTabsRowProps) { @@ -410,6 +416,54 @@ export function WorkspaceDesktopTabsRow({ + + [ + styles.newTabActionButton, + (hovered || pressed) && styles.newTabActionButtonHovered, + ]} + > + + + + New terminal tab + + + + [ + styles.newTabActionButton, + (hovered || pressed) && styles.newTabActionButtonHovered, + ]} + > + + + + Split pane right + + + + [ + styles.newTabActionButton, + (hovered || pressed) && styles.newTabActionButtonHovered, + ]} + > + + + + Split pane down + + ); @@ -514,11 +568,13 @@ function ResolvedDesktopTabChip({ const styles = StyleSheet.create((theme) => ({ tabsContainer: { minWidth: 0, + height: theme.spacing[2] * 2 + theme.iconSize.sm + theme.spacing[1], borderBottomWidth: 1, borderBottomColor: theme.colors.border, backgroundColor: theme.colors.surface0, flexDirection: "row", alignItems: "center", + overflow: "visible", }, tabsScroll: { minWidth: 0, @@ -536,7 +592,6 @@ const styles = StyleSheet.create((theme) => ({ tabsActions: { flexDirection: "row", alignItems: "center", - gap: theme.spacing[1], paddingHorizontal: theme.spacing[2], }, tab: { @@ -613,8 +668,8 @@ const styles = StyleSheet.create((theme) => ({ backgroundColor: theme.colors.surface3, }, newTabActionButton: { - width: 28, - height: 28, + width: 22, + height: 22, borderRadius: theme.borderRadius.md, alignItems: "center", justifyContent: "center", diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 267fcecdf..da3c6b55e 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -781,6 +781,7 @@ function WorkspaceScreenContent({ const closeWorkspaceTab = useWorkspaceLayoutStore((state) => state.closeTab); const retargetWorkspaceTab = useWorkspaceLayoutStore((state) => state.retargetTab); const splitWorkspacePane = useWorkspaceLayoutStore((state) => state.splitPane); + const splitWorkspacePaneEmpty = useWorkspaceLayoutStore((state) => state.splitPaneEmpty); const moveWorkspaceTabToPane = useWorkspaceLayoutStore((state) => state.moveTabToPane); const focusWorkspacePane = useWorkspaceLayoutStore((state) => state.focusPane); const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit); @@ -2017,9 +2018,13 @@ function WorkspaceScreenContent({ onFocusPane={(paneId) => { focusWorkspacePane(persistenceKey, paneId); }} + onNewTerminalTab={handleCreateTerminal} onSplitPane={(input) => { splitWorkspacePane(persistenceKey, input); }} + onSplitPaneEmpty={(input) => { + splitWorkspacePaneEmpty(persistenceKey, input); + }} onMoveTabToPane={(tabId, toPaneId) => { moveWorkspaceTabToPane(persistenceKey, tabId, toPaneId); }} diff --git a/packages/app/src/stores/workspace-layout-actions.ts b/packages/app/src/stores/workspace-layout-actions.ts index 978914d3e..e65efb2ad 100644 --- a/packages/app/src/stores/workspace-layout-actions.ts +++ b/packages/app/src/stores/workspace-layout-actions.ts @@ -145,6 +145,14 @@ interface SplitPaneInLayoutResult { paneId: string; } +interface SplitPaneEmptyInLayoutInput { + layout: WorkspaceLayout; + targetPaneId: string; + position: "left" | "right" | "top" | "bottom"; + createNodeId: (prefix: "pane" | "group") => string; + maxTreeDepth: number; +} + interface MoveTabToPaneInLayoutInput { layout: WorkspaceLayout; tabId: string; @@ -1066,6 +1074,60 @@ export function splitPaneInLayout(input: SplitPaneInLayoutInput): SplitPaneInLay }; } +export function splitPaneEmptyInLayout(input: SplitPaneEmptyInLayoutInput): SplitPaneInLayoutResult | null { + const layout = asInternalLayout(input.layout); + if (!findPaneById(layout.root, input.targetPaneId)) { + return null; + } + + const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical"; + const insertAfter = input.position === "right" || input.position === "bottom"; + + const targetPath = findPanePathById(layout.root, input.targetPaneId); + invariant(targetPath, `Target pane not found: ${input.targetPaneId}`); + const targetNode = getNodeAtPath(layout.root, targetPath); + invariant(targetNode.kind === "pane", "Expected target pane"); + + const newPaneId = input.createNodeId("pane"); + const newPaneNode = createPaneNode({ id: newPaneId }); + + const parentPath = targetPath.slice(0, -1); + const targetIndex = targetPath[targetPath.length - 1] ?? 0; + const parentNode = parentPath.length > 0 ? getNodeAtPath(layout.root, parentPath) : null; + + let nextRoot: SplitNodeInternal; + if (parentNode?.kind === "group" && parentNode.group.direction === direction) { + const targetSize = parentNode.group.sizes[targetIndex] ?? 0; + const nextSizes = parentNode.group.sizes.slice(); + const insertIndex = insertAfter ? targetIndex + 1 : targetIndex; + nextSizes.splice(insertIndex, 0, targetSize / 2); + nextSizes[targetIndex + (insertAfter ? 0 : 1)] = targetSize / 2; + nextRoot = replaceNodeAtPath(layout.root, parentPath, () => + insertChildIntoGroup(parentNode, { index: insertIndex, node: newPaneNode, sizes: nextSizes }) + ); + } else { + const newGroup = createGroupNode({ + id: input.createNodeId("group"), + direction, + children: insertAfter ? [targetNode, newPaneNode] : [newPaneNode, targetNode], + sizes: [0.5, 0.5], + }); + nextRoot = replaceNodeAtPath(layout.root, targetPath, () => newGroup); + } + + if (getTreeDepth(nextRoot) > input.maxTreeDepth) { + return null; + } + + return { + paneId: newPaneId, + layout: { + root: nextRoot, + focusedPaneId: newPaneId, + }, + }; +} + export function moveTabToPaneInLayout(input: MoveTabToPaneInLayoutInput): WorkspaceLayout | null { const layout = asInternalLayout(input.layout); const sourcePane = findPaneContainingTab(layout.root, input.tabId); diff --git a/packages/app/src/stores/workspace-layout-store.ts b/packages/app/src/stores/workspace-layout-store.ts index 09ad69600..3f2524dec 100644 --- a/packages/app/src/stores/workspace-layout-store.ts +++ b/packages/app/src/stores/workspace-layout-store.ts @@ -26,6 +26,7 @@ import { reorderFocusedPaneTabsInLayout, reorderPaneTabsInLayout, retargetTabInLayout, + splitPaneEmptyInLayout, splitPaneInLayout, type SplitGroup, type SplitNode, @@ -66,6 +67,13 @@ interface WorkspaceLayoutStore { position: "left" | "right" | "top" | "bottom"; } ) => string | null; + splitPaneEmpty: ( + workspaceKey: string, + input: { + targetPaneId: string; + position: "left" | "right" | "top" | "bottom"; + } + ) => string | null; moveTabToPane: (workspaceKey: string, tabId: string, toPaneId: string) => void; focusPane: (workspaceKey: string, paneId: string) => void; resizeSplit: (workspaceKey: string, groupId: string, sizes: number[]) => void; @@ -252,6 +260,39 @@ export const useWorkspaceLayoutStore = create()( return result.paneId; }, + splitPaneEmpty: (workspaceKey, input) => { + const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); + const normalizedTargetPaneId = trimNonEmpty(input.targetPaneId); + if (!normalizedWorkspaceKey || !normalizedTargetPaneId) { + return null; + } + + const result = splitPaneEmptyInLayout({ + layout: getWorkspaceLayout(get().layoutByWorkspace, normalizedWorkspaceKey), + targetPaneId: normalizedTargetPaneId, + position: input.position, + maxTreeDepth: MAX_TREE_DEPTH, + createNodeId: (prefix) => { + const randomValue = + typeof globalThis.crypto?.randomUUID === "function" + ? globalThis.crypto.randomUUID() + : `${Date.now()}-${Math.random().toString(16).slice(2)}`; + return `${prefix}_${randomValue}`; + }, + }); + if (!result) { + return null; + } + + set((state) => ({ + layoutByWorkspace: { + ...state.layoutByWorkspace, + [normalizedWorkspaceKey]: result.layout, + }, + })); + + return result.paneId; + }, moveTabToPane: (workspaceKey, tabId, toPaneId) => { const normalizedWorkspaceKey = trimNonEmpty(workspaceKey); const normalizedTabId = trimNonEmpty(tabId);