* Merge workspaces across all hosts in the sidebar Replace per-host sidebar sections with a single merged project list. Projects that exist on multiple hosts (matched by projectKey) are collapsed into one row. Host identity is surfaced via: - A status-only footer pill showing online/offline host counts - Hover cards with a mandatory host row on every workspace entry - Subtitles showing the host name when a project spans >1 host - A host filter in the grouping dropdown (not a sidebar switcher) Order and group-mode stores migrated from per-server to global keys with zustand persist migrations. New-workspace screen now shows a merged project list with a host selector combobox. Key files: - workspace-structure.ts: merge algorithm by projectKey - sidebar-view-store.ts / sidebar-order-store.ts: global state - sidebar-workspace-list.tsx: context-based subtitle injection - new-workspace-screen.tsx: host selector in footer row * Add host filtering to the merged sidebar The sidebar now merges workspaces from every connected host, so a single global "active host" no longer fits. Host selection becomes explicit per action via a host chooser, and agent history pages across all hosts at once. Removes the active-host / active-server-id model and the all-agents-list hook that fed it. * Fix rebase fallout: translate archive host error, update stale workspace-key test archiveWorkspacesOptimistically used a raw "Host is not connected" string that the i18n completeness test forbids; route it through i18n.t with the existing hostDisconnected key. The selectors memoization test asserted un-prefixed workspace keys, but merged-sidebar keys are serverId-prefixed. * Fix sidebar review fallout * Address sidebar review followups * Fix sidebar workspace identity migration * Address sidebar review followups * Handle partial host history failures * Fix merged sidebar project removal and E2E drift * Restore history routes and project removal shape * Remove sidebar host context plumbing * Tighten sidebar status and host filter state * Keep new workspace project selection host-compatible * Migrate sidebar view storage * Streamline host selection flows * Update all-host history on archive * Fix empty project sidebar action Code regression: the main merge dropped the empty-project New workspace child row while keeping the empty project persistence contract. Restore the row using the project host target so the sidebar stays aligned with the no-host-selection model. * Fix Japanese shortcut translation key Stale-base integration: main added the Japanese locale after this branch added cycleAgentMode to the English shortcut help map. Merging main exposed the missing ja key in CI typecheck and resources.test. * Fix Playwright speech teardown race
355 lines
9.7 KiB
TypeScript
355 lines
9.7 KiB
TypeScript
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
import {
|
|
Modal,
|
|
Pressable,
|
|
ScrollView,
|
|
Text,
|
|
TextInput,
|
|
View,
|
|
type PressableStateCallbackType,
|
|
} from "react-native";
|
|
import { router } from "expo-router";
|
|
import { Server } from "lucide-react-native";
|
|
import { create } from "zustand";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { HostStatusDotSlot } from "@/components/hosts/host-picker";
|
|
import { isNative } from "@/constants/platform";
|
|
import { useLocalDaemonServerId } from "@/hooks/use-is-local-daemon";
|
|
import { useHosts } from "@/runtime/host-runtime";
|
|
import { orderHostsLocalFirst, type HostProfile } from "@/types/host-connection";
|
|
import { buildSettingsAddHostRoute } from "@/utils/host-routes";
|
|
|
|
type HostFilter = (host: HostProfile) => boolean;
|
|
type HostChoiceHandler = (serverId: string) => void | Promise<void>;
|
|
|
|
export interface ChooseHostInput {
|
|
title?: string;
|
|
filter?: HostFilter;
|
|
onChooseHost: HostChoiceHandler;
|
|
onNoHosts?: () => void;
|
|
}
|
|
|
|
interface HostChoiceRequest {
|
|
id: number;
|
|
title: string;
|
|
serverIds: string[];
|
|
onChooseHost: HostChoiceHandler;
|
|
}
|
|
|
|
interface HostChooserState {
|
|
request: HostChoiceRequest | null;
|
|
open: (request: Omit<HostChoiceRequest, "id">) => void;
|
|
close: () => void;
|
|
}
|
|
|
|
let nextRequestId = 1;
|
|
|
|
const useHostChooserStore = create<HostChooserState>((set) => ({
|
|
request: null,
|
|
open: (request) => {
|
|
set({ request: { ...request, id: nextRequestId++ } });
|
|
},
|
|
close: () => set({ request: null }),
|
|
}));
|
|
|
|
function matchesHostQuery(host: HostProfile, query: string): boolean {
|
|
const normalized = query.trim().toLowerCase();
|
|
if (!normalized) return true;
|
|
return (
|
|
host.label.toLowerCase().includes(normalized) ||
|
|
host.serverId.toLowerCase().includes(normalized)
|
|
);
|
|
}
|
|
|
|
export function useHostChooser() {
|
|
const hosts = useHosts();
|
|
const localServerId = useLocalDaemonServerId();
|
|
const open = useHostChooserStore((state) => state.open);
|
|
|
|
return useCallback(
|
|
(input: ChooseHostInput) => {
|
|
const availableHosts = orderHostsLocalFirst(hosts, localServerId).filter(
|
|
input.filter ?? (() => true),
|
|
);
|
|
|
|
if (availableHosts.length === 0) {
|
|
(input.onNoHosts ?? (() => router.push(buildSettingsAddHostRoute(Date.now()))))();
|
|
return false;
|
|
}
|
|
|
|
if (availableHosts.length === 1) {
|
|
void input.onChooseHost(availableHosts[0].serverId);
|
|
return true;
|
|
}
|
|
|
|
open({
|
|
title: input.title ?? "Choose host",
|
|
serverIds: availableHosts.map((host) => host.serverId),
|
|
onChooseHost: input.onChooseHost,
|
|
});
|
|
return true;
|
|
},
|
|
[hosts, localServerId, open],
|
|
);
|
|
}
|
|
|
|
function HostChooserRow({
|
|
host,
|
|
active,
|
|
onChooseHost,
|
|
}: {
|
|
host: HostProfile;
|
|
active: boolean;
|
|
onChooseHost: (serverId: string) => void;
|
|
}) {
|
|
const { theme } = useUnistyles();
|
|
const handlePress = useCallback(() => onChooseHost(host.serverId), [host.serverId, onChooseHost]);
|
|
const rowStyle = useCallback(
|
|
({ hovered = false, pressed }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
styles.row,
|
|
(active || hovered || pressed) && styles.rowActive,
|
|
],
|
|
[active],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
accessibilityRole="button"
|
|
onPress={handlePress}
|
|
style={rowStyle}
|
|
testID={`host-chooser-row-${host.serverId}`}
|
|
>
|
|
<View style={styles.rowIconSlot}>
|
|
<HostStatusDotSlot serverId={host.serverId} />
|
|
</View>
|
|
<View style={styles.rowText}>
|
|
<Text style={styles.rowTitle} numberOfLines={1}>
|
|
{host.label}
|
|
</Text>
|
|
<Text style={styles.rowSubtitle} numberOfLines={1}>
|
|
{host.serverId}
|
|
</Text>
|
|
</View>
|
|
<Server size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
export function HostChooserModal() {
|
|
const { theme } = useUnistyles();
|
|
const hosts = useHosts();
|
|
const request = useHostChooserStore((state) => state.request);
|
|
const close = useHostChooserStore((state) => state.close);
|
|
const inputRef = useRef<TextInput>(null);
|
|
const [query, setQuery] = useState("");
|
|
const [activeIndex, setActiveIndex] = useState(0);
|
|
|
|
const requestHosts = useMemo(() => {
|
|
if (!request) return [];
|
|
const hostByServerId = new Map(hosts.map((host) => [host.serverId, host] as const));
|
|
return request.serverIds.flatMap((serverId) => {
|
|
const host = hostByServerId.get(serverId);
|
|
return host ? [host] : [];
|
|
});
|
|
}, [hosts, request]);
|
|
|
|
const options = useMemo(
|
|
() => requestHosts.filter((host) => matchesHostQuery(host, query)),
|
|
[query, requestHosts],
|
|
);
|
|
const activeOptionIndex = options.length === 0 ? 0 : Math.min(activeIndex, options.length - 1);
|
|
|
|
useEffect(() => {
|
|
if (!request) return;
|
|
setQuery("");
|
|
setActiveIndex(0);
|
|
const id = setTimeout(() => inputRef.current?.focus(), 0);
|
|
return () => clearTimeout(id);
|
|
}, [request]);
|
|
|
|
const handleQueryChange = useCallback((value: string) => {
|
|
setQuery(value);
|
|
setActiveIndex(0);
|
|
}, []);
|
|
|
|
const chooseHost = useCallback(
|
|
(serverId: string) => {
|
|
const currentRequest = request;
|
|
close();
|
|
if (!currentRequest) return;
|
|
void currentRequest.onChooseHost(serverId);
|
|
},
|
|
[close, request],
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (!request || isNative || typeof window === "undefined") return;
|
|
|
|
const handleKeyDown = (event: KeyboardEvent) => {
|
|
if (
|
|
event.key !== "ArrowDown" &&
|
|
event.key !== "ArrowUp" &&
|
|
event.key !== "Enter" &&
|
|
event.key !== "Escape"
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
close();
|
|
return;
|
|
}
|
|
|
|
if (event.key === "Enter") {
|
|
const host = options[activeOptionIndex];
|
|
if (!host) return;
|
|
event.preventDefault();
|
|
chooseHost(host.serverId);
|
|
return;
|
|
}
|
|
|
|
if (options.length === 0) return;
|
|
event.preventDefault();
|
|
const next = event.key === "ArrowDown" ? activeOptionIndex + 1 : activeOptionIndex - 1;
|
|
if (next < 0) {
|
|
setActiveIndex(options.length - 1);
|
|
return;
|
|
}
|
|
if (next >= options.length) {
|
|
setActiveIndex(0);
|
|
return;
|
|
}
|
|
setActiveIndex(next);
|
|
};
|
|
|
|
window.addEventListener("keydown", handleKeyDown, true);
|
|
return () => window.removeEventListener("keydown", handleKeyDown, true);
|
|
}, [activeOptionIndex, chooseHost, close, options, request]);
|
|
|
|
if (!request) return null;
|
|
|
|
return (
|
|
<Modal visible transparent animationType="fade" onRequestClose={close} testID="host-chooser">
|
|
<View style={styles.overlay}>
|
|
<Pressable style={styles.backdrop} onPress={close} />
|
|
<View style={styles.panel}>
|
|
<View style={styles.header}>
|
|
<Text style={styles.title}>{request.title}</Text>
|
|
<TextInput
|
|
ref={inputRef}
|
|
value={query}
|
|
onChangeText={handleQueryChange}
|
|
placeholder="Search hosts..."
|
|
placeholderTextColor={theme.colors.foregroundMuted}
|
|
style={styles.input}
|
|
autoCapitalize="none"
|
|
autoCorrect={false}
|
|
testID="host-chooser-search"
|
|
/>
|
|
</View>
|
|
<ScrollView
|
|
style={styles.results}
|
|
contentContainerStyle={styles.resultsContent}
|
|
keyboardShouldPersistTaps="always"
|
|
showsVerticalScrollIndicator={false}
|
|
>
|
|
{options.length === 0 ? <Text style={styles.emptyText}>No matching hosts</Text> : null}
|
|
{options.map((host, index) => (
|
|
<HostChooserRow
|
|
key={host.serverId}
|
|
host={host}
|
|
active={index === activeOptionIndex}
|
|
onChooseHost={chooseHost}
|
|
/>
|
|
))}
|
|
</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,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.lg,
|
|
backgroundColor: theme.colors.surface0,
|
|
overflow: "hidden",
|
|
...theme.shadow.lg,
|
|
},
|
|
header: {
|
|
gap: theme.spacing[3],
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[4],
|
|
borderBottomWidth: 1,
|
|
borderBottomColor: theme.colors.border,
|
|
},
|
|
title: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.base,
|
|
fontWeight: theme.fontWeight.medium,
|
|
},
|
|
input: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.lg,
|
|
padding: 0,
|
|
outlineStyle: "none",
|
|
} as object,
|
|
results: {
|
|
maxHeight: 420,
|
|
},
|
|
resultsContent: {
|
|
paddingVertical: theme.spacing[2],
|
|
},
|
|
row: {
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[3],
|
|
minHeight: 56,
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[3],
|
|
},
|
|
rowActive: {
|
|
backgroundColor: theme.colors.surface1,
|
|
},
|
|
rowIconSlot: {
|
|
width: 18,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
rowText: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
gap: theme.spacing[1],
|
|
},
|
|
rowTitle: {
|
|
color: theme.colors.foreground,
|
|
fontSize: theme.fontSize.base,
|
|
},
|
|
rowSubtitle: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
paddingHorizontal: theme.spacing[4],
|
|
paddingVertical: theme.spacing[3],
|
|
},
|
|
}));
|