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 EnvironmentId, type ModelSelection, type ProjectIconOverride, type ProjectId, type ProjectScript, type ResolvedKeybindingsConfig, type ServerSettings, type ProviderDriverKind, type PullRequestMergeMethod, type SidebarProjectGroupingMode, type T3ProjectFileScript, type ThreadEnvMode, } from "@t3tools/contracts"; import { resolveEnvModeLabel } from "../BranchToolbar.logic"; import { createModelSelection } from "@t3tools/shared/model"; import { resolveProjectAutoPull } from "@t3tools/shared/serverSettings"; import { projectScriptsInheritDefaults, resolveProjectScripts, } from "@t3tools/shared/projectScripts"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import { useNavigate } from "@tanstack/react-router"; import * as Equal from "effect/Equal"; import * as Cause from "effect/Cause"; import { ChevronDownIcon, PlusIcon, Trash2Icon } from "lucide-react"; import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useComposerDraftStore } from "../../composerDraftStore"; import { useClientSettings, useEnvironmentSettings, useUpdateClientSettings, } from "../../hooks/useSettings"; import { useT3ProjectFileState } from "../../hooks/useT3ProjectFileScripts"; import { ProjectActionsList } from "./ProjectActionsList"; import { isElectron } from "../../env"; import { decodeProjectScriptKeybindingRule, keybindingValueForCommand, } from "../../lib/projectScriptKeybindings"; import { buildProjectScript, commandForProjectScript, nextProjectScriptId, } from "../../projectScripts"; import { releaseProjectDraftUploads } from "../../lib/composerDraftUploads"; import { readLocalApi } from "../../localApi"; 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 { PULL_REQUEST_MERGE_METHOD_LABELS } from "../pullRequest/pullRequestDetail.logic"; 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 { Switch } from "../ui/switch"; import { stackedThreadToast, toastManager } from "../ui/toast"; 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 = { 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 ProjectSettingsPanel({ projectKey, environmentId = null, }: { projectKey: string; environmentId?: EnvironmentId | null; }) { const groups = useSettingsProjectGroups(); const navigate = useNavigate(); const selected = groups.find((group) => group.projectKey === projectKey) ?? null; const members = useMemo( () => selected?.memberProjects.filter( (member) => environmentId === null || member.environmentId === environmentId, ) ?? [], [selected, environmentId], ); // 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; environmentId: EnvironmentId | null; memberKeys: string[]; } | null>(null); useEffect(() => { if (!selected || members.length === 0) return; lastSelectionRef.current = { key: selected.projectKey, environmentId, memberKeys: members.map((member) => member.physicalProjectKey), }; }, [selected, members, environmentId]); // 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 (members.length > 0) return; const last = lastSelectionRef.current; if (last?.key !== projectKey || last.environmentId !== environmentId) return; const successor = groups.find((group) => group.memberProjects.some((member) => last.memberKeys.includes(member.physicalProjectKey)), ); if (successor) { void navigate({ to: "/settings/projects", search: { project: successor.projectKey, machine: environmentId ?? undefined }, replace: true, hashScrollIntoView: false, }); } }, [groups, navigate, projectKey, members.length, environmentId]); if (!selected) { return (
{groups.length === 0 ? "Add a project from the sidebar to configure it here." : "This project is no longer available."}
); } if (members.length === 0) return (

This project has no checkout on this machine.

); const scopedGroup = { ...selected, memberProjects: members, environmentId: members[0]!.environmentId, id: members[0]!.id, }; return ( ); } function reportScriptFailure(result: AtomCommandResult) { if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); toastManager.add({ type: "error", title: "Failed to save project actions", description: error instanceof Error ? error.message : "An error occurred.", }); } return mapAtomCommandResult(result, () => undefined); } export function useProjectScriptSettings( targets: readonly { environmentId: EnvironmentId; settings: ServerSettings; keybindings: ResolvedKeybindingsConfig; project?: { id: ProjectId; scripts: readonly ProjectScript[] }; }[], ) { const projects = useProjects(); const [saving, setSaving] = useState(false); const savingRef = useRef(false); const updateSettings = useAtomCommand(serverEnvironment.updateSettings, "project actions update"); const upsertKeybinding = useAtomCommand( serverEnvironment.upsertKeybinding, "action shortcut update", ); const removeKeybinding = useAtomCommand( serverEnvironment.removeKeybinding, "action shortcut removal", ); async function persist( transform: (current: readonly ProjectScript[]) => readonly ProjectScript[] | null, scriptId?: string, keybinding?: string | null, ): Promise> { if (savingRef.current || targets.length === 0) { const message = "No available machine, or another action change is saving."; toastManager.add({ type: "error", title: "Actions not saved", description: message }); return AsyncResult.failure(Cause.fail(new Error(message))); } savingRef.current = true; setSaving(true); try { for (const { environmentId, settings, keybindings, project } of targets) { const current = project ? resolveProjectScripts(settings, project) : settings.defaultProjectScripts; const nextScripts = transform(current); const effectiveScripts = nextScripts ?? settings.defaultProjectScripts; const result = await updateSettings({ environmentId, input: { patch: project ? { projectScriptOverrides: { [project.id]: nextScripts } } : { defaultProjectScripts: nextScripts ?? [] }, }, }); if (result._tag === "Failure") return reportScriptFailure(result); if (!isElectron) continue; const changedIds = scriptId ? [scriptId] : current .filter((script) => !effectiveScripts.some((next) => next.id === script.id)) .map((script) => script.id); for (const id of changedIds) { const command = commandForProjectScript(id); const previousValue = keybindingValueForCommand(keybindings, command); const previous = previousValue ? decodeProjectScriptKeybindingRule({ keybinding: previousValue, command }) : null; const next = decodeProjectScriptKeybindingRule({ keybinding, command }); const retainedElsewhere = !nextScripts?.some((script) => script.id === id) && ((project && settings.defaultProjectScripts.some((script) => script.id === id)) || Object.entries(settings.projectScriptOverrides).some( ([projectId, scripts]) => projectId !== project?.id && scripts?.some((script) => script.id === id), ) || projects.some( (other) => other.environmentId === environmentId && other.id !== project?.id && (project ? resolveProjectScripts(settings, other) : other.scripts).some( (script) => script.id === id, ), )); const bindingResult = next ? await upsertKeybinding({ environmentId, input: previous && previous.key !== next.key ? { ...next, replace: previous } : next, }) : previous && !retainedElsewhere ? await removeKeybinding({ environmentId, input: previous }) : null; if (bindingResult?._tag === "Failure") return reportScriptFailure(bindingResult); } } return AsyncResult.success(undefined); } finally { savingRef.current = false; setSaving(false); } } function submit(scriptId: string | null, input: NewProjectScriptInput) { const existingIds = [ ...projects.flatMap((project) => project.scripts.map((script) => script.id)), ...targets.flatMap(({ settings, project }) => [ ...settings.defaultProjectScripts, ...Object.values(settings.projectScriptOverrides).flatMap((scripts) => scripts ?? []), ...(project?.scripts ?? []), ].map((script) => script.id), ), ]; const id = scriptId ?? nextProjectScriptId(input.name, existingIds); const next = buildProjectScript(id, input); return persist( (current) => { const updated = current.map((script) => script.id === id ? next : input.runOnWorktreeCreate ? { ...script, runOnWorktreeCreate: false } : script, ); return scriptId === null ? [...updated, next] : updated; }, id, input.keybinding, ); } return { saving, persist, submit }; } function ProjectDetail({ group, hasOtherMembers, }: { group: SidebarProjectSnapshot; hasOtherMembers: boolean; }) { const navigate = useNavigate(); const primaryEnvironmentId = usePrimaryEnvironmentId(); const { environments } = useEnvironments(); const environmentById = useMemo( () => new Map(environments.map((environment) => [environment.environmentId, environment])), [environments], ); const representative = group.memberProjects.find( (member) => environmentById.get(member.environmentId)?.serverConfig != null, ) ?? group.memberProjects[0]!; // 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 updateServerSettings = useAtomCommand(serverEnvironment.updateSettings, "project setting"); const [savingBrowserAccess, setSavingBrowserAccess] = useState(false); const savingBrowserAccessRef = useRef(false); const browserOverrides = group.memberProjects.map( (member) => environmentById.get(member.environmentId)?.serverConfig?.settings .projectAgentBrowserAccessOverrides[member.id], ); const browserOverride = projectSettings.projectAgentBrowserAccessOverrides[representative.id]; const browserMixed = group.memberProjects.some((member, index) => { const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; if (!settings || !environmentById.get(representative.environmentId)?.serverConfig) return false; return ( browserOverrides[index] !== browserOverride || (browserOverrides[index] ?? settings.enableAgentBrowserAccess) !== (browserOverride ?? projectSettings.enableAgentBrowserAccess) ); }); const setBooleanOverride = async ( key: "projectAgentBrowserAccessOverrides" | "projectAutoPullOverrides", enabled: boolean | undefined, ) => { if (savingBrowserAccessRef.current) return; savingBrowserAccessRef.current = true; setSavingBrowserAccess(true); try { const environmentIds = new Set(group.memberProjects.map((member) => member.environmentId)); for (const environmentId of environmentIds) { const environment = environmentById.get(environmentId); if (!environment?.serverConfig || environment.connection.phase !== "connected") { toastManager.add({ type: "warning", title: "Setting not saved", description: `Connect ${environment?.label ?? "this machine"} and try again.`, }); return; } } if (key === "projectAutoPullOverrides" && enabled === undefined) { const result = await updateAllMembers( { autoPull: false }, "Failed to reset automatic pull", ); if (result._tag === "Failure") return; } for (const environmentId of environmentIds) { const overrides = Object.fromEntries( group.memberProjects .filter((member) => member.environmentId === environmentId) .map((member) => [member.id, enabled ?? null]), ); const result = await updateServerSettings({ environmentId, input: { patch: { [key]: overrides } }, }); if (result._tag === "Failure") { reportFailure( `Failed to save project setting on ${environmentById.get(environmentId)?.label ?? "this machine"}`, mapAtomCommandResult(result, () => undefined), ); return; } } } finally { savingBrowserAccessRef.current = false; setSavingBrowserAccess(false); } }; const setBrowserAccess = (enabled: boolean | undefined) => setBooleanOverride("projectAgentBrowserAccessOverrides", enabled); const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false }); const projectNameEditedRef = useRef(false); const mergeMethodOverrides = useClientSettings( (settings) => settings.pullRequestMergeMethodOverrides, ); const projectMergeMethod = mergeMethodOverrides[group.projectKey]; const setProjectMergeMethod = (method: PullRequestMergeMethod | null) => { const nextOverrides = { ...mergeMethodOverrides }; if (method === null) { delete nextOverrides[group.projectKey]; } else { nextOverrides[group.projectKey] = method; } updateClientSettings({ pullRequestMergeMethodOverrides: nextOverrides }); }; 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 reportFailure = useCallback((title: string, result: AtomCommandResult) => { 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> => { 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 ?? projectSettings.defaultModelSelection, ); const mixedModel = group.memberProjects.some((member) => { const config = environmentById.get(member.environmentId)?.serverConfig; return ( !Equal.equals(member.defaultModelSelection, storedSelection) || (config !== null && config !== undefined && environmentById.get(representative.environmentId)?.serverConfig != null && JSON.stringify( resolveDefaultProviderModelSelection( config.providers, member.defaultModelSelection ?? config.settings.defaultModelSelection, ), ) !== JSON.stringify(resolvedSelection)) ); }); 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 = (selection: ModelSelection | null) => { if (selection !== null) { for (const member of group.memberProjects) { const environment = environmentById.get(member.environmentId); const config = environment?.serverConfig; const entry = config ? applyProviderInstanceSettings( deriveProviderInstanceEntries(config.providers), config.settings, ).find((candidate) => candidate.instanceId === selection.instanceId) : undefined; const options = config ? getCustomModelOptionsByInstance( { ...projectSettings, ...config.settings }, config.providers, ).get(selection.instanceId) : undefined; if ( !entry?.enabled || !entry.isAvailable || !options?.some((model) => model.slug === selection.model && !model.isUnavailable) ) { toastManager.add({ type: "warning", title: "Project model not saved", description: `This model is unavailable on ${environment?.label ?? "a selected machine"}. Select a machine to choose its model separately.`, }); return; } } } void updateAllMembers({ defaultModelSelection: selection }, "Failed to update default model"); }; // ----- new-thread workspace mode ----- const storedEnvMode = representative.defaultThreadEnvMode ?? null; const mixedWorkspace = group.memberProjects.some( (member) => member.defaultThreadEnvMode !== storedEnvMode, ); const setDefaultThreadEnvMode = useCallback( (mode: ThreadEnvMode | null) => void updateAllMembers( { defaultThreadEnvMode: mode }, "Failed to update new-thread workspace", ), [updateAllMembers], ); const autoPull = resolveProjectAutoPull( projectSettings, representative.id, representative.autoPull, ); const autoPullOverridden = group.memberProjects.some( (member) => member.autoPull || environmentById.get(member.environmentId)?.serverConfig?.settings.projectAutoPullOverrides[ member.id ] !== undefined, ); const mixedAutoPull = group.memberProjects.some((member) => { const settings = environmentById.get(member.environmentId)?.serverConfig?.settings; return settings && resolveProjectAutoPull(settings, member.id, member.autoPull) !== autoPull; }); const setAutoPull = (enabled: boolean | undefined) => setBooleanOverride("projectAutoPullOverrides", enabled); // ----- 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 { savingFaviconRef.current = false; setIsSavingFavicon(false); } }, [updateAllMembers], ); // ----- checkout selection and scripts ----- const hasMultipleCheckouts = group.memberProjects.length > 1; const [selectedCheckoutKey, setSelectedCheckoutKey] = useState(null); const selectedCheckoutMatch = group.memberProjects.find( (member) => member.physicalProjectKey === selectedCheckoutKey, ); const selectedCheckout = selectedCheckoutMatch ?? representative; const selectedServerConfig = useAtomValue( serverEnvironment.configValueAtom(selectedCheckout.environmentId), ); const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS; const scriptSettings = useEnvironmentSettings(selectedCheckout.environmentId); const scripts = resolveProjectScripts(scriptSettings, selectedCheckout); const scriptsInherited = projectScriptsInheritDefaults(scriptSettings, selectedCheckout); const [editorRequest, setEditorRequest] = useState(null); const { saving: isSavingScripts, persist: persistScripts, submit: submitScript, } = useProjectScriptSettings([ { environmentId: selectedCheckout.environmentId, settings: scriptSettings, keybindings, project: selectedCheckout, }, ]); const t3File = useT3ProjectFileState( selectedCheckout.environmentId, selectedCheckout.workspaceRoot, ); // What the "Default" option resolves to while no override is set: the // repo's t3.json value when present, otherwise the global setting. const inheritedEnvMode = t3File.file?.defaultThreadEnvMode ?? scriptSettings.defaultThreadEnvMode; const inheritedEnvModeSource = t3File.file?.defaultThreadEnvMode != null ? "t3.json" : "global"; const importableScripts = useMemo( () => t3File.scripts.filter( (fileScript) => !scripts.some( (script) => script.command === fileScript.command || script.name.toLowerCase() === fileScript.name.toLowerCase(), ), ), [scripts, t3File.scripts], ); const deleteScript = (scriptId: string) => void persistScripts( (current) => current.filter((script) => script.id !== scriptId), scriptId, null, ); const importFileScript = useCallback( async (fileScript: T3ProjectFileScript) => { const payload: NewProjectScriptInput = { name: fileScript.name, command: fileScript.command, icon: fileScript.icon ?? "play", runOnWorktreeCreate: fileScript.runOnWorktreeCreate ?? false, keybinding: null, previewUrl: fileScript.previewUrl ?? null, autoOpenPreview: fileScript.previewUrl ? (fileScript.autoOpenPreview ?? false) : false, }; const result = await submitScript(null, payload); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); setEditorRequest({ scriptId: null, initial: payload, error: error instanceof Error ? error.message : "Failed to import action.", }); } }, [submitScript, setEditorRequest], ); // ----- checkouts ----- const updateGroupingPreference = useCallback( (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { const overrideKey = deriveProjectGroupingOverrideKey(member); const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; if (selection === "inherit") { delete nextOverrides[overrideKey]; } else { nextOverrides[overrideKey] = selection; } updateClientSettings({ sidebarProjectGroupingOverrides: nextOverrides }); }, [projectGroupingSettings.sidebarProjectGroupingOverrides, updateClientSettings], ); const removeMembers = useCallback( async (members: ReadonlyArray) => { const api = readLocalApi(); if (!api) return; const memberKeys = new Set(members.map(memberKey)); const projectThreads = threads.filter((thread) => memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); const isWholeGroup = members.length === group.memberProjects.length; const targetKind = hasOtherMembers || !isWholeGroup ? "checkout" : "project"; const singleMember = members.length === 1 ? members[0]! : null; const targetLabel = singleMember?.title ?? group.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( [ projectThreads.length > 0 ? `Remove ${targetKind} "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?` : `Remove ${targetKind} "${targetLabel}"?`, ...(singleMember ? [ `Path: ${singleMember.workspaceRoot}`, ...(singleMember.environmentLabel ? [`Environment: ${singleMember.environmentLabel}`] : []), ] : [`This removes ${members.length} grouped project entries.`]), ...(projectThreads.length > 0 ? [ "This permanently clears conversation history for those threads and any archived threads.", ] : ["This permanently clears any archived conversation history."]), isWholeGroup && !hasOtherMembers ? "This removes only the project entries, not the files on disk." : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", ].join("\n"), { variant: "destructive" }, ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; const draftStore = useComposerDraftStore.getState(); for (const member of members) { const memberThreads = projectThreads.filter( (thread) => thread.environmentId === member.environmentId && thread.projectId === member.id, ); const result = mapAtomCommandResult( await deleteProject({ environmentId: member.environmentId, input: { projectId: member.id, force: true, }, }), () => undefined, ); if (result._tag === "Failure") { reportFailure(`Failed to remove "${member.title}"`, result); return; } const projectRef = scopeProjectRef(member.environmentId, member.id); releaseProjectDraftUploads( projectRef, memberThreads.map((thread) => scopeThreadRef(thread.environmentId, thread.id)), ); const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); if (projectDraftThread) { draftStore.clearDraftThread(projectDraftThread.draftId); } draftStore.clearProjectDraftThreadId(projectRef); } if (isWholeGroup) { if (hasOtherMembers) { void navigate({ to: "/settings/projects", search: { project: group.projectKey, machine: undefined }, replace: true, }); } else { void navigate({ to: "/", replace: true }); } } }, [ deleteProject, group.displayName, group.memberProjects.length, group.projectKey, hasOtherMembers, navigate, reportFailure, threads, ], ); const selectedCheckoutGrouping = projectGroupingSettings.sidebarProjectGroupingOverrides?.[ deriveProjectGroupingOverrideKey(selectedCheckout) ] ?? "inherit"; const checkoutLabel = (member: SidebarProjectGroupMember) => { const label = member.environmentLabel ?? "This machine"; return group.memberProjects.some( (other) => other.physicalProjectKey !== member.physicalProjectKey && (other.environmentLabel ?? "This machine") === label, ) ? `${label} · ${member.workspaceRoot}` : label; }; const selectedCheckoutLabel = checkoutLabel(selectedCheckout); return ( <> { projectNameEditedRef.current = true; }} onBlur={(event) => { const wasEdited = projectNameEditedRef.current; projectNameEditedRef.current = false; void renameGroup(event.currentTarget.value, wasEdited); }} onKeyDown={(event) => { if (event.key === "Enter") event.currentTarget.blur(); }} /> } /> void setProjectIcon({ faviconPath: null, projectIcon: null })} /> ) : null } control={
} /> setProjectMergeMethod(null)} /> ) : null } control={ } /> member.defaultModelSelection !== null) ? ( setDefaultModel(null)} /> ) : null } control={ resolvedSelection && activeEntry ? (
{ void navigate({ to: "/settings/providers", search: { environmentId: representative.environmentId, instanceId }, }); }} onInstanceModelChange={(instanceId, model) => { setDefaultModel(createModelSelection(instanceId, model)); }} /> {}} modelOptions={resolvedSelection.options ?? []} allowPromptInjectedEffort={false} planModeEnabled={projectSettings.planModeEnabled} triggerVariant="outline" triggerClassName={SETTINGS_PICKER_TRIGGER_CLASSNAME} onModelOptionsChange={(nextOptions) => { setDefaultModel( createModelSelection( resolvedSelection.instanceId, resolvedSelection.model, nextOptions, ), ); }} />
) : ( No providers available ) } /> member.defaultThreadEnvMode !== null) ? ( setDefaultThreadEnvMode(null)} /> ) : null } control={ } /> void setAutoPull(undefined)} /> ) : null } control={ void setAutoPull(enabled)} /> } /> value !== undefined) ? ( void setBrowserAccess(undefined)} /> ) : null } control={ } />
{hasMultipleCheckouts ? ( { if (value) setSelectedCheckoutKey(value); }} > {selectedCheckoutLabel} {group.memberProjects.map((member) => ( {checkoutLabel(member)} ))} } /> ) : null} updateGroupingPreference(selectedCheckout, "inherit")} /> ) : null } control={ } /> {group.memberProjects.length > 1 ? ( void removeMembers([selectedCheckout])} > Remove checkout } /> ) : null}

