diff --git a/apps/mobile/src/state/notes.ts b/apps/mobile/src/state/notes.ts index 68ee42ea1aa2..10b70d1fa4a2 100644 --- a/apps/mobile/src/state/notes.ts +++ b/apps/mobile/src/state/notes.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createNotesEnvironmentAtoms } from "@t3tools/client-runtime/state/notes"; import type { EnvironmentId, NoteSummary } from "@t3tools/contracts"; +import { isAnswerExpected } from "@t3tools/client-runtime/connection"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -15,11 +16,11 @@ const allNotesAtom = Atom.family((query: string) => Atom.make((get) => { const notes: EnvironmentNote[] = []; let isPending = false; - for (const environmentId of get(environmentPresentations.presentationsAtom).keys()) { + for (const [environmentId, presentation] of get(environmentPresentations.presentationsAtom)) { const result = get(notesEnvironment.list({ environmentId, input: query ? { query } : {} })); const value = Option.getOrNull(AsyncResult.value(result)); if (value === null) { - isPending ||= result._tag !== "Failure"; + isPending ||= result._tag !== "Failure" && isAnswerExpected(presentation); continue; } notes.push(...value.notes.map((note) => ({ ...note, environmentId }))); diff --git a/apps/web/src/components/settings/SkillsSettings.tsx b/apps/web/src/components/settings/SkillsSettings.tsx index f6e28f845999..65efcef891c7 100644 --- a/apps/web/src/components/settings/SkillsSettings.tsx +++ b/apps/web/src/components/settings/SkillsSettings.tsx @@ -1,5 +1,12 @@ import { normalizeSkillVisibilityPath } from "@t3tools/client-runtime/providerSkills"; -import { CopyIcon, EyeIcon, FolderOpenIcon, RefreshCwIcon } from "lucide-react"; +import { + CopyIcon, + EyeIcon, + FolderOpenIcon, + PlusIcon, + SearchIcon, + TriangleAlertIcon, +} from "lucide-react"; import { useEffect, useState } from "react"; import { useProjectFileQuery } from "../files/projectFilesQueryState"; @@ -12,9 +19,11 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { Badge } from "../ui/badge"; import { Button } from "../ui/button"; import { Dialog, DialogHeader, DialogPopup, DialogTitle } from "../ui/dialog"; -import { Input } from "../ui/input"; +import { InputGroup, InputGroupAddon, InputGroupInput } from "../ui/input-group"; +import { RefreshIcon } from "../ui/refresh-icon"; import { toastManager } from "../ui/toast"; -import { SettingsPageContainer } from "./settingsLayout"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { SettingsPageContainer, SettingsSection } from "./settingsLayout"; import { useSettingsScope } from "./SettingsScopeContext"; import { discoverLocalSkillFiles, @@ -109,158 +118,188 @@ export function SkillsSettings() { return ( -
-

Skills

-

- File skills in your home folders and selected projects. -

-
-
- - {filtered.length} {filtered.length === 1 ? "skill" : "skills"} - - setQuery(event.currentTarget.value)} - placeholder="Filter" - aria-label="Filter skills" - className="min-w-0 flex-1" - /> - - -
- {errors.length > 0 && ( -

- Some skills could not be loaded. {errors.join("; ")} -

- )} -

- Switches show or hide skills in this client's T3 skill pickers. Providers manage automatic - skill discovery. -

-
+ + + {filtered.length} {filtered.length === 1 ? "skill" : "skills"} + + + + + + setQuery(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Escape") setQuery(""); + }} + placeholder="Search skills" + aria-label="Search skills" + /> + + + setRevision((value) => value + 1)} + > + + + } + /> + Refresh skills + + + setAdding(true)} + > + + + } + /> + Add skill + +
+ } + > + {errors.map((error) => ( +

+ + {error} +

+ ))} {filtered.length === 0 ? (

{refreshing ? "Loading skills..." : normalizedQuery ? "No matching skills" - : "No file skills found for this selection"} + : "No skills in your home folders or selected projects"}

) : ( - filtered.map((skill) => ( -
-
- - - {skill.scope === "project" ? "Project" : "Personal"} - - - {skill.source} - - - (hiddenByEnvironment[skill.environmentId] ?? []).some( - (hidden) => - normalizeSkillVisibilityPath(hidden) === - normalizeSkillVisibilityPath(alias), - ), - ) - } - aria-label={`Show ${skill.name} in skill pickers`} - onCheckedChange={(checked) => - setVisible(skill.environmentId, skill.aliases, checked) - } - /> -
- {skill.description ? ( -

{skill.description}

- ) : null} -
-

- {skill.path} -

- {multipleEnvironments ? ( - - {skill.environmentLabel} + filtered.map((skill) => { + const visible = !skill.aliases.some((alias) => + (hiddenByEnvironment[skill.environmentId] ?? []).some( + (hidden) => + normalizeSkillVisibilityPath(hidden) === normalizeSkillVisibilityPath(alias), + ), + ); + return ( +
+
+ + + {skill.scope === "project" ? "Project" : "Personal"} + + + {skill.source} + + + setVisible(skill.environmentId, skill.aliases, checked) + } + /> + } + /> + + {visible ? "Shown in skill pickers" : "Hidden from skill pickers"} + + +
+ {skill.description ? ( +

{skill.description}

) : null} - - - {revealableEnvironments.has(skill.environmentId) ? ( +
+

+ {skill.path} +

+ {multipleEnvironments ? ( + + {skill.environmentLabel} + + ) : null} + - ) : null} + {revealableEnvironments.has(skill.environmentId) ? ( + + ) : null} +
-
- )) + ); + }) )} -
+ {adding && ( { const automations: EnvironmentAutomation[] = []; let isPending = false; - for (const environmentId of get(environmentPresentations.presentationsAtom).keys()) { + for (const [environmentId, presentation] of get(environmentPresentations.presentationsAtom)) { const result = get(automationsByEnvironment({ environmentId, input: {} })); const snapshot = Option.getOrNull(AsyncResult.value(result)); if (snapshot === null) { - isPending ||= result._tag !== "Failure"; + // An unreachable environment never answers; it must not hold the whole list in loading. + isPending ||= result._tag !== "Failure" && isAnswerExpected(presentation); continue; } for (const automation of snapshot.automations) { diff --git a/apps/web/src/state/notes.ts b/apps/web/src/state/notes.ts index c83e8741c458..7f1fc17e67e5 100644 --- a/apps/web/src/state/notes.ts +++ b/apps/web/src/state/notes.ts @@ -1,6 +1,7 @@ import { useAtomValue } from "@effect/atom-react"; import { createNotesEnvironmentAtoms } from "@t3tools/client-runtime/state/notes"; import type { EnvironmentId, NoteSummary } from "@t3tools/contracts"; +import { isAnswerExpected } from "@t3tools/client-runtime/connection"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -15,11 +16,11 @@ const allNotesAtom = Atom.family((query: string) => Atom.make((get) => { const notes: EnvironmentNote[] = []; let isPending = false; - for (const environmentId of get(environmentPresentations.presentationsAtom).keys()) { + for (const [environmentId, presentation] of get(environmentPresentations.presentationsAtom)) { const result = get(notesEnvironment.list({ environmentId, input: query ? { query } : {} })); const snapshot = Option.getOrNull(AsyncResult.value(result)); if (snapshot === null) { - isPending ||= result._tag !== "Failure"; + isPending ||= result._tag !== "Failure" && isAnswerExpected(presentation); continue; } notes.push(...snapshot.notes.map((note) => ({ ...note, environmentId }))); diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index f7586c5e3dbf..463920269c44 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -25,6 +25,15 @@ export interface EnvironmentPresentation { readonly serverConfig: ServerConfig | null; } +/** + * Whether a pending subscription on this environment is worth showing as loading. + * Offline or failing environments never answer and must not hold a merged list in loading. + */ +export function isAnswerExpected(presentation: EnvironmentPresentation): boolean { + const { phase } = presentation.connection; + return phase === "connected" || phase === "connecting"; +} + export function presentConnectionState( state: SupervisorConnectionState, ): EnvironmentConnectionPresentation {