Update files
This commit is contained in:
@@ -32,8 +32,10 @@ import {
|
|||||||
HorizontalScrollProvider,
|
HorizontalScrollProvider,
|
||||||
useHorizontalScrollOptional,
|
useHorizontalScrollOptional,
|
||||||
} from "@/contexts/horizontal-scroll-context";
|
} from "@/contexts/horizontal-scroll-context";
|
||||||
import { getIsTauriMac } from "@/constants/layout";
|
import { getIsTauri, getIsTauriMac } from "@/constants/layout";
|
||||||
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
import { useTrafficLightPadding } from "@/utils/tauri-window";
|
||||||
|
import { CommandCenter } from "@/components/command-center";
|
||||||
|
import { useGlobalKeyboardNav } from "@/hooks/use-global-keyboard-nav";
|
||||||
|
|
||||||
function PushNotificationRouter() {
|
function PushNotificationRouter() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -134,19 +136,11 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
: desktopAgentListOpen
|
: desktopAgentListOpen
|
||||||
: false;
|
: false;
|
||||||
|
|
||||||
// Cmd+B to toggle sidebar (web only)
|
useGlobalKeyboardNav({
|
||||||
useEffect(() => {
|
enabled: chromeEnabled,
|
||||||
if (!chromeEnabled) return;
|
isMobile,
|
||||||
if (Platform.OS !== "web") return;
|
toggleAgentList,
|
||||||
function handleKeyDown(event: KeyboardEvent) {
|
});
|
||||||
if ((event.metaKey || event.ctrlKey) && event.key === "b") {
|
|
||||||
event.preventDefault();
|
|
||||||
toggleAgentList();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
window.addEventListener("keydown", handleKeyDown);
|
|
||||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
|
||||||
}, [chromeEnabled, toggleAgentList]);
|
|
||||||
const {
|
const {
|
||||||
translateX,
|
translateX,
|
||||||
backdropOpacity,
|
backdropOpacity,
|
||||||
@@ -249,6 +243,7 @@ function AppContainer({ children, selectedAgentId }: AppContainerProps) {
|
|||||||
</View>
|
</View>
|
||||||
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
{isMobile && chromeEnabled && <SlidingSidebar selectedAgentId={selectedAgentId} />}
|
||||||
<DownloadToast />
|
<DownloadToast />
|
||||||
|
<CommandCenter />
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,273 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
Pressable,
|
||||||
|
ScrollView,
|
||||||
|
Text,
|
||||||
|
TextInput,
|
||||||
|
View,
|
||||||
|
Platform,
|
||||||
|
} from "react-native";
|
||||||
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
|
import { router, usePathname } from "expo-router";
|
||||||
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
|
import { useAggregatedAgents, type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
|
import { shortenPath } from "@/utils/shorten-path";
|
||||||
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
|
|
||||||
|
function agentKey(agent: Pick<AggregatedAgent, "serverId" | "id">): string {
|
||||||
|
return `${agent.serverId}:${agent.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMatch(agent: AggregatedAgent, query: string): boolean {
|
||||||
|
if (!query) return true;
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const title = (agent.title ?? "New agent").toLowerCase();
|
||||||
|
const cwd = agent.cwd.toLowerCase();
|
||||||
|
const host = agent.serverLabel.toLowerCase();
|
||||||
|
return title.includes(q) || cwd.includes(q) || host.includes(q);
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortAgents(left: AggregatedAgent, right: AggregatedAgent): number {
|
||||||
|
const leftAttention = left.requiresAttention ? 1 : 0;
|
||||||
|
const rightAttention = right.requiresAttention ? 1 : 0;
|
||||||
|
if (leftAttention !== rightAttention) return rightAttention - leftAttention;
|
||||||
|
|
||||||
|
const leftRunning = left.status === "running" ? 1 : 0;
|
||||||
|
const rightRunning = right.status === "running" ? 1 : 0;
|
||||||
|
if (leftRunning !== rightRunning) return rightRunning - leftRunning;
|
||||||
|
|
||||||
|
return right.lastActivityAt.getTime() - left.lastActivityAt.getTime();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CommandCenter() {
|
||||||
|
const { theme } = useUnistyles();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const { agents } = useAggregatedAgents();
|
||||||
|
const open = useKeyboardNavStore((s) => s.commandCenterOpen);
|
||||||
|
const setOpen = useKeyboardNavStore((s) => s.setCommandCenterOpen);
|
||||||
|
const inputRef = useRef<TextInput>(null);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [activeIndex, setActiveIndex] = useState(0);
|
||||||
|
|
||||||
|
const results = useMemo(() => {
|
||||||
|
const filtered = agents.filter((agent) => isMatch(agent, query));
|
||||||
|
filtered.sort(sortAgents);
|
||||||
|
return filtered;
|
||||||
|
}, [agents, query]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setQuery("");
|
||||||
|
setActiveIndex(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = setTimeout(() => {
|
||||||
|
inputRef.current?.focus();
|
||||||
|
}, 0);
|
||||||
|
return () => clearTimeout(id);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
if (activeIndex >= results.length) {
|
||||||
|
setActiveIndex(results.length > 0 ? results.length - 1 : 0);
|
||||||
|
}
|
||||||
|
}, [activeIndex, open, results.length]);
|
||||||
|
|
||||||
|
const handleClose = useCallback(() => {
|
||||||
|
setOpen(false);
|
||||||
|
}, [setOpen]);
|
||||||
|
|
||||||
|
const handleSelect = useCallback(
|
||||||
|
(agent: AggregatedAgent) => {
|
||||||
|
const session = useSessionStore.getState().sessions[agent.serverId];
|
||||||
|
session?.client?.clearAgentAttention(agent.id);
|
||||||
|
|
||||||
|
const shouldReplace = pathname.startsWith("/agent/");
|
||||||
|
const navigate = shouldReplace ? router.replace : router.push;
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
navigate(`/agent/${agent.serverId}/${agent.id}` as any);
|
||||||
|
},
|
||||||
|
[pathname, setOpen]
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
const handler = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key;
|
||||||
|
if (key !== "ArrowDown" && key !== "ArrowUp" && key !== "Enter" && key !== "Escape") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "Escape") {
|
||||||
|
event.preventDefault();
|
||||||
|
handleClose();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "Enter") {
|
||||||
|
if (results.length === 0) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const index = Math.max(0, Math.min(activeIndex, results.length - 1));
|
||||||
|
handleSelect(results[index]!);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (key === "ArrowDown" || key === "ArrowUp") {
|
||||||
|
if (results.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;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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]);
|
||||||
|
|
||||||
|
if (Platform.OS !== "web") return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
visible={open}
|
||||||
|
transparent
|
||||||
|
animationType="fade"
|
||||||
|
onRequestClose={handleClose}
|
||||||
|
>
|
||||||
|
<View style={styles.overlay}>
|
||||||
|
<Pressable style={styles.backdrop} onPress={handleClose} />
|
||||||
|
|
||||||
|
<View style={[styles.panel, { borderColor: theme.colors.border, backgroundColor: theme.colors.surface0 }]}>
|
||||||
|
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
|
||||||
|
<TextInput
|
||||||
|
ref={inputRef}
|
||||||
|
value={query}
|
||||||
|
onChangeText={setQuery}
|
||||||
|
placeholder="Search agents…"
|
||||||
|
placeholderTextColor={theme.colors.foregroundMuted}
|
||||||
|
style={[
|
||||||
|
styles.input,
|
||||||
|
{ color: theme.colors.foreground },
|
||||||
|
]}
|
||||||
|
autoCapitalize="none"
|
||||||
|
autoCorrect={false}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<ScrollView
|
||||||
|
style={styles.results}
|
||||||
|
contentContainerStyle={styles.resultsContent}
|
||||||
|
keyboardShouldPersistTaps="always"
|
||||||
|
showsVerticalScrollIndicator={false}
|
||||||
|
>
|
||||||
|
{results.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}>
|
||||||
|
<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>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ScrollView>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const styles = StyleSheet.create((theme) => ({
|
||||||
|
overlay: {
|
||||||
|
flex: 1,
|
||||||
|
justifyContent: "flex-start",
|
||||||
|
alignItems: "center",
|
||||||
|
paddingTop: theme.spacing[12],
|
||||||
|
},
|
||||||
|
backdrop: {
|
||||||
|
...StyleSheet.absoluteFillObject,
|
||||||
|
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||||
|
},
|
||||||
|
panel: {
|
||||||
|
width: 640,
|
||||||
|
maxWidth: "92%",
|
||||||
|
maxHeight: "80%",
|
||||||
|
borderWidth: 1,
|
||||||
|
borderRadius: theme.borderRadius.lg,
|
||||||
|
overflow: "hidden",
|
||||||
|
shadowColor: "#000",
|
||||||
|
shadowOpacity: 0.4,
|
||||||
|
shadowRadius: 24,
|
||||||
|
shadowOffset: { width: 0, height: 12 },
|
||||||
|
},
|
||||||
|
header: {
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
borderBottomWidth: 1,
|
||||||
|
},
|
||||||
|
input: {
|
||||||
|
fontSize: theme.fontSize.lg,
|
||||||
|
paddingVertical: theme.spacing[1],
|
||||||
|
outlineStyle: "none",
|
||||||
|
} as any,
|
||||||
|
results: {
|
||||||
|
flexGrow: 0,
|
||||||
|
},
|
||||||
|
resultsContent: {
|
||||||
|
paddingVertical: theme.spacing[2],
|
||||||
|
},
|
||||||
|
row: {
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[3],
|
||||||
|
},
|
||||||
|
rowContent: {
|
||||||
|
gap: 2,
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
fontWeight: theme.fontWeight.medium,
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
fontSize: theme.fontSize.sm,
|
||||||
|
},
|
||||||
|
emptyText: {
|
||||||
|
paddingHorizontal: theme.spacing[4],
|
||||||
|
paddingVertical: theme.spacing[4],
|
||||||
|
fontSize: theme.fontSize.base,
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -17,25 +17,20 @@ import {
|
|||||||
import { router, usePathname } from "expo-router";
|
import { router, usePathname } from "expo-router";
|
||||||
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
||||||
import { type GestureType } from "react-native-gesture-handler";
|
import { type GestureType } from "react-native-gesture-handler";
|
||||||
import { useQueries, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native";
|
import { Archive, ChevronDown, ChevronRight, Plus } from "lucide-react-native";
|
||||||
import {
|
import {
|
||||||
DraggableList,
|
DraggableList,
|
||||||
type DraggableRenderItemInfo,
|
type DraggableRenderItemInfo,
|
||||||
} from "./draggable-list";
|
} from "./draggable-list";
|
||||||
import { formatTimeAgo } from "@/utils/time";
|
import { formatTimeAgo } from "@/utils/time";
|
||||||
import {
|
import { parseRepoNameFromRemoteUrl, parseRepoShortNameFromRemoteUrl } from "@/utils/agent-grouping";
|
||||||
groupAgents,
|
|
||||||
parseRepoNameFromRemoteUrl,
|
|
||||||
parseRepoShortNameFromRemoteUrl,
|
|
||||||
} from "@/utils/agent-grouping";
|
|
||||||
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
import { type AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
import { useSessionStore } from "@/stores/session-store";
|
import { useSessionStore } from "@/stores/session-store";
|
||||||
import {
|
import {
|
||||||
type CheckoutStatusPayload,
|
CHECKOUT_STATUS_STALE_TIME,
|
||||||
checkoutStatusQueryKey,
|
checkoutStatusQueryKey,
|
||||||
useCheckoutStatusCacheOnly,
|
useCheckoutStatusCacheOnly,
|
||||||
CHECKOUT_STATUS_STALE_TIME,
|
|
||||||
} from "@/hooks/use-checkout-status-query";
|
} from "@/hooks/use-checkout-status-query";
|
||||||
import {
|
import {
|
||||||
buildAgentNavigationKey,
|
buildAgentNavigationKey,
|
||||||
@@ -43,22 +38,14 @@ import {
|
|||||||
} from "@/utils/navigation-timing";
|
} from "@/utils/navigation-timing";
|
||||||
import {
|
import {
|
||||||
useSectionOrderStore,
|
useSectionOrderStore,
|
||||||
sortProjectsByStoredOrder,
|
|
||||||
} from "@/stores/section-order-store";
|
} from "@/stores/section-order-store";
|
||||||
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
import { useProjectIconQuery } from "@/hooks/use-project-icon-query";
|
||||||
|
import { useSidebarAgentSections, type SidebarSectionData } from "@/hooks/use-sidebar-agent-sections";
|
||||||
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
|
import { getIsTauri } from "@/constants/layout";
|
||||||
|
|
||||||
interface SectionData {
|
type SectionData = SidebarSectionData;
|
||||||
key: string;
|
|
||||||
projectKey: string;
|
|
||||||
title: string;
|
|
||||||
agents: AggregatedAgent[];
|
|
||||||
/** For project sections, the first agent's serverId (to lookup checkout status) */
|
|
||||||
firstAgentServerId?: string;
|
|
||||||
/** For project sections, the first agent's id (to lookup checkout status) */
|
|
||||||
firstAgentId?: string;
|
|
||||||
/** Working directory for the project (from first agent) */
|
|
||||||
workingDir?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface GroupedAgentListProps {
|
interface GroupedAgentListProps {
|
||||||
agents: AggregatedAgent[];
|
agents: AggregatedAgent[];
|
||||||
@@ -209,9 +196,24 @@ export function GroupedAgentList({
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const insets = useSafeAreaInsets();
|
const insets = useSafeAreaInsets();
|
||||||
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
const [actionAgent, setActionAgent] = useState<AggregatedAgent | null>(null);
|
||||||
const [collapsedSections, setCollapsedSections] = useState<Set<string>>(
|
|
||||||
new Set()
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
||||||
);
|
const toggleProjectCollapsed = useSidebarCollapsedSectionsStore((s) => s.toggleProjectCollapsed);
|
||||||
|
|
||||||
|
const altDown = useKeyboardNavStore((s) => s.altDown);
|
||||||
|
const cmdOrCtrlDown = useKeyboardNavStore((s) => s.cmdOrCtrlDown);
|
||||||
|
const sidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.sidebarShortcutAgentKeys);
|
||||||
|
const isTauri = getIsTauri();
|
||||||
|
const showShortcutBadges = altDown || (isTauri && cmdOrCtrlDown);
|
||||||
|
const shortcutIndexByAgentKey = useMemo(() => {
|
||||||
|
const map = new Map<string, number>();
|
||||||
|
for (let i = 0; i < sidebarShortcutAgentKeys.length; i++) {
|
||||||
|
const key = sidebarShortcutAgentKeys[i];
|
||||||
|
if (!key) continue;
|
||||||
|
map.set(key, i + 1);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [sidebarShortcutAgentKeys]);
|
||||||
|
|
||||||
const actionClient = useSessionStore((state) =>
|
const actionClient = useSessionStore((state) =>
|
||||||
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
|
actionAgent?.serverId ? state.sessions[actionAgent.serverId]?.client ?? null : null
|
||||||
@@ -265,18 +267,6 @@ export function GroupedAgentList({
|
|||||||
setActionAgent(null);
|
setActionAgent(null);
|
||||||
}, [actionAgent, actionClient]);
|
}, [actionAgent, actionClient]);
|
||||||
|
|
||||||
const toggleSection = useCallback((sectionKey: string) => {
|
|
||||||
setCollapsedSections((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
if (next.has(sectionKey)) {
|
|
||||||
next.delete(sectionKey);
|
|
||||||
} else {
|
|
||||||
next.add(sectionKey);
|
|
||||||
}
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const handleCreateAgentInProject = useCallback(
|
const handleCreateAgentInProject = useCallback(
|
||||||
(workingDir: string) => {
|
(workingDir: string) => {
|
||||||
onAgentSelect?.();
|
onAgentSelect?.();
|
||||||
@@ -315,87 +305,10 @@ export function GroupedAgentList({
|
|||||||
}
|
}
|
||||||
}, [agents, queryClient]);
|
}, [agents, queryClient]);
|
||||||
|
|
||||||
// Subscribe to checkout status cache entries so project grouping can react
|
|
||||||
// to remote URL updates (e.g. git worktrees in different directories).
|
|
||||||
const checkoutCacheQueries = useQueries({
|
|
||||||
queries: agents.map(
|
|
||||||
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
|
|
||||||
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd),
|
|
||||||
enabled: false,
|
|
||||||
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
|
||||||
queryFn: async (): Promise<CheckoutStatusPayload> => {
|
|
||||||
throw new Error("checkout status cache-only query should not run");
|
|
||||||
},
|
|
||||||
})
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
const remoteUrlByAgentKey = useMemo(() => {
|
|
||||||
const result = new Map<string, string | null>();
|
|
||||||
for (let i = 0; i < agents.length; i++) {
|
|
||||||
const agent = agents[i];
|
|
||||||
if (!agent) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const checkout = checkoutCacheQueries[i]?.data ?? null;
|
|
||||||
const remoteUrl = checkout?.remoteUrl ?? null;
|
|
||||||
result.set(`${agent.serverId}:${agent.id}`, remoteUrl);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}, [agents, checkoutCacheQueries]);
|
|
||||||
|
|
||||||
// Section order from store
|
// Section order from store
|
||||||
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
|
||||||
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
||||||
|
|
||||||
// Group agents
|
const sections: SectionData[] = useSidebarAgentSections(agents);
|
||||||
const { activeGroups } = useMemo(
|
|
||||||
() =>
|
|
||||||
groupAgents(agents, {
|
|
||||||
getRemoteUrl: (agent) =>
|
|
||||||
remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null,
|
|
||||||
}),
|
|
||||||
[agents, remoteUrlByAgentKey]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Sort groups by persisted order
|
|
||||||
const sortedGroups = useMemo(
|
|
||||||
() => sortProjectsByStoredOrder(activeGroups, projectOrder),
|
|
||||||
[activeGroups, projectOrder]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Build sections for DraggableFlatList
|
|
||||||
const sections: SectionData[] = useMemo(() => {
|
|
||||||
const result: SectionData[] = [];
|
|
||||||
|
|
||||||
for (const group of sortedGroups) {
|
|
||||||
const sectionKey = `project:${group.projectKey}`;
|
|
||||||
const firstAgent = group.agents[0];
|
|
||||||
result.push({
|
|
||||||
key: sectionKey,
|
|
||||||
projectKey: group.projectKey,
|
|
||||||
title: group.projectName,
|
|
||||||
agents: group.agents,
|
|
||||||
firstAgentServerId: firstAgent?.serverId,
|
|
||||||
firstAgentId: firstAgent?.id,
|
|
||||||
workingDir: firstAgent?.cwd,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [sortedGroups]);
|
|
||||||
|
|
||||||
// Sync section order when new projects appear
|
|
||||||
useEffect(() => {
|
|
||||||
const currentKeys = sections.map((s) => s.projectKey);
|
|
||||||
const storedKeys = new Set(projectOrder);
|
|
||||||
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
|
|
||||||
|
|
||||||
if (newKeys.length > 0) {
|
|
||||||
// Add new projects at the end of the stored order
|
|
||||||
setProjectOrder([...projectOrder, ...newKeys]);
|
|
||||||
}
|
|
||||||
}, [sections, projectOrder, setProjectOrder]);
|
|
||||||
|
|
||||||
const handleDragEnd = useCallback(
|
const handleDragEnd = useCallback(
|
||||||
(newData: SectionData[]) => {
|
(newData: SectionData[]) => {
|
||||||
@@ -424,6 +337,8 @@ export function GroupedAgentList({
|
|||||||
const agentKey = `${agent.serverId}:${agent.id}`;
|
const agentKey = `${agent.serverId}:${agent.id}`;
|
||||||
const isSelected = selectedAgentId === agentKey;
|
const isSelected = selectedAgentId === agentKey;
|
||||||
const isRunning = agent.status === "running";
|
const isRunning = agent.status === "running";
|
||||||
|
const shortcutNumber =
|
||||||
|
showShortcutBadges ? (shortcutIndexByAgentKey.get(agentKey) ?? null) : null;
|
||||||
const statusColor = isRunning
|
const statusColor = isRunning
|
||||||
? theme.colors.palette.blue[500]
|
? theme.colors.palette.blue[500]
|
||||||
: agent.requiresAttention
|
: agent.requiresAttention
|
||||||
@@ -476,7 +391,13 @@ export function GroupedAgentList({
|
|||||||
>
|
>
|
||||||
{agent.title || "New agent"}
|
{agent.title || "New agent"}
|
||||||
</Text>
|
</Text>
|
||||||
{isHovered && canArchive ? (
|
{shortcutNumber !== null ? (
|
||||||
|
<View style={styles.branchBadge}>
|
||||||
|
<Text style={styles.branchBadgeText} numberOfLines={1}>
|
||||||
|
{shortcutNumber}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
) : isHovered && canArchive ? (
|
||||||
<Pressable
|
<Pressable
|
||||||
style={styles.branchBadge}
|
style={styles.branchBadge}
|
||||||
onPress={(e) => handleArchiveAgent(e, agent)}
|
onPress={(e) => handleArchiveAgent(e, agent)}
|
||||||
@@ -512,6 +433,8 @@ export function GroupedAgentList({
|
|||||||
handleAgentPress,
|
handleAgentPress,
|
||||||
handleArchiveAgent,
|
handleArchiveAgent,
|
||||||
selectedAgentId,
|
selectedAgentId,
|
||||||
|
showShortcutBadges,
|
||||||
|
shortcutIndexByAgentKey,
|
||||||
theme.colors.foreground,
|
theme.colors.foreground,
|
||||||
theme.colors.foregroundMuted,
|
theme.colors.foregroundMuted,
|
||||||
theme.colors.palette.blue,
|
theme.colors.palette.blue,
|
||||||
@@ -521,14 +444,14 @@ export function GroupedAgentList({
|
|||||||
|
|
||||||
const renderSection = useCallback(
|
const renderSection = useCallback(
|
||||||
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
({ item: section, drag, isActive }: DraggableRenderItemInfo<SectionData>) => {
|
||||||
const isCollapsed = collapsedSections.has(section.key);
|
const isCollapsed = collapsedProjectKeys.has(section.projectKey);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
|
<View style={[styles.sectionContainer, isActive && styles.sectionDragging]}>
|
||||||
<SectionHeader
|
<SectionHeader
|
||||||
section={section}
|
section={section}
|
||||||
isCollapsed={isCollapsed}
|
isCollapsed={isCollapsed}
|
||||||
onToggle={() => toggleSection(section.key)}
|
onToggle={() => toggleProjectCollapsed(section.projectKey)}
|
||||||
onCreateAgent={handleCreateAgentInProject}
|
onCreateAgent={handleCreateAgentInProject}
|
||||||
onDrag={drag}
|
onDrag={drag}
|
||||||
isDragging={isActive}
|
isDragging={isActive}
|
||||||
@@ -540,7 +463,7 @@ export function GroupedAgentList({
|
|||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
[AgentListRow, collapsedSections, handleCreateAgentInProject, toggleSection]
|
[AgentListRow, collapsedProjectKeys, handleCreateAgentInProject, toggleProjectCollapsed]
|
||||||
);
|
);
|
||||||
|
|
||||||
const keyExtractor = useCallback(
|
const keyExtractor = useCallback(
|
||||||
|
|||||||
@@ -19,6 +19,10 @@ import { useTauriDragHandlers, useTrafficLightPadding } from "@/utils/tauri-wind
|
|||||||
import { useVoice } from "@/contexts/voice-context";
|
import { useVoice } from "@/contexts/voice-context";
|
||||||
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
|
||||||
import { VoicePanel } from "./voice-panel";
|
import { VoicePanel } from "./voice-panel";
|
||||||
|
import { useSidebarAgentSections } from "@/hooks/use-sidebar-agent-sections";
|
||||||
|
import { useSidebarCollapsedSectionsStore } from "@/stores/sidebar-collapsed-sections-store";
|
||||||
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
|
import { deriveSidebarShortcutAgentKeys } from "@/utils/sidebar-shortcuts";
|
||||||
|
|
||||||
const DESKTOP_SIDEBAR_WIDTH = 320;
|
const DESKTOP_SIDEBAR_WIDTH = 320;
|
||||||
const SIDEBAR_AGENT_LIMIT = 15;
|
const SIDEBAR_AGENT_LIMIT = 15;
|
||||||
@@ -83,6 +87,17 @@ export function SlidingSidebar({ selectedAgentId }: SlidingSidebarProps) {
|
|||||||
[sortedAgents]
|
[sortedAgents]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sidebarSections = useSidebarAgentSections(limitedAgents);
|
||||||
|
const collapsedProjectKeys = useSidebarCollapsedSectionsStore((s) => s.collapsedProjectKeys);
|
||||||
|
const setSidebarShortcutAgentKeys = useKeyboardNavStore((s) => s.setSidebarShortcutAgentKeys);
|
||||||
|
const sidebarShortcutAgentKeys = useMemo(() => {
|
||||||
|
return deriveSidebarShortcutAgentKeys(sidebarSections, collapsedProjectKeys, 9);
|
||||||
|
}, [collapsedProjectKeys, sidebarSections]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setSidebarShortcutAgentKeys(sidebarShortcutAgentKeys);
|
||||||
|
}, [setSidebarShortcutAgentKeys, sidebarShortcutAgentKeys]);
|
||||||
|
|
||||||
const handleClose = useCallback(() => {
|
const handleClose = useCallback(() => {
|
||||||
closeToAgent();
|
closeToAgent();
|
||||||
}, [closeToAgent]);
|
}, [closeToAgent]);
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ export const MAX_CONTENT_WIDTH = 820;
|
|||||||
export const TAURI_TRAFFIC_LIGHT_WIDTH = 78;
|
export const TAURI_TRAFFIC_LIGHT_WIDTH = 78;
|
||||||
export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
|
export const TAURI_TRAFFIC_LIGHT_HEIGHT = 56;
|
||||||
|
|
||||||
|
// Check if running in Tauri desktop app (any OS)
|
||||||
|
function isTauri(): boolean {
|
||||||
|
if (Platform.OS !== "web") return false;
|
||||||
|
if (typeof window === "undefined") return false;
|
||||||
|
return "__TAURI__" in window;
|
||||||
|
}
|
||||||
|
|
||||||
// Check if running in Tauri desktop app on macOS
|
// Check if running in Tauri desktop app on macOS
|
||||||
function isTauriMac(): boolean {
|
function isTauriMac(): boolean {
|
||||||
if (Platform.OS !== "web") return false;
|
if (Platform.OS !== "web") return false;
|
||||||
@@ -28,6 +35,7 @@ function isTauriMac(): boolean {
|
|||||||
|
|
||||||
// Cached result - only cache true, keep checking if false (in case __TAURI__ loads later)
|
// Cached result - only cache true, keep checking if false (in case __TAURI__ loads later)
|
||||||
let _isTauriMacCached: boolean | null = null;
|
let _isTauriMacCached: boolean | null = null;
|
||||||
|
let _isTauriCached: boolean | null = null;
|
||||||
|
|
||||||
export function getIsTauriMac(): boolean {
|
export function getIsTauriMac(): boolean {
|
||||||
if (_isTauriMacCached === true) {
|
if (_isTauriMacCached === true) {
|
||||||
@@ -40,6 +48,17 @@ export function getIsTauriMac(): boolean {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getIsTauri(): boolean {
|
||||||
|
if (_isTauriCached === true) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
const result = isTauri();
|
||||||
|
if (result) {
|
||||||
|
_isTauriCached = true;
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// Get traffic light padding values (only non-zero on Tauri macOS)
|
// Get traffic light padding values (only non-zero on Tauri macOS)
|
||||||
export function getTrafficLightPadding(): { left: number; top: number } {
|
export function getTrafficLightPadding(): { left: number; top: number } {
|
||||||
if (!getIsTauriMac()) {
|
if (!getIsTauriMac()) {
|
||||||
|
|||||||
@@ -0,0 +1,150 @@
|
|||||||
|
import { useEffect } from "react";
|
||||||
|
import { Platform } from "react-native";
|
||||||
|
import { usePathname, useRouter } from "expo-router";
|
||||||
|
import { getIsTauri } from "@/constants/layout";
|
||||||
|
import { useKeyboardNavStore } from "@/stores/keyboard-nav-store";
|
||||||
|
import { parseSidebarAgentKey } from "@/utils/sidebar-shortcuts";
|
||||||
|
|
||||||
|
export function useGlobalKeyboardNav({
|
||||||
|
enabled,
|
||||||
|
isMobile,
|
||||||
|
toggleAgentList,
|
||||||
|
}: {
|
||||||
|
enabled: boolean;
|
||||||
|
isMobile: boolean;
|
||||||
|
toggleAgentList: () => void;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const resetModifiers = useKeyboardNavStore((s) => s.resetModifiers);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled) return;
|
||||||
|
if (Platform.OS !== "web") return;
|
||||||
|
if (isMobile) return;
|
||||||
|
|
||||||
|
const isTauri = getIsTauri();
|
||||||
|
const shouldHandle = () => {
|
||||||
|
if (typeof document === "undefined") return false;
|
||||||
|
if (document.visibilityState !== "visible") return false;
|
||||||
|
if (!document.hasFocus()) return false;
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
const parseShortcutDigit = (event: KeyboardEvent): number | null => {
|
||||||
|
const code = event.code ?? "";
|
||||||
|
if (code.startsWith("Digit")) {
|
||||||
|
const n = Number(code.slice("Digit".length));
|
||||||
|
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
|
||||||
|
}
|
||||||
|
if (code.startsWith("Numpad")) {
|
||||||
|
const n = Number(code.slice("Numpad".length));
|
||||||
|
return Number.isFinite(n) && n >= 1 && n <= 9 ? n : null;
|
||||||
|
}
|
||||||
|
const key = event.key ?? "";
|
||||||
|
if (key >= "1" && key <= "9") {
|
||||||
|
return Number(key);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const navigateToSidebarShortcut = (digit: number) => {
|
||||||
|
const state = useKeyboardNavStore.getState();
|
||||||
|
const targetKey = state.sidebarShortcutAgentKeys[digit - 1] ?? null;
|
||||||
|
if (!targetKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = parseSidebarAgentKey(targetKey);
|
||||||
|
if (!parsed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { serverId, agentId } = parsed;
|
||||||
|
|
||||||
|
const shouldReplace = pathname.startsWith("/agent/");
|
||||||
|
const navigate = shouldReplace ? router.replace : router.push;
|
||||||
|
navigate(`/agent/${serverId}/${agentId}` as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
|
if (!shouldHandle()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = event.key ?? "";
|
||||||
|
const lowerKey = key.toLowerCase();
|
||||||
|
|
||||||
|
if (key === "Alt") {
|
||||||
|
useKeyboardNavStore.getState().setAltDown(true);
|
||||||
|
}
|
||||||
|
if (isTauri && (key === "Meta" || key === "Control")) {
|
||||||
|
useKeyboardNavStore.getState().setCmdOrCtrlDown(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+B: toggle sidebar
|
||||||
|
if ((event.metaKey || event.ctrlKey) && lowerKey === "b") {
|
||||||
|
event.preventDefault();
|
||||||
|
toggleAgentList();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd+K: command center
|
||||||
|
if ((event.metaKey || event.ctrlKey) && lowerKey === "k") {
|
||||||
|
event.preventDefault();
|
||||||
|
const s = useKeyboardNavStore.getState();
|
||||||
|
s.setCommandCenterOpen(!s.commandCenterOpen);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Number switching: ignore while command center is open.
|
||||||
|
if (useKeyboardNavStore.getState().commandCenterOpen) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const digit = parseShortcutDigit(event);
|
||||||
|
if (!digit) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Alt/Option+number: always (web + Tauri)
|
||||||
|
if (event.altKey) {
|
||||||
|
event.preventDefault();
|
||||||
|
navigateToSidebarShortcut(digit);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cmd/Ctrl+number: Tauri only (avoid browser tab switching)
|
||||||
|
if (isTauri && (event.metaKey || event.ctrlKey)) {
|
||||||
|
event.preventDefault();
|
||||||
|
navigateToSidebarShortcut(digit);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleKeyUp = (event: KeyboardEvent) => {
|
||||||
|
const key = event.key ?? "";
|
||||||
|
if (key === "Alt") {
|
||||||
|
useKeyboardNavStore.getState().setAltDown(false);
|
||||||
|
}
|
||||||
|
if (isTauri && (key === "Meta" || key === "Control")) {
|
||||||
|
useKeyboardNavStore.getState().setCmdOrCtrlDown(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBlurOrHide = () => {
|
||||||
|
resetModifiers();
|
||||||
|
};
|
||||||
|
|
||||||
|
// react-native-web can stop propagation on key events, so listen in capture phase.
|
||||||
|
window.addEventListener("keydown", handleKeyDown, true);
|
||||||
|
window.addEventListener("keyup", handleKeyUp, true);
|
||||||
|
window.addEventListener("blur", handleBlurOrHide);
|
||||||
|
document.addEventListener("visibilitychange", handleBlurOrHide);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener("keydown", handleKeyDown, true);
|
||||||
|
window.removeEventListener("keyup", handleKeyUp, true);
|
||||||
|
window.removeEventListener("blur", handleBlurOrHide);
|
||||||
|
document.removeEventListener("visibilitychange", handleBlurOrHide);
|
||||||
|
};
|
||||||
|
}, [enabled, isMobile, pathname, resetModifiers, router, toggleAgentList]);
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import { useEffect, useMemo } from "react";
|
||||||
|
import { useQueries, type UseQueryOptions } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
CHECKOUT_STATUS_STALE_TIME,
|
||||||
|
checkoutStatusQueryKey,
|
||||||
|
type CheckoutStatusPayload,
|
||||||
|
} from "@/hooks/use-checkout-status-query";
|
||||||
|
import { groupAgents } from "@/utils/agent-grouping";
|
||||||
|
import { useSectionOrderStore, sortProjectsByStoredOrder } from "@/stores/section-order-store";
|
||||||
|
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
|
||||||
|
|
||||||
|
export interface SidebarSectionData {
|
||||||
|
key: string;
|
||||||
|
projectKey: string;
|
||||||
|
title: string;
|
||||||
|
agents: AggregatedAgent[];
|
||||||
|
/** For project sections, the first agent's serverId (to lookup checkout status) */
|
||||||
|
firstAgentServerId?: string;
|
||||||
|
/** For project sections, the first agent's id (to lookup checkout status) */
|
||||||
|
firstAgentId?: string;
|
||||||
|
/** Working directory for the project (from first agent) */
|
||||||
|
workingDir?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSidebarAgentSections(agents: AggregatedAgent[]): SidebarSectionData[] {
|
||||||
|
const checkoutCacheQueries = useQueries({
|
||||||
|
queries: agents.map(
|
||||||
|
(agent): UseQueryOptions<CheckoutStatusPayload> => ({
|
||||||
|
queryKey: checkoutStatusQueryKey(agent.serverId, agent.cwd),
|
||||||
|
enabled: false,
|
||||||
|
staleTime: CHECKOUT_STATUS_STALE_TIME,
|
||||||
|
queryFn: async (): Promise<CheckoutStatusPayload> => {
|
||||||
|
throw new Error("checkout status cache-only query should not run");
|
||||||
|
},
|
||||||
|
})
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
const remoteUrlByAgentKey = useMemo(() => {
|
||||||
|
const result = new Map<string, string | null>();
|
||||||
|
for (let i = 0; i < agents.length; i++) {
|
||||||
|
const agent = agents[i];
|
||||||
|
if (!agent) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const checkout = checkoutCacheQueries[i]?.data ?? null;
|
||||||
|
const remoteUrl = checkout?.remoteUrl ?? null;
|
||||||
|
result.set(`${agent.serverId}:${agent.id}`, remoteUrl);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}, [agents, checkoutCacheQueries]);
|
||||||
|
|
||||||
|
const projectOrder = useSectionOrderStore((state) => state.projectOrder);
|
||||||
|
const setProjectOrder = useSectionOrderStore((state) => state.setProjectOrder);
|
||||||
|
|
||||||
|
const { activeGroups } = useMemo(
|
||||||
|
() =>
|
||||||
|
groupAgents(agents, {
|
||||||
|
getRemoteUrl: (agent) =>
|
||||||
|
remoteUrlByAgentKey.get(`${agent.serverId}:${agent.id}`) ?? null,
|
||||||
|
}),
|
||||||
|
[agents, remoteUrlByAgentKey]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sortedGroups = useMemo(
|
||||||
|
() => sortProjectsByStoredOrder(activeGroups, projectOrder),
|
||||||
|
[activeGroups, projectOrder]
|
||||||
|
);
|
||||||
|
|
||||||
|
const sections: SidebarSectionData[] = useMemo(() => {
|
||||||
|
const result: SidebarSectionData[] = [];
|
||||||
|
|
||||||
|
for (const group of sortedGroups) {
|
||||||
|
const sectionKey = `project:${group.projectKey}`;
|
||||||
|
const firstAgent = group.agents[0];
|
||||||
|
result.push({
|
||||||
|
key: sectionKey,
|
||||||
|
projectKey: group.projectKey,
|
||||||
|
title: group.projectName,
|
||||||
|
agents: group.agents,
|
||||||
|
firstAgentServerId: firstAgent?.serverId,
|
||||||
|
firstAgentId: firstAgent?.id,
|
||||||
|
workingDir: firstAgent?.cwd,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [sortedGroups]);
|
||||||
|
|
||||||
|
// Sync section order when new projects appear.
|
||||||
|
useEffect(() => {
|
||||||
|
const currentKeys = sections.map((s) => s.projectKey);
|
||||||
|
const storedKeys = new Set(projectOrder);
|
||||||
|
const newKeys = currentKeys.filter((key) => !storedKeys.has(key));
|
||||||
|
|
||||||
|
if (newKeys.length > 0) {
|
||||||
|
setProjectOrder([...projectOrder, ...newKeys]);
|
||||||
|
}
|
||||||
|
}, [projectOrder, sections, setProjectOrder]);
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface KeyboardNavState {
|
||||||
|
commandCenterOpen: boolean;
|
||||||
|
altDown: boolean;
|
||||||
|
cmdOrCtrlDown: boolean;
|
||||||
|
/** Sidebar-visible agent keys (up to 9), in top-to-bottom visual order. */
|
||||||
|
sidebarShortcutAgentKeys: string[];
|
||||||
|
|
||||||
|
setCommandCenterOpen: (open: boolean) => void;
|
||||||
|
setAltDown: (down: boolean) => void;
|
||||||
|
setCmdOrCtrlDown: (down: boolean) => void;
|
||||||
|
setSidebarShortcutAgentKeys: (keys: string[]) => void;
|
||||||
|
resetModifiers: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useKeyboardNavStore = create<KeyboardNavState>((set) => ({
|
||||||
|
commandCenterOpen: false,
|
||||||
|
altDown: false,
|
||||||
|
cmdOrCtrlDown: false,
|
||||||
|
sidebarShortcutAgentKeys: [],
|
||||||
|
|
||||||
|
setCommandCenterOpen: (open) => set({ commandCenterOpen: open }),
|
||||||
|
setAltDown: (down) => set({ altDown: down }),
|
||||||
|
setCmdOrCtrlDown: (down) => set({ cmdOrCtrlDown: down }),
|
||||||
|
setSidebarShortcutAgentKeys: (keys) => set({ sidebarShortcutAgentKeys: keys }),
|
||||||
|
resetModifiers: () => set({ altDown: false, cmdOrCtrlDown: false }),
|
||||||
|
}));
|
||||||
|
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { create } from "zustand";
|
||||||
|
|
||||||
|
interface SidebarCollapsedSectionsState {
|
||||||
|
collapsedProjectKeys: Set<string>;
|
||||||
|
toggleProjectCollapsed: (projectKey: string) => void;
|
||||||
|
setProjectCollapsed: (projectKey: string, collapsed: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useSidebarCollapsedSectionsStore = create<SidebarCollapsedSectionsState>(
|
||||||
|
(set) => ({
|
||||||
|
collapsedProjectKeys: new Set(),
|
||||||
|
toggleProjectCollapsed: (projectKey) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.collapsedProjectKeys);
|
||||||
|
if (next.has(projectKey)) {
|
||||||
|
next.delete(projectKey);
|
||||||
|
} else {
|
||||||
|
next.add(projectKey);
|
||||||
|
}
|
||||||
|
return { collapsedProjectKeys: next };
|
||||||
|
}),
|
||||||
|
setProjectCollapsed: (projectKey, collapsed) =>
|
||||||
|
set((state) => {
|
||||||
|
const next = new Set(state.collapsedProjectKeys);
|
||||||
|
if (collapsed) {
|
||||||
|
next.add(projectKey);
|
||||||
|
} else {
|
||||||
|
next.delete(projectKey);
|
||||||
|
}
|
||||||
|
return { collapsedProjectKeys: next };
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { deriveSidebarShortcutAgentKeys, parseSidebarAgentKey } from "./sidebar-shortcuts";
|
||||||
|
|
||||||
|
describe("parseSidebarAgentKey", () => {
|
||||||
|
it("parses serverId and agentId", () => {
|
||||||
|
expect(parseSidebarAgentKey("server:agent")).toEqual({ serverId: "server", agentId: "agent" });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid keys", () => {
|
||||||
|
expect(parseSidebarAgentKey("")).toBeNull();
|
||||||
|
expect(parseSidebarAgentKey("no-separator")).toBeNull();
|
||||||
|
expect(parseSidebarAgentKey(":agent")).toBeNull();
|
||||||
|
expect(parseSidebarAgentKey("server:")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("deriveSidebarShortcutAgentKeys", () => {
|
||||||
|
it("skips collapsed projects and preserves visual order", () => {
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
projectKey: "p1",
|
||||||
|
agents: [
|
||||||
|
{ serverId: "s", id: "a1" },
|
||||||
|
{ serverId: "s", id: "a2" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
projectKey: "p2",
|
||||||
|
agents: [
|
||||||
|
{ serverId: "s", id: "b1" },
|
||||||
|
{ serverId: "s", id: "b2" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(deriveSidebarShortcutAgentKeys(sections, new Set(["p2"]), 9)).toEqual([
|
||||||
|
"s:a1",
|
||||||
|
"s:a2",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("limits to 9", () => {
|
||||||
|
const sections = [
|
||||||
|
{
|
||||||
|
projectKey: "p1",
|
||||||
|
agents: Array.from({ length: 20 }, (_, i) => ({ serverId: "s", id: `a${i + 1}` })),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)).toHaveLength(9);
|
||||||
|
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[0]).toBe("s:a1");
|
||||||
|
expect(deriveSidebarShortcutAgentKeys(sections, new Set(), 9)[8]).toBe("s:a9");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
export interface SidebarShortcutSection {
|
||||||
|
projectKey: string;
|
||||||
|
agents: Array<{ serverId: string; id: string }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseSidebarAgentKey(
|
||||||
|
key: string
|
||||||
|
): { serverId: string; agentId: string } | null {
|
||||||
|
const sep = key.indexOf(":");
|
||||||
|
if (sep === -1) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const serverId = key.slice(0, sep);
|
||||||
|
const agentId = key.slice(sep + 1);
|
||||||
|
if (!serverId || !agentId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { serverId, agentId };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deriveSidebarShortcutAgentKeys(
|
||||||
|
sections: SidebarShortcutSection[],
|
||||||
|
collapsedProjectKeys: ReadonlySet<string>,
|
||||||
|
limit = 9
|
||||||
|
): string[] {
|
||||||
|
const keys: string[] = [];
|
||||||
|
const max = Math.max(0, Math.floor(limit));
|
||||||
|
if (max === 0) {
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const section of sections) {
|
||||||
|
if (collapsedProjectKeys.has(section.projectKey)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
for (const agent of section.agents) {
|
||||||
|
keys.push(`${agent.serverId}:${agent.id}`);
|
||||||
|
if (keys.length >= max) {
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
Reference in New Issue
Block a user