Update files

This commit is contained in:
Mohamed Boudra
2026-02-10 10:50:54 +07:00
parent 7a21a5cce1
commit 7001928f4f
33 changed files with 3490 additions and 304 deletions
@@ -492,6 +492,9 @@ interface AgentConfigRowProps {
selectedModel: string;
isModelLoading: boolean;
onSelectModel: (modelId: string) => void;
thinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
selectedThinkingOptionId: string;
onSelectThinkingOption: (thinkingOptionId: string) => void;
disabled?: boolean;
}
@@ -506,6 +509,9 @@ export function AgentConfigRow({
selectedModel,
isModelLoading,
onSelectModel,
thinkingOptions,
selectedThinkingOptionId,
onSelectThinkingOption,
disabled,
}: AgentConfigRowProps): ReactElement {
const providerOptions: ComboSelectOption[] = useMemo(
@@ -540,7 +546,18 @@ export function AgentConfigRow({
return opts;
}, [models]);
const thinkingSelectOptions: ComboSelectOption[] = useMemo(
() =>
thinkingOptions.map((option) => ({
id: option.id,
label: option.label,
})),
[thinkingOptions]
);
const effectiveSelectedMode = selectedMode || (modeOptions.length > 0 ? modeOptions[0]?.id : "");
const effectiveSelectedThinkingOption =
selectedThinkingOptionId || thinkingSelectOptions[0]?.id || "";
return (
<View style={styles.agentConfigRow}>
@@ -584,6 +601,21 @@ export function AgentConfigRow({
showLabel={false}
/>
</View>
{thinkingSelectOptions.length > 0 ? (
<View style={styles.agentConfigColumn}>
<ComboSelect
label="Thinking"
title="Select thinking effort"
value={effectiveSelectedThinkingOption}
options={thinkingSelectOptions}
placeholder="Select..."
disabled={disabled}
onSelect={onSelectThinkingOption}
icon={<Brain size={16} color={defaultTheme.colors.foregroundMuted} />}
showLabel={false}
/>
</View>
) : null}
</View>
);
}
@@ -4,9 +4,11 @@ import { StyleSheet, useUnistyles } from "react-native-unistyles";
export function AgentStatusDot({
status,
requiresAttention,
showInactive = false,
}: {
status: string | null | undefined;
requiresAttention: boolean | null | undefined;
showInactive?: boolean;
}) {
const { theme } = useUnistyles();
@@ -15,7 +17,9 @@ export function AgentStatusDot({
? theme.colors.palette.blue[500]
: requiresAttention
? theme.colors.success
: null;
: showInactive
? theme.colors.border
: null;
if (!color) {
return null;
@@ -31,4 +35,3 @@ const styles = StyleSheet.create((theme) => ({
borderRadius: theme.borderRadius.full,
},
}));
@@ -1099,11 +1099,13 @@ function PermissionRequestCard({
{!isPlanRequest ? (
<ToolCallDetailsContent
detail={{
type: "unknown",
input: request.input ?? null,
output: null,
}}
detail={
request.detail ?? {
type: "unknown",
input: request.input ?? null,
output: null,
}
}
maxHeight={200}
/>
) : null}
+158 -41
View File
@@ -7,12 +7,14 @@ import {
View,
Platform,
} from "react-native";
import { Plus, Settings } from "lucide-react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { useCommandCenter } from "@/hooks/use-command-center";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { formatTimeAgo } from "@/utils/time";
import { shortenPath } from "@/utils/shorten-path";
import { AgentStatusDot } from "@/components/agent-status-dot";
import { Shortcut } from "@/components/ui/shortcut";
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
return `${agent.serverId}:${agent.id}`;
@@ -26,13 +28,16 @@ export function CommandCenter() {
query,
setQuery,
activeIndex,
results,
items,
handleClose,
handleSelect,
handleSelectItem,
} = useCommandCenter();
if (Platform.OS !== "web") return null;
const actionItems = items.filter((item) => item.kind === "action");
const agentItems = items.filter((item) => item.kind === "agent");
return (
<Modal
visible={open}
@@ -49,7 +54,7 @@ export function CommandCenter() {
ref={inputRef}
value={query}
onChangeText={setQuery}
placeholder="Search agents…"
placeholder="Type a command or search agents..."
placeholderTextColor={theme.colors.foregroundMuted}
style={[
styles.input,
@@ -67,44 +72,125 @@ export function CommandCenter() {
keyboardShouldPersistTaps="always"
showsVerticalScrollIndicator={false}
>
{results.length === 0 ? (
{items.length === 0 ? (
<Text style={[styles.emptyText, { color: theme.colors.foregroundMuted }]}>
No matches
</Text>
) : (
results.map((agent, index) => {
const active = index === activeIndex;
return (
<Pressable
key={agentKey(agent)}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={() => handleSelect(agent)}
>
<View style={styles.rowContent}>
<View style={styles.rowTitle}>
<AgentStatusDot status={agent.status} requiresAttention={agent.requiresAttention} />
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
<>
{actionItems.length > 0 ? (
<>
<Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
Actions
</Text>
{actionItems.map((item, index) => {
const active = index === activeIndex;
const action = item.action;
const actionIcon =
action.icon === "plus" ? (
<Plus
size={16}
strokeWidth={2.4}
color={theme.colors.foregroundMuted}
/>
) : action.icon === "settings" ? (
<Settings
size={16}
strokeWidth={2.2}
color={theme.colors.foregroundMuted}
/>
) : null;
return (
<Pressable
key={`action:${action.id}`}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={() => handleSelectItem(item)}
>
{agent.title || "New agent"}
</Text>
</View>
<Text
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
numberOfLines={1}
>
{agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
</Pressable>
);
})
<View style={styles.rowContent}>
<View style={styles.rowMain}>
{actionIcon ? (
<View style={styles.iconSlot}>{actionIcon}</View>
) : null}
<View style={styles.textContent}>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{action.title}
</Text>
</View>
</View>
{action.shortcutKeys ? (
<Shortcut keys={action.shortcutKeys} style={styles.rowShortcut} />
) : null}
</View>
</Pressable>
);
})}
</>
) : null}
{agentItems.length > 0 ? (
<>
{actionItems.length > 0 ? (
<View
style={[styles.sectionDivider, { backgroundColor: theme.colors.border }]}
/>
) : null}
<Text style={[styles.sectionLabel, { color: theme.colors.foregroundMuted }]}>
Agents
</Text>
{agentItems.map((item, index) => {
const rowIndex = actionItems.length + index;
const active = rowIndex === activeIndex;
const agent = item.agent;
return (
<Pressable
key={agentKey(agent)}
style={({ hovered, pressed }) => [
styles.row,
(hovered || pressed || active) && {
backgroundColor: theme.colors.surface1,
},
]}
onPress={() => handleSelectItem(item)}
>
<View style={styles.rowContent}>
<View style={styles.rowMain}>
<View style={styles.iconSlot}>
<AgentStatusDot
status={agent.status}
requiresAttention={agent.requiresAttention}
showInactive
/>
</View>
<View style={styles.textContent}>
<Text
style={[styles.title, { color: theme.colors.foreground }]}
numberOfLines={1}
>
{agent.title || "New agent"}
</Text>
<Text
style={[styles.subtitle, { color: theme.colors.foregroundMuted }]}
numberOfLines={1}
>
{agent.serverLabel} · {shortenPath(agent.cwd)} · {formatTimeAgo(agent.lastActivityAt)}
</Text>
</View>
</View>
</View>
</Pressable>
);
})}
</>
) : null}
</>
)}
</ScrollView>
</View>
@@ -152,24 +238,55 @@ const styles = StyleSheet.create((theme) => ({
resultsContent: {
paddingVertical: theme.spacing[2],
},
sectionLabel: {
paddingHorizontal: theme.spacing[4],
paddingTop: 0,
paddingBottom: theme.spacing[2],
fontSize: theme.fontSize.xs,
},
sectionDivider: {
height: 1,
marginTop: theme.spacing[2],
marginBottom: theme.spacing[2],
},
row: {
paddingHorizontal: theme.spacing[4],
paddingVertical: theme.spacing[3],
paddingVertical: theme.spacing[2],
},
rowContent: {
gap: 2,
},
rowTitle: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
justifyContent: "space-between",
gap: theme.spacing[3],
},
rowMain: {
flex: 1,
minWidth: 0,
flexDirection: "row",
alignItems: "flex-start",
gap: theme.spacing[3],
},
iconSlot: {
width: 16,
height: 20,
alignItems: "center",
justifyContent: "center",
},
textContent: {
gap: 2,
},
rowShortcut: {
marginLeft: theme.spacing[2],
flexShrink: 0,
},
title: {
fontSize: theme.fontSize.base,
fontWeight: "400",
lineHeight: 20,
},
subtitle: {
fontSize: theme.fontSize.sm,
lineHeight: 18,
},
emptyText: {
paddingHorizontal: theme.spacing[4],
@@ -5,6 +5,7 @@ import {
closestCenter,
KeyboardSensor,
PointerSensor,
type Modifier,
useSensor,
useSensors,
type DragEndEvent,
@@ -25,6 +26,11 @@ import type {
export type { DraggableListProps, DraggableRenderItemInfo };
const restrictToVerticalAxis: Modifier = ({ transform }) => ({
...transform,
x: 0,
});
interface SortableItemProps<T> {
id: string;
item: T;
@@ -157,6 +163,7 @@ export function DraggableList<T>({
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
modifiers={[restrictToVerticalAxis]}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
>
+93 -14
View File
@@ -177,6 +177,7 @@ interface DiffFileSectionProps {
file: ParsedDiffFile;
isExpanded: boolean;
onToggle: (path: string) => void;
onHeaderHeightChange?: (path: string, height: number) => void;
testID?: string;
}
@@ -218,6 +219,7 @@ const DiffFileHeader = memo(function DiffFileHeader({
file,
isExpanded,
onToggle,
onHeaderHeightChange,
testID,
}: DiffFileSectionProps) {
const expandStartRef = useRef<number | null>(null);
@@ -292,6 +294,9 @@ const DiffFileHeader = memo(function DiffFileHeader({
styles.fileSectionHeaderContainer,
!isExpanded && styles.fileSectionBorder,
]}
onLayout={(event) => {
onHeaderHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
<Pressable
@@ -329,7 +334,15 @@ const DiffFileHeader = memo(function DiffFileHeader({
);
});
function DiffFileBody({ file, testID }: { file: ParsedDiffFile; testID?: string }) {
function DiffFileBody({
file,
onBodyHeightChange,
testID,
}: {
file: ParsedDiffFile;
onBodyHeightChange?: (path: string, height: number) => void;
testID?: string;
}) {
const [scrollViewWidth, setScrollViewWidth] = useState(0);
const [isAtLeftEdge, setIsAtLeftEdge] = useState(true);
const horizontalScroll = useHorizontalScrollOptional();
@@ -368,7 +381,13 @@ function DiffFileBody({ file, testID }: { file: ParsedDiffFile; testID?: string
);
return (
<View style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]} testID={testID}>
<View
style={[styles.fileSectionBodyContainer, styles.fileSectionBorder]}
onLayout={(event) => {
onBodyHeightChange?.(file.path, event.nativeEvent.layout.height);
}}
testID={testID}
>
{file.status === "too_large" || file.status === "binary" ? (
<View style={styles.statusMessageContainer}>
<Text style={styles.statusMessageText}>
@@ -464,6 +483,10 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
// Track user-initiated refresh to avoid iOS RefreshControl animation on background fetches
const [isManualRefresh, setIsManualRefresh] = useState(false);
const [expandedByPath, setExpandedByPath] = useState<Record<string, boolean>>({});
const diffListRef = useRef<FlatList<DiffFlatItem>>(null);
const headerHeightByPathRef = useRef<Record<string, number>>({});
const bodyHeightByPathRef = useRef<Record<string, number>>({});
const defaultHeaderHeightRef = useRef<number>(44);
const diffMetrics = useMemo(() => {
let hunkCount = 0;
let lineCount = 0;
@@ -533,13 +556,6 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
[shipDefaultStorageKey]
);
const handleToggleExpanded = useCallback((path: string) => {
setExpandedByPath((prev) => ({
...prev,
[path]: !prev[path],
}));
}, []);
const { flatItems, stickyHeaderIndices } = useMemo(() => {
const items: DiffFlatItem[] = [];
const stickyIndices: number[] = [];
@@ -555,6 +571,60 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
return { flatItems: items, stickyHeaderIndices: stickyIndices };
}, [files, expandedByPath]);
const handleHeaderHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height <= 0) {
return;
}
headerHeightByPathRef.current[path] = height;
defaultHeaderHeightRef.current = height;
}, []);
const handleBodyHeightChange = useCallback((path: string, height: number) => {
if (!Number.isFinite(height) || height < 0) {
return;
}
bodyHeightByPathRef.current[path] = height;
}, []);
const computeHeaderOffset = useCallback(
(path: string): number => {
const defaultHeaderHeight = defaultHeaderHeightRef.current;
let offset = 0;
for (const file of files) {
if (file.path === path) {
break;
}
offset += headerHeightByPathRef.current[file.path] ?? defaultHeaderHeight;
if (expandedByPath[file.path]) {
offset += bodyHeightByPathRef.current[file.path] ?? 0;
}
}
return Math.max(0, offset);
},
[expandedByPath, files]
);
const handleToggleExpanded = useCallback(
(path: string) => {
const isCurrentlyExpanded = expandedByPath[path] ?? false;
const targetOffset = isCurrentlyExpanded ? computeHeaderOffset(path) : null;
// Anchor to the clicked header before collapsing so visual context is preserved.
if (isCurrentlyExpanded && targetOffset !== null) {
diffListRef.current?.scrollToOffset({
offset: targetOffset,
animated: false,
});
}
setExpandedByPath((prev) => ({
...prev,
[path]: !prev[path],
}));
},
[computeHeaderOffset, expandedByPath]
);
const allExpanded = useMemo(() => {
if (files.length === 0) return false;
return files.every((file) => expandedByPath[file.path]);
@@ -707,15 +777,20 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
file={item.file}
isExpanded={item.isExpanded}
onToggle={handleToggleExpanded}
onHeaderHeightChange={handleHeaderHeightChange}
testID={`diff-file-${item.fileIndex}`}
/>
);
}
return (
<DiffFileBody file={item.file} testID={`diff-file-${item.fileIndex}-body`} />
<DiffFileBody
file={item.file}
onBodyHeightChange={handleBodyHeightChange}
testID={`diff-file-${item.fileIndex}-body`}
/>
);
},
[handleToggleExpanded]
[handleBodyHeightChange, handleHeaderHeightChange, handleToggleExpanded]
);
const flatKeyExtractor = useCallback(
@@ -808,6 +883,7 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
} else {
bodyContent = (
<FlatList
ref={diffListRef}
data={flatItems}
renderItem={renderFlatItem}
keyExtractor={flatKeyExtractor}
@@ -818,9 +894,12 @@ export function GitDiffPane({ serverId, agentId, cwd }: GitDiffPaneProps) {
testID="git-diff-scroll"
onRefresh={handleRefresh}
refreshing={isManualRefresh && isDiffFetching}
initialNumToRender={6}
maxToRenderPerBatch={6}
windowSize={5}
// Mixed-height rows (header + potentially very large body) are prone to clipping artifacts.
// Keep a larger render window and disable clipping to avoid bodies disappearing mid-scroll.
removeClippedSubviews={false}
initialNumToRender={12}
maxToRenderPerBatch={12}
windowSize={10}
/>
);
}
@@ -138,31 +138,14 @@ export function ToolCallDetailsContent({
</View>
);
} else if (detail?.type === "unknown") {
const sectionsFromTopLevel = [
{ title: "Input", value: detail.input },
{ title: "Output", value: detail.output },
].filter((entry) => entry.value !== null && entry.value !== undefined);
const plainInputText =
typeof detail.input === "string" && detail.output === null
? detail.input
: null;
for (const section of sectionsFromTopLevel) {
let value = "";
try {
value =
typeof section.value === "string"
? section.value
: JSON.stringify(section.value, null, 2);
} catch {
value = String(section.value);
}
if (!value.length) {
continue;
}
if (plainInputText !== null) {
sections.push(
<View key={`${section.title}-header`} style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>{section.title}</Text>
</View>
);
sections.push(
<View key={`${section.title}-value`} style={styles.section}>
<View key="unknown-plain-text" style={styles.section}>
<ScrollView
horizontal
nestedScrollEnabled
@@ -170,10 +153,48 @@ export function ToolCallDetailsContent({
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{value}</Text>
<Text selectable style={styles.scrollText}>{plainInputText}</Text>
</ScrollView>
</View>
);
} else {
const sectionsFromTopLevel = [
{ title: "Input", value: detail.input },
{ title: "Output", value: detail.output },
].filter((entry) => entry.value !== null && entry.value !== undefined);
for (const section of sectionsFromTopLevel) {
let value = "";
try {
value =
typeof section.value === "string"
? section.value
: JSON.stringify(section.value, null, 2);
} catch {
value = String(section.value);
}
if (!value.length) {
continue;
}
sections.push(
<View key={`${section.title}-header`} style={styles.groupHeader}>
<Text style={styles.groupHeaderText}>{section.title}</Text>
</View>
);
sections.push(
<View key={`${section.title}-value`} style={styles.section}>
<ScrollView
horizontal
nestedScrollEnabled
style={styles.jsonScroll}
contentContainerStyle={styles.jsonContent}
showsHorizontalScrollIndicator={true}
>
<Text selectable style={styles.scrollText}>{value}</Text>
</ScrollView>
</View>
);
}
}
}