Merge sidebar workspaces across all connected hosts (#1538)

* 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
This commit is contained in:
Mohamed Boudra
2026-06-26 12:16:49 +08:00
committed by GitHub
parent 1109e453bc
commit a49c658d1f
103 changed files with 6856 additions and 2857 deletions
@@ -1,12 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Pressable, Text, TextInput, View } from "react-native";
import { router } from "expo-router";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { StyleSheet } from "react-native-unistyles";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ArrowLeft, Check, ChevronDown, MoreVertical, Pencil, Plus, X } from "lucide-react-native";
import { ProjectIconView } from "@/components/project-icon-view";
import { HostPicker as SharedHostPicker, HostStatusDotSlot } from "@/components/hosts/host-picker";
import type {
PaseoConfigRaw,
PaseoConfigRevision,
@@ -216,7 +217,6 @@ function ProjectSettingsBody({
[project.projectKey, selectedHost.repoRoot, selectedHost.serverId],
);
const projectIconDataByKey = useProjectIconDataByProjectKey({
serverId: null,
projects: projectIconTargets,
});
const projectIconDataUri = projectIconDataByKey.get(project.projectKey) ?? null;
@@ -942,7 +942,7 @@ function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) {
}
return (
<View testID="host-indicator" style={styles.hostIndicator}>
<HostStatusDot serverId={selectedHost.serverId} />
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
@@ -950,18 +950,6 @@ function HostContext({ hosts, selectedHost, onSelectHost }: HostContextProps) {
);
}
function HostStatusDot({ serverId }: { serverId: string }) {
const { theme } = useUnistyles();
const snapshot = useHostRuntimeSnapshot(serverId);
const status = snapshot?.connectionStatus ?? "connecting";
let color: string;
if (status === "online") color = theme.colors.palette.green[400];
else if (status === "connecting") color = theme.colors.palette.amber[500];
else color = theme.colors.palette.red[500];
const dotStyle = useMemo(() => [styles.hostStatusDot, { backgroundColor: color }], [color]);
return <View style={dotStyle} />;
}
interface HostPickerProps {
hosts: ProjectHostEntry[];
selectedHost: ProjectHostEntry;
@@ -970,52 +958,42 @@ interface HostPickerProps {
function HostPicker({ hosts, selectedHost, onSelectHost }: HostPickerProps) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const triggerRef = useRef<View | null>(null);
const hostOptions = useMemo(
() => hosts.map((host) => ({ serverId: host.serverId, label: host.serverName })),
[hosts],
);
const handleOpen = useCallback(() => setOpen(true), []);
const hostOptionTestID = useCallback((serverId: string) => `host-picker-item-${serverId}`, []);
return (
<DropdownMenu>
<DropdownMenuTrigger
<SharedHostPicker
hosts={hostOptions}
value={selectedHost.serverId}
onSelect={onSelectHost}
open={open}
onOpenChange={setOpen}
anchorRef={triggerRef}
searchable={false}
title={t("settings.project.switchHost")}
desktopPlacement="bottom-start"
desktopMinWidth={240}
hostOptionTestID={hostOptionTestID}
>
<Pressable
ref={triggerRef}
accessibilityLabel={t("settings.project.switchHost")}
testID="host-picker"
style={styles.hostIndicator}
onPress={handleOpen}
>
<HostStatusDot serverId={selectedHost.serverId} />
<HostStatusDotSlot serverId={selectedHost.serverId} />
<Text style={styles.hostName} numberOfLines={1}>
{selectedHost.serverName}
</Text>
<ChevronDown size={ICON_SIZE} color={styles.chevronColor.color} />
</DropdownMenuTrigger>
<DropdownMenuContent align="start" minWidth={240}>
{hosts.map((host) => (
<HostPickerItem
key={host.serverId}
host={host}
isSelected={host.serverId === selectedHost.serverId}
onSelectHost={onSelectHost}
/>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
interface HostPickerItemProps {
host: ProjectHostEntry;
isSelected: boolean;
onSelectHost: (serverId: string) => void;
}
function HostPickerItem({ host, isSelected, onSelectHost }: HostPickerItemProps) {
const handleSelect = useCallback(
() => onSelectHost(host.serverId),
[host.serverId, onSelectHost],
);
return (
<DropdownMenuItem
testID={`host-picker-item-${host.serverId}`}
selected={isSelected}
onSelect={handleSelect}
>
{host.serverName}
</DropdownMenuItem>
</Pressable>
</SharedHostPicker>
);
}
@@ -1353,11 +1331,6 @@ const styles = StyleSheet.create((theme) => ({
alignSelf: "flex-start",
minWidth: 0,
},
hostStatusDot: {
width: 8,
height: 8,
borderRadius: theme.borderRadius.full,
},
hostName: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,