* 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
235 lines
6.4 KiB
TypeScript
235 lines
6.4 KiB
TypeScript
import { useCallback, useMemo } from "react";
|
|
import { Pressable, Text, View, type PressableStateCallbackType } from "react-native";
|
|
import { router } from "expo-router";
|
|
import { StyleSheet, useUnistyles } from "react-native-unistyles";
|
|
import { ChevronRight } from "lucide-react-native";
|
|
import { useTranslation } from "react-i18next";
|
|
import { ProjectIconView } from "@/components/project-icon-view";
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
import { useProjects, type ProjectHostError } from "@/hooks/use-projects";
|
|
import { useProjectIconDataByProjectKey } from "@/projects/project-icons";
|
|
import { settingsStyles } from "@/styles/settings";
|
|
import { buildProjectSettingsRoute } from "@/utils/host-routes";
|
|
import type { ProjectSummary } from "@/utils/projects";
|
|
|
|
interface ProjectsScreenProps {
|
|
view: { kind: "projects" } | { kind: "project"; projectKey: string };
|
|
}
|
|
|
|
export default function ProjectsScreen({ view }: ProjectsScreenProps) {
|
|
const { t } = useTranslation();
|
|
const { projects, hostErrors, isLoading } = useProjects();
|
|
const selectedProjectKey = view.kind === "project" ? view.projectKey : null;
|
|
const iconTargets = useMemo(
|
|
() =>
|
|
projects.flatMap((project) => {
|
|
const host = project.hosts[0];
|
|
if (!host) return [];
|
|
return [
|
|
{
|
|
serverId: host.serverId,
|
|
projectKey: project.projectKey,
|
|
iconWorkingDir: host.repoRoot,
|
|
},
|
|
];
|
|
}),
|
|
[projects],
|
|
);
|
|
const iconDataByProjectKey = useProjectIconDataByProjectKey({
|
|
projects: iconTargets,
|
|
});
|
|
|
|
if (isLoading && projects.length === 0) {
|
|
return (
|
|
<View style={styles.centered} testID="projects-list">
|
|
<LoadingSpinner size="large" color={styles.spinnerColor.color} />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
if (projects.length === 0) {
|
|
return (
|
|
<View style={styles.centered} testID="projects-list">
|
|
<Text style={styles.emptyText}>{t("sidebar.project.empty.title")}</Text>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<View testID="projects-list">
|
|
{hostErrors.length > 0 ? <HostErrorsBanner errors={hostErrors} /> : null}
|
|
<View style={settingsStyles.card}>
|
|
{projects.map((project, index) => (
|
|
<ProjectRow
|
|
key={project.projectKey}
|
|
project={project}
|
|
isFirst={index === 0}
|
|
isSelected={selectedProjectKey === project.projectKey}
|
|
iconDataUri={iconDataByProjectKey.get(project.projectKey) ?? null}
|
|
/>
|
|
))}
|
|
</View>
|
|
</View>
|
|
);
|
|
}
|
|
|
|
function HostErrorsBanner({ errors }: { errors: ProjectHostError[] }) {
|
|
const { t } = useTranslation();
|
|
return (
|
|
<View style={styles.errorsBanner} testID="projects-host-errors">
|
|
{errors.map((error) => (
|
|
<Text key={error.serverId} style={styles.errorsBannerText}>
|
|
{t("settings.projectList.hostLoadFailed", {
|
|
hostName: error.serverName,
|
|
message: error.message,
|
|
})}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
);
|
|
}
|
|
|
|
interface ProjectRowProps {
|
|
project: ProjectSummary;
|
|
isFirst: boolean;
|
|
isSelected: boolean;
|
|
iconDataUri: string | null;
|
|
}
|
|
|
|
function ProjectRow({ project, isFirst, isSelected, iconDataUri }: ProjectRowProps) {
|
|
const { t } = useTranslation();
|
|
const { theme } = useUnistyles();
|
|
const { projectKey, projectName } = project;
|
|
|
|
const handleNavigate = useCallback(() => {
|
|
router.navigate(buildProjectSettingsRoute(projectKey));
|
|
}, [projectKey]);
|
|
|
|
const rowStyle = useCallback(
|
|
({ pressed, hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
|
settingsStyles.row,
|
|
!isFirst && settingsStyles.rowBorder,
|
|
styles.row,
|
|
isSelected && styles.rowSelected,
|
|
hovered && !isSelected && styles.rowHovered,
|
|
pressed && styles.rowPressed,
|
|
],
|
|
[isFirst, isSelected],
|
|
);
|
|
|
|
return (
|
|
<Pressable
|
|
style={rowStyle}
|
|
onPress={handleNavigate}
|
|
accessibilityRole="button"
|
|
accessibilityLabel={t("settings.projectList.editProject", { projectName })}
|
|
testID={`project-row-${projectKey}`}
|
|
data-selected={isSelected ? "true" : "false"}
|
|
>
|
|
<View style={styles.rowMain}>
|
|
<View style={styles.leading}>
|
|
<ProjectRowIcon
|
|
iconDataUri={iconDataUri}
|
|
projectName={projectName}
|
|
projectKey={projectKey}
|
|
/>
|
|
</View>
|
|
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
|
{projectName}
|
|
</Text>
|
|
</View>
|
|
<ChevronRight size={theme.iconSize.sm} color={theme.colors.foregroundMuted} />
|
|
</Pressable>
|
|
);
|
|
}
|
|
|
|
function ProjectRowIcon({
|
|
iconDataUri,
|
|
projectName,
|
|
projectKey,
|
|
}: {
|
|
iconDataUri: string | null;
|
|
projectName: string;
|
|
projectKey: string;
|
|
}) {
|
|
const initial = projectName.trim().charAt(0).toUpperCase() || "?";
|
|
return (
|
|
<ProjectIconView
|
|
iconDataUri={iconDataUri}
|
|
initial={initial}
|
|
projectKey={projectKey}
|
|
imageStyle={styles.iconImage}
|
|
fallbackStyle={styles.iconFallback}
|
|
textStyle={styles.iconFallbackText}
|
|
/>
|
|
);
|
|
}
|
|
|
|
const styles = StyleSheet.create((theme) => ({
|
|
centered: {
|
|
flex: 1,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: theme.spacing[6],
|
|
},
|
|
emptyText: {
|
|
color: theme.colors.foregroundMuted,
|
|
fontSize: theme.fontSize.sm,
|
|
},
|
|
errorsBanner: {
|
|
borderWidth: 1,
|
|
borderColor: theme.colors.border,
|
|
borderRadius: theme.borderRadius.lg,
|
|
padding: theme.spacing[3],
|
|
marginBottom: theme.spacing[3],
|
|
gap: theme.spacing[1],
|
|
},
|
|
errorsBannerText: {
|
|
color: theme.colors.palette.red[300],
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
row: {
|
|
gap: theme.spacing[3],
|
|
},
|
|
rowMain: {
|
|
flex: 1,
|
|
minWidth: 0,
|
|
flexDirection: "row",
|
|
alignItems: "center",
|
|
gap: theme.spacing[3],
|
|
},
|
|
rowHovered: {
|
|
backgroundColor: theme.colors.surface2,
|
|
},
|
|
rowPressed: {
|
|
backgroundColor: theme.colors.surface3,
|
|
},
|
|
rowSelected: {
|
|
backgroundColor: theme.colors.surfaceSidebarHover,
|
|
},
|
|
leading: {
|
|
width: 16,
|
|
height: 16,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
iconImage: {
|
|
width: 16,
|
|
height: 16,
|
|
borderRadius: theme.borderRadius.sm,
|
|
},
|
|
iconFallback: {
|
|
width: 16,
|
|
height: 16,
|
|
borderRadius: theme.borderRadius.sm,
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
},
|
|
iconFallbackText: {
|
|
fontSize: theme.fontSize.xs,
|
|
},
|
|
spinnerColor: {
|
|
color: theme.colors.foregroundMuted,
|
|
},
|
|
}));
|