Actions

{scriptsInherited ? "Inherited from machine defaults." : `Overridden for ${selectedCheckoutLabel}.`}

{!scriptsInherited ? ( void persistScripts(() => null)} /> ) : null} {importableScripts.length > 0 ? ( } > Import scripts Import from t3.json

Add actions declared by this checkout without editing them first.

{importableScripts.map((fileScript) => ( void importFileScript(fileScript)} >
{fileScript.name}
{fileScript.command}
))}
) : null}
setEditorRequest(editorRequestForScript(script, keybindings))} /> {t3File.status === "invalid" ? ( ) : null}
1 ? "Remove this project everywhere" : "Remove project" } description={ hasOtherMembers ? "Deletes the selected machine's checkout entries and their threads. Other machines and files on disk are not touched." : group.memberProjects.length > 1 ? `Deletes all ${group.memberProjects.length} checkout entries and their threads on every machine. Files on disk are not touched.` : "Deletes the project entry and its threads. Files on disk are not touched." } control={ } />
setEditorRequest(null)} /> pickProjectFavicon(representative.workspaceRoot) } : {})} onSelect={(path) => void setProjectIcon({ faviconPath: path, projectIcon: null })} open={faviconPickerOpen} projectName={group.displayName} /> {iconPickerOpen ? ( void setProjectIcon({ faviconPath: null, projectIcon: icon })} /> ) : null} ); }