* feat(files): edit workspace files on web Keep source buffers synchronized with host file changes and require an explicit overwrite or reload when revisions diverge. * feat(panels): surface and protect modified tabs Expose tooltip and modification state through the generic panel boundary so tabs can show stable metadata and guard every close route consistently. * fix(tests): use portable fake timeout handle * fix(files): harden editor conflict handling Preserve modified panel state across tab eviction, use precise revisions for optimistic writes, coalesce concurrent file watchers, and localize the editor interface. * fix(files): close editor concurrency gaps Coalesce clean reloads, preserve subscriber identities and file permissions, suspend pending saves during close confirmation, and carry precise revisions through file reads. * test(files): expect read revision metadata
416 lines
12 KiB
TypeScript
416 lines
12 KiB
TypeScript
import { useCallback, useMemo, useState, type ReactElement } from "react";
|
|
import { Pressable, ScrollView, Text, View, type PressableStateCallbackType } from "react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Archive, ChevronDown, ChevronRight, Unlink } from "lucide-react-native";
|
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
|
import { getProviderIcon } from "@/components/provider-icons";
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
|
import { useIsCompactFormFactor, MAX_CONTENT_WIDTH } from "@/constants/layout";
|
|
import { isNative } from "@/constants/platform";
|
|
import {
|
|
WorkspaceTabIcon,
|
|
type WorkspaceTabPresentation,
|
|
} from "@/screens/workspace/workspace-tab-presentation";
|
|
import type { Theme } from "@/styles/theme";
|
|
import type { SubagentRow } from "./select";
|
|
import {
|
|
buildSubagentRowPresentationData,
|
|
countFinishedSubagents,
|
|
formatHeaderLabel,
|
|
} from "./track-presentation";
|
|
|
|
const ThemedArchive = withUnistyles(Archive);
|
|
const ThemedChevronDown = withUnistyles(ChevronDown);
|
|
const ThemedChevronRight = withUnistyles(ChevronRight);
|
|
const ThemedUnlink = withUnistyles(Unlink);
|
|
|
|
const foregroundColorMapping = (theme: Theme) => ({ color: theme.colors.foreground });
|
|
const foregroundMutedColorMapping = (theme: Theme) => ({
|
|
color: theme.colors.foregroundMuted,
|
|
});
|
|
|
|
export interface SubagentsTrackProps {
|
|
rows: SubagentRow[];
|
|
onOpenSubagent: (id: string) => void;
|
|
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
|
onArchiveSubagent: (id: string) => void;
|
|
onArchiveFinished?: () => void;
|
|
onDetachSubagent?: (id: string) => void;
|
|
}
|
|
|
|
const SUBAGENTS_LIST_MAX_HEIGHT = 200;
|
|
|
|
function buildRowPresentation(row: SubagentRow): WorkspaceTabPresentation {
|
|
const data = buildSubagentRowPresentationData(row);
|
|
return {
|
|
...data,
|
|
tooltip: data.label,
|
|
modified: false,
|
|
icon: getProviderIcon(row.provider),
|
|
};
|
|
}
|
|
|
|
export function SubagentsTrack({
|
|
rows,
|
|
onOpenSubagent,
|
|
onOpenProviderSubagent,
|
|
onArchiveSubagent,
|
|
onArchiveFinished,
|
|
onDetachSubagent,
|
|
}: SubagentsTrackProps): ReactElement | null {
|
|
const { t } = useTranslation();
|
|
const [expanded, setExpanded] = useState(false);
|
|
|
|
const toggleExpanded = useCallback(() => {
|
|
setExpanded((current) => !current);
|
|
}, []);
|
|
|
|
const surfaceStyle = useMemo(
|
|
() => [styles.surface, expanded && styles.surfaceExpanded],
|
|
[expanded],
|
|
);
|
|
|
|
const headerStyle = useCallback(
|
|
({ hovered, pressed }: PressableStateCallbackType) => [
|
|
styles.headerToggle,
|
|
(hovered || pressed) && styles.headerActive,
|
|
],
|
|
[],
|
|
);
|
|
const headerContainerStyle = useMemo(
|
|
() => [styles.header, expanded ? styles.headerDivider : styles.headerCollapsed],
|
|
[expanded],
|
|
);
|
|
|
|
if (rows.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
const headerLabel = formatHeaderLabel(rows);
|
|
const finishedCount = countFinishedSubagents(rows);
|
|
|
|
return (
|
|
<View style={styles.outer} testID="subagents-track">
|
|
<View style={styles.track}>
|
|
<View style={surfaceStyle}>
|
|
<View style={headerContainerStyle}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={headerLabel}
|
|
testID="subagents-track-header"
|
|
onPress={toggleExpanded}
|
|
style={headerStyle}
|
|
>
|
|
{expanded ? (
|
|
<ThemedChevronDown size={12} uniProps={foregroundMutedColorMapping} />
|
|
) : (
|
|
<ThemedChevronRight size={12} uniProps={foregroundMutedColorMapping} />
|
|
)}
|
|
<Text style={styles.headerLabel} numberOfLines={1}>
|
|
{headerLabel}
|
|
</Text>
|
|
</Pressable>
|
|
{finishedCount > 0 && onArchiveFinished ? (
|
|
<View style={styles.headerAction}>
|
|
<SubagentActionButton
|
|
accessibilityLabel={t("subagents.archiveFinishedAction")}
|
|
testID="subagents-track-archive-finished"
|
|
tooltipLabel={t("subagents.archiveFinishedTooltip")}
|
|
icon="archive"
|
|
visible
|
|
onPress={onArchiveFinished}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
</View>
|
|
{expanded ? (
|
|
<ScrollView
|
|
style={styles.scroll}
|
|
contentContainerStyle={styles.scrollContent}
|
|
showsVerticalScrollIndicator={false}
|
|
nestedScrollEnabled
|
|
>
|
|
{rows.map((row) => (
|
|
<SubagentsTrackRow
|
|
key={row.id}
|
|
row={row}
|
|
onOpenSubagent={onOpenSubagent}
|
|
onOpenProviderSubagent={onOpenProviderSubagent}
|
|
onArchiveSubagent={onArchiveSubagent}
|
|
onDetachSubagent={onDetachSubagent}
|
|
/>
|
|
))}
|
|
</ScrollView>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface SubagentsTrackRowProps {
|
|
row: SubagentRow;
|
|
onOpenSubagent: (id: string) => void;
|
|
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
|
|
onArchiveSubagent: (id: string) => void;
|
|
onDetachSubagent?: (id: string) => void;
|
|
}
|
|
|
|
function SubagentsTrackRow({
|
|
row,
|
|
onOpenSubagent,
|
|
onOpenProviderSubagent,
|
|
onArchiveSubagent,
|
|
onDetachSubagent,
|
|
}: SubagentsTrackRowProps): ReactElement {
|
|
const { t } = useTranslation();
|
|
const isCompact = useIsCompactFormFactor();
|
|
const [hovered, setHovered] = useState(false);
|
|
const presentation = useMemo(() => buildRowPresentation(row), [row]);
|
|
const displayLabel =
|
|
presentation.titleState === "loading" ? t("common.states.loading") : presentation.label;
|
|
const handlePress = useCallback(() => {
|
|
if (row.kind === "provider") {
|
|
onOpenProviderSubagent(row.parentAgentId, row.id);
|
|
} else {
|
|
onOpenSubagent(row.id);
|
|
}
|
|
}, [onOpenProviderSubagent, onOpenSubagent, row]);
|
|
const handleArchivePress = useCallback(() => {
|
|
onArchiveSubagent(row.id);
|
|
}, [onArchiveSubagent, row.id]);
|
|
const handleDetachPress = useCallback(() => {
|
|
onDetachSubagent?.(row.id);
|
|
}, [onDetachSubagent, row.id]);
|
|
const handlePointerEnter = useCallback(() => setHovered(true), []);
|
|
const handlePointerLeave = useCallback(() => setHovered(false), []);
|
|
const actionsAlwaysVisible = isNative || isCompact;
|
|
const actionsVisible = actionsAlwaysVisible || hovered;
|
|
|
|
return (
|
|
// Wrapper View handles hover so moving the pointer between the row and
|
|
// the archive button doesn't drop the hover state — the same pattern
|
|
// used by sidebar workspace rows.
|
|
<View onPointerEnter={handlePointerEnter} onPointerLeave={handlePointerLeave}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={displayLabel}
|
|
testID={`subagents-track-row-${row.id}`}
|
|
onPress={handlePress}
|
|
>
|
|
{({ pressed }) => (
|
|
<View style={hovered || pressed ? styles.rowActive : styles.row}>
|
|
<WorkspaceTabIcon presentation={presentation} />
|
|
<Text style={styles.rowLabel} numberOfLines={1}>
|
|
{displayLabel}
|
|
</Text>
|
|
{row.kind === "paseo" ? (
|
|
<SubagentRowActions
|
|
rowId={row.id}
|
|
displayLabel={displayLabel}
|
|
visible={actionsVisible}
|
|
onDetachPress={onDetachSubagent ? handleDetachPress : undefined}
|
|
onArchivePress={handleArchivePress}
|
|
/>
|
|
) : null}
|
|
</View>
|
|
)}
|
|
</Pressable>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function SubagentRowActions({
|
|
rowId,
|
|
displayLabel,
|
|
visible,
|
|
onDetachPress,
|
|
onArchivePress,
|
|
}: {
|
|
rowId: string;
|
|
displayLabel: string;
|
|
visible: boolean;
|
|
onDetachPress?: () => void;
|
|
onArchivePress: () => void;
|
|
}): ReactElement {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<View
|
|
style={visible ? styles.actionClusterVisible : styles.actionClusterHidden}
|
|
pointerEvents={visible ? "auto" : "none"}
|
|
>
|
|
{onDetachPress ? (
|
|
<SubagentActionButton
|
|
accessibilityLabel={t("subagents.detachAction", { label: displayLabel })}
|
|
testID={`subagents-track-detach-${rowId}`}
|
|
tooltipLabel={t("subagents.detachTooltip")}
|
|
icon="detach"
|
|
visible={visible}
|
|
onPress={onDetachPress}
|
|
/>
|
|
) : null}
|
|
<SubagentActionButton
|
|
accessibilityLabel={t("subagents.archiveAction", { label: displayLabel })}
|
|
testID={`subagents-track-archive-${rowId}`}
|
|
tooltipLabel={t("subagents.archiveTooltip")}
|
|
icon="archive"
|
|
visible={visible}
|
|
onPress={onArchivePress}
|
|
/>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
type SubagentActionIcon = "archive" | "detach";
|
|
|
|
function renderSubagentActionIcon(icon: SubagentActionIcon, isActive: boolean): ReactElement {
|
|
const uniProps = isActive ? foregroundColorMapping : foregroundMutedColorMapping;
|
|
if (icon === "detach") {
|
|
return <ThemedUnlink size={14} uniProps={uniProps} />;
|
|
}
|
|
return <ThemedArchive size={14} uniProps={uniProps} />;
|
|
}
|
|
|
|
function SubagentActionButton({
|
|
accessibilityLabel,
|
|
testID,
|
|
tooltipLabel,
|
|
icon,
|
|
visible,
|
|
onPress,
|
|
}: {
|
|
accessibilityLabel: string;
|
|
testID: string;
|
|
tooltipLabel: string;
|
|
icon: SubagentActionIcon;
|
|
visible: boolean;
|
|
onPress: () => void;
|
|
}): ReactElement {
|
|
return (
|
|
<Tooltip delayDuration={0} enabledOnDesktop enabledOnMobile={false}>
|
|
<TooltipTrigger asChild disabled={!visible}>
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
accessibilityLabel={accessibilityLabel}
|
|
testID={testID}
|
|
onPress={onPress}
|
|
style={styles.actionButton}
|
|
hitSlop={8}
|
|
>
|
|
{({ hovered, pressed }) => renderSubagentActionIcon(icon, hovered || pressed)}
|
|
</Pressable>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top" align="center" offset={8}>
|
|
<Text style={styles.tooltipText}>{tooltipLabel}</Text>
|
|
</TooltipContent>
|
|
</Tooltip>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
outer: {
|
|
width: "100%",
|
|
alignItems: "center",
|
|
paddingHorizontal: theme.spacing[4],
|
|
},
|
|
track: {
|
|
width: "100%",
|
|
maxWidth: MAX_CONTENT_WIDTH,
|
|
marginBottom: -theme.spacing[4],
|
|
},
|
|
surface: {
|
|
alignSelf: "stretch",
|
|
backgroundColor: theme.colors.surface1,
|
|
borderWidth: theme.borderWidth[1],
|
|
borderColor: theme.colors.borderAccent,
|
|
borderBottomWidth: 0,
|
|
borderTopLeftRadius: theme.borderRadius["2xl"],
|
|
borderTopRightRadius: theme.borderRadius["2xl"],
|
|
overflow: "hidden",
|
|
},
|
|
surfaceExpanded: {
|
|
paddingBottom: theme.spacing[4],
|
|
},
|
|
header: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
},
|
|
headerToggle: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingLeft: theme.spacing[3],
|
|
paddingRight: theme.spacing[1],
|
|
paddingVertical: theme.spacing[2],
|
|
},
|
|
headerAction: {
|
|
paddingRight: theme.spacing[2],
|
|
},
|
|
headerCollapsed: {
|
|
paddingBottom: theme.spacing[4],
|
|
},
|
|
headerActive: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
headerDivider: {
|
|
borderBottomWidth: theme.borderWidth[1],
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
headerLabel: {
|
|
flexShrink: 1,
|
|
minWidth: 0,
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
scroll: {
|
|
maxHeight: SUBAGENTS_LIST_MAX_HEIGHT,
|
|
},
|
|
scrollContent: {
|
|
paddingVertical: theme.spacing[1],
|
|
},
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
},
|
|
rowActive: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[2],
|
|
paddingHorizontal: theme.spacing[3],
|
|
paddingVertical: theme.spacing[2],
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
rowLabel: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
fontSize: theme.fontSize.sm,
|
|
color: theme.colors.foreground,
|
|
},
|
|
actionClusterVisible: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
opacity: 1,
|
|
},
|
|
actionClusterHidden: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[1],
|
|
opacity: 0,
|
|
},
|
|
actionButton: {
|
|
padding: theme.spacing[1],
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
tooltipText: {
|
|
fontSize: theme.fontSize.xs,
|
|
color: theme.colors.foreground,
|
|
},
|
|
}));
|