import { useAtomValue } from "@effect/atom-react";import { isAtomCommandInterrupted, mapAtomCommandResult, settlePromise, squashAtomCommandFailure, type AtomCommandResult,} from "@t3tools/client-runtime/state/runtime";import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";import { AsyncResult } from "effect/unstable/reactivity";import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings,} from "../../logicalProject";import type { ContextMenuItem, ModelSelection, ProjectIconOverride, ProviderDriverKind, SidebarProjectGroupingMode, T3ProjectFileScript, ThreadEnvMode,} from "@t3tools/contracts";import { resolveEnvModeLabel } from "../BranchToolbar.logic";import { createModelSelection } from "@t3tools/shared/model";import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings";import { useCanGoBack, useNavigate } from "@tanstack/react-router";import * as Cause from "effect/Cause";import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react";import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent,} from "react"; import { useComposerDraftStore } from "../../composerDraftStore";import { isElectron } from "../../env";import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, usePrimarySettings,} from "../../hooks/useSettings";import { useCopyToClipboard } from "../../hooks/useCopyToClipboard";import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts";import { shortcutLabelForCommand } from "../../keybindings";import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings";import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads";import { readLocalApi } from "../../localApi";import { buildProjectScript, commandForProjectScript, nextProjectScriptId,} from "../../projectScripts";import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings";import { applyProviderInstanceSettings, deriveProviderInstanceEntries, resolveDefaultProviderModelSelection, sortProviderInstanceEntries,} from "../../providerInstances";import { getCustomModelOptionsByInstance } from "../../modelSelection";import { buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot,} from "../../sidebarProjectGrouping";import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments";import { useProjects, useThreadShells } from "../../state/entities";import { projectEnvironment } from "../../state/projects";import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server";import { useAtomCommand } from "../../state/use-atom-command";import { ProviderModelPicker } from "../chat/ProviderModelPicker";import { TraitsPicker } from "../chat/TraitsPicker";import { ProjectFavicon } from "../ProjectFavicon";import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, ProjectScriptEditorDialog, ScriptIcon, type NewProjectScriptInput, type ProjectScriptEditorRequest,} from "../projectScriptEditor";import { Button } from "../ui/button";import { Input } from "../ui/input";import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuTrigger,} from "../ui/menu";import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";import { SidebarInset } from "../ui/sidebar";import { Switch } from "../ui/switch";import { stackedThreadToast, toastManager } from "../ui/toast";import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator,} from "../WorkspaceBreadcrumb";import { WorkspacePageHeader } from "../WorkspacePageHeader";import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection,} from "./settingsLayout";import { canPickExternalProjectFavicon, ProjectFaviconPickerDialog,} from "./ProjectFaviconPickerDialog";import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; const ProjectIconPickerDialog = lazy(() => import("./ProjectIconPickerDialog").then((module) => ({ default: module.ProjectIconPickerDialog, })),); export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate",}; /** Logical project groups for the settings page, sorted by display name. */export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { const projects = useProjects(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const environmentLabelById = useMemo( () => new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ), [environments], ); return useMemo( () => buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }).sort((a, b) => a.displayName.localeCompare(b.displayName)), [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects], );} function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`;} export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { const navigate = useNavigate(); const canGoBack = useCanGoBack(); const navigateBackWithinApp = useCallback(() => { if (canGoBack) { window.history.back(); return; } void navigate({ to: "/" }); }, [canGoBack, navigate]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; if (event.key !== "Escape") return; event.preventDefault(); const activeElement = document.activeElement; if (activeElement instanceof HTMLElement) { activeElement.blur(); } navigateBackWithinApp(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [navigateBackWithinApp]); return ( <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> <WorkspacePageHeader electron={isElectron}> <ProjectSettingsBreadcrumb projectKey={projectKey} /> </WorkspacePageHeader> <ProjectSettingsPanel projectKey={projectKey} /> </div> </SidebarInset> );} function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; const openProjectMenu = (event: ReactMouseEvent<HTMLButtonElement>) => { const api = readLocalApi(); if (!api) return; const rect = event.currentTarget.getBoundingClientRect(); const items: ContextMenuItem<string>[] = groups.map((group) => ({ id: group.projectKey, label: group.displayName, })); void settlePromise(() => api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), ).then((clicked) => { if (clicked._tag === "Failure" || clicked.value === null) return; void navigate({ to: "/projects/$projectKey", params: { projectKey: clicked.value }, replace: true, hashScrollIntoView: false, }); }); }; return ( <WorkspaceBreadcrumb ariaLabel="Project settings breadcrumb"> <WorkspaceBreadcrumbItem>Projects</WorkspaceBreadcrumbItem> <WorkspaceBreadcrumbSeparator /> <WorkspaceBreadcrumbItem current> {selected ? ( <button type="button" aria-haspopup="menu" aria-label="Switch project" onClick={openProjectMenu} className="group/project-title inline-flex min-w-0 max-w-64 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" > <span className="min-w-0 truncate">{selected.displayName}</span> <ChevronDownIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/project-title:opacity-100 group-focus-visible/project-title:opacity-100" /> </button> ) : ( <span className="truncate text-muted-foreground">Unavailable project</span> )} </WorkspaceBreadcrumbItem> </WorkspaceBreadcrumb> );} export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null); useEffect(() => { if (!selected) return; lastSelectionRef.current = { key: selected.projectKey, memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey), }; }, [selected]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { if (selected !== null) return; const last = lastSelectionRef.current; if (last?.key !== projectKey) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ to: "/projects/$projectKey", params: { projectKey: successor.projectKey }, replace: true, hashScrollIntoView: false, }); } }, [groups, navigate, projectKey, selected]); if (!selected) { return ( <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground"> {groups.length === 0 ? "Add a project from the sidebar to configure it here." : "This project is no longer available."} </div> ); } return <ProjectDetail key={selected.projectKey} group={selected} />;} function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const representative = group.memberProjects.find( (member) => member.environmentId === group.environmentId && member.id === group.id, ) ?? group.memberProjects[0]!; const settings = usePrimarySettings(); // Provider instances and model options belong to the environment that runs // the project's threads. The hosted app has no primary environment, so // reading them from there would show "No providers available" everywhere. const projectSettings = useEnvironmentSettings(representative.environmentId); const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ?? EMPTY_SERVER_PROVIDERS; const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { reportFailure: false, }); const projectNameEditedRef = useRef(false); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", title: "Path copied", description: path }); }, onError: (error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to copy path", description: error instanceof Error ? error.message : "An error occurred.", }), ); }, }); const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; const pickProjectFavicon = typeof window !== "undefined" && group.memberProjects.every( (member) => member.environmentId === primaryEnvironmentId && canPickExternalProjectFavicon(member.workspaceRoot, navigator.platform), ) ? window.desktopBridge?.pickProjectFavicon : undefined; const threadCountByMember = useMemo(() => { const counts = new Map<string, number>(); for (const thread of threads) { const key = `${thread.environmentId}:${thread.projectId}`; counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; }, [threads]); const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title, description: error instanceof Error ? error.message : "An error occurred.", }), ); }, []); // Group-shared fields live on each physical project record, so a // group-level edit fans out to every member. const updateAllMembers = useCallback( async ( input: Partial<{ title: string; defaultModelSelection: ModelSelection | null; defaultThreadEnvMode: ThreadEnvMode | null; autoPull: boolean; faviconPath: string | null; projectIcon: ProjectIconOverride | null; }>, failureTitle: string, ): Promise<AtomCommandResult<void, unknown>> => { for (const member of group.memberProjects) { const result = mapAtomCommandResult( await updateProject({ environmentId: member.environmentId, input: { projectId: member.id, ...input }, }), () => undefined, ); if (result._tag === "Failure") { // A partial fan-out is possible: earlier members already took the // write. Name the environment so the user knows where it stopped. reportFailure( group.memberProjects.length > 1 ? `${failureTitle} on ${member.environmentLabel ?? "the current environment"}` : failureTitle, result, ); return result; } } return AsyncResult.success(undefined); }, [group.memberProjects, reportFailure, updateProject], ); const renameGroup = useCallback( async (nextTitle: string, wasEdited: boolean) => { const title = nextTitle.trim(); if (!title) { toastManager.add({ type: "warning", title: "Project title cannot be empty" }); return; } if ( !projectGroupTitleNeedsUpdate( group.memberProjects.map((member) => member.title), title, wasEdited, ) ) { return; } await updateAllMembers({ title }, "Failed to rename project"); }, [group.memberProjects, updateAllMembers], ); // ----- default model ----- const storedSelection = representative.defaultModelSelection; const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); const resolvedInstanceId = resolvedSelection?.instanceId ?? null; const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( () => sortProviderInstanceEntries( applyProviderInstanceSettings( deriveProviderInstanceEntries(serverProviders), projectSettings, ), ), [serverProviders, projectSettings], ); const modelOptionsByInstance = useMemo( () => getCustomModelOptionsByInstance( projectSettings, serverProviders, resolvedInstanceId, resolvedModel, ), [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); const setDefaultModel = useCallback( (selection: ModelSelection | null) => void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), [updateAllMembers], ); // ----- new-thread workspace mode ----- const storedEnvMode = representative.defaultThreadEnvMode ?? null; const setDefaultThreadEnvMode = useCallback( (mode: ThreadEnvMode | null) => void updateAllMembers( { defaultThreadEnvMode: mode }, "Failed to update new-thread workspace", ), [updateAllMembers], ); const autoPull = representative.autoPull ?? false; const setAutoPull = useCallback( (enabled: boolean) => void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), [updateAllMembers], ); // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [isSavingFavicon, setIsSavingFavicon] = useState(false); const savingFaviconRef = useRef(false); const setProjectIcon = useCallback( async (input: { faviconPath: string | null; projectIcon: ProjectIconOverride | null }) => { if (savingFaviconRef.current) return; savingFaviconRef.current = true; setIsSavingFavicon(true); try { await updateAllMembers(input, "Failed to update project icon"); } finally {Lines 1–500 of 1299. Load more
import { useAtomValue } from "@effect/atom-react"; import { isAtomCommandInterrupted, mapAtomCommandResult, settlePromise, squashAtomCommandFailure, type AtomCommandResult, } from "@t3tools/client-runtime/state/runtime"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import { AsyncResult } from "effect/unstable/reactivity"; import { deriveProjectGroupingOverrideKey, selectProjectGroupingSettings, } from "../../logicalProject"; import type { ContextMenuItem, ModelSelection, ProjectIconOverride, ProviderDriverKind, SidebarProjectGroupingMode, T3ProjectFileScript, ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { useCanGoBack, useNavigate } from "@tanstack/react-router"; import * as Cause from "effect/Cause"; import { ChevronDownIcon, CopyIcon, PlusIcon, SettingsIcon, Trash2Icon } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState, type MouseEvent as ReactMouseEvent, } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; import { isElectron } from "../../env"; import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, usePrimarySettings, } from "../../hooks/useSettings"; import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { shortcutLabelForCommand } from "../../keybindings"; import { keybindingValueForCommand } from "../../lib/projectScriptKeybindings"; import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; import { readLocalApi } from "../../localApi"; import { buildProjectScript, commandForProjectScript, nextProjectScriptId, } from "../../projectScripts"; import { decodeProjectScriptKeybindingRule } from "../../lib/projectScriptKeybindings"; import { applyProviderInstanceSettings, deriveProviderInstanceEntries, resolveDefaultProviderModelSelection, sortProviderInstanceEntries, } from "../../providerInstances"; import { getCustomModelOptionsByInstance } from "../../modelSelection"; import { buildSidebarProjectSnapshots, type SidebarProjectGroupMember, type SidebarProjectSnapshot, } from "../../sidebarProjectGrouping"; import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; import { useProjects, useThreadShells } from "../../state/entities"; import { projectEnvironment } from "../../state/projects"; import { EMPTY_SERVER_PROVIDERS, serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { ProviderModelPicker } from "../chat/ProviderModelPicker"; import { TraitsPicker } from "../chat/TraitsPicker"; import { ProjectFavicon } from "../ProjectFavicon"; import { EMPTY_PROJECT_SCRIPT_INPUT, editorRequestForScript, ProjectScriptEditorDialog, ScriptIcon, type NewProjectScriptInput, type ProjectScriptEditorRequest, } from "../projectScriptEditor"; import { Button } from "../ui/button"; import { Input } from "../ui/input"; import { Menu, MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, MenuSeparator, MenuTrigger, } from "../ui/menu"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; import { SidebarInset } from "../ui/sidebar"; import { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { WorkspaceBreadcrumb, WorkspaceBreadcrumbItem, WorkspaceBreadcrumbSeparator, } from "../WorkspaceBreadcrumb"; import { WorkspacePageHeader } from "../WorkspacePageHeader"; import { SETTINGS_PICKER_TRIGGER_CLASSNAME, SettingResetButton, SettingsPageContainer, SettingsRow, SettingsSection, } from "./settingsLayout"; import { canPickExternalProjectFavicon, ProjectFaviconPickerDialog, } from "./ProjectFaviconPickerDialog"; import { projectGroupTitleNeedsUpdate } from "./ProjectSettingsPanel.logic"; const ProjectIconPickerDialog = lazy(() => import("./ProjectIconPickerDialog").then((module) => ({ default: module.ProjectIconPickerDialog, })), ); export const PROJECT_GROUPING_MODE_LABELS: Record<SidebarProjectGroupingMode, string> = { repository: "Group by repository", repository_path: "Group by repository path", separate: "Keep separate", }; /** Logical project groups for the settings page, sorted by display name. */ export function useSettingsProjectGroups(): SidebarProjectSnapshot[] { const projects = useProjects(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const environmentLabelById = useMemo( () => new Map( environments.map((environment) => [environment.environmentId, environment.label] as const), ), [environments], ); return useMemo( () => buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }).sort((a, b) => a.displayName.localeCompare(b.displayName)), [environmentLabelById, primaryEnvironmentId, projectGroupingSettings, projects], ); } function memberKey(member: { environmentId: string; id: string }): string { return `${member.environmentId}:${member.id}`; } export function ProjectSettingsPage({ projectKey }: { projectKey: string }) { const navigate = useNavigate(); const canGoBack = useCanGoBack(); const navigateBackWithinApp = useCallback(() => { if (canGoBack) { window.history.back(); return; } void navigate({ to: "/" }); }, [canGoBack, navigate]); useEffect(() => { const onKeyDown = (event: KeyboardEvent) => { if (event.defaultPrevented) return; if (event.key !== "Escape") return; event.preventDefault(); const activeElement = document.activeElement; if (activeElement instanceof HTMLElement) { activeElement.blur(); } navigateBackWithinApp(); }; window.addEventListener("keydown", onKeyDown); return () => window.removeEventListener("keydown", onKeyDown); }, [navigateBackWithinApp]); return ( <SidebarInset className="h-dvh min-h-0 overflow-hidden overscroll-y-none bg-background text-foreground isolate"> <div className="flex min-h-0 min-w-0 flex-1 flex-col bg-background text-foreground"> <WorkspacePageHeader electron={isElectron}> <ProjectSettingsBreadcrumb projectKey={projectKey} /> </WorkspacePageHeader> <ProjectSettingsPanel projectKey={projectKey} /> </div> </SidebarInset> ); } function ProjectSettingsBreadcrumb({ projectKey }: { projectKey: string }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; const openProjectMenu = (event: ReactMouseEvent<HTMLButtonElement>) => { const api = readLocalApi(); if (!api) return; const rect = event.currentTarget.getBoundingClientRect(); const items: ContextMenuItem<string>[] = groups.map((group) => ({ id: group.projectKey, label: group.displayName, })); void settlePromise(() => api.contextMenu.show(items, { x: rect.left, y: rect.bottom + 4 }), ).then((clicked) => { if (clicked._tag === "Failure" || clicked.value === null) return; void navigate({ to: "/projects/$projectKey", params: { projectKey: clicked.value }, replace: true, hashScrollIntoView: false, }); }); }; return ( <WorkspaceBreadcrumb ariaLabel="Project settings breadcrumb"> <WorkspaceBreadcrumbItem>Projects</WorkspaceBreadcrumbItem> <WorkspaceBreadcrumbSeparator /> <WorkspaceBreadcrumbItem current> {selected ? ( <button type="button" aria-haspopup="menu" aria-label="Switch project" onClick={openProjectMenu} className="group/project-title inline-flex min-w-0 max-w-64 cursor-pointer items-center gap-1 rounded-sm text-left focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" > <span className="min-w-0 truncate">{selected.displayName}</span> <ChevronDownIcon aria-hidden className="size-3.5 shrink-0 text-muted-foreground opacity-0 transition-opacity group-hover/project-title:opacity-100 group-focus-visible/project-title:opacity-100" /> </button> ) : ( <span className="truncate text-muted-foreground">Unavailable project</span> )} </WorkspaceBreadcrumbItem> </WorkspaceBreadcrumb> ); } export function ProjectSettingsPanel({ projectKey }: { projectKey: string }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; // Remember the members of the last rendered group so a grouping-rule change // (which changes the group key) can follow the project to its new group. const lastSelectionRef = useRef<{ key: string; memberKeys: string[] } | null>(null); useEffect(() => { if (!selected) return; lastSelectionRef.current = { key: selected.projectKey, memberKeys: selected.memberProjects.map((member) => member.physicalProjectKey), }; }, [selected]); // A grouping-rule change replaces the group key mid-visit; follow the // project to its new key instead of parking on the not-found state. useEffect(() => { if (selected !== null) return; const last = lastSelectionRef.current; if (last?.key !== projectKey) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ to: "/projects/$projectKey", params: { projectKey: successor.projectKey }, replace: true, hashScrollIntoView: false, }); } }, [groups, navigate, projectKey, selected]); if (!selected) { return ( <div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground"> {groups.length === 0 ? "Add a project from the sidebar to configure it here." : "This project is no longer available."} </div> ); } return <ProjectDetail key={selected.projectKey} group={selected} />; } function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const representative = group.memberProjects.find( (member) => member.environmentId === group.environmentId && member.id === group.id, ) ?? group.memberProjects[0]!; const settings = usePrimarySettings(); // Provider instances and model options belong to the environment that runs // the project's threads. The hosted app has no primary environment, so // reading them from there would show "No providers available" everywhere. const projectSettings = useEnvironmentSettings(representative.environmentId); const serverProviders = useAtomValue(serverEnvironment.providersValueAtom(representative.environmentId)) ?? EMPTY_SERVER_PROVIDERS; const updateClientSettings = useUpdateClientSettings(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const threads = useThreadShells(); const updateProject = useAtomCommand(projectEnvironment.update, { reportFailure: false }); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const upsertKeybinding = useAtomCommand(serverEnvironment.upsertKeybinding, { reportFailure: false, }); const removeKeybinding = useAtomCommand(serverEnvironment.removeKeybinding, { reportFailure: false, }); const projectNameEditedRef = useRef(false); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ type: "success", title: "Path copied", description: path }); }, onError: (error) => { toastManager.add( stackedThreadToast({ type: "error", title: "Failed to copy path", description: error instanceof Error ? error.message : "An error occurred.", }), ); }, }); const faviconPath = representative.faviconPath ?? null; const projectIcon = representative.projectIcon ?? null; const pickProjectFavicon = typeof window !== "undefined" && group.memberProjects.every( (member) => member.environmentId === primaryEnvironmentId && canPickExternalProjectFavicon(member.workspaceRoot, navigator.platform), ) ? window.desktopBridge?.pickProjectFavicon : undefined; const threadCountByMember = useMemo(() => { const counts = new Map<string, number>(); for (const thread of threads) { const key = `${thread.environmentId}:${thread.projectId}`; counts.set(key, (counts.get(key) ?? 0) + 1); } return counts; }, [threads]); const reportFailure = useCallback((title: string, result: AtomCommandResult<void, unknown>) => { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; const error = squashAtomCommandFailure(result); toastManager.add( stackedThreadToast({ type: "error", title, description: error instanceof Error ? error.message : "An error occurred.", }), ); }, []); // Group-shared fields live on each physical project record, so a // group-level edit fans out to every member. const updateAllMembers = useCallback( async ( input: Partial<{ title: string; defaultModelSelection: ModelSelection | null; defaultThreadEnvMode: ThreadEnvMode | null; autoPull: boolean; faviconPath: string | null; projectIcon: ProjectIconOverride | null; }>, failureTitle: string, ): Promise<AtomCommandResult<void, unknown>> => { for (const member of group.memberProjects) { const result = mapAtomCommandResult( await updateProject({ environmentId: member.environmentId, input: { projectId: member.id, ...input }, }), () => undefined, ); if (result._tag === "Failure") { // A partial fan-out is possible: earlier members already took the // write. Name the environment so the user knows where it stopped. reportFailure( group.memberProjects.length > 1 ? `${failureTitle} on ${member.environmentLabel ?? "the current environment"}` : failureTitle, result, ); return result; } } return AsyncResult.success(undefined); }, [group.memberProjects, reportFailure, updateProject], ); const renameGroup = useCallback( async (nextTitle: string, wasEdited: boolean) => { const title = nextTitle.trim(); if (!title) { toastManager.add({ type: "warning", title: "Project title cannot be empty" }); return; } if ( !projectGroupTitleNeedsUpdate( group.memberProjects.map((member) => member.title), title, wasEdited, ) ) { return; } await updateAllMembers({ title }, "Failed to rename project"); }, [group.memberProjects, updateAllMembers], ); // ----- default model ----- const storedSelection = representative.defaultModelSelection; const resolvedSelection = resolveDefaultProviderModelSelection(serverProviders, storedSelection); const resolvedInstanceId = resolvedSelection?.instanceId ?? null; const resolvedModel = resolvedSelection?.model ?? null; const instanceEntries = useMemo( () => sortProviderInstanceEntries( applyProviderInstanceSettings( deriveProviderInstanceEntries(serverProviders), projectSettings, ), ), [serverProviders, projectSettings], ); const modelOptionsByInstance = useMemo( () => getCustomModelOptionsByInstance( projectSettings, serverProviders, resolvedInstanceId, resolvedModel, ), [resolvedInstanceId, resolvedModel, serverProviders, projectSettings], ); const activeEntry = instanceEntries.find((entry) => entry.instanceId === resolvedInstanceId); const setDefaultModel = useCallback( (selection: ModelSelection | null) => void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"), [updateAllMembers], ); // ----- new-thread workspace mode ----- const storedEnvMode = representative.defaultThreadEnvMode ?? null; const setDefaultThreadEnvMode = useCallback( (mode: ThreadEnvMode | null) => void updateAllMembers( { defaultThreadEnvMode: mode }, "Failed to update new-thread workspace", ), [updateAllMembers], ); const autoPull = representative.autoPull ?? false; const setAutoPull = useCallback( (enabled: boolean) => void updateAllMembers({ autoPull: enabled }, "Failed to update automatic pull setting"), [updateAllMembers], ); // ----- project icon ----- const [faviconPickerOpen, setFaviconPickerOpen] = useState(false); const [iconPickerOpen, setIconPickerOpen] = useState(false); const [isSavingFavicon, setIsSavingFavicon] = useState(false); const savingFaviconRef = useRef(false); const setProjectIcon = useCallback( async (input: { faviconPath: string | null; projectIcon: ProjectIconOverride | null }) => { if (savingFaviconRef.current) return; savingFaviconRef.current = true; setIsSavingFavicon(true); try { await updateAllMembers(input, "Failed to update project icon"); } finally {