mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
feat(onboarding): suggest projects from agent history
The setup's Projects step lists the repos the user already ran agents in, with busy recent ones preselected, so a new user adds their projects in one click instead of browsing for folders. With rows checked, the footer's main button adds them and only moves on once every one was added; a checked row always means "this will be added". The daemon resolves each session folder to its project root (main checkout for worktrees, repo root for subfolders) because only it can read the git state; the app asks once per provider so a slow or broken provider never holds up the others and can be retried on its own. Hosts without the projectSuggestions feature show only the Add project button. Onboarding e2e hosts read an empty Claude history with other providers off, so suggestions never depend on the machine running the tests.
This commit is contained in:
@@ -1667,7 +1667,20 @@ export const ar: TranslationResources = {
|
||||
description:
|
||||
"أضف المجلدات التي تريد أن يعمل فيها الوكلاء. يمكنك إضافة المزيد لاحقًا من الشريط الجانبي.",
|
||||
addProject: "إضافة مشروع",
|
||||
addAnotherProject: "إضافة مشروع آخر…",
|
||||
added: "تمت الإضافة",
|
||||
suggestions: {
|
||||
loading: "جارٍ البحث عن المجلدات التي عملت فيها",
|
||||
stillReading: "ما زلنا نقرأ سجل {{providers}}…",
|
||||
readFailed: "تعذّرت قراءة سجل {{provider}}",
|
||||
empty: "لم يتم العثور على مشاريع حديثة",
|
||||
sessions_one: "جلسة واحدة ({{count}})",
|
||||
sessions_other: "{{count}} جلسات",
|
||||
addSelected_one: "إضافة مشروع واحد ({{count}})",
|
||||
addSelected_other: "إضافة {{count}} مشاريع",
|
||||
adding: "جارٍ الإضافة…",
|
||||
addFailed: "تعذّرت إضافة هذا المجلد",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "التنسيق",
|
||||
|
||||
@@ -1692,7 +1692,20 @@ export const en = {
|
||||
description:
|
||||
"Add the folders you want agents to work in. You can add more later from the sidebar.",
|
||||
addProject: "Add project",
|
||||
addAnotherProject: "Add another project…",
|
||||
added: "Added",
|
||||
suggestions: {
|
||||
loading: "Looking for folders you've worked in",
|
||||
stillReading: "Still reading {{providers}} history…",
|
||||
readFailed: "Couldn't read {{provider}} history",
|
||||
empty: "No recent projects found",
|
||||
sessions_one: "{{count}} session",
|
||||
sessions_other: "{{count}} sessions",
|
||||
addSelected_one: "Add {{count}} project",
|
||||
addSelected_other: "Add {{count}} projects",
|
||||
adding: "Adding…",
|
||||
addFailed: "Couldn't add this folder",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Orchestration",
|
||||
|
||||
@@ -1713,7 +1713,20 @@ export const es: TranslationResources = {
|
||||
description:
|
||||
"Añade las carpetas en las que quieres que trabajen los agentes. Puedes añadir más después desde la barra lateral.",
|
||||
addProject: "Añadir proyecto",
|
||||
addAnotherProject: "Añadir otro proyecto…",
|
||||
added: "Añadido",
|
||||
suggestions: {
|
||||
loading: "Buscando carpetas en las que has trabajado",
|
||||
stillReading: "Todavía leyendo el historial de {{providers}}…",
|
||||
readFailed: "No se pudo leer el historial de {{provider}}",
|
||||
empty: "No se encontraron proyectos recientes",
|
||||
sessions_one: "{{count}} sesión",
|
||||
sessions_other: "{{count}} sesiones",
|
||||
addSelected_one: "Añadir {{count}} proyecto",
|
||||
addSelected_other: "Añadir {{count}} proyectos",
|
||||
adding: "Añadiendo…",
|
||||
addFailed: "No se pudo añadir esta carpeta",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Orquestación",
|
||||
|
||||
@@ -1711,7 +1711,20 @@ export const fr: TranslationResources = {
|
||||
description:
|
||||
"Ajoutez les dossiers dans lesquels les agents doivent travailler. Vous pourrez en ajouter d'autres depuis la barre latérale.",
|
||||
addProject: "Ajouter un projet",
|
||||
addAnotherProject: "Ajouter un autre projet…",
|
||||
added: "Ajouté",
|
||||
suggestions: {
|
||||
loading: "Recherche des dossiers dans lesquels vous avez travaillé",
|
||||
stillReading: "Lecture de l'historique {{providers}} en cours…",
|
||||
readFailed: "Impossible de lire l'historique {{provider}}",
|
||||
empty: "Aucun projet récent trouvé",
|
||||
sessions_one: "{{count}} session",
|
||||
sessions_other: "{{count}} sessions",
|
||||
addSelected_one: "Ajouter {{count}} projet",
|
||||
addSelected_other: "Ajouter {{count}} projets",
|
||||
adding: "Ajout…",
|
||||
addFailed: "Impossible d'ajouter ce dossier",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Orchestration",
|
||||
|
||||
@@ -1684,7 +1684,20 @@ export const ja: TranslationResources = {
|
||||
description:
|
||||
"エージェントに作業させるフォルダーを追加します。後からサイドバーで追加することもできます。",
|
||||
addProject: "プロジェクトを追加",
|
||||
addAnotherProject: "別のプロジェクトを追加…",
|
||||
added: "追加済み",
|
||||
suggestions: {
|
||||
loading: "作業したフォルダーを探しています",
|
||||
stillReading: "{{providers}} の履歴を読み込んでいます…",
|
||||
readFailed: "{{provider}} の履歴を読み込めませんでした",
|
||||
empty: "最近のプロジェクトは見つかりませんでした",
|
||||
sessions_one: "{{count}} セッション",
|
||||
sessions_other: "{{count}} セッション",
|
||||
addSelected_one: "{{count}} 件のプロジェクトを追加",
|
||||
addSelected_other: "{{count}} 件のプロジェクトを追加",
|
||||
adding: "追加しています…",
|
||||
addFailed: "このフォルダーを追加できませんでした",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "オーケストレーション",
|
||||
|
||||
@@ -1677,7 +1677,20 @@ export const ko: TranslationResources = {
|
||||
description:
|
||||
"에이전트가 작업할 폴더를 추가하세요. 나중에 사이드바에서 더 추가할 수 있습니다.",
|
||||
addProject: "프로젝트 추가",
|
||||
addAnotherProject: "다른 프로젝트 추가…",
|
||||
added: "추가됨",
|
||||
suggestions: {
|
||||
loading: "작업했던 폴더를 찾는 중",
|
||||
stillReading: "{{providers}} 기록을 아직 읽는 중…",
|
||||
readFailed: "{{provider}} 기록을 읽지 못했습니다",
|
||||
empty: "최근 프로젝트를 찾지 못했습니다",
|
||||
sessions_one: "세션 {{count}}개",
|
||||
sessions_other: "세션 {{count}}개",
|
||||
addSelected_one: "프로젝트 {{count}}개 추가",
|
||||
addSelected_other: "프로젝트 {{count}}개 추가",
|
||||
adding: "추가하는 중…",
|
||||
addFailed: "이 폴더를 추가하지 못했습니다",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "오케스트레이션",
|
||||
|
||||
@@ -1698,7 +1698,20 @@ export const ptBR: TranslationResources = {
|
||||
description:
|
||||
"Adicione as pastas em que os agentes devem trabalhar. Você pode adicionar mais depois pela barra lateral.",
|
||||
addProject: "Adicionar projeto",
|
||||
addAnotherProject: "Adicionar outro projeto…",
|
||||
added: "Adicionado",
|
||||
suggestions: {
|
||||
loading: "Procurando pastas em que você trabalhou",
|
||||
stillReading: "Ainda lendo o histórico de {{providers}}…",
|
||||
readFailed: "Não foi possível ler o histórico de {{provider}}",
|
||||
empty: "Nenhum projeto recente encontrado",
|
||||
sessions_one: "{{count}} sessão",
|
||||
sessions_other: "{{count}} sessões",
|
||||
addSelected_one: "Adicionar {{count}} projeto",
|
||||
addSelected_other: "Adicionar {{count}} projetos",
|
||||
adding: "Adicionando…",
|
||||
addFailed: "Não foi possível adicionar esta pasta",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Orquestração",
|
||||
|
||||
@@ -1696,7 +1696,20 @@ export const ru: TranslationResources = {
|
||||
description:
|
||||
"Добавьте папки, в которых будут работать агенты. Позже можно добавить ещё из боковой панели.",
|
||||
addProject: "Добавить проект",
|
||||
addAnotherProject: "Добавить другой проект…",
|
||||
added: "Добавлено",
|
||||
suggestions: {
|
||||
loading: "Ищем папки, в которых вы работали",
|
||||
stillReading: "Ещё читаем историю {{providers}}…",
|
||||
readFailed: "Не удалось прочитать историю {{provider}}",
|
||||
empty: "Недавних проектов не найдено",
|
||||
sessions_one: "{{count}} сессия",
|
||||
sessions_other: "Сессий: {{count}}",
|
||||
addSelected_one: "Добавить {{count}} проект",
|
||||
addSelected_other: "Добавить проекты: {{count}}",
|
||||
adding: "Добавляем…",
|
||||
addFailed: "Не удалось добавить эту папку",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "Оркестрация",
|
||||
|
||||
@@ -1648,7 +1648,20 @@ export const zhCN: TranslationResources = {
|
||||
title: "添加项目",
|
||||
description: "添加你希望代理工作的文件夹。之后也可以从侧边栏添加更多。",
|
||||
addProject: "添加项目",
|
||||
addAnotherProject: "添加其他项目…",
|
||||
added: "已添加",
|
||||
suggestions: {
|
||||
loading: "正在查找你工作过的文件夹",
|
||||
stillReading: "仍在读取 {{providers}} 历史记录…",
|
||||
readFailed: "无法读取 {{provider}} 历史记录",
|
||||
empty: "未找到最近的项目",
|
||||
sessions_one: "{{count}} 个会话",
|
||||
sessions_other: "{{count}} 个会话",
|
||||
addSelected_one: "添加 {{count}} 个项目",
|
||||
addSelected_other: "添加 {{count}} 个项目",
|
||||
adding: "正在添加…",
|
||||
addFailed: "无法添加此文件夹",
|
||||
},
|
||||
},
|
||||
orchestration: {
|
||||
label: "编排",
|
||||
|
||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { ScrollView } from "@/components/ui/scroll-view";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useProjectSuggestions, type ProjectSuggestionsController } from "@/project-suggestions";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { OnboardingModel, OnboardingState, OnboardingStepId } from "./model";
|
||||
import { OnboardingStepContent } from "./steps";
|
||||
@@ -24,12 +25,29 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
|
||||
const step = state.steps[state.stepIndex];
|
||||
const isFirst = state.stepIndex === 0;
|
||||
const isLast = state.stepIndex === state.steps.length - 1;
|
||||
// Held here so Continue can add the checked folders; reading history from the first step means
|
||||
// the Projects step rarely waits.
|
||||
const projectSuggestions = useProjectSuggestions(state.serverId);
|
||||
const { addChecked } = projectSuggestions;
|
||||
|
||||
const handleComplete = useCallback(() => {
|
||||
void model.complete();
|
||||
}, [model]);
|
||||
const handleBack = useCallback(() => model.back(), [model]);
|
||||
const handleNext = useCallback(() => model.next(), [model]);
|
||||
const handleAddSuggestions = useCallback(() => {
|
||||
void (async () => {
|
||||
if (await addChecked()) model.next();
|
||||
})();
|
||||
}, [addChecked, model]);
|
||||
const primary = usePrimaryAction({
|
||||
step,
|
||||
isLast,
|
||||
projectSuggestions,
|
||||
onFinish: handleComplete,
|
||||
onNext: handleNext,
|
||||
onAddSuggestions: handleAddSuggestions,
|
||||
});
|
||||
|
||||
const header = useMemo(
|
||||
() => ({
|
||||
@@ -62,8 +80,8 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
|
||||
{t("common.actions.back")}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="default" onPress={isLast ? handleComplete : handleNext}>
|
||||
{isLast ? t("onboarding.setup.finish") : t("onboarding.setup.continue")}
|
||||
<Button variant="default" onPress={primary.onPress} loading={primary.busy}>
|
||||
{primary.label}
|
||||
</Button>
|
||||
</View>
|
||||
</>
|
||||
@@ -98,6 +116,7 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
|
||||
<OnboardingStepContent
|
||||
step={step}
|
||||
serverId={state.serverId}
|
||||
projectSuggestions={projectSuggestions}
|
||||
onFinish={handleComplete}
|
||||
/>
|
||||
</View>
|
||||
@@ -107,6 +126,37 @@ export function OnboardingModal({ model, state }: { model: OnboardingModel; stat
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The footer's main button. On Projects, with folders checked, it adds them and moves on only once
|
||||
* every one was added; otherwise it continues, or finishes on the last step.
|
||||
*/
|
||||
function usePrimaryAction(input: {
|
||||
step: OnboardingStepId;
|
||||
isLast: boolean;
|
||||
projectSuggestions: ProjectSuggestionsController;
|
||||
onFinish: () => void;
|
||||
onNext: () => void;
|
||||
onAddSuggestions: () => void;
|
||||
}): { label: string; busy: boolean; onPress: () => void } {
|
||||
const { t } = useTranslation();
|
||||
const { step, isLast, projectSuggestions, onFinish, onNext, onAddSuggestions } = input;
|
||||
const { checkedCount, isAdding } = projectSuggestions;
|
||||
if (isLast) {
|
||||
return { label: t("onboarding.setup.finish"), busy: false, onPress: onFinish };
|
||||
}
|
||||
// Rows leave the list as they are added, so the count can reach zero while adding.
|
||||
if (step === "projects" && (isAdding || checkedCount > 0)) {
|
||||
return {
|
||||
label: isAdding
|
||||
? t("onboarding.setup.steps.projects.suggestions.adding")
|
||||
: t("onboarding.setup.steps.projects.suggestions.addSelected", { count: checkedCount }),
|
||||
busy: isAdding,
|
||||
onPress: onAddSuggestions,
|
||||
};
|
||||
}
|
||||
return { label: t("onboarding.setup.continue"), busy: false, onPress: onNext };
|
||||
}
|
||||
|
||||
function StepRail({
|
||||
state,
|
||||
onSelect,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ProjectSuggestionsController } from "@/project-suggestions";
|
||||
import type { OnboardingStepId } from "../model";
|
||||
import { AppearanceStep } from "./appearance-step";
|
||||
import { OrchestrationStep } from "./orchestration-step";
|
||||
@@ -8,10 +9,12 @@ import { ProvidersStep } from "./providers-step";
|
||||
export function OnboardingStepContent({
|
||||
step,
|
||||
serverId,
|
||||
projectSuggestions,
|
||||
onFinish,
|
||||
}: {
|
||||
step: OnboardingStepId;
|
||||
serverId: string;
|
||||
projectSuggestions: ProjectSuggestionsController;
|
||||
onFinish: () => void;
|
||||
}) {
|
||||
switch (step) {
|
||||
@@ -20,7 +23,7 @@ export function OnboardingStepContent({
|
||||
case "providers":
|
||||
return <ProvidersStep serverId={serverId} />;
|
||||
case "projects":
|
||||
return <ProjectsStep serverId={serverId} />;
|
||||
return <ProjectsStep serverId={serverId} suggestions={projectSuggestions} />;
|
||||
case "orchestration":
|
||||
return <OrchestrationStep serverId={serverId} />;
|
||||
case "phone":
|
||||
|
||||
@@ -5,6 +5,7 @@ import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check, FolderPlus } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useOpenAddProject } from "@/hooks/use-open-add-project";
|
||||
import { ProjectSuggestionList, type ProjectSuggestionsController } from "@/project-suggestions";
|
||||
import { useProjectRootPaths } from "@/stores/session-store-hooks";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
@@ -17,7 +18,14 @@ function basename(path: string): string {
|
||||
return segments.at(-1) ?? path;
|
||||
}
|
||||
|
||||
export function ProjectsStep({ serverId }: { serverId: string }) {
|
||||
/** Adding the checked suggestions is the setup's primary action; see the modal footer. */
|
||||
export function ProjectsStep({
|
||||
serverId,
|
||||
suggestions,
|
||||
}: {
|
||||
serverId: string;
|
||||
suggestions: ProjectSuggestionsController;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const openAddProject = useOpenAddProject();
|
||||
const projectRootPaths = useProjectRootPaths(serverId);
|
||||
@@ -25,6 +33,7 @@ export function ProjectsStep({ serverId }: { serverId: string }) {
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<ProjectSuggestionList suggestions={suggestions} />
|
||||
{projectRootPaths.length > 0 ? (
|
||||
<View style={settingsStyles.card}>
|
||||
{projectRootPaths.map((rootPath, index) => (
|
||||
@@ -50,7 +59,9 @@ export function ProjectsStep({ serverId }: { serverId: string }) {
|
||||
) : null}
|
||||
<View style={styles.actions}>
|
||||
<Button variant="outline" leftIcon={FolderPlus} onPress={handleAddProject}>
|
||||
{t("onboarding.setup.steps.projects.addProject")}
|
||||
{suggestions.list.status === "ready" && suggestions.list.rows.length > 0
|
||||
? t("onboarding.setup.steps.projects.addAnotherProject")
|
||||
: t("onboarding.setup.steps.projects.addProject")}
|
||||
</Button>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Project suggestions: folders the user already ran agents in, offered as projects to add.
|
||||
* Requires the host's `projectSuggestions` feature; on older hosts the list renders nothing.
|
||||
*/
|
||||
export { ProjectSuggestionList } from "./internal/project-suggestions";
|
||||
export {
|
||||
useProjectSuggestions,
|
||||
type ProjectSuggestionsController,
|
||||
} from "./internal/use-project-suggestions";
|
||||
@@ -0,0 +1,262 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Text, View, type PressableStateCallbackType } from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { Check } from "lucide-react-native";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import { PressHighlight } from "@/components/ui/press-highlight";
|
||||
import { SkeletonPulse, useSkeletonPulse } from "@/components/ui/skeleton";
|
||||
import { useTimeAgo } from "@/hooks/use-time-ago";
|
||||
import { settingsStyles } from "@/styles/settings";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
import type { ProjectSuggestion, ProviderSuggestions } from "./suggestions";
|
||||
import type {
|
||||
ProjectSuggestionsController,
|
||||
SuggestionRow as SuggestionRowModel,
|
||||
} from "./use-project-suggestions";
|
||||
|
||||
const ThemedCheck = withUnistyles(Check);
|
||||
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
|
||||
const accentForegroundMapping = (theme: Theme) => ({ color: theme.colors.accentForeground });
|
||||
const mutedMapping = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
|
||||
const SKELETON_ROWS = [0, 1, 2];
|
||||
|
||||
/**
|
||||
* Folders the user already ran agents in, ready to add as projects. Rows appear as each provider
|
||||
* answers and a failed provider can be retried. Adding the checked rows is the owner's primary
|
||||
* action, through the same controller. Renders nothing on a host that cannot suggest projects.
|
||||
*/
|
||||
export function ProjectSuggestionList({
|
||||
suggestions,
|
||||
}: {
|
||||
suggestions: ProjectSuggestionsController;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { list } = suggestions;
|
||||
if (list.status === "unsupported") return null;
|
||||
if (list.status === "loading") return <SuggestionsSkeleton />;
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
{list.rows.length > 0 ? (
|
||||
<View style={settingsStyles.card}>
|
||||
{list.rows.map((row, index) => (
|
||||
<SuggestionRow
|
||||
key={row.suggestion.rootPath}
|
||||
row={row}
|
||||
first={index === 0}
|
||||
providerLabels={list.providerLabels}
|
||||
onToggle={list.toggle}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
) : (
|
||||
<Text style={styles.statusText}>
|
||||
{t("onboarding.setup.steps.projects.suggestions.empty")}
|
||||
</Text>
|
||||
)}
|
||||
{list.reading.length > 0 ? <StillReading providers={list.reading} /> : null}
|
||||
{list.failed.map((provider) => (
|
||||
<ProviderFailure key={provider.provider} provider={provider} onRetry={list.retry} />
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionRow({
|
||||
row,
|
||||
first,
|
||||
providerLabels,
|
||||
onToggle,
|
||||
}: {
|
||||
row: SuggestionRowModel;
|
||||
first: boolean;
|
||||
providerLabels: ReadonlyMap<string, string>;
|
||||
onToggle: (suggestion: ProjectSuggestion) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { suggestion, checked, addFailure } = row;
|
||||
const handlePress = useCallback(() => onToggle(suggestion), [onToggle, suggestion]);
|
||||
const accessibilityState = useMemo(() => ({ checked }), [checked]);
|
||||
const lastActivity = useTimeAgo(useMemo(() => new Date(suggestion.lastActivityAt), [suggestion]));
|
||||
const providers = suggestion.providerIds.map((id) => providerLabels.get(id) ?? id).join(" · ");
|
||||
const rowStyle = useCallback(
|
||||
({ hovered }: PressableStateCallbackType & { hovered?: boolean }) => [
|
||||
settingsStyles.row,
|
||||
styles.row,
|
||||
!first && settingsStyles.rowBorder,
|
||||
hovered && styles.rowHovered,
|
||||
],
|
||||
[first],
|
||||
);
|
||||
return (
|
||||
<PressHighlight
|
||||
style={rowStyle}
|
||||
onPress={handlePress}
|
||||
accessibilityRole="checkbox"
|
||||
accessibilityLabel={suggestion.displayName}
|
||||
accessibilityState={accessibilityState}
|
||||
aria-checked={checked}
|
||||
>
|
||||
<View style={checked ? styles.checkboxChecked : styles.checkbox}>
|
||||
{checked ? (
|
||||
<ThemedCheck size={12} strokeWidth={3} uniProps={accentForegroundMapping} />
|
||||
) : null}
|
||||
</View>
|
||||
<View style={settingsStyles.rowContent}>
|
||||
<Text style={settingsStyles.rowTitle} numberOfLines={1}>
|
||||
{suggestion.displayName}
|
||||
</Text>
|
||||
<Text style={settingsStyles.rowHint} numberOfLines={1}>
|
||||
{suggestion.rootPath}
|
||||
</Text>
|
||||
{addFailure === undefined ? null : (
|
||||
<Text style={settingsStyles.rowError}>
|
||||
{addFailure ?? t("onboarding.setup.steps.projects.suggestions.addFailed")}
|
||||
</Text>
|
||||
)}
|
||||
</View>
|
||||
<View style={styles.meta}>
|
||||
<Text style={styles.metaText} numberOfLines={1}>
|
||||
{providers}
|
||||
</Text>
|
||||
<Text style={styles.metaText} numberOfLines={1}>
|
||||
{t("onboarding.setup.steps.projects.suggestions.sessions", {
|
||||
count: suggestion.sessionCount,
|
||||
})}
|
||||
{" · "}
|
||||
{lastActivity}
|
||||
</Text>
|
||||
</View>
|
||||
</PressHighlight>
|
||||
);
|
||||
}
|
||||
|
||||
function StillReading({ providers }: { providers: readonly ProviderSuggestions[] }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<View style={styles.statusRow}>
|
||||
<ThemedLoadingSpinner uniProps={mutedMapping} />
|
||||
<Text style={styles.statusText}>
|
||||
{t("onboarding.setup.steps.projects.suggestions.stillReading", {
|
||||
providers: providers.map((provider) => provider.label).join(", "),
|
||||
})}
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function ProviderFailure({
|
||||
provider,
|
||||
onRetry,
|
||||
}: {
|
||||
provider: ProviderSuggestions;
|
||||
onRetry: (provider: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const handleRetry = useCallback(() => onRetry(provider.provider), [onRetry, provider.provider]);
|
||||
return (
|
||||
<View style={styles.statusRow}>
|
||||
<Text style={styles.statusText}>
|
||||
{t("onboarding.setup.steps.projects.suggestions.readFailed", { provider: provider.label })}
|
||||
</Text>
|
||||
<Button variant="ghost" size="sm" onPress={handleRetry}>
|
||||
{t("common.actions.retry")}
|
||||
</Button>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
function SuggestionsSkeleton() {
|
||||
const { t } = useTranslation();
|
||||
const pulse = useSkeletonPulse();
|
||||
return (
|
||||
<View
|
||||
style={settingsStyles.card}
|
||||
accessibilityLabel={t("onboarding.setup.steps.projects.suggestions.loading")}
|
||||
testID="project-suggestions-skeleton"
|
||||
>
|
||||
{SKELETON_ROWS.map((row) => (
|
||||
<View
|
||||
key={row}
|
||||
style={[settingsStyles.row, styles.row, row > 0 && settingsStyles.rowBorder]}
|
||||
>
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonCheckbox} />
|
||||
<View style={styles.skeletonText}>
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonTitle} />
|
||||
<SkeletonPulse pulse={pulse} style={styles.skeletonPath} />
|
||||
</View>
|
||||
</View>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKBOX_SIZE = 16;
|
||||
|
||||
const styles = StyleSheet.create((theme) => ({
|
||||
container: {
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
row: {
|
||||
justifyContent: "flex-start",
|
||||
gap: theme.spacing[3],
|
||||
},
|
||||
rowHovered: {
|
||||
backgroundColor: theme.colors.surface2,
|
||||
},
|
||||
checkbox: {
|
||||
width: CHECKBOX_SIZE,
|
||||
height: CHECKBOX_SIZE,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.borderAccent,
|
||||
},
|
||||
checkboxChecked: {
|
||||
width: CHECKBOX_SIZE,
|
||||
height: CHECKBOX_SIZE,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
borderWidth: theme.borderWidth[1],
|
||||
borderColor: theme.colors.accent,
|
||||
backgroundColor: theme.colors.accent,
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
},
|
||||
meta: {
|
||||
alignItems: "flex-end",
|
||||
gap: theme.spacing[1],
|
||||
},
|
||||
metaText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
statusRow: {
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
statusText: {
|
||||
color: theme.colors.foregroundMuted,
|
||||
fontSize: theme.fontSize.sm,
|
||||
},
|
||||
skeletonCheckbox: {
|
||||
width: CHECKBOX_SIZE,
|
||||
height: CHECKBOX_SIZE,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
skeletonText: {
|
||||
flex: 1,
|
||||
gap: theme.spacing[2],
|
||||
},
|
||||
skeletonTitle: {
|
||||
width: 140,
|
||||
height: 12,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
skeletonPath: {
|
||||
width: 220,
|
||||
height: 10,
|
||||
borderRadius: theme.borderRadius.sm,
|
||||
},
|
||||
}));
|
||||
@@ -0,0 +1,130 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
addSuggestedProjects,
|
||||
isSuggestionChecked,
|
||||
mergeProviderSuggestions,
|
||||
type ProjectSuggestion,
|
||||
} from "./suggestions";
|
||||
|
||||
const NOW = new Date("2026-10-31T12:00:00.000Z");
|
||||
|
||||
function suggestion(
|
||||
rootPath: string,
|
||||
overrides: Partial<ProjectSuggestion> = {},
|
||||
): ProjectSuggestion {
|
||||
return {
|
||||
rootPath,
|
||||
displayName: rootPath.split("/").at(-1) ?? rootPath,
|
||||
sessionCount: 1,
|
||||
providerIds: ["claude"],
|
||||
lastActivityAt: "2026-10-30T12:00:00.000Z",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mergeProviderSuggestions", () => {
|
||||
it("shows one row per folder across providers that answered, newest first", () => {
|
||||
const rows = mergeProviderSuggestions(
|
||||
[
|
||||
{
|
||||
provider: "claude",
|
||||
label: "Claude",
|
||||
status: "ready",
|
||||
suggestions: [
|
||||
suggestion("/dev/app", { sessionCount: 2, lastActivityAt: "2026-10-29T00:00:00.000Z" }),
|
||||
suggestion("/dev/docs", { lastActivityAt: "2026-10-20T00:00:00.000Z" }),
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "codex",
|
||||
label: "Codex",
|
||||
status: "failed",
|
||||
message: "Timed out",
|
||||
suggestions: [
|
||||
suggestion("/dev/app", {
|
||||
sessionCount: 3,
|
||||
providerIds: ["codex"],
|
||||
lastActivityAt: "2026-10-30T00:00:00.000Z",
|
||||
}),
|
||||
],
|
||||
},
|
||||
{ provider: "opencode", label: "OpenCode", status: "loading" },
|
||||
],
|
||||
new Set(),
|
||||
);
|
||||
|
||||
expect(rows).toEqual([
|
||||
suggestion("/dev/app", {
|
||||
sessionCount: 5,
|
||||
providerIds: ["claude", "codex"],
|
||||
lastActivityAt: "2026-10-30T00:00:00.000Z",
|
||||
}),
|
||||
suggestion("/dev/docs", { lastActivityAt: "2026-10-20T00:00:00.000Z" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops folders that are already projects", () => {
|
||||
const rows = mergeProviderSuggestions(
|
||||
[
|
||||
{
|
||||
provider: "claude",
|
||||
label: "Claude",
|
||||
status: "ready",
|
||||
suggestions: [suggestion("/dev/app"), suggestion("/dev/docs")],
|
||||
},
|
||||
],
|
||||
new Set(["/dev/app"]),
|
||||
);
|
||||
|
||||
expect(rows.map((row) => row.rootPath)).toEqual(["/dev/docs"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSuggestionChecked", () => {
|
||||
const busyRecent = suggestion("/dev/app", { sessionCount: 3 });
|
||||
const quiet = suggestion("/dev/docs", { sessionCount: 2 });
|
||||
const busyOld = suggestion("/dev/legacy", {
|
||||
sessionCount: 9,
|
||||
lastActivityAt: "2026-09-01T00:00:00.000Z",
|
||||
});
|
||||
|
||||
it("starts checked for folders used at least three times in the last 30 days", () => {
|
||||
expect([busyRecent, quiet, busyOld].map((row) => isSuggestionChecked(row, {}, NOW))).toEqual([
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the user's choice once they toggle a row", () => {
|
||||
const choices = { "/dev/app": false, "/dev/docs": true };
|
||||
expect(isSuggestionChecked(busyRecent, choices, NOW)).toBe(false);
|
||||
expect(isSuggestionChecked(quiet, choices, NOW)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("addSuggestedProjects", () => {
|
||||
it("adds every folder and reports the ones the host refused", async () => {
|
||||
const added: string[] = [];
|
||||
const failures = await addSuggestedProjects(
|
||||
["/dev/app", "/dev/gone", "/dev/docs"],
|
||||
async (path) => {
|
||||
if (path === "/dev/gone") return { ok: false, error: "Directory not found" };
|
||||
added.push(path);
|
||||
return { ok: true };
|
||||
},
|
||||
);
|
||||
|
||||
expect(added).toEqual(["/dev/app", "/dev/docs"]);
|
||||
expect(failures).toEqual(new Map([["/dev/gone", "Directory not found"]]));
|
||||
});
|
||||
|
||||
it("keeps going when adding one folder throws", async () => {
|
||||
const failures = await addSuggestedProjects(["/dev/app", "/dev/docs"], async (path) => {
|
||||
if (path === "/dev/app") throw new Error("Host disconnected");
|
||||
return { ok: true };
|
||||
});
|
||||
|
||||
expect(failures).toEqual(new Map([["/dev/app", "Host disconnected"]]));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import type { ProjectSuggestion } from "@getpaseo/protocol/messages";
|
||||
|
||||
export type { ProjectSuggestion };
|
||||
|
||||
const PRESELECT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const PRESELECT_MIN_SESSIONS = 3;
|
||||
|
||||
/** One provider's answer. Each provider is asked separately so a slow one never holds up the rest. */
|
||||
export type ProviderSuggestions =
|
||||
| { provider: string; label: string; status: "loading" }
|
||||
| { provider: string; label: string; status: "ready"; suggestions: ProjectSuggestion[] }
|
||||
| {
|
||||
provider: string;
|
||||
label: string;
|
||||
status: "failed";
|
||||
message: string;
|
||||
suggestions: ProjectSuggestion[];
|
||||
};
|
||||
|
||||
/** Combines the providers that answered into one row per folder, most recent first. */
|
||||
export function mergeProviderSuggestions(
|
||||
providers: readonly ProviderSuggestions[],
|
||||
addedRootPaths: ReadonlySet<string>,
|
||||
): ProjectSuggestion[] {
|
||||
const byRoot = new Map<string, ProjectSuggestion>();
|
||||
for (const provider of providers) {
|
||||
if (provider.status === "loading") continue;
|
||||
for (const suggestion of provider.suggestions) {
|
||||
if (addedRootPaths.has(suggestion.rootPath)) continue;
|
||||
const existing = byRoot.get(suggestion.rootPath);
|
||||
byRoot.set(
|
||||
suggestion.rootPath,
|
||||
existing ? combineSuggestions(existing, suggestion) : suggestion,
|
||||
);
|
||||
}
|
||||
}
|
||||
return Array.from(byRoot.values()).sort((a, b) =>
|
||||
b.lastActivityAt.localeCompare(a.lastActivityAt),
|
||||
);
|
||||
}
|
||||
|
||||
function combineSuggestions(a: ProjectSuggestion, b: ProjectSuggestion): ProjectSuggestion {
|
||||
return {
|
||||
...a,
|
||||
sessionCount: a.sessionCount + b.sessionCount,
|
||||
providerIds: Array.from(new Set([...a.providerIds, ...b.providerIds])).sort(),
|
||||
lastActivityAt: a.lastActivityAt > b.lastActivityAt ? a.lastActivityAt : b.lastActivityAt,
|
||||
};
|
||||
}
|
||||
|
||||
/** Folders the user worked in often and recently start checked. */
|
||||
export function isPreselected(suggestion: ProjectSuggestion, now: Date): boolean {
|
||||
return (
|
||||
suggestion.sessionCount >= PRESELECT_MIN_SESSIONS &&
|
||||
now.getTime() - Date.parse(suggestion.lastActivityAt) <= PRESELECT_WINDOW_MS
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks start from the preselection; once the user toggles a row, their choice sticks even as
|
||||
* more providers answer and the row's counts change.
|
||||
*/
|
||||
export function isSuggestionChecked(
|
||||
suggestion: ProjectSuggestion,
|
||||
choices: Readonly<Record<string, boolean>>,
|
||||
now: Date,
|
||||
): boolean {
|
||||
return choices[suggestion.rootPath] ?? isPreselected(suggestion, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds each folder in turn and returns the ones that failed, with the host's message when it gave
|
||||
* one. Sequential so the sidebar fills in the order the user sees.
|
||||
*/
|
||||
export async function addSuggestedProjects(
|
||||
rootPaths: readonly string[],
|
||||
addProject: (rootPath: string) => Promise<{ ok: true } | { ok: false; error: string | null }>,
|
||||
): Promise<Map<string, string | null>> {
|
||||
const failures = new Map<string, string | null>();
|
||||
for (const rootPath of rootPaths) {
|
||||
try {
|
||||
const result = await addProject(rootPath);
|
||||
if (!result.ok) failures.set(rootPath, result.error);
|
||||
} catch (error) {
|
||||
failures.set(rootPath, error instanceof Error ? error.message : null);
|
||||
}
|
||||
}
|
||||
return failures;
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useOpenProject } from "@/hooks/use-open-project";
|
||||
import { useProjectRootPaths } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
addSuggestedProjects,
|
||||
isSuggestionChecked,
|
||||
mergeProviderSuggestions,
|
||||
type ProjectSuggestion,
|
||||
type ProviderSuggestions,
|
||||
} from "./suggestions";
|
||||
import { useProviderSuggestions } from "./use-provider-suggestions";
|
||||
|
||||
export interface SuggestionRow {
|
||||
suggestion: ProjectSuggestion;
|
||||
checked: boolean;
|
||||
/** Undefined when the folder was not attempted; null when the host gave no reason. */
|
||||
addFailure: string | null | undefined;
|
||||
}
|
||||
|
||||
export type SuggestionList =
|
||||
| { status: "unsupported" }
|
||||
/** Nothing to show yet: the provider list or every provider's first answer is pending. */
|
||||
| { status: "loading" }
|
||||
| {
|
||||
status: "ready";
|
||||
rows: SuggestionRow[];
|
||||
providerLabels: ReadonlyMap<string, string>;
|
||||
reading: ProviderSuggestions[];
|
||||
failed: ProviderSuggestions[];
|
||||
toggle: (suggestion: ProjectSuggestion) => void;
|
||||
retry: (provider: string) => void;
|
||||
};
|
||||
|
||||
export interface ProjectSuggestionsController {
|
||||
list: SuggestionList;
|
||||
checkedCount: number;
|
||||
isAdding: boolean;
|
||||
/** Adds every checked folder. Resolves true when all of them were added. */
|
||||
addChecked: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The suggestion list and the user's selection. Whoever owns the step's primary action holds
|
||||
* this, so proceeding can add what is checked; calling it early also starts reading history.
|
||||
*/
|
||||
export function useProjectSuggestions(serverId: string): ProjectSuggestionsController {
|
||||
const state = useProviderSuggestions(serverId);
|
||||
const projectRootPaths = useProjectRootPaths(serverId);
|
||||
const openProject = useOpenProject(serverId);
|
||||
const [now] = useState(() => new Date());
|
||||
const [choices, setChoices] = useState<Record<string, boolean>>({});
|
||||
const [addFailures, setAddFailures] = useState<Map<string, string | null>>(new Map());
|
||||
|
||||
const providers = state.status === "ready" ? state.providers : null;
|
||||
const suggestions = useMemo(
|
||||
() => (providers ? mergeProviderSuggestions(providers, new Set(projectRootPaths)) : []),
|
||||
[providers, projectRootPaths],
|
||||
);
|
||||
const checkedRootPaths = useMemo(
|
||||
() =>
|
||||
suggestions
|
||||
.filter((suggestion) => isSuggestionChecked(suggestion, choices, now))
|
||||
.map((suggestion) => suggestion.rootPath),
|
||||
[suggestions, choices, now],
|
||||
);
|
||||
|
||||
const toggle = useCallback(
|
||||
(suggestion: ProjectSuggestion) => {
|
||||
setChoices((current) => ({
|
||||
...current,
|
||||
[suggestion.rootPath]: !isSuggestionChecked(suggestion, current, now),
|
||||
}));
|
||||
},
|
||||
[now],
|
||||
);
|
||||
|
||||
const { mutateAsync, isPending } = useMutation({
|
||||
mutationFn: (rootPaths: string[]) => addSuggestedProjects(rootPaths, openProject),
|
||||
onSuccess: setAddFailures,
|
||||
});
|
||||
const addChecked = useCallback(async () => {
|
||||
const failures = await mutateAsync(checkedRootPaths);
|
||||
return failures.size === 0;
|
||||
}, [mutateAsync, checkedRootPaths]);
|
||||
|
||||
const list = useMemo((): SuggestionList => {
|
||||
if (state.status !== "ready") return state;
|
||||
const reading = state.providers.filter((provider) => provider.status === "loading");
|
||||
if (suggestions.length === 0 && reading.length > 0) return { status: "loading" };
|
||||
return {
|
||||
status: "ready",
|
||||
rows: suggestions.map((suggestion) => ({
|
||||
suggestion,
|
||||
checked: checkedRootPaths.includes(suggestion.rootPath),
|
||||
addFailure: addFailures.get(suggestion.rootPath),
|
||||
})),
|
||||
providerLabels: new Map(
|
||||
state.providers.map((provider) => [provider.provider, provider.label]),
|
||||
),
|
||||
reading,
|
||||
failed: state.providers.filter((provider) => provider.status === "failed"),
|
||||
toggle,
|
||||
retry: state.retry,
|
||||
};
|
||||
}, [state, suggestions, checkedRootPaths, addFailures, toggle]);
|
||||
|
||||
return { list, checkedCount: checkedRootPaths.length, isAdding: isPending, addChecked };
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useFetchQueries } from "@/data/query";
|
||||
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
|
||||
import { useHostFeature } from "@/runtime/host-features";
|
||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import type { ProviderSuggestions } from "./suggestions";
|
||||
|
||||
type SuggestionsResponse = Awaited<ReturnType<DaemonClient["listProjectSuggestions"]>>;
|
||||
|
||||
// History changes slowly. Opening the step still re-asks, keeping the rows already shown.
|
||||
const SUGGESTIONS_STALE_TIME_MS = 5 * 60 * 1000;
|
||||
|
||||
export type ProjectSuggestionsState =
|
||||
| { status: "unsupported" }
|
||||
/** The provider list itself has not arrived yet. */
|
||||
| { status: "loading" }
|
||||
| { status: "ready"; providers: ProviderSuggestions[]; retry: (provider: string) => void };
|
||||
|
||||
function suggestionsQueryKey(serverId: string, provider: string) {
|
||||
return ["project-suggestions", serverId, provider] as const;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asks the host for suggestions once per enabled provider, in parallel. The queries are shared, so
|
||||
* calling this early (when setup opens) means the Projects step usually finds them answered.
|
||||
*/
|
||||
export function useProviderSuggestions(serverId: string): ProjectSuggestionsState {
|
||||
const queryClient = useQueryClient();
|
||||
const supported = useHostFeature(serverId, "projectSuggestions");
|
||||
const client = useHostRuntimeClient(serverId);
|
||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
||||
const { entries } = useProvidersSnapshot(serverId, { enabled: supported });
|
||||
const providers = useMemo(
|
||||
() =>
|
||||
entries
|
||||
?.filter((entry) => entry.enabled !== false)
|
||||
.map((entry) => ({ id: entry.provider, label: entry.label ?? entry.provider })),
|
||||
[entries],
|
||||
);
|
||||
|
||||
const results = useFetchQueries<SuggestionsResponse>(
|
||||
(supported && providers ? providers : []).map((provider) => ({
|
||||
queryKey: suggestionsQueryKey(serverId, provider.id),
|
||||
dataShape: "value",
|
||||
enabled: Boolean(client) && isConnected,
|
||||
// A provider that cannot answer stays failed until the user presses Retry.
|
||||
retry: false,
|
||||
staleTimeMs: SUGGESTIONS_STALE_TIME_MS,
|
||||
queryFn: () => {
|
||||
if (!client) throw new Error("Host disconnected");
|
||||
return client.listProjectSuggestions({ providers: [provider.id] });
|
||||
},
|
||||
})),
|
||||
);
|
||||
|
||||
const retry = useCallback(
|
||||
(provider: string) => {
|
||||
void queryClient.refetchQueries({ queryKey: suggestionsQueryKey(serverId, provider) });
|
||||
},
|
||||
[queryClient, serverId],
|
||||
);
|
||||
|
||||
return useMemo((): ProjectSuggestionsState => {
|
||||
if (!supported) return { status: "unsupported" };
|
||||
if (!providers) return { status: "loading" };
|
||||
return {
|
||||
status: "ready",
|
||||
providers: providers.map((provider, index) =>
|
||||
toProviderSuggestions(provider, results[index]),
|
||||
),
|
||||
retry,
|
||||
};
|
||||
}, [supported, providers, results, retry]);
|
||||
}
|
||||
|
||||
function toProviderSuggestions(
|
||||
provider: { id: string; label: string },
|
||||
result: { data?: SuggestionsResponse; error: Error | null; isFetching: boolean } | undefined,
|
||||
): ProviderSuggestions {
|
||||
const base = { provider: provider.id, label: provider.label };
|
||||
const data = result?.data;
|
||||
const providerError = data?.providerErrors.find((error) => error.provider === provider.id);
|
||||
const failure = result?.error?.message ?? providerError?.message ?? null;
|
||||
// A retry shows as reading again; a background refresh of good results keeps the rows.
|
||||
if (!result || (result.isFetching && (failure !== null || !data))) {
|
||||
return { ...base, status: "loading" };
|
||||
}
|
||||
if (failure !== null) {
|
||||
return { ...base, status: "failed", message: failure, suggestions: data?.suggestions ?? [] };
|
||||
}
|
||||
if (!data) return { ...base, status: "loading" };
|
||||
return { ...base, status: "ready", suggestions: data.suggestions };
|
||||
}
|
||||
Reference in New Issue
Block a user