Update files
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { __private__ } from "./use-agent-form-state";
|
||||
import type { AgentModelDefinition } from "@server/server/agent/agent-sdk-types";
|
||||
|
||||
describe("useAgentFormState", () => {
|
||||
describe("__private__.combineInitialValues", () => {
|
||||
@@ -35,5 +36,115 @@ describe("useAgentFormState", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("__private__.resolveFormState", () => {
|
||||
const codexModels: AgentModelDefinition[] = [
|
||||
{
|
||||
provider: "codex",
|
||||
id: "gpt-5.3-codex",
|
||||
label: "gpt-5.3-codex",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "xhigh",
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "low" },
|
||||
{ id: "xhigh", label: "xhigh", isDefault: true },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
it("auto-selects the model's default thinking option when none is configured", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{ provider: "codex" },
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
|
||||
it("keeps provider thinking preference when it is valid for the effective model", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
thinkingOptionId: "low",
|
||||
},
|
||||
},
|
||||
},
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
expect(resolved.thinkingOptionId).toBe("low");
|
||||
});
|
||||
|
||||
it("falls back to model default when saved thinking preference is invalid", () => {
|
||||
const resolved = __private__.resolveFormState(
|
||||
undefined,
|
||||
{
|
||||
provider: "codex",
|
||||
providerPreferences: {
|
||||
codex: {
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
},
|
||||
},
|
||||
codexModels,
|
||||
{
|
||||
serverId: false,
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
},
|
||||
{
|
||||
serverId: null,
|
||||
provider: "codex",
|
||||
modeId: "",
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
},
|
||||
new Set<string>()
|
||||
);
|
||||
|
||||
expect(resolved.thinkingOptionId).toBe("xhigh");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export interface FormInitialValues {
|
||||
provider?: AgentProvider;
|
||||
modeId?: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
workingDir?: string;
|
||||
}
|
||||
|
||||
@@ -28,6 +29,7 @@ interface UserModifiedFields {
|
||||
provider: boolean;
|
||||
modeId: boolean;
|
||||
model: boolean;
|
||||
thinkingOptionId: boolean;
|
||||
workingDir: boolean;
|
||||
}
|
||||
|
||||
@@ -36,6 +38,7 @@ const INITIAL_USER_MODIFIED: UserModifiedFields = {
|
||||
provider: false,
|
||||
modeId: false,
|
||||
model: false,
|
||||
thinkingOptionId: false,
|
||||
workingDir: false,
|
||||
};
|
||||
|
||||
@@ -45,6 +48,7 @@ interface FormState {
|
||||
provider: AgentProvider;
|
||||
modeId: string;
|
||||
model: string;
|
||||
thinkingOptionId: string;
|
||||
workingDir: string;
|
||||
}
|
||||
|
||||
@@ -67,6 +71,8 @@ type UseAgentFormStateResult = {
|
||||
setModeFromUser: (modeId: string) => void;
|
||||
selectedModel: string;
|
||||
setModelFromUser: (modelId: string) => void;
|
||||
selectedThinkingOptionId: string;
|
||||
setThinkingOptionFromUser: (thinkingOptionId: string) => void;
|
||||
workingDir: string;
|
||||
setWorkingDir: (value: string) => void;
|
||||
setWorkingDirFromUser: (value: string) => void;
|
||||
@@ -75,6 +81,7 @@ type UseAgentFormStateResult = {
|
||||
agentDefinition?: AgentProviderDefinition;
|
||||
modeOptions: AgentMode[];
|
||||
availableModels: AgentModelDefinition[];
|
||||
availableThinkingOptions: NonNullable<AgentModelDefinition["thinkingOptions"]>;
|
||||
isModelLoading: boolean;
|
||||
modelError: string | null;
|
||||
refreshProviderModels: () => void;
|
||||
@@ -91,6 +98,32 @@ const DEFAULT_PROVIDER: AgentProvider = fallbackDefinition?.id ?? "claude";
|
||||
const DEFAULT_MODE_FOR_DEFAULT_PROVIDER =
|
||||
fallbackDefinition?.defaultModeId ?? "";
|
||||
|
||||
function resolveDefaultModel(
|
||||
availableModels: AgentModelDefinition[] | null
|
||||
): AgentModelDefinition | null {
|
||||
if (!availableModels || availableModels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return availableModels.find((model) => model.isDefault) ?? availableModels[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveEffectiveModel(
|
||||
availableModels: AgentModelDefinition[] | null,
|
||||
modelId: string
|
||||
): AgentModelDefinition | null {
|
||||
if (!availableModels || availableModels.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const normalizedModelId = modelId.trim();
|
||||
if (!normalizedModelId) {
|
||||
return resolveDefaultModel(availableModels);
|
||||
}
|
||||
return (
|
||||
availableModels.find((model) => model.id === normalizedModelId) ??
|
||||
resolveDefaultModel(availableModels)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pure function that resolves form state from multiple data sources.
|
||||
* Priority: explicit (URL params) > preferences > provider defaults > fallback
|
||||
@@ -175,7 +208,43 @@ function resolveFormState(
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Resolve serverId (independent)
|
||||
// 4. Resolve thinking option (depends on effective model)
|
||||
const initialThinkingOptionId =
|
||||
typeof initialValues?.thinkingOptionId === "string"
|
||||
? initialValues.thinkingOptionId.trim()
|
||||
: "";
|
||||
const preferredThinkingOptionId =
|
||||
providerPrefs?.thinkingOptionId?.trim() ?? "";
|
||||
|
||||
if (!userModified.thinkingOptionId) {
|
||||
if (initialThinkingOptionId.length > 0) {
|
||||
result.thinkingOptionId = initialThinkingOptionId;
|
||||
} else if (preferredThinkingOptionId.length > 0) {
|
||||
result.thinkingOptionId = preferredThinkingOptionId;
|
||||
} else {
|
||||
result.thinkingOptionId = "";
|
||||
}
|
||||
}
|
||||
|
||||
// Validate thinking option once model metadata is available.
|
||||
if (availableModels) {
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, result.model);
|
||||
const thinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
if (thinkingOptions.length === 0) {
|
||||
result.thinkingOptionId = "";
|
||||
} else {
|
||||
const thinkingIds = new Set(thinkingOptions.map((option) => option.id));
|
||||
const defaultThinkingOptionId =
|
||||
effectiveModel?.defaultThinkingOptionId ??
|
||||
thinkingOptions[0]?.id ??
|
||||
"";
|
||||
if (!result.thinkingOptionId || !thinkingIds.has(result.thinkingOptionId)) {
|
||||
result.thinkingOptionId = defaultThinkingOptionId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Resolve serverId (independent)
|
||||
// Only use stored serverId if the host still exists in the registry
|
||||
if (!userModified.serverId) {
|
||||
if (initialValues?.serverId !== undefined) {
|
||||
@@ -186,7 +255,7 @@ function resolveFormState(
|
||||
// else keep current
|
||||
}
|
||||
|
||||
// 5. Resolve workingDir (independent)
|
||||
// 6. Resolve workingDir (independent)
|
||||
if (!userModified.workingDir) {
|
||||
if (initialValues?.workingDir !== undefined) {
|
||||
result.workingDir = initialValues.workingDir;
|
||||
@@ -258,6 +327,7 @@ export function useAgentFormState(
|
||||
provider: DEFAULT_PROVIDER,
|
||||
modeId: DEFAULT_MODE_FOR_DEFAULT_PROVIDER,
|
||||
model: "",
|
||||
thinkingOptionId: "",
|
||||
workingDir: "",
|
||||
}));
|
||||
const formStateRef = useRef(formState);
|
||||
@@ -342,6 +412,7 @@ export function useAgentFormState(
|
||||
resolved.provider !== formStateRef.current.provider ||
|
||||
resolved.modeId !== formStateRef.current.modeId ||
|
||||
resolved.model !== formStateRef.current.model ||
|
||||
resolved.thinkingOptionId !== formStateRef.current.thinkingOptionId ||
|
||||
resolved.workingDir !== formStateRef.current.workingDir
|
||||
) {
|
||||
setFormState(resolved);
|
||||
@@ -430,6 +501,7 @@ export function useAgentFormState(
|
||||
provider,
|
||||
modeId: providerPrefs?.mode ?? providerDef?.defaultModeId ?? "",
|
||||
model: providerPrefs?.model ?? "",
|
||||
thinkingOptionId: providerPrefs?.thinkingOptionId ?? "",
|
||||
}));
|
||||
},
|
||||
[preferences?.providerPreferences, updatePreferences]
|
||||
@@ -453,6 +525,15 @@ export function useAgentFormState(
|
||||
[formState.provider, updateProviderPreferences]
|
||||
);
|
||||
|
||||
const setThinkingOptionFromUser = useCallback(
|
||||
(thinkingOptionId: string) => {
|
||||
setFormState((prev) => ({ ...prev, thinkingOptionId }));
|
||||
setUserModified((prev) => ({ ...prev, thinkingOptionId: true }));
|
||||
void updateProviderPreferences(formState.provider, { thinkingOptionId });
|
||||
},
|
||||
[formState.provider, updateProviderPreferences]
|
||||
);
|
||||
|
||||
const setWorkingDir = useCallback((value: string) => {
|
||||
setFormState((prev) => ({ ...prev, workingDir: value }));
|
||||
}, []);
|
||||
@@ -475,27 +556,40 @@ export function useAgentFormState(
|
||||
}, [providerModelsQuery]);
|
||||
|
||||
const persistFormPreferences = useCallback(async () => {
|
||||
const providerPreferenceUpdates: {
|
||||
mode: string;
|
||||
model: string;
|
||||
thinkingOptionId?: string;
|
||||
} = {
|
||||
mode: formState.modeId,
|
||||
model: formState.model,
|
||||
};
|
||||
if (userModified.thinkingOptionId) {
|
||||
providerPreferenceUpdates.thinkingOptionId = formState.thinkingOptionId;
|
||||
}
|
||||
|
||||
await updatePreferences({
|
||||
workingDir: formState.workingDir,
|
||||
provider: formState.provider,
|
||||
serverId: formState.serverId ?? undefined,
|
||||
});
|
||||
await updateProviderPreferences(formState.provider, {
|
||||
mode: formState.modeId,
|
||||
model: formState.model,
|
||||
});
|
||||
await updateProviderPreferences(formState.provider, providerPreferenceUpdates);
|
||||
}, [
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
formState.provider,
|
||||
formState.serverId,
|
||||
formState.thinkingOptionId,
|
||||
formState.workingDir,
|
||||
userModified.thinkingOptionId,
|
||||
updatePreferences,
|
||||
updateProviderPreferences,
|
||||
]);
|
||||
|
||||
const agentDefinition = providerDefinitionMap.get(formState.provider);
|
||||
const modeOptions = agentDefinition?.modes ?? [];
|
||||
const effectiveModel = resolveEffectiveModel(availableModels, formState.model);
|
||||
const availableThinkingOptions = effectiveModel?.thinkingOptions ?? [];
|
||||
const isModelLoading = providerModelsQuery.isLoading || providerModelsQuery.isFetching;
|
||||
const modelError =
|
||||
providerModelsQuery.error instanceof Error ? providerModelsQuery.error.message : null;
|
||||
@@ -513,6 +607,8 @@ export function useAgentFormState(
|
||||
setModeFromUser,
|
||||
selectedModel: formState.model,
|
||||
setModelFromUser,
|
||||
selectedThinkingOptionId: formState.thinkingOptionId,
|
||||
setThinkingOptionFromUser,
|
||||
workingDir: formState.workingDir,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
@@ -521,6 +617,7 @@ export function useAgentFormState(
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels: availableModels ?? [],
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
@@ -532,17 +629,20 @@ export function useAgentFormState(
|
||||
formState.provider,
|
||||
formState.modeId,
|
||||
formState.model,
|
||||
formState.thinkingOptionId,
|
||||
formState.workingDir,
|
||||
setSelectedServerId,
|
||||
setSelectedServerIdFromUser,
|
||||
setProviderFromUser,
|
||||
setModeFromUser,
|
||||
setModelFromUser,
|
||||
setThinkingOptionFromUser,
|
||||
setWorkingDir,
|
||||
setWorkingDirFromUser,
|
||||
agentDefinition,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
|
||||
@@ -4,10 +4,20 @@ import { router, usePathname } from "expo-router";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
clearCommandCenterFocusRestoreElement,
|
||||
takeCommandCenterFocusRestoreElement,
|
||||
} from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
import type { ShortcutKey } from "@/utils/format-shortcut";
|
||||
import { focusWithRetries } from "@/utils/web-focus";
|
||||
|
||||
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||
@@ -41,6 +51,74 @@ function parseAgentKeyFromPathname(pathname: string): string | null {
|
||||
return `${match[1]}:${match[2]}`;
|
||||
}
|
||||
|
||||
function parseAgentRouteFromPathname(
|
||||
pathname: string
|
||||
): { serverId: string; agentId: string } | null {
|
||||
const match = pathname.match(/^\/agent\/([^/]+)\/([^/]+)/);
|
||||
if (!match) return null;
|
||||
const [, serverId, agentId] = match;
|
||||
if (!serverId || !agentId) return null;
|
||||
return { serverId, agentId };
|
||||
}
|
||||
|
||||
type CommandCenterActionDefinition = {
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
keywords: string[];
|
||||
buildRoute: (params: { newAgentRoute: string }) => string;
|
||||
};
|
||||
|
||||
const COMMAND_CENTER_ACTIONS: readonly CommandCenterActionDefinition[] = [
|
||||
{
|
||||
id: "new-agent",
|
||||
title: "New agent",
|
||||
icon: "plus",
|
||||
shortcutKeys: ["mod", "alt", "N"],
|
||||
keywords: ["new", "new agent", "create", "start", "launch", "agent"],
|
||||
buildRoute: ({ newAgentRoute }) => newAgentRoute,
|
||||
},
|
||||
{
|
||||
id: "settings",
|
||||
title: "Settings",
|
||||
icon: "settings",
|
||||
keywords: ["settings", "preferences", "config", "configuration"],
|
||||
buildRoute: () => "/settings",
|
||||
},
|
||||
];
|
||||
|
||||
function matchesActionQuery(
|
||||
query: string,
|
||||
action: CommandCenterActionDefinition
|
||||
): boolean {
|
||||
const normalized = query.trim().toLowerCase();
|
||||
if (!normalized) return true;
|
||||
if (action.title.toLowerCase().includes(normalized)) {
|
||||
return true;
|
||||
}
|
||||
return action.keywords.some((keyword) => keyword.includes(normalized));
|
||||
}
|
||||
|
||||
export type CommandCenterActionItem = {
|
||||
kind: "action";
|
||||
id: string;
|
||||
title: string;
|
||||
icon?: "plus" | "settings";
|
||||
route: string;
|
||||
shortcutKeys?: ShortcutKey[];
|
||||
};
|
||||
|
||||
export type CommandCenterItem =
|
||||
| {
|
||||
kind: "action";
|
||||
action: CommandCenterActionItem;
|
||||
}
|
||||
| {
|
||||
kind: "agent";
|
||||
agent: AggregatedAgent;
|
||||
};
|
||||
|
||||
export function useCommandCenter() {
|
||||
const pathname = usePathname();
|
||||
const { agents } = useAggregatedAgents();
|
||||
@@ -53,7 +131,7 @@ export function useCommandCenter() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
|
||||
const results = useMemo(() => {
|
||||
const agentResults = useMemo(() => {
|
||||
const filtered = agents.filter((agent) => isMatch(agent, query));
|
||||
filtered.sort(sortAgents);
|
||||
return filtered;
|
||||
@@ -64,11 +142,62 @@ export function useCommandCenter() {
|
||||
[pathname]
|
||||
);
|
||||
|
||||
const newAgentRoute = useMemo(() => {
|
||||
const routeAgent = parseAgentRouteFromPathname(pathname);
|
||||
if (!routeAgent) {
|
||||
return "/agent";
|
||||
}
|
||||
|
||||
const { serverId, agentId } = routeAgent;
|
||||
const currentAgent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
||||
const cwd = currentAgent?.cwd?.trim();
|
||||
if (!cwd) {
|
||||
return "/agent";
|
||||
}
|
||||
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
) ?? null;
|
||||
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
return buildNewAgentRoute(workingDir);
|
||||
}, [pathname]);
|
||||
|
||||
const actionItems = useMemo(() => {
|
||||
return COMMAND_CENTER_ACTIONS.filter((action) =>
|
||||
matchesActionQuery(query, action)
|
||||
).map<CommandCenterActionItem>((action) => ({
|
||||
kind: "action",
|
||||
id: action.id,
|
||||
title: action.title,
|
||||
icon: action.icon,
|
||||
route: action.buildRoute({ newAgentRoute }),
|
||||
shortcutKeys: action.shortcutKeys,
|
||||
}));
|
||||
}, [newAgentRoute, query]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const next: CommandCenterItem[] = [];
|
||||
for (const action of actionItems) {
|
||||
next.push({
|
||||
kind: "action",
|
||||
action,
|
||||
});
|
||||
}
|
||||
for (const agent of agentResults) {
|
||||
next.push({
|
||||
kind: "agent",
|
||||
agent,
|
||||
});
|
||||
}
|
||||
return next;
|
||||
}, [actionItems, agentResults]);
|
||||
|
||||
const handleClose = useCallback(() => {
|
||||
setOpen(false);
|
||||
}, [setOpen]);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
const handleSelectAgent = useCallback(
|
||||
(agent: AggregatedAgent) => {
|
||||
didNavigateRef.current = true;
|
||||
const session = useSessionStore.getState().sessions[agent.serverId];
|
||||
@@ -86,6 +215,24 @@ export function useCommandCenter() {
|
||||
[pathname, requestFocusChatInput, setOpen]
|
||||
);
|
||||
|
||||
const handleSelectAction = useCallback((action: CommandCenterActionItem) => {
|
||||
didNavigateRef.current = true;
|
||||
clearCommandCenterFocusRestoreElement();
|
||||
setOpen(false);
|
||||
router.push(action.route as any);
|
||||
}, [setOpen]);
|
||||
|
||||
const handleSelectItem = useCallback(
|
||||
(item: CommandCenterItem) => {
|
||||
if (item.kind === "action") {
|
||||
handleSelectAction(item.action);
|
||||
return;
|
||||
}
|
||||
handleSelectAgent(item.agent);
|
||||
},
|
||||
[handleSelectAction, handleSelectAgent]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const prevOpen = prevOpenRef.current;
|
||||
prevOpenRef.current = open;
|
||||
@@ -126,10 +273,10 @@ export function useCommandCenter() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (activeIndex >= results.length) {
|
||||
setActiveIndex(results.length > 0 ? results.length - 1 : 0);
|
||||
if (activeIndex >= items.length) {
|
||||
setActiveIndex(items.length > 0 ? items.length - 1 : 0);
|
||||
}
|
||||
}, [activeIndex, open, results.length]);
|
||||
}, [activeIndex, items.length, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
@@ -152,21 +299,21 @@ export function useCommandCenter() {
|
||||
}
|
||||
|
||||
if (key === "Enter") {
|
||||
if (results.length === 0) return;
|
||||
if (items.length === 0) return;
|
||||
event.preventDefault();
|
||||
const index = Math.max(0, Math.min(activeIndex, results.length - 1));
|
||||
handleSelect(results[index]!);
|
||||
const index = Math.max(0, Math.min(activeIndex, items.length - 1));
|
||||
handleSelectItem(items[index]!);
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||
if (results.length === 0) return;
|
||||
if (items.length === 0) return;
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => {
|
||||
const delta = key === "ArrowDown" ? 1 : -1;
|
||||
const next = current + delta;
|
||||
if (next < 0) return results.length - 1;
|
||||
if (next >= results.length) return 0;
|
||||
if (next < 0) return items.length - 1;
|
||||
if (next >= items.length) return 0;
|
||||
return next;
|
||||
});
|
||||
}
|
||||
@@ -175,7 +322,7 @@ export function useCommandCenter() {
|
||||
// react-native-web can stop propagation on key events, so listen in capture phase.
|
||||
window.addEventListener("keydown", handler, true);
|
||||
return () => window.removeEventListener("keydown", handler, true);
|
||||
}, [activeIndex, handleClose, handleSelect, open, results]);
|
||||
}, [activeIndex, handleClose, handleSelectItem, items, open]);
|
||||
|
||||
return {
|
||||
open,
|
||||
@@ -184,9 +331,8 @@ export function useCommandCenter() {
|
||||
setQuery,
|
||||
activeIndex,
|
||||
setActiveIndex,
|
||||
results,
|
||||
items,
|
||||
handleClose,
|
||||
handleSelect,
|
||||
handleSelectItem,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
|
||||
const providerPreferencesSchema = z.object({
|
||||
model: z.string().optional(),
|
||||
mode: z.string().optional(),
|
||||
thinkingOptionId: z.string().optional(),
|
||||
});
|
||||
|
||||
const formPreferencesSchema = z.object({
|
||||
|
||||
@@ -3,8 +3,18 @@ import { Platform } from "react-native";
|
||||
import { usePathname, useRouter } from "expo-router";
|
||||
import { getIsTauri } from "@/constants/layout";
|
||||
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
|
||||
import { setCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore";
|
||||
import {
|
||||
checkoutStatusQueryKey,
|
||||
type CheckoutStatusPayload,
|
||||
} from "@/hooks/use-checkout-status-query";
|
||||
import { queryClient } from "@/query/query-client";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "@/utils/new-agent-routing";
|
||||
|
||||
export function useGlobalKeyboardNav({
|
||||
enabled,
|
||||
@@ -81,6 +91,29 @@ export function useGlobalKeyboardNav({
|
||||
navigate(`/agent/${serverId}/${agentId}` as any);
|
||||
};
|
||||
|
||||
const navigateToNewAgent = () => {
|
||||
let target = "/agent";
|
||||
if (selectedAgentId) {
|
||||
const separatorIndex = selectedAgentId.indexOf(":");
|
||||
if (separatorIndex > 0) {
|
||||
const serverId = selectedAgentId.slice(0, separatorIndex);
|
||||
const agentId = selectedAgentId.slice(separatorIndex + 1);
|
||||
const agent = useSessionStore.getState().sessions[serverId]?.agents?.get(agentId);
|
||||
const cwd = agent?.cwd?.trim();
|
||||
if (cwd) {
|
||||
const checkout =
|
||||
queryClient.getQueryData<CheckoutStatusPayload>(
|
||||
checkoutStatusQueryKey(serverId, cwd)
|
||||
) ?? null;
|
||||
const workingDir = resolveNewAgentWorkingDir(cwd, checkout);
|
||||
target = buildNewAgentRoute(workingDir);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
router.push(target as any);
|
||||
};
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (!shouldHandle()) {
|
||||
return;
|
||||
@@ -103,9 +136,28 @@ export function useGlobalKeyboardNav({
|
||||
}
|
||||
}
|
||||
|
||||
const isMod = event.metaKey || event.ctrlKey;
|
||||
const isKeyN = event.code === "KeyN" || lowerKey === "n";
|
||||
|
||||
// Cmd/Ctrl+Alt+N: new agent (web + Tauri)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (isMod && event.altKey && !event.shiftKey && isKeyN) {
|
||||
event.preventDefault();
|
||||
navigateToNewAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+N: new agent (Tauri only)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (isTauri && isMod && !event.altKey && !event.shiftKey && isKeyN) {
|
||||
event.preventDefault();
|
||||
navigateToNewAgent();
|
||||
return;
|
||||
}
|
||||
|
||||
// Cmd+B: toggle sidebar
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
isMod &&
|
||||
(event.code === "KeyB" || lowerKey === "b")
|
||||
) {
|
||||
// The MessageInput already handles Cmd+B inside editable fields. If we also
|
||||
@@ -121,7 +173,7 @@ export function useGlobalKeyboardNav({
|
||||
// Cmd+.: toggle sidebar (VS Code quick-fix muscle memory)
|
||||
// Note: intentionally works even when focus is inside an input/textarea.
|
||||
if (
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
isMod &&
|
||||
(event.code === "Period" || key === ".")
|
||||
) {
|
||||
// Ignore while command center is open.
|
||||
@@ -137,7 +189,7 @@ export function useGlobalKeyboardNav({
|
||||
if (
|
||||
selectedAgentId &&
|
||||
toggleFileExplorer &&
|
||||
(event.metaKey || event.ctrlKey) &&
|
||||
isMod &&
|
||||
(event.code === "KeyE" || lowerKey === "e")
|
||||
) {
|
||||
// Same double-toggle issue as Cmd+B when focus is inside a text input.
|
||||
@@ -168,7 +220,7 @@ export function useGlobalKeyboardNav({
|
||||
}
|
||||
|
||||
// Cmd+K: command center
|
||||
if ((event.metaKey || event.ctrlKey) && lowerKey === "k") {
|
||||
if (isMod && lowerKey === "k") {
|
||||
event.preventDefault();
|
||||
const s = useKeyboardNavStore.getState();
|
||||
if (!s.commandCenterOpen) {
|
||||
@@ -205,7 +257,7 @@ export function useGlobalKeyboardNav({
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
|
||||
if (isTauri && (event.metaKey || event.ctrlKey)) {
|
||||
if (isTauri && isMod) {
|
||||
event.preventDefault();
|
||||
navigateToSidebarShortcut(digit);
|
||||
}
|
||||
|
||||
@@ -104,6 +104,7 @@ type DraftAgentParams = {
|
||||
provider?: string;
|
||||
modeId?: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
workingDir?: string;
|
||||
};
|
||||
|
||||
@@ -146,6 +147,7 @@ export function DraftAgentScreen({
|
||||
const resolvedProvider = getValidProvider(getParamValue(params.provider));
|
||||
const resolvedMode = getValidMode(resolvedProvider, getParamValue(params.modeId));
|
||||
const resolvedModel = getParamValue(params.model);
|
||||
const resolvedThinkingOptionId = getParamValue(params.thinkingOptionId);
|
||||
const resolvedWorkingDir = getParamValue(params.workingDir);
|
||||
|
||||
const onlineServerIds = useMemo(() => {
|
||||
@@ -172,8 +174,17 @@ export function DraftAgentScreen({
|
||||
if (resolvedModel) {
|
||||
values.model = resolvedModel;
|
||||
}
|
||||
if (resolvedThinkingOptionId) {
|
||||
values.thinkingOptionId = resolvedThinkingOptionId;
|
||||
}
|
||||
return values;
|
||||
}, [resolvedMode, resolvedModel, resolvedProvider, resolvedWorkingDir]);
|
||||
}, [
|
||||
resolvedMode,
|
||||
resolvedModel,
|
||||
resolvedProvider,
|
||||
resolvedThinkingOptionId,
|
||||
resolvedWorkingDir,
|
||||
]);
|
||||
|
||||
const {
|
||||
selectedServerId,
|
||||
@@ -184,11 +195,14 @@ export function DraftAgentScreen({
|
||||
setModeFromUser,
|
||||
selectedModel,
|
||||
setModelFromUser,
|
||||
selectedThinkingOptionId,
|
||||
setThinkingOptionFromUser,
|
||||
workingDir,
|
||||
setWorkingDirFromUser,
|
||||
providerDefinitions,
|
||||
modeOptions,
|
||||
availableModels,
|
||||
availableThinkingOptions,
|
||||
isModelLoading,
|
||||
modelError,
|
||||
refreshProviderModels,
|
||||
@@ -688,6 +702,7 @@ export function DraftAgentScreen({
|
||||
const cwd = (isAttachWorktree && selectedWorktreePath ? selectedWorktreePath : workingDir).trim() || ".";
|
||||
const provider = selectedProvider;
|
||||
const model = selectedModel.trim() || null;
|
||||
const thinkingOptionId = selectedThinkingOptionId.trim() || null;
|
||||
const modeId = modeOptions.length > 0 && selectedMode !== "" ? selectedMode : null;
|
||||
|
||||
return {
|
||||
@@ -713,6 +728,7 @@ export function DraftAgentScreen({
|
||||
title: "New agent",
|
||||
cwd,
|
||||
model,
|
||||
thinkingOptionId,
|
||||
labels: {},
|
||||
};
|
||||
}, [
|
||||
@@ -722,6 +738,7 @@ export function DraftAgentScreen({
|
||||
modeOptions.length,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedThinkingOptionId,
|
||||
selectedProvider,
|
||||
selectedServerId,
|
||||
selectedWorktreePath,
|
||||
@@ -797,11 +814,15 @@ export function DraftAgentScreen({
|
||||
const modeId =
|
||||
modeOptions.length > 0 && selectedMode !== "" ? selectedMode : undefined;
|
||||
const trimmedModel = selectedModel.trim();
|
||||
const trimmedThinkingOptionId = selectedThinkingOptionId.trim();
|
||||
const config: AgentSessionConfig = {
|
||||
provider: selectedProvider,
|
||||
cwd: resolvedWorkingDir,
|
||||
...(modeId ? { modeId } : {}),
|
||||
...(trimmedModel ? { model: trimmedModel } : {}),
|
||||
...(trimmedThinkingOptionId
|
||||
? { thinkingOptionId: trimmedThinkingOptionId }
|
||||
: {}),
|
||||
};
|
||||
const effectiveBaseBranch = baseBranch.trim();
|
||||
const effectiveWorktreeSlug =
|
||||
@@ -874,6 +895,7 @@ export function DraftAgentScreen({
|
||||
router,
|
||||
selectedMode,
|
||||
selectedModel,
|
||||
selectedThinkingOptionId,
|
||||
selectedProvider,
|
||||
selectedServerId,
|
||||
createAgentClient,
|
||||
@@ -979,6 +1001,9 @@ export function DraftAgentScreen({
|
||||
selectedModel={selectedModel}
|
||||
isModelLoading={isModelLoading}
|
||||
onSelectModel={setModelFromUser}
|
||||
thinkingOptions={availableThinkingOptions}
|
||||
selectedThinkingOptionId={selectedThinkingOptionId}
|
||||
onSelectThinkingOption={setThinkingOptionFromUser}
|
||||
/>
|
||||
{isMobile && trimmedWorkingDir.length > 0 && !isNonGitDirectory ? (
|
||||
<View style={styles.formSeparator} />
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
import {
|
||||
buildNewAgentRoute,
|
||||
resolveNewAgentWorkingDir,
|
||||
} from "./new-agent-routing";
|
||||
|
||||
describe("buildNewAgentRoute", () => {
|
||||
it("falls back to /agent when no working directory is provided", () => {
|
||||
expect(buildNewAgentRoute(undefined)).toBe("/agent");
|
||||
expect(buildNewAgentRoute(" ")).toBe("/agent");
|
||||
});
|
||||
|
||||
it("encodes the working directory query parameter", () => {
|
||||
expect(buildNewAgentRoute("/Users/me/dev/paseo")).toBe(
|
||||
"/agent?workingDir=%2FUsers%2Fme%2Fdev%2Fpaseo"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveNewAgentWorkingDir", () => {
|
||||
it("returns the current cwd for regular checkouts", () => {
|
||||
expect(resolveNewAgentWorkingDir("/repo/path", null)).toBe("/repo/path");
|
||||
});
|
||||
|
||||
it("returns the main repo root for paseo-owned worktrees", () => {
|
||||
const checkout = {
|
||||
isPaseoOwnedWorktree: true,
|
||||
mainRepoRoot: "/repo/main",
|
||||
} as CheckoutStatusPayload;
|
||||
|
||||
expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe(
|
||||
"/repo/main"
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CheckoutStatusPayload } from "@/hooks/use-checkout-status-query";
|
||||
|
||||
export function resolveNewAgentWorkingDir(
|
||||
cwd: string,
|
||||
checkout: CheckoutStatusPayload | null
|
||||
): string {
|
||||
return (checkout?.isPaseoOwnedWorktree ? checkout.mainRepoRoot : null) ?? cwd;
|
||||
}
|
||||
|
||||
export function buildNewAgentRoute(workingDir?: string | null): string {
|
||||
const trimmedWorkingDir = workingDir?.trim();
|
||||
if (!trimmedWorkingDir) {
|
||||
return "/agent";
|
||||
}
|
||||
return `/agent?workingDir=${encodeURIComponent(trimmedWorkingDir)}`;
|
||||
}
|
||||
@@ -14,16 +14,19 @@ const TOOL_DETAIL_ICONS: Record<ToolCallDetail["type"], ToolCallIconComponent> =
|
||||
};
|
||||
|
||||
export function resolveToolCallIcon(toolName: string, detail?: ToolCallDetail): ToolCallIconComponent {
|
||||
const lowerName = toolName.trim().toLowerCase();
|
||||
|
||||
// Thoughts are rendered through ToolCall with unknown detail payloads.
|
||||
if (lowerName === "thinking" && (!detail || detail.type === "unknown")) {
|
||||
return Brain;
|
||||
}
|
||||
|
||||
if (detail) {
|
||||
return TOOL_DETAIL_ICONS[detail.type];
|
||||
}
|
||||
|
||||
const lowerName = toolName.trim().toLowerCase();
|
||||
if (lowerName === "task") {
|
||||
return Bot;
|
||||
}
|
||||
if (lowerName === "thinking") {
|
||||
return Brain;
|
||||
}
|
||||
return Wrench;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user