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);