From ffaa1dba76d86c073533055e48d7f8f015c8a7b7 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 02:37:18 -0700 Subject: [PATCH 001/174] feat: pin favorite projects and archive old ones (#26154) Co-authored-by: Claude Opus 5.5 --- .../src/features/home/HomeRouteScreen.tsx | 17 +- apps/mobile/src/features/home/HomeScreen.tsx | 12 +- .../src/features/home/homeThreadList.ts | 4 + .../src/features/keyboard/CommandPalette.tsx | 6 +- .../features/projects/AddProjectScreen.tsx | 17 +- .../SettingsProjectOverviewRouteScreen.tsx | 94 ++++++++- .../SettingsScheduledTasksRouteScreen.tsx | 11 +- .../SettingsEnvironmentFilterHeader.tsx | 7 +- .../settings/scheduled-task-editor.tsx | 6 +- .../features/threads/NewTaskDraftScreen.tsx | 14 +- .../features/threads/NewTaskRouteScreen.tsx | 3 +- .../threads/ThreadNavigationSidebar.tsx | 10 +- .../new-task-project-selection.test.ts | 16 ++ .../threads/new-task-project-selection.ts | 4 +- .../src/environment/ServerEnvironment.ts | 1 + .../orchestration-v2/ProjectCommands.test.ts | 34 ++++ .../src/orchestration-v2/ProjectCommands.ts | 9 + .../src/orchestration-v2/ProjectStore.test.ts | 45 +++++ .../src/orchestration-v2/ProjectStore.ts | 16 ++ .../orchestration-v2/RuntimePolicy.test.ts | 2 + .../ThreadLaunchService.test.ts | 4 + .../ThreadTitleRegenerationService.test.ts | 2 + apps/server/src/persistence/Migrations.ts | 2 + .../076_ProjectionProjectsPinArchive.ts | 9 + .../reconcileV2PreviewMigration.test.ts | 2 + apps/server/src/project/ProjectMutation.ts | 2 + apps/server/src/project/ProjectService.ts | 4 + .../src/provider/agentContextBudget.json | 2 +- .../components/CommandPalette.logic.test.ts | 23 +++ .../src/components/CommandPalette.logic.ts | 69 +++++-- apps/web/src/components/CommandPalette.tsx | 179 ++++++++++++++---- .../components/CommandPaletteProjectItems.tsx | 4 + .../src/components/CommandPaletteResults.tsx | 9 + apps/web/src/components/ProjectPinButton.tsx | 67 +++++++ apps/web/src/components/Sidebar.logic.test.ts | 29 ++- apps/web/src/components/Sidebar.logic.ts | 10 +- apps/web/src/components/Sidebar.tsx | 121 +++++++++--- .../src/components/chat/DraftHeroHeadline.tsx | 42 +++- .../components/chat/useThreadLifecycle.tsx | 9 +- .../DesktopAppActivationCoordinator.tsx | 21 +- .../ProjectSettingsPanel.browser.test.tsx | 23 ++- .../settings/ProjectSettingsPanel.tsx | 170 ++++++++--------- .../settings/ScheduledTasksSettings.tsx | 29 ++- .../settings/SettingsScopeNotice.tsx | 1 + .../settings/SettingsScopeSentence.tsx | 17 +- .../scheduledTasksSettings.logic.test.ts | 19 ++ .../settings/scheduledTasksSettings.logic.ts | 22 +++ .../settings/scopedSettings.test.ts | 2 + .../components/settings/settingsScope.test.ts | 2 + apps/web/src/environmentGrouping.test.ts | 28 ++- apps/web/src/hooks/useHandleNewThread.ts | 2 +- apps/web/src/hooks/useOrderWhileOpen.test.ts | 21 ++ apps/web/src/hooks/useOrderWhileOpen.ts | 46 +++++ apps/web/src/hooks/useProjectGroupUpdate.ts | 160 ++++++++++++++++ apps/web/src/routes/_chat.index.tsx | 6 +- apps/web/src/shortcuts/chatRouteCommands.ts | 23 ++- apps/web/src/sidebarProjectGrouping.ts | 18 +- apps/web/src/state/environments.ts | 8 + docs/user/project-settings.md | 16 ++ docs/user/thread-sidebar.md | 13 +- evals/mcp-tool-use/cases/projects.ndjson | 1 + .../client-runtime/src/operations/commands.ts | 4 + .../src/state/projectGrouping.test.ts | 26 +++ .../src/state/projectGrouping.ts | 13 ++ packages/contracts/src/applicationEvent.ts | 2 + packages/contracts/src/environment.ts | 2 + .../contracts/src/orchestrationProject.ts | 3 + packages/contracts/src/project.ts | 8 + 68 files changed, 1367 insertions(+), 256 deletions(-) create mode 100644 apps/server/src/persistence/Migrations/076_ProjectionProjectsPinArchive.ts create mode 100644 apps/web/src/components/ProjectPinButton.tsx create mode 100644 apps/web/src/hooks/useOrderWhileOpen.test.ts create mode 100644 apps/web/src/hooks/useOrderWhileOpen.ts create mode 100644 apps/web/src/hooks/useProjectGroupUpdate.ts create mode 100644 evals/mcp-tool-use/cases/projects.ndjson diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index fe1401326ab56..c522da701e75c 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -10,7 +10,10 @@ import { useMemo, useState } from "react"; import { Platform, useWindowDimensions } from "react-native"; import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; -import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; +import { + pickableProjectGroups, + resolveScratchProjectScopeKey, +} from "@t3tools/client-runtime/state/project-grouping"; import { NativeStackScreenOptions } from "../../native/StackHeader"; import { useProjects } from "../../state/entities"; @@ -138,11 +141,13 @@ export function HomeRouteScreen() { ); const projectFilterOptions = useMemo( () => - buildHomeProjectScopes({ - projects, - environmentId: selectedEnvironmentId, - projectGroupingMode: listOptions.projectGroupingMode, - }).map((scope) => ({ + pickableProjectGroups( + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode: listOptions.projectGroupingMode, + }), + ).map((scope) => ({ key: scope.key, label: scope.title, })), diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 192755ec435df..243e693d1b893 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -9,6 +9,7 @@ import { type EnvironmentProject, type EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search"; import { type EnvironmentId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; import { useAtomValue } from "@effect/atom-react"; @@ -376,7 +377,11 @@ export function HomeScreen(props: HomeScreenProps) { })(); const v2ProjectScopeKey = props.selectedProjectKey; - const v2ScopedProjectGroup = findHomeProjectScope(projectScopes, v2ProjectScopeKey); + // An archived project's filter reads as all projects, matching the filter menu. + const v2ScopedProjectGroup = findHomeProjectScope( + pickableProjectGroups(projectScopes), + v2ProjectScopeKey, + ); const threadSearch = useThreadSearch( searchEnvironmentIds, props.searchQuery, @@ -711,7 +716,10 @@ export function HomeScreen(props: HomeScreenProps) { ({ key: scope.key, label: scope.title }))} + projects={pickableProjectGroups(projectScopes).map((scope) => ({ + key: scope.key, + label: scope.title, + }))} selectedEnvironmentId={props.selectedEnvironmentId} selectedProjectKey={props.selectedProjectKey} onEnvironmentChange={props.onEnvironmentChange} diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index 82eab6b384b9c..1b2e1a8c541f7 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -27,6 +27,8 @@ export interface HomeProjectScope { readonly representative: EnvironmentProject; readonly projects: ReadonlyArray; readonly projectRefs: ReadonlyArray; + readonly pinned: boolean; + readonly archived: boolean; } function getProjectSortTimestamp( @@ -62,6 +64,8 @@ export function buildHomeProjectScopes(input: { representative: group.representative, projects: group.members.map((member) => member.project), projectRefs: group.memberProjectRefs, + pinned: group.pinned, + archived: group.archived, }; }); } diff --git a/apps/mobile/src/features/keyboard/CommandPalette.tsx b/apps/mobile/src/features/keyboard/CommandPalette.tsx index cc9946dded8ab..841eaae7c6dc2 100644 --- a/apps/mobile/src/features/keyboard/CommandPalette.tsx +++ b/apps/mobile/src/features/keyboard/CommandPalette.tsx @@ -401,7 +401,11 @@ export function CommandPalette(props: { })), ); } - const projectItems: CommandPaletteItem[] = projects.map((project) => ({ + const openProjects = projects.filter((project) => project.archivedAt == null); + const projectItems: CommandPaletteItem[] = [ + ...openProjects.filter((project) => project.pinnedAt != null), + ...openProjects.filter((project) => project.pinnedAt == null), + ].map((project) => ({ key: `project:${scopedProjectKey(project.environmentId, project.id)}`, kind: "project", project, diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx index 218d01041561d..98f6a909899a9 100644 --- a/apps/mobile/src/features/projects/AddProjectScreen.tsx +++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx @@ -713,6 +713,9 @@ function useCreateProject(environment: EnvironmentOption | null) { const openNewTaskDraft = useOpenNewTaskDraft(); const navigation = useNavigation(); const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const updateProject = useAtomCommand(projectEnvironment.update, { + label: "project unarchive", + }); const projects = useProjects(); return async (workspaceRoot: string, originRoute = getActiveRoute(navigation)) => { @@ -734,7 +737,19 @@ function useCreateProject(environment: EnvironmentOption | null) { path: workspaceRoot, }); if (existing) { - Dialog.alert("Project already exists", existing.title); + // Adding an archived project's folder again brings the project back. + const unarchived = + existing.archivedAt == null || + ( + await updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }) + )._tag === "Success"; + Dialog.alert( + "Project already exists", + unarchived ? existing.title : `${existing.title} is still archived. Try again later.`, + ); openNewTaskDraft( { environmentId: existing.environmentId, diff --git a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx index 5eecd33e3837d..d3ae769181165 100644 --- a/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsProjectOverviewRouteScreen.tsx @@ -9,7 +9,10 @@ import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; -import { deriveProjectGroupLabel } from "@t3tools/client-runtime/state/project-grouping"; +import { + deriveProjectGroupLabel, + type ProjectGroup, +} from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject } from "@t3tools/client-runtime/state/shell"; import { useNavigation } from "@react-navigation/native"; import { useRef, useState } from "react"; @@ -34,6 +37,7 @@ import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsScreen } from "./components/SettingsScreen"; import { SettingsSection } from "./components/SettingsSection"; import { ListActionRow as SettingsActionRow } from "../../components/ui/ListActionRow"; +import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { AndroidSettingsEnvironmentFilter, SettingsEnvironmentFilterHeader, @@ -59,6 +63,7 @@ export function SettingsProjectOverviewRouteScreen() { }; const navigation = useNavigation(); const { + availableTargets, selectedTargets, projectGroups, selectedProjectKey, @@ -95,7 +100,7 @@ export function SettingsProjectOverviewRouteScreen() { Choose a project with the filter above to manage its name, checkouts and removal. - ) : members.length === 0 ? ( + ) : !group || members.length === 0 ? ( This project has no checkout on the selected connected environments. Change the filter above. @@ -104,6 +109,8 @@ export function SettingsProjectOverviewRouteScreen() { member.id).join(",")}`} members={members} + group={group} + connectedTargets={availableTargets} environments={selectedTargets} hasOtherMembers={members.length < (group?.members.length ?? 0)} isRemoving={isRemoving} @@ -122,6 +129,9 @@ export function SettingsProjectOverviewRouteScreen() { function ProjectOverviewContent(props: { readonly members: readonly EnvironmentProject[]; + /** Pin and archive belong to the whole project, even when the filter picks one machine. */ + readonly group: ProjectGroup; + readonly connectedTargets: readonly SettingsTarget[]; readonly environments: readonly SettingsTarget[]; readonly hasOtherMembers: boolean; readonly isRemoving: boolean; @@ -137,10 +147,8 @@ function ProjectOverviewContent(props: { const [draftName, setDraftName] = useState(null); const [isSaving, setIsSaving] = useState(false); const threads = useThreadShells(); - const removableEnvironments = useEnvironmentsWithScope( - props.members, - AuthOrchestrationOperateScope, - ); + const allMembers = props.group.members.map((member) => member.project); + const removableEnvironments = useEnvironmentsWithScope(allMembers, AuthOrchestrationOperateScope); const canRemoveGroup = props.members.every((member) => removableEnvironments.has(member.environmentId), ); @@ -149,6 +157,52 @@ function ProjectOverviewContent(props: { label: "project name update", reportFailure: true, }); + const organizeProject = useAtomCommand(projectEnvironment.update, { + label: "project update", + reportFailure: true, + }); + const [isOrganizing, setIsOrganizing] = useState(false); + const memberTargets = allMembers.map((member) => + props.connectedTargets.find((entry) => entry.environmentId === member.environmentId), + ); + const offline = memberTargets.some((target) => target === undefined); + // Older servers drop pin and archive, so their projects do not offer them. + // Offline members are checked when the write runs, so their reason can show. + const canOrganize = + allMembers.every( + (member, index) => + memberTargets[index] === undefined || removableEnvironments.has(member.environmentId), + ) && + memberTargets.every( + (target) => + target === undefined || + target.serverConfig.environment.capabilities.projectOrganization === true, + ); + // One machine at a time, stopping at the first failure, as web does. + const organize = async (input: { readonly pinned?: boolean; readonly archived?: boolean }) => { + setIsOrganizing(true); + for (const member of allMembers) { + if (!readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope)) { + Dialog.alert("Failed to update project", "This connection can no longer change it."); + break; + } + const result = await organizeProject({ + environmentId: member.environmentId, + input: { projectId: member.id, ...input }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + Dialog.alert( + `Failed to update "${member.title}"`, + error instanceof Error ? error.message : "An error occurred.", + ); + } + break; + } + } + setIsOrganizing(false); + }; const nextName = (draftName ?? displayName).trim(); const canSave = !isSaving && nextName.length > 0 && nextName !== displayName; @@ -295,6 +349,34 @@ function ProjectOverviewContent(props: { + {canOrganize ? ( + + void organize({ pinned })} + /> + void organize({ archived: !props.group.archived })} + /> + + ) : null} + {props.members.map((member, index) => { const environment = props.environments.find( diff --git a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx index bb1faf28f0d5c..fcb66ae1e6262 100644 --- a/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsScheduledTasksRouteScreen.tsx @@ -666,7 +666,16 @@ function TaskForm({ const tasks = useEnvironmentQuery( serverEnvironment.scheduledTasksLive({ environmentId, input: {} }), ); - const projects = useProjects().filter((project) => project.environmentId === environmentId); + // Pinned first. The chosen project stays listed after it is archived. + const projects = useProjects() + .filter( + (project) => + project.environmentId === environmentId && + (project.archivedAt == null || + project.id === draft.projectId || + project.id === draft.task?.projectId), + ) + .sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null)); const config = useEnvironmentServerConfig(environmentId); const modelOptions = buildModelOptions(config, null, undefined, draft.projectId); const canOperate = useAtomValue( diff --git a/apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx b/apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx index d298e972b409d..b0c27f3974823 100644 --- a/apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx +++ b/apps/mobile/src/features/settings/components/SettingsEnvironmentFilterHeader.tsx @@ -100,7 +100,7 @@ export function SettingsEnvironmentFilterHeader(props: { }, ...selectableProjectGroups.map((group) => ({ type: "action" as const, - label: group.label, + label: projectMenuLabel(group), state: selectedProjectKey === group.key ? ("on" as const) : undefined, onPress: () => selectProject(group.key), })), @@ -160,7 +160,7 @@ export function AndroidSettingsEnvironmentFilter() { }, ...selectableProjectGroups.map((group) => ({ id: `project:${group.key}`, - title: `Project · ${group.label}`, + title: `Project · ${projectMenuLabel(group)}`, state: selectedProjectKey === group.key ? ("on" as const) : ("off" as const), })), ]} @@ -190,3 +190,6 @@ export function AndroidSettingsEnvironmentFilter() { ); } + +const projectMenuLabel = (group: { readonly label: string; readonly archived: boolean }) => + group.archived ? `${group.label} · Archived` : group.label; diff --git a/apps/mobile/src/features/settings/scheduled-task-editor.tsx b/apps/mobile/src/features/settings/scheduled-task-editor.tsx index cb543e24eb2b6..0af51a27f6ae4 100644 --- a/apps/mobile/src/features/settings/scheduled-task-editor.tsx +++ b/apps/mobile/src/features/settings/scheduled-task-editor.tsx @@ -37,7 +37,11 @@ export function useScheduledTaskEditor() { (member) => member.project.environmentId === environmentId && member.project.id === project.id, ), - ) ?? environmentProjects[0]; + ) ?? + environmentProjects.find( + (project) => project.pinnedAt != null && project.archivedAt == null, + ) ?? + environmentProjects.find((project) => project.archivedAt == null); return createDraft( project?.id ?? null, scheduledTaskDefaultModel(configs.get(environmentId) ?? null, project ?? null), diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index a21fcf039aca4..f3488ebd3bfff 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -18,6 +18,7 @@ import * as Dialog from "../../components/ui/Dialog"; import { useAtomValue } from "@effect/atom-react"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; import { CommonActions, @@ -458,10 +459,15 @@ export function NewTaskDraftScreen(props: { return; } - const selection = resolveDraftProjectSelection(selectedProjectKey, projects, projectScopes, { - incomingShareId: props.incomingShareId, - pickerClosed: initialPickerRef.current === "open" && isFocused, - }); + const selection = resolveDraftProjectSelection( + selectedProjectKey, + projects, + pickableProjectGroups(projectScopes), + { + incomingShareId: props.incomingShareId, + pickerClosed: initialPickerRef.current === "open" && isFocused, + }, + ); if (selection.kind === "preserve") { initialPickerRef.current = null; return; diff --git a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx index 8dfe63a95cb1a..13a50c7f4289d 100644 --- a/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskRouteScreen.tsx @@ -10,6 +10,7 @@ import { type StaticScreenProps, } from "@react-navigation/native"; import { SymbolView } from "../../components/ui/AppSymbol"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { isScratchProject } from "@t3tools/client-runtime/state/projects"; import { isAtomCommandInterrupted, @@ -175,7 +176,7 @@ export function NewTaskRouteScreen({ route }: StaticScreenProps !scope.projects.every((project) => isScratchProject(project, serverConfigs.get(project.environmentId)?.scratchWorkspaceRoot), diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index d57fb7cee9ab0..96f0a8177111b 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -1,7 +1,10 @@ import { useActiveNewTask } from "../../state/active-new-task"; import { ThreadUndoNotice } from "../home/ThreadUndoNotice"; import { useAndroidControlSizing } from "../../components/ui/useAndroidControlSizing"; -import { resolveScratchProjectScopeKey } from "@t3tools/client-runtime/state/project-grouping"; +import { + pickableProjectGroups, + resolveScratchProjectScopeKey, +} from "@t3tools/client-runtime/state/project-grouping"; import { useAppearancePreferences, useAndroidFloatingTabs, @@ -219,7 +222,7 @@ function ThreadNavigationSidebarPane( }), [projects, options.selectedEnvironmentId, options.projectGroupingMode], ); - const projectFilterOptions = projectScopes.map((scope) => ({ + const projectFilterOptions = pickableProjectGroups(projectScopes).map((scope) => ({ key: scope.key, label: scope.title, })); @@ -238,8 +241,9 @@ function ThreadNavigationSidebarPane( ), [projectScopes], ); + // An archived project's filter reads as all projects, matching the filter menu. const selectedProjectScope = - projectScopes.find((scope) => scope.key === resolvedProjectKey) ?? null; + pickableProjectGroups(projectScopes).find((scope) => scope.key === resolvedProjectKey) ?? null; const threadSearch = useThreadSearch( searchEnvironmentIds, props.searchQuery, diff --git a/apps/mobile/src/features/threads/new-task-project-selection.test.ts b/apps/mobile/src/features/threads/new-task-project-selection.test.ts index 40be74007e82d..99cf9e7d37ef4 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.test.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.test.ts @@ -52,6 +52,8 @@ function makeScope(projects: ReadonlyArray): HomeProjectScop environmentId: project.environmentId, projectId: project.id, })), + pinned: false, + archived: false, }; } @@ -199,6 +201,20 @@ describe("newTaskDestination", () => { }); }); + it("opens the picker for an archived last project, but keeps an archived current one", () => { + const archived = { ...makeProject("archived"), archivedAt: "2026-01-01T00:00:00.000Z" }; + expect( + destination({ + useLastProject: true, + projects: [archived], + last: { environmentId: "environment", projectId: "archived" }, + }), + ).toEqual(picker); + expect(destination({ useLastProject: true, projects: [archived], current: archived })).toEqual({ + draft: { requestId: "request", environmentId: "environment", projectId: "archived" }, + }); + }); + it("treats an unset preference as on", () => { const projects = [makeProject("first"), makeProject("current")]; expect(destination({ useLastProject: undefined, projects, current: projects[1] })).toEqual({ diff --git a/apps/mobile/src/features/threads/new-task-project-selection.ts b/apps/mobile/src/features/threads/new-task-project-selection.ts index f6d728ae27cfb..93c8f1d5f51d6 100644 --- a/apps/mobile/src/features/threads/new-task-project-selection.ts +++ b/apps/mobile/src/features/threads/new-task-project-selection.ts @@ -159,10 +159,12 @@ export function newTaskDestination(options: { options.projects.find( (project) => project.environmentId === ref.environmentId && project.id === ref.projectId, ); + // A remembered project that was archived since opens the picker; the current one still wins. + const last = find(options.lastProject); const project = options.useLastProject === false ? undefined - : (find(options.currentProject) ?? find(options.lastProject)); + : (find(options.currentProject) ?? (last?.archivedAt == null ? last : undefined)); return project ? { draft: { diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index c8bf344513093..72d7f0c2de7ef 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -264,6 +264,7 @@ export const make = Effect.gen(function* () { ...(hostPlatform === "win32" ? {} : { localServerStop: true }), environmentIcon: true, projectCloneTracking: true, + projectOrganization: true, ignoredFileListing: true, triage: true, npmReleaseLookup: true, diff --git a/apps/server/src/orchestration-v2/ProjectCommands.test.ts b/apps/server/src/orchestration-v2/ProjectCommands.test.ts index 608c8b0ddad48..96249ece4c106 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.test.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.test.ts @@ -23,6 +23,8 @@ const row = (overrides: Partial = {}): ProjectRow => ({ faviconPath: null, projectIcon: null, scripts: [], + pinnedAt: null, + archivedAt: null, createdAt: "2026-01-01T00:00:00.000Z", updatedAt: "2026-01-01T00:00:00.000Z", deletedAt: null, @@ -77,6 +79,38 @@ describe("planProjectCommand", () => { ); }); + it("keeps the first pin and archive time, and clears both on undo", () => { + const earlier = "2025-12-01T00:00:00.000Z"; + const update = (project: ProjectRow, change: { pinned?: boolean; archived?: boolean }) => + payloadOf( + plan( + { + type: "project.meta.update", + commandId: CommandId.make("cmd-organize"), + projectId, + ...change, + }, + { project }, + ), + ); + assert.deepInclude(update(row(), { pinned: true, archived: true }), { + pinnedAt: "2026-01-01T00:00:00.000Z", + archivedAt: "2026-01-01T00:00:00.000Z", + }); + const organized = row({ pinnedAt: earlier, archivedAt: earlier }); + assert.deepInclude(update(organized, { pinned: true, archived: true }), { + pinnedAt: earlier, + archivedAt: earlier, + }); + assert.deepInclude(update(organized, { pinned: false, archived: false }), { + pinnedAt: null, + archivedAt: null, + }); + const untouched = update(organized, {}); + assert.notProperty(untouched, "pinnedAt"); + assert.notProperty(untouched, "archivedAt"); + }); + it("rejects a workspace root held by another active project", () => { const owner = row({ projectId: ProjectId.make("project-existing"), diff --git a/apps/server/src/orchestration-v2/ProjectCommands.ts b/apps/server/src/orchestration-v2/ProjectCommands.ts index 674a04783e347..d4c1abb3ceda2 100644 --- a/apps/server/src/orchestration-v2/ProjectCommands.ts +++ b/apps/server/src/orchestration-v2/ProjectCommands.ts @@ -37,6 +37,8 @@ export interface ProjectMetaUpdateCommand { readonly faviconPath?: string | null; readonly projectIcon?: ProjectIconOverride | null; readonly scripts?: ReadonlyArray; + readonly pinned?: boolean; + readonly archived?: boolean; } export interface ProjectDeleteCommand { @@ -208,6 +210,13 @@ export function planProjectCommand(input: { ...(command.faviconPath === undefined ? {} : { faviconPath: command.faviconPath }), ...(command.projectIcon === undefined ? {} : { projectIcon: command.projectIcon }), ...(command.scripts === undefined ? {} : { scripts: command.scripts }), + // Repeating a pin or archive keeps the original time. + ...(command.pinned === undefined + ? {} + : { pinnedAt: command.pinned ? (project.pinnedAt ?? occurredAt) : null }), + ...(command.archived === undefined + ? {} + : { archivedAt: command.archived ? (project.archivedAt ?? occurredAt) : null }), updatedAt: occurredAt, }, }); diff --git a/apps/server/src/orchestration-v2/ProjectStore.test.ts b/apps/server/src/orchestration-v2/ProjectStore.test.ts index 77cc0fda60e25..3eeabb6f89a83 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.test.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.test.ts @@ -51,5 +51,50 @@ it.layer(ProjectStore.layer.pipe(Layer.provideMerge(SqlitePersistence.layerMemor ); }), ); + + it.effect("keeps pin and archive times across later updates", () => + Effect.gen(function* () { + const projects = yield* ProjectStore.ProjectStoreV2; + const projectId = ProjectId.make("project-organized"); + const at = "2026-03-24T00:00:00.000Z"; + const event = { aggregateKind: "project", aggregateId: projectId, occurredAt: at } as const; + const meta = { commandId: null, causationEventId: null, correlationId: null, metadata: {} }; + yield* projects.apply({ + ...event, + ...meta, + sequence: 1, + eventId: EventId.make("event-organized-created"), + type: "project.created", + payload: { + projectId, + title: "Organized", + workspaceRoot: "/tmp/project-organized", + defaultModelSelection: null, + scripts: [], + createdAt: at, + updatedAt: at, + }, + }); + yield* projects.apply({ + ...event, + ...meta, + sequence: 2, + eventId: EventId.make("event-organized-pinned"), + type: "project.meta-updated", + payload: { projectId, pinnedAt: at, archivedAt: at, updatedAt: at }, + }); + yield* projects.apply({ + ...event, + ...meta, + sequence: 3, + eventId: EventId.make("event-organized-renamed"), + type: "project.meta-updated", + payload: { projectId, title: "Renamed", updatedAt: at }, + }); + const project = Option.getOrNull(yield* projects.get(projectId)); + assert.strictEqual(project?.pinnedAt, at); + assert.strictEqual(project?.archivedAt, at); + }), + ); }, ); diff --git a/apps/server/src/orchestration-v2/ProjectStore.ts b/apps/server/src/orchestration-v2/ProjectStore.ts index 4a81ee2b62500..2be0a765c784f 100644 --- a/apps/server/src/orchestration-v2/ProjectStore.ts +++ b/apps/server/src/orchestration-v2/ProjectStore.ts @@ -39,6 +39,8 @@ export const ProjectRow = Schema.Struct({ faviconPath: Schema.NullOr(Schema.String), projectIcon: Schema.NullOr(StoredProjectIcon), scripts: Schema.Array(ProjectScript), + pinnedAt: Schema.NullOr(IsoDateTime), + archivedAt: Schema.NullOr(IsoDateTime), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), @@ -65,6 +67,8 @@ function toShell(row: ProjectRow): OrchestrationProjectShell { faviconPath: row.faviconPath, projectIcon: row.projectIcon, scripts: row.scripts, + pinnedAt: row.pinnedAt, + archivedAt: row.archivedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -120,6 +124,8 @@ export const make = Effect.gen(function* () { favicon_path AS "faviconPath", project_icon_json AS "projectIcon", scripts_json AS "scripts", + pinned_at AS "pinnedAt", + archived_at AS "archivedAt", created_at AS "createdAt", updated_at AS "updatedAt", deleted_at AS "deletedAt" @@ -150,6 +156,8 @@ export const make = Effect.gen(function* () { favicon_path, project_icon_json, scripts_json, + pinned_at, + archived_at, created_at, updated_at, deleted_at @@ -164,6 +172,8 @@ export const make = Effect.gen(function* () { ${encoded.faviconPath}, ${encoded.projectIcon}, ${encoded.scripts}, + ${encoded.pinnedAt}, + ${encoded.archivedAt}, ${encoded.createdAt}, ${encoded.updatedAt}, ${encoded.deletedAt} @@ -178,6 +188,8 @@ export const make = Effect.gen(function* () { favicon_path = excluded.favicon_path, project_icon_json = excluded.project_icon_json, scripts_json = excluded.scripts_json, + pinned_at = excluded.pinned_at, + archived_at = excluded.archived_at, created_at = excluded.created_at, updated_at = excluded.updated_at, deleted_at = excluded.deleted_at @@ -226,6 +238,8 @@ export const make = Effect.gen(function* () { faviconPath: payload.faviconPath ?? null, projectIcon: payload.projectIcon ?? null, scripts: payload.scripts, + pinnedAt: null, + archivedAt: null, createdAt: payload.createdAt, updatedAt: payload.updatedAt, deletedAt: null, @@ -256,6 +270,8 @@ export const make = Effect.gen(function* () { ...(payload.faviconPath === undefined ? {} : { faviconPath: payload.faviconPath }), ...(payload.projectIcon === undefined ? {} : { projectIcon: payload.projectIcon }), ...(payload.scripts === undefined ? {} : { scripts: payload.scripts }), + ...(payload.pinnedAt === undefined ? {} : { pinnedAt: payload.pinnedAt }), + ...(payload.archivedAt === undefined ? {} : { archivedAt: payload.archivedAt }), updatedAt: payload.updatedAt, }).pipe(mapError("apply")); }, diff --git a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts index 9ab00130f8d36..d410be8ddf1c3 100644 --- a/apps/server/src/orchestration-v2/RuntimePolicy.test.ts +++ b/apps/server/src/orchestration-v2/RuntimePolicy.test.ts @@ -96,6 +96,8 @@ const layerTest = RuntimePolicy.layerFromProjectStore.pipe( faviconPath: null, projectIcon: null, scripts: [], + pinnedAt: null, + archivedAt: null, createdAt: "2026-06-21T00:00:00.000Z", updatedAt: "2026-06-21T00:00:00.000Z", deletedAt: null, diff --git a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts index 852c6e81b0188..d3c62a7e134bd 100644 --- a/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadLaunchService.test.ts @@ -82,6 +82,8 @@ const project = { defaultModelSelection: modelSelection, defaultThreadEnvMode: null, scripts: [], + pinnedAt: null, + archivedAt: null, createdAt: "2026-06-20T00:00:00.000Z", updatedAt: "2026-06-20T00:00:00.000Z", deletedAt: null, @@ -247,6 +249,8 @@ function makeHarness(options: HarnessOptions = {}) { faviconPath: null, projectIcon: null, scripts: project.scripts, + pinnedAt: null, + archivedAt: null, createdAt: project.createdAt, updatedAt: project.updatedAt, deletedAt: project.deletedAt, diff --git a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts index 8300d50acb021..8f7de819f5718 100644 --- a/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts +++ b/apps/server/src/orchestration-v2/ThreadTitleRegenerationService.test.ts @@ -82,6 +82,8 @@ function makeHarness( faviconPath: null, projectIcon: null, scripts: [], + pinnedAt: null, + archivedAt: null, createdAt: "2026-06-20T00:00:00.000Z", updatedAt: "2026-06-20T00:00:00.000Z", deletedAt: null, diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 1f3fb4bc6b8bc..e13c2b449d9e7 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -78,6 +78,7 @@ import Migration0072 from "./Migrations/072_WaitingLinkRequestIndex.ts"; import Migration0073 from "./Migrations/073_ActiveTurnItemIndexes.ts"; import Migration0074 from "./Migrations/074_ExcludeAttachmentPlaceholdersFromSearch.ts"; import Migration0075 from "./Migrations/075_SubagentDeliveryState.ts"; +import Migration0076 from "./Migrations/076_ProjectionProjectsPinArchive.ts"; const migrationEntries = [ [1, "OrchestrationEvents", Migration0001], @@ -156,6 +157,7 @@ const migrationEntries = [ [73, "ActiveTurnItemIndexes", Migration0073], [74, "ExcludeAttachmentPlaceholdersFromSearch", Migration0074], [75, "SubagentDeliveryState", Migration0075], + [76, "ProjectionProjectsPinArchive", Migration0076], ] as const; export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const); diff --git a/apps/server/src/persistence/Migrations/076_ProjectionProjectsPinArchive.ts b/apps/server/src/persistence/Migrations/076_ProjectionProjectsPinArchive.ts new file mode 100644 index 0000000000000..279255ec4d5f9 --- /dev/null +++ b/apps/server/src/persistence/Migrations/076_ProjectionProjectsPinArchive.ts @@ -0,0 +1,9 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/sql/SqlClient"; + +// Null means unpinned and not archived. +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql`ALTER TABLE projection_projects ADD COLUMN pinned_at TEXT`; + yield* sql`ALTER TABLE projection_projects ADD COLUMN archived_at TEXT`; +}); diff --git a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts index 4fefa61ec010f..08c5094bf49c8 100644 --- a/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts +++ b/apps/server/src/persistence/reconcileV2PreviewMigration.test.ts @@ -55,6 +55,7 @@ describe("V2 preview upgrade", () => { [73, "ActiveTurnItemIndexes"], [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], + [76, "ProjectionProjectsPinArchive"], ]); assert.deepStrictEqual(yield* runMigrations(), []); assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports); @@ -153,6 +154,7 @@ describe("V2 preview upgrade", () => { [73, "ActiveTurnItemIndexes"], [74, "ExcludeAttachmentPlaceholdersFromSearch"], [75, "SubagentDeliveryState"], + [76, "ProjectionProjectsPinArchive"], ]); }).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))), ); diff --git a/apps/server/src/project/ProjectMutation.ts b/apps/server/src/project/ProjectMutation.ts index 8cf70f302b8f2..550738b24baae 100644 --- a/apps/server/src/project/ProjectMutation.ts +++ b/apps/server/src/project/ProjectMutation.ts @@ -42,6 +42,8 @@ export const projectMutationOperation = Effect.fn("projectMutationOperation")(fu ? {} : { defaultThreadEnvMode: mutation.defaultThreadEnvMode }), ...(mutation.scripts === undefined ? {} : { scripts: mutation.scripts }), + ...(mutation.pinned === undefined ? {} : { pinned: mutation.pinned }), + ...(mutation.archived === undefined ? {} : { archived: mutation.archived }), }); case "project.delete": diff --git a/apps/server/src/project/ProjectService.ts b/apps/server/src/project/ProjectService.ts index 3ba57db6d7e5a..ee7318fb5d5b7 100644 --- a/apps/server/src/project/ProjectService.ts +++ b/apps/server/src/project/ProjectService.ts @@ -179,6 +179,8 @@ export const make = Effect.gen(function* () { autoPull: row.autoPull, projectIcon: row.projectIcon, scripts: row.scripts, + pinnedAt: row.pinnedAt, + archivedAt: row.archivedAt, createdAt: row.createdAt, updatedAt: row.updatedAt, deletedAt: row.deletedAt, @@ -386,6 +388,8 @@ export const make = Effect.gen(function* () { ? {} : { defaultThreadEnvMode: input.defaultThreadEnvMode }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), + ...(input.pinned === undefined ? {} : { pinned: input.pinned }), + ...(input.archived === undefined ? {} : { archived: input.archived }), }); if (workspaceRoot !== previousRoot) { yield* projectEnrichment.invalidate([previousRoot, workspaceRoot]); diff --git a/apps/server/src/provider/agentContextBudget.json b/apps/server/src/provider/agentContextBudget.json index c8085be773a63..07830f90d0567 100644 --- a/apps/server/src/provider/agentContextBudget.json +++ b/apps/server/src/provider/agentContextBudget.json @@ -1,5 +1,5 @@ { "claudeSystemAppendBytes": 5129, "codexAdditionalContextBytes": 6859, - "mcpToolListJsonBytes": 46007 + "mcpToolListJsonBytes": 46109 } diff --git a/apps/web/src/components/CommandPalette.logic.test.ts b/apps/web/src/components/CommandPalette.logic.test.ts index 8cf712b113cf7..c034c7f4131c1 100644 --- a/apps/web/src/components/CommandPalette.logic.test.ts +++ b/apps/web/src/components/CommandPalette.logic.test.ts @@ -3,6 +3,7 @@ import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools import type { Thread } from "../types"; import { makeThreadFixture } from "../test-fixtures"; import { + newThreadNoProjectIndex, buildNestedSearchItems, resolveCommandPaletteProject, buildThreadActionItems, @@ -938,3 +939,25 @@ it("resolves project metadata and browse roots within the active environment", ( expect(resolveCommandPaletteProject(projects, null, sharedId)).toBeUndefined(); expect(resolveCommandPaletteProject(projects, EnvironmentId.make("local"), null)).toBeUndefined(); }); + +describe("newThreadNoProjectIndex", () => { + const entry = (pinned = false, isPreferred = false, isScratch = false) => ({ + pinned, + isPreferred, + isScratch, + }); + + it("goes after the pinned projects and the first unpinned one", () => { + expect(newThreadNoProjectIndex([entry(true), entry(true), entry(false, true), entry()])).toBe( + 3, + ); + expect(newThreadNoProjectIndex([entry(false, true), entry(), entry(false, false, true)])).toBe( + 1, + ); + }); + + it("keeps a pinned or current scratch project in its own place", () => { + expect(newThreadNoProjectIndex([entry(false, true, true), entry()])).toBe(0); + expect(newThreadNoProjectIndex([entry(true), entry(true, false, true), entry()])).toBe(1); + }); +}); diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index f9b47f75180bd..cc8fd9091ded8 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -208,6 +208,14 @@ export interface CommandPaletteActionItem extends CommandPaletteItem { readonly kind: "action"; readonly keepOpen?: boolean; readonly run: () => Promise; + /** A pin toggle on the row, also reached with the pin shortcut while the row is highlighted. */ + readonly pin?: CommandPaletteItemPin; +} + +export interface CommandPaletteItemPin { + readonly name: string; + readonly pinned: boolean; + readonly toggle: () => void; } export interface CommandPaletteSubmenuItem extends CommandPaletteItem { @@ -364,24 +372,29 @@ export function buildProjectActionItems(input: { searchTerms?: (project: CommandPaletteProject) => ReadonlyArray; renderDescription?: (project: CommandPaletteProject) => ReactNode; shortcutCommand?: KeybindingCommand; + pin?: (project: CommandPaletteProject) => CommandPaletteItemPin | null; }): CommandPaletteActionItem[] { - return input.projects.map((project) => ({ - kind: "action", - value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [ - project.displayName, - project.title, - project.workspaceRoot, - ...(input.searchTerms?.(project) ?? []), - ], - title: project.displayName, - description: input.renderDescription?.(project) ?? project.workspaceRoot, - icon: input.icon(project), - ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), - run: async () => { - await input.runProject(project); - }, - })); + return input.projects.map((project) => { + const pin = input.pin?.(project) ?? null; + return { + kind: "action", + value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, + searchTerms: [ + project.displayName, + project.title, + project.workspaceRoot, + ...(input.searchTerms?.(project) ?? []), + ], + title: project.displayName, + description: input.renderDescription?.(project) ?? project.workspaceRoot, + icon: input.icon(project), + ...(input.shortcutCommand !== undefined ? { shortcutCommand: input.shortcutCommand } : {}), + ...(pin ? { pin } : {}), + run: async () => { + await input.runProject(project); + }, + }; + }); } export type BuildThreadActionItemsThread = Pick< @@ -869,3 +882,25 @@ export function resolveCommandPaletteProject( ) { return environmentId && projectId ? projects.get(`${environmentId}:${projectId}`) : undefined; } + +/** + * Where "No project" goes among the new-thread picker's real projects: at the + * scratch project's own place when it is pinned or current, else after the + * pinned projects and the first unpinned one. + */ +export function newThreadNoProjectIndex( + entries: ReadonlyArray<{ + readonly isScratch: boolean; + readonly pinned: boolean; + readonly isPreferred: boolean; + }>, +): number { + const projects = entries.filter((entry) => !entry.isScratch); + const scratchAt = entries.findIndex((entry) => entry.isScratch); + const scratch = entries[scratchAt]; + if (scratch && (scratch.pinned || scratch.isPreferred)) { + return entries.slice(0, scratchAt).filter((entry) => !entry.isScratch).length; + } + const pinnedCount = projects.filter((entry) => entry.pinned).length; + return Math.min(pinnedCount + 1, projects.length); +} diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index deb9092726641..486d80a988666 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -68,6 +68,8 @@ import { RotateCcwIcon, SettingsIcon, MonitorIcon, + PinIcon, + PinOffIcon, SquarePenIcon, TextSearchIcon, UserPlusIcon, @@ -111,11 +113,14 @@ import { threadEnvironment } from "../state/threads"; import { readEnvironmentScope, useEnvironmentScope } from "~/state/session"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; +import { useOrchestrationCommand } from "../state/use-orchestration-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; import { useScratchProject } from "../hooks/useScratchProject"; import { useStartTriage } from "../hooks/useStartTriage"; import { pickTriageEnvironment } from "../lib/triageTarget"; import { useNewProject } from "../hooks/useNewProject"; +import { useOrderWhileOpen } from "../hooks/useOrderWhileOpen"; +import { reportProjectFailure, useProjectPin } from "../hooks/useProjectGroupUpdate"; import { useEnvironmentProviders, useEnvironments, @@ -167,6 +172,7 @@ import { buildLinkedThreadActionItems, buildCommandPaletteRows, enumerateCommandPaletteItems, + newThreadNoProjectIndex, findHighlightedCommandPaletteItem, type CommandPaletteActionItem, type CommandPaletteOpenIntent, @@ -236,6 +242,7 @@ import { suggestAppAccessForRemote } from "./sourceControlAppAccessToast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +import { pickableProjectGroups } from "@t3tools/client-runtime/state/project-grouping"; import { buildSidebarProjectPickerEntries } from "../sidebarProjectGrouping"; import { PullRequestGlyph } from "~/components/pullRequest/pullRequestIcons"; import { readPullRequestListPreferences } from "~/components/pullRequest/pullRequestListPreferences"; @@ -270,6 +277,8 @@ function openIntentView(intent: CommandPaletteOpenIntent | null): CommandPalette } } +const NEW_THREAD_NO_PROJECT_VALUE = "new-thread-in:no-project"; + export function CommandPalette({ children }: { children: ReactNode }) { useCommandPaletteMount(); const state = useCommandPaletteStore(); @@ -408,6 +417,9 @@ function OpenCommandPaletteDialog(props: { const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false, }); + const updateProject = useOrchestrationCommand(projectEnvironment.update, { + label: "project unarchive", + }); const { scratchEnvironmentId, scratchWorkspaceRootFor, startScratchThread } = useScratchProject(); const startTriage = useStartTriage(); const lookupRepository = useAtomQueryRunner(sourceControlEnvironment.repository, { @@ -500,7 +512,7 @@ function OpenCommandPaletteDialog(props: { handleNewThread, }); const projectPickerEntries = buildSidebarProjectPickerEntries({ - groups: projectGroups, + groups: pickableProjectGroups(projectGroups), preferredProjectRef: contextualProjectRef, }); const pickerProjects = projectPickerEntries.map(({ group, targetProject }) => ({ @@ -513,6 +525,21 @@ function OpenCommandPaletteDialog(props: { group, ]), ); + const { canPinProjectGroup, toggleProjectGroupPin } = useProjectPin(); + // A row's target record can change when another machine's copy loads, so the kept + // order follows the project group instead. + const rowOrderKey = (prefix: string) => (item: CommandPaletteActionItem) => + projectGroupByTargetKey.get(item.value.slice(prefix.length + 1))?.projectKey ?? item.value; + const projectPin = (project: CommandPaletteProject) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + return group && canPinProjectGroup(group) + ? { + name: group.displayName, + pinned: group.pinned, + toggle: () => toggleProjectGroupPin(group), + } + : null; + }; const addProjectEnvironmentOptions = (() => { const options = environments @@ -755,21 +782,28 @@ function OpenCommandPaletteDialog(props: { await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }; - const projectSearchItems = buildProjectSearchItems({ - projects: pickerProjects, - projectGroupByTargetKey, - locationByEnvironmentId: projectEnvironmentLocationById, - runProject: openProjectFromSearch, - }); + // Pinning from a row keeps rows in place while the palette is open. + const projectSearchItems = useOrderWhileOpen( + buildProjectSearchItems({ + projects: pickerProjects, + projectGroupByTargetKey, + locationByEnvironmentId: projectEnvironmentLocationById, + runProject: openProjectFromSearch, + pin: projectPin, + }), + rowOrderKey("project"), + true, + ); - const projectThreadItems = (() => { - const isScratch = (project: CommandPaletteProject) => - isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); - const projectItems = enumerateCommandPaletteItems( - buildNewThreadInProjectItems({ + const openingOrderThreadItems = useOrderWhileOpen( + (() => { + const isScratch = (project: CommandPaletteProject) => + isScratchProject(project, scratchWorkspaceRootFor(project.environmentId)); + const projectItems = buildNewThreadInProjectItems({ projects: pickerProjects.filter((project) => !isScratch(project)), projectGroupByTargetKey, locationByEnvironmentId: projectEnvironmentLocationById, + pin: projectPin, runProject: async (project) => { const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); const contextualRefBelongsToGroup = @@ -785,25 +819,45 @@ function OpenCommandPaletteDialog(props: { : scopeProjectRef(project.environmentId, project.id), ); }, - }), - ); - if (scratchTargetEnvironmentId === null) return projectItems; + }); + if (scratchTargetEnvironmentId === null) return projectItems; - const noProjectIndex = pickerProjects[0] !== undefined && isScratch(pickerProjects[0]) ? 0 : 1; - return [ - ...projectItems.slice(0, noProjectIndex), - { - kind: "action" as const, - value: "new-thread-in:no-project", - searchTerms: ["no project", "without project", "none"], - title: "No project", - icon: , - shortcutCommand: "chat.newWithoutProject" as const, - run: () => startScratchThread(scratchTargetEnvironmentId), - }, - ...projectItems.slice(noProjectIndex), - ]; - })(); + const noProjectIndex = newThreadNoProjectIndex( + projectPickerEntries.map(({ group, targetProject, isPreferred }) => ({ + isScratch: isScratchProject( + targetProject, + scratchWorkspaceRootFor(targetProject.environmentId), + ), + pinned: group.pinned, + isPreferred, + })), + ); + return [ + ...projectItems.slice(0, noProjectIndex), + { + kind: "action" as const, + value: NEW_THREAD_NO_PROJECT_VALUE, + searchTerms: ["no project", "without project", "none"], + title: "No project", + icon: , + shortcutCommand: "chat.newWithoutProject" as const, + run: () => startScratchThread(scratchTargetEnvironmentId), + }, + ...projectItems.slice(noProjectIndex), + ]; + })(), + rowOrderKey("new-thread-in"), + true, + ); + // Jump shortcuts follow the rows as shown, so a pin cannot move a number to another row. + const projectJumpItems = new Map( + enumerateCommandPaletteItems( + openingOrderThreadItems.filter((item) => item.value !== NEW_THREAD_NO_PROJECT_VALUE), + ).map((item) => [item.value, item] as const), + ); + const projectThreadItems = openingOrderThreadItems.map( + (item) => projectJumpItems.get(item.value) ?? item, + ); const { threadItems: allThreadItems, @@ -1420,7 +1474,7 @@ function OpenCommandPaletteDialog(props: { }); } - actionItems.push({ + const addProjectAction: CommandPaletteActionItem = { kind: "action", value: "action:add-project", searchTerms: [ @@ -1448,7 +1502,8 @@ function OpenCommandPaletteDialog(props: { run: async () => { openAddProjectFlow(); }, - }); + }; + actionItems.push(addProjectAction); if (wslAddProjectEnvironmentOption) { actionItems.push({ @@ -1536,14 +1591,33 @@ function OpenCommandPaletteDialog(props: { }, }); - const contextualProjectGroup = + // Every group, so an archived project's thread still opens its own settings. + const currentProjectGroup = (contextualProjectRef - ? projectGroupByTargetKey.get( - `${contextualProjectRef.environmentId}:${contextualProjectRef.projectId}`, + ? projectGroups.find((group) => + group.memberProjectRefs.some( + (ref) => + ref.environmentId === contextualProjectRef.environmentId && + ref.projectId === contextualProjectRef.projectId, + ), ) - : null) ?? - projectGroups[0] ?? - null; + : null) ?? null; + if (currentProjectGroup && canPinProjectGroup(currentProjectGroup)) { + actionItems.push({ + kind: "action", + value: "action:project-pin", + searchTerms: ["pin", "unpin", "project", "favorite"], + title: currentProjectGroup.pinned ? "Unpin project" : "Pin project", + description: currentProjectGroup.displayName, + icon: currentProjectGroup.pinned ? ( + + ) : ( + + ), + run: async () => toggleProjectGroupPin(currentProjectGroup), + }); + } + const contextualProjectGroup = currentProjectGroup ?? projectGroups[0] ?? null; if (contextualProjectGroup) { actionItems.push({ kind: "action", @@ -1608,7 +1682,14 @@ function OpenCommandPaletteDialog(props: { : currentView?.groups[0]?.value === ADD_PROJECT_ENTRY_GROUP ? addProjectEntryGroups() : currentView?.groups[0]?.value === NEW_THREAD_IN_GROUP - ? [{ value: NEW_THREAD_IN_GROUP, label: "Projects", items: projectThreadItems }] + ? [ + { + value: NEW_THREAD_IN_GROUP, + label: "Projects", + // Every project archived and no "No project" home leaves only adding one. + items: projectThreadItems.length > 0 ? projectThreadItems : [addProjectAction], + }, + ] : currentView?.groups[0]?.value === "themes" ? changeThemeItem.groups : currentView?.groups[0]?.value === "appearance" @@ -1700,6 +1781,13 @@ function OpenCommandPaletteDialog(props: { cwd, ); if (existing) { + // Adding an archived project's folder again brings the project back. + if (existing.archivedAt != null) { + void updateProject({ + environmentId: existing.environmentId, + input: { projectId: existing.id, archived: false }, + }).then((result) => reportProjectFailure("Failed to unarchive project", result)); + } const latestThread = getLatestThreadForProject( threads.filter((thread) => thread.environmentId === existing.environmentId), existing.id, @@ -2302,6 +2390,19 @@ function OpenCommandPaletteDialog(props: { platform: navigator.platform, context: { modelPickerOpen: false, editableFocus: true }, }); + if (command === "thread.pin") { + const highlightedItem = findHighlightedCommandPaletteItem( + displayedGroups, + highlightedItemValue ?? + (autoHighlightsFirstRow ? (resultRows.itemValues[0] ?? null) : null), + ); + if (highlightedItem?.kind === "action" && highlightedItem.pin) { + event.preventDefault(); + event.stopPropagation(); + highlightedItem.pin.toggle(); + return; + } + } // Global new-thread handlers ignore shortcuts while the palette is open. const isJump = threadJumpIndexFromCommand(command ?? "") !== null; const isNewThread = diff --git a/apps/web/src/components/CommandPaletteProjectItems.tsx b/apps/web/src/components/CommandPaletteProjectItems.tsx index 90373413be4fa..2a3d466474d2e 100644 --- a/apps/web/src/components/CommandPaletteProjectItems.tsx +++ b/apps/web/src/components/CommandPaletteProjectItems.tsx @@ -5,6 +5,7 @@ import type { Project } from "../types"; import { buildCommandPaletteProjectMetadata, buildProjectActionItems, + type CommandPaletteItemPin, type CommandPaletteProject, ITEM_ICON_CLASS, } from "./CommandPalette.logic"; @@ -29,6 +30,7 @@ interface ProjectItemsInput { readonly projectGroupByTargetKey: ReadonlyMap; readonly locationByEnvironmentId: ReadonlyMap; readonly runProject: (project: CommandPaletteProject) => Promise; + readonly pin?: (project: CommandPaletteProject) => CommandPaletteItemPin | null; } function projectFaviconIcon(project: Project) { @@ -66,6 +68,7 @@ export function buildProjectSearchItems(input: ProjectItemsInput) { }, icon: projectFaviconIcon, runProject: input.runProject, + ...(input.pin ? { pin: input.pin } : {}), }); } @@ -90,6 +93,7 @@ export function buildNewThreadInProjectItems(input: ProjectItemsInput) { ), icon: projectFaviconIcon, runProject: input.runProject, + ...(input.pin ? { pin: input.pin } : {}), }); } diff --git a/apps/web/src/components/CommandPaletteResults.tsx b/apps/web/src/components/CommandPaletteResults.tsx index c481d57d5fc8c..e3cc3a3791afa 100644 --- a/apps/web/src/components/CommandPaletteResults.tsx +++ b/apps/web/src/components/CommandPaletteResults.tsx @@ -22,6 +22,7 @@ import { CommandShortcut, } from "./ui/command"; import { getVirtualizedScrollFadeClassName } from "./ui/scroll-area"; +import { ProjectPinButton } from "./ProjectPinButton"; import { SearchMatchHintLine, ThreadSearchMatchExcerpt } from "./ThreadSearchMatch"; interface CommandPaletteResultsProps { @@ -268,6 +269,14 @@ function CommandPaletteResultRow(props: { )} {props.item.titleTrailingContent} + {props.item.kind === "action" && props.item.pin ? ( + + ) : null} {props.item.timestamp ? ( {props.item.timestamp} diff --git a/apps/web/src/components/ProjectPinButton.tsx b/apps/web/src/components/ProjectPinButton.tsx new file mode 100644 index 0000000000000..a8bd7d5ee8e99 --- /dev/null +++ b/apps/web/src/components/ProjectPinButton.tsx @@ -0,0 +1,67 @@ +import type { ResolvedKeybindingsConfig } from "@t3tools/contracts"; +import { PinIcon, PinOffIcon } from "lucide-react"; +import type { KeyboardEvent } from "react"; + +import { resolveShortcutCommand } from "../keybindings"; + +import { cn } from "~/lib/utils"; +import { Button } from "./ui/button"; + +/** The thread pin shortcut, which pins the highlighted row in a project list. */ +export function isProjectPinShortcut( + event: KeyboardEvent, + keybindings: ResolvedKeybindingsConfig, +): boolean { + return ( + resolveShortcutCommand(event.nativeEvent, keybindings, { + platform: navigator.platform, + context: { modelPickerOpen: false, editableFocus: true }, + }) === "thread.pin" + ); +} + +/** + * Pin toggle for a project row in a menu or list. A pinned project always shows its pin; + * an unpinned one shows it only on the highlighted row. Keyboard users get a shortcut + * from the list that hosts it, so the button stays out of the tab order. + */ +export function ProjectPinButton(props: { + readonly name: string; + readonly pinned: boolean; + readonly onToggle: () => void; + readonly shortcutLabel?: string | null; +}) { + const action = `${props.pinned ? "Unpin" : "Pin"} ${props.name}`; + return ( + + + + ); +} diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 68d8577690643..f6e66a1ac6506 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it, vi } from "vite-plus/test"; import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/reactivity"; import { + shouldCreateNewThreadInCurrentProject, archiveSelectedThreadEntries, deleteSelectedThreadEntries, getFallbackThreadIdAfterDelete, @@ -1630,6 +1631,12 @@ describe("groupSidebarThreadsByProject", () => { projectKey: "fleet", memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: fleet }], }, + { + projectKey: "notes", + memberProjectRefs: [ + { environmentId: localEnvironmentId, projectId: ProjectId.make("notes") }, + ], + }, ]; const threads = [ @@ -1637,17 +1644,23 @@ describe("groupSidebarThreadsByProject", () => { thread("orphan", localEnvironmentId, ProjectId.make("deleted")), thread("b", remoteEnvironmentId, t3codeRemote), ]; - const summarize = (keepEmpty: boolean) => + const summarize = (keepEmpty: (group: (typeof groups)[number]) => boolean) => groupSidebarThreadsByProject(groups, threads, keepEmpty).map((folder) => ({ projectKey: folder.group.projectKey, threads: folder.threads.map((entry) => entry.id), })); - expect(summarize(true)).toEqual([ + expect(summarize(() => true)).toEqual([ + { projectKey: "t3code", threads: ["a", "b"] }, + { projectKey: "fleet", threads: [] }, + { projectKey: "notes", threads: [] }, + ]); + expect(summarize(() => false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); + // A pinned project keeps its empty folder while other empty projects drop out. + expect(summarize((group) => group.projectKey === "fleet")).toEqual([ { projectKey: "t3code", threads: ["a", "b"] }, { projectKey: "fleet", threads: [] }, ]); - expect(summarize(false)).toEqual([{ projectKey: "t3code", threads: ["a", "b"] }]); }); }); @@ -1687,3 +1700,13 @@ describe("resolveSidebarFolderDot", () => { expect(resolveSidebarFolderDot([unread, limited])).toBe("attention"); }); }); + +describe("shouldCreateNewThreadInCurrentProject", () => { + it("opens the picker when several projects are open, or only archived ones exist", () => { + expect(shouldCreateNewThreadInCurrentProject(false, 2, 3)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 2)).toBe(false); + expect(shouldCreateNewThreadInCurrentProject(false, 1, 3)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(false, 0, 0)).toBe(true); + expect(shouldCreateNewThreadInCurrentProject(true, 2, 2)).toBe(true); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index 6648ab93d515c..730a3508bb8cc 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -782,11 +782,13 @@ export function isSidebarNestedLinkClick(target: EventTarget | null): boolean { return nodeClosest(parent, "a[href]") !== null; } +/** The "New thread in" picker opens unless there is one open project or none; then the current context decides. */ export function shouldCreateNewThreadInCurrentProject( shiftKey: boolean, + openProjectGroupCount: number, projectGroupCount: number, ): boolean { - return shiftKey || projectGroupCount <= 1; + return shiftKey || openProjectGroupCount === 1 || projectGroupCount === 0; } export function orderItemsByPreferredIds(input: { @@ -973,7 +975,7 @@ export function sidebarFolderExpansionKeys(group: { /** * Boring sidebar folders. Folders follow `groups` order and threads keep their - * input order. A project with no threads gets a folder only with `keepEmpty`. + * input order. A project with no threads gets a folder only when `keepEmpty` says so. * A thread whose project is in no group has no folder and is left out. */ export function groupSidebarThreadsByProject< @@ -987,7 +989,7 @@ export function groupSidebarThreadsByProject< >( groups: readonly TGroup[], threads: readonly TThread[], - keepEmpty: boolean, + keepEmpty: (group: TGroup) => boolean, ): Array<{ group: TGroup; threads: TThread[] }> { const folders = groups.map((group) => ({ group, threads: [] as TThread[] })); const folderByProject = new Map(); @@ -999,7 +1001,7 @@ export function groupSidebarThreadsByProject< for (const thread of threads) { folderByProject.get(`${thread.environmentId}:${thread.projectId}`)?.threads.push(thread); } - return keepEmpty ? folders : folders.filter((folder) => folder.threads.length > 0); + return folders.filter((folder) => folder.threads.length > 0 || keepEmpty(folder.group)); } export type SidebarV2TopStatusKind = diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 3c09b32bd2db3..e6f23149dfedb 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -103,7 +103,10 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { readLocalApi } from "../localApi"; import { useSidebarPendingFileDropStore } from "../sidebarPendingFileDropStore"; -import { selectProjectGroupingSettings } from "@t3tools/client-runtime/state/project-grouping"; +import { + pickableProjectGroups, + selectProjectGroupingSettings, +} from "@t3tools/client-runtime/state/project-grouping"; import { projectGroupsSpanEnvironments, resolveSidebarProjectScopeKeys, @@ -121,6 +124,8 @@ import { useThreadActions, } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; +import { useOrderWhileOpen } from "../hooks/useOrderWhileOpen"; +import { useProjectPin } from "../hooks/useProjectGroupUpdate"; import { useSidebarProjectGroups } from "../hooks/useSidebarProjectGroups"; import { useTerminalFocus } from "../hooks/useTerminalFocus"; import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; @@ -230,6 +235,7 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { copyThreadReference } from "../lib/copyThreadReference"; import { threadChipCopyTarget } from "@t3tools/client-runtime/thread-reference"; import { Button } from "./ui/button"; +import { isProjectPinShortcut, ProjectPinButton } from "./ProjectPinButton"; import { Combobox, ComboboxEmpty, @@ -408,8 +414,8 @@ function useSidebarThreads(input: { readonly routeRowThreadKey: string | null; // Set in boring mode: the project groups to fold active threads into, in order. readonly folderGroups: readonly SidebarProjectSnapshot[] | null; - // Keeps folders with no active threads, so a project the user filtered to can start one. - readonly keepEmptyFolders: boolean; + // Keeps folders with no active threads, so a project the user filtered to or pinned can start one. + readonly keepEmptyFolders: (group: SidebarProjectSnapshot) => boolean; }) { const { projectScopeKey, @@ -811,7 +817,10 @@ function useSidebarLookups() { ); const projectScopeItems = [ { value: "all", label: "All projects" }, - ...projectGroups.map((project) => ({ value: project.projectKey, label: project.displayName })), + ...pickableProjectGroups(projectGroups).map((project) => ({ + value: project.projectKey, + label: project.displayName, + })), ]; return { projects, @@ -886,7 +895,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve projectByKey, projectDisplayNameByKey, projectGroupByScopeKey, - projectScopeItems, + projectScopeItems: lookupProjectScopeItems, } = useSidebarLookups(); const delegatedUnderParent = useDelegatedUnderParent(); const router = useRouter(); @@ -1054,13 +1063,18 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }; // A scope whose project is gone reads as all projects once every snapshot has // loaded. The stored keys are left alone, so the scope returns with its project. + // An archived project's scope reads as all projects, matching the menu. + const scopeGroups = pickableProjectGroups(projectGroups); + const archivedProjectKeys = new Set( + projectGroups.filter((project) => project.archived).map((project) => project.projectKey), + ); const chosenProjectScopeKeys = resolveSidebarProjectScopeKeys({ groups: projectGroups, keys: persistedProjectScopeKeys, settings: projectGroupingSettings, canClearMissingScope: allProjectSnapshotsReady && allProjectConfigsReady, - }); - const chosenProjectGroups = projectGroups.filter((project) => + }).filter((key) => !archivedProjectKeys.has(key)); + const chosenProjectGroups = scopeGroups.filter((project) => chosenProjectScopeKeys.includes(project.projectKey), ); const chosenScopeProjectKeys = @@ -1134,6 +1148,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve } return environmentLabelById.get(thread.environmentId) ?? null; }; + // Pinning from a row keeps rows in place while the menu is open. + const projectScopeItems = useOrderWhileOpen( + lookupProjectScopeItems, + (item) => item.value, + projectScopeMenuState.open, + ); const selectedProjectScopeItem = projectScopeKeys.length > 1 ? null @@ -1194,6 +1214,11 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve }); }, ); + const { + canPinProjectGroup: canPinProject, + setProjectGroupPinned, + toggleProjectGroupPin, + } = useProjectPin(); const handleProjectSettings = useStableCallback( ( event: ReactMouseEvent | ReactKeyboardEvent, @@ -1220,6 +1245,13 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve [ { id: "new-thread", label: "New thread" }, { id: "copy-path", label: "Copy path" }, + ...(canPinProject(group) + ? [ + group.pinned + ? { id: "unpin", label: "Unpin project", icon: "pin-off" } + : { id: "pin", label: "Pin project", icon: "pin" }, + ] + : []), { id: "project-settings", label: "Project settings", icon: "settings" }, ], position, @@ -1233,6 +1265,10 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve case "copy-path": copyPathToClipboard(group.workspaceRoot, { path: group.workspaceRoot }); return; + case "pin": + case "unpin": + setProjectGroupPinned(group, clicked.value === "pin"); + return; case "project-settings": openProjectSettings(group); return; @@ -1288,10 +1324,12 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve routeRowThreadKey, folderGroups: boringSidebarEnabled ? scopedProjectGroups.length === 0 - ? projectGroups + ? // An archived project keeps a folder for its active threads, below the rest. + [...scopeGroups, ...projectGroups.filter((project) => project.archived)] : scopedProjectGroups : null, - keepEmptyFolders: scopedProjectGroups.length > 0, + keepEmptyFolders: (group) => + scopedProjectGroups.length > 0 || (group.pinned && !group.archived), }); // Ranked across environments as the palette ranks them, so one @@ -1862,7 +1900,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const key = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id)); return { kind: "thread", key, section }; }); - // Boring mode keeps a project the user filtered to, so it can start a thread. + // Boring mode keeps a pinned open project or one the user filtered to, so it can start a thread. if ( !hasProjectFolders && pinnedThreads.length + @@ -2552,12 +2590,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve AuthOrchestrationOperateScope, ), branch: thread.branch ?? null, - projectFilter: threadProjectGroup - ? { - label: threadProjectGroup.displayName, - isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), - } - : null, + // An archived project is not a filter choice. + projectFilter: + threadProjectGroup && !threadProjectGroup.archived + ? { + label: threadProjectGroup.displayName, + isActive: projectScopeKeys.includes(threadProjectGroup.projectKey), + } + : null, isPinned, isSettled, autoSettleEnabled: thread.autoSettleDisabledAt == null, @@ -2875,14 +2915,21 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const handleNewThreadClick = (event?: ReactMouseEvent) => { if ( boringSidebarEnabled || - shouldCreateNewThreadInCurrentProject(event?.shiftKey ?? false, projectGroups.length) + shouldCreateNewThreadInCurrentProject( + event?.shiftKey ?? false, + scopeGroups.length, + projectGroups.length, + ) ) { if (isMobile) setOpenMobile(false); + // With no current or open project, such as when every project is archived, pick one. void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, activeThread: newThreadContext.activeThread ?? undefined, defaultProjectRef: newThreadContext.defaultProjectRef, handleNewThread, + }).then((started) => { + if (!started) openCommandPalette({ open: "new-thread-in" }); }); return; } @@ -2892,7 +2939,9 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve const newThreadShortcutLabel = shortcutLabelForCommand(keybindings, "chat.new") ?? - (projectGroups.length <= 1 ? shortcutLabelForCommand(keybindings, "chat.newLocal") : undefined); + (shouldCreateNewThreadInCurrentProject(false, scopeGroups.length, projectGroups.length) + ? shortcutLabelForCommand(keybindings, "chat.newLocal") + : undefined); const newThreadInProjectShortcutLabel = shortcutLabelForCommand(keybindings, "chat.newLocal"); return ( <> @@ -2984,15 +3033,24 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve if ( event.defaultPrevented || event.nativeEvent.isComposing || - event.keyCode === 229 || - event.ctrlKey || - event.altKey || - event.metaKey + event.keyCode === 229 ) { return; } const scopeKey = highlightedProjectScopeKeyRef.current; const project = scopeKey ? projectGroupByScopeKey.get(scopeKey) : null; + // The thread pin shortcut pins the highlighted project here. + if ( + project && + canPinProject(project) && + isProjectPinShortcut(event, keybindings) + ) { + event.preventDefault(); + event.stopPropagation(); + toggleProjectGroupPin(project); + return; + } + if (event.ctrlKey || event.altKey || event.metaKey) return; if (event.shiftKey && event.key === "Enter") { if (!project) return; event.preventDefault(); @@ -3048,6 +3106,14 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve machineByEnvironmentId={environmentMachineById} /> ) : null} + {project && canPinProject(project) ? ( + toggleProjectGroupPin(project)} + /> + ) : null} {project ? ( + } + /> + + ) : null} diff --git a/apps/web/src/components/settings/ScheduledTasksSettings.tsx b/apps/web/src/components/settings/ScheduledTasksSettings.tsx index 7747562dfeb4c..934436d8dce4e 100644 --- a/apps/web/src/components/settings/ScheduledTasksSettings.tsx +++ b/apps/web/src/components/settings/ScheduledTasksSettings.tsx @@ -79,6 +79,7 @@ import { WEBHOOK_SIGNATURE_DEFAULTS, webhookDraftErrors, webhookDeliveryLabel, + defaultScheduledTaskProjectId, matchesScheduledTaskScope, scheduleFromDraft, scheduledTaskDefaultModel, @@ -1176,11 +1177,11 @@ function ScheduledTaskEditorForm({ connected ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null, ); const allProjects = useProjects(); - const projects = allProjects.filter( - (project) => - project.environmentId === environmentId && - matchesScheduledTaskScope(scope, environmentId, project.id), - ); + const projectsOn = (id: EnvironmentId) => + allProjects.filter( + (project) => project.environmentId === id && matchesScheduledTaskScope(scope, id, project.id), + ); + const scopedProjects = projectsOn(environmentId); const settings = useEnvironmentSettings(environmentId); const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)) ?? EMPTY_SERVER_PROVIDERS; @@ -1191,8 +1192,22 @@ function ScheduledTaskEditorForm({ label: "scheduled task upsert", }); const [draft, setDraft] = useState(() => - task ? taskToDraft(task) : { ...EMPTY_DRAFT, projectId: projects[0]?.id ?? "" }, + task + ? taskToDraft(task) + : { + ...EMPTY_DRAFT, + projectId: defaultScheduledTaskProjectId(scopedProjects, scope), + }, ); + // Pinned first. The chosen project stays listed after it is archived. + const projects = scopedProjects + .filter( + (project) => + project.archivedAt == null || + project.id === draft.projectId || + project.id === task?.projectId, + ) + .sort((left, right) => Number(right.pinnedAt != null) - Number(left.pinnedAt != null)); const submissionPending = useRef(false); const editingTaskMissing = draft.editingId !== null && @@ -1357,7 +1372,7 @@ function ScheduledTaskEditorForm({ setEnvironmentId(next.environmentId); setDraft((current) => ({ ...current, - projectId: "", + projectId: defaultScheduledTaskProjectId(projectsOn(next.environmentId), scope), modelKey: "", baseModelSelection: null, baseRef: "main", diff --git a/apps/web/src/components/settings/SettingsScopeNotice.tsx b/apps/web/src/components/settings/SettingsScopeNotice.tsx index 6646f4fe3f620..fb4f9e36fc924 100644 --- a/apps/web/src/components/settings/SettingsScopeNotice.tsx +++ b/apps/web/src/components/settings/SettingsScopeNotice.tsx @@ -84,6 +84,7 @@ export function SettingsScopeNotice({ ? groups.map((group) => ({ label: group.displayName, secondary: [ + ...(group.archived ? ["Archived"] : []), ...(groups.some( (other) => other.projectKey !== group.projectKey && other.displayName === group.displayName, diff --git a/apps/web/src/components/settings/SettingsScopeSentence.tsx b/apps/web/src/components/settings/SettingsScopeSentence.tsx index dfefa375bb974..bb6a896d45869 100644 --- a/apps/web/src/components/settings/SettingsScopeSentence.tsx +++ b/apps/web/src/components/settings/SettingsScopeSentence.tsx @@ -324,17 +324,18 @@ function ProjectScopeMenu({ value, groups, environments, onChange }: SettingsSco other.group.projectKey !== group.projectKey && other.group.displayName === group.displayName, ); + const location = + sameName.length === 0 + ? environments.length > 1 + ? group.workspaceRoot + : "" + : sameName.some((other) => other.machines === machines) + ? `${machines} · ${group.workspaceRoot}` + : machines; return { value: group.projectKey, label: group.displayName, - secondary: - sameName.length === 0 - ? environments.length > 1 - ? group.workspaceRoot - : "" - : sameName.some((other) => other.machines === machines) - ? `${machines} · ${group.workspaceRoot}` - : machines, + secondary: group.archived ? ["Archived", location].filter(Boolean).join(" · ") : location, icon: , }; })} diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts index 750f31f2229a5..7d13e1edc4e36 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.test.ts @@ -18,6 +18,7 @@ import { resolveSettingsScope, type SettingsScopeSearch } from "./settingsScope" import { deriveProviderInstanceEntries } from "../../providerInstances"; import { scheduledTaskDefaultModel, + defaultScheduledTaskProjectId, matchesScheduledTaskScope, scheduleFromDraft, taskToDraft, @@ -70,6 +71,8 @@ function group( allRemoteMembersAreDesktopLocal: false, allRemoteMembersAreWsl: false, remoteEnvironmentLabels: [], + pinned: false, + archived: false, }; } @@ -101,6 +104,22 @@ describe("scheduled task settings scope", () => { ).toEqual(expected); }); + it("defaults a new task to a pinned open project, and to an archived one only when scoped to it", () => { + const at = "2026-01-01T00:00:00.000Z"; + const project = (id: string, pinnedAt: string | null, archivedAt: string | null) => ({ + id: ProjectId.make(id), + pinnedAt, + archivedAt, + }); + const all = resolveSettingsScope({}, groups, environments); + const scoped = resolveSettingsScope({ project: "t3code" }, groups, environments); + expect( + defaultScheduledTaskProjectId([project("a", null, null), project("b", at, null)], all), + ).toBe("b"); + expect(defaultScheduledTaskProjectId([project("x", null, at)], all)).toBe(""); + expect(defaultScheduledTaskProjectId([project("x", null, at)], scoped)).toBe("x"); + }); + it("keeps tasks with removed projects manageable at environment scope", () => { const removedProject = ProjectId.make("removed"); expect( diff --git a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts index 218e2a6e683b0..27ddc59f84c7d 100644 --- a/apps/web/src/components/settings/scheduledTasksSettings.logic.ts +++ b/apps/web/src/components/settings/scheduledTasksSettings.logic.ts @@ -285,3 +285,25 @@ export function scheduledTaskDefaultModel( const fallback = models.find(({ model }) => model.isDefault) ?? models[0]; return fallback ? { instanceId: fallback.instanceId, model: fallback.model.slug } : null; } + +/** + * A new task's project: a pinned open project, then any open one. An archived + * project only when Settings is scoped to it, so that scope can still save. + */ +export function defaultScheduledTaskProjectId< + TProject extends { + readonly id: ProjectId; + readonly pinnedAt?: string | null | undefined; + readonly archivedAt?: string | null | undefined; + }, +>(scopedProjects: ReadonlyArray, scope: ResolvedSettingsScope): ProjectId | "" { + const open = scopedProjects.filter((project) => project.archivedAt == null); + const scopedToProject = scope.kind === "project" || scope.kind === "checkout"; + return ( + ( + open.find((project) => project.pinnedAt != null) ?? + open[0] ?? + (scopedToProject ? scopedProjects[0] : undefined) + )?.id ?? "" + ); +} diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 9f6ef5c5f9262..bbe1f1fd00f8a 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -91,6 +91,8 @@ const group: SidebarProjectSnapshot = { allRemoteMembersAreDesktopLocal: false, allRemoteMembersAreWsl: false, remoteEnvironmentLabels: [server.label, laptop.label], + pinned: false, + archived: false, }; const project = resolveSettingsScope({ project: group.projectKey }, [group], environments); const checkout = resolveSettingsScope( diff --git a/apps/web/src/components/settings/settingsScope.test.ts b/apps/web/src/components/settings/settingsScope.test.ts index 84a70a4faf5d6..60e69b7714f11 100644 --- a/apps/web/src/components/settings/settingsScope.test.ts +++ b/apps/web/src/components/settings/settingsScope.test.ts @@ -54,6 +54,8 @@ function group( allRemoteMembersAreDesktopLocal: false, allRemoteMembersAreWsl: false, remoteEnvironmentLabels: [], + pinned: false, + archived: false, }; } diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 27468e0c82e68..81e7c37b470e3 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -1,7 +1,10 @@ import { EnvironmentId, ProjectId, ProviderInstanceId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { deriveLogicalProjectKeyFromSettings } from "@t3tools/client-runtime/state/project-grouping"; +import { + deriveLogicalProjectKeyFromSettings, + pickableProjectGroups, +} from "@t3tools/client-runtime/state/project-grouping"; import { buildSidebarProjectPickerEntries, buildSidebarProjectSnapshots, @@ -275,4 +278,27 @@ describe("environment grouping", () => { id: fallbackPrimary.id, }); }); + + it("puts the preferred project after pinned projects", () => { + const project = (title: string, pinnedAt: string | null = null) => + makeProject({ + id: ProjectId.make(title), + title, + workspaceRoot: `/tmp/${title}`, + repositoryIdentity: null, + pinnedAt, + }); + const current = project("current"); + const groups = buildSidebarProjectSnapshots({ + projects: [project("other"), project("pinned", "2026-01-01T00:00:00.000Z"), current], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + const entries = buildSidebarProjectPickerEntries({ + groups: pickableProjectGroups(groups), + preferredProjectRef: { environmentId: current.environmentId, projectId: current.id }, + }); + expect(entries.map((entry) => entry.group.displayName)).toEqual(["pinned", "current", "other"]); + }); }); diff --git a/apps/web/src/hooks/useHandleNewThread.ts b/apps/web/src/hooks/useHandleNewThread.ts index e95a2d0d799bd..a43825472f357 100644 --- a/apps/web/src/hooks/useHandleNewThread.ts +++ b/apps/web/src/hooks/useHandleNewThread.ts @@ -428,7 +428,7 @@ export function resolveDefaultProjectRef( projectOrder: readonly string[], ): ScopedProjectRef | null { const [defaultProject] = orderItemsByPreferredIds({ - items: projects, + items: projects.filter((project) => project.archivedAt == null), preferredIds: projectOrder, getId: getProjectOrderKey, getPreferenceIds: (project) => [ diff --git a/apps/web/src/hooks/useOrderWhileOpen.test.ts b/apps/web/src/hooks/useOrderWhileOpen.test.ts new file mode 100644 index 0000000000000..7e4308ef45551 --- /dev/null +++ b/apps/web/src/hooks/useOrderWhileOpen.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { keepOrder, withArrivedKeys } from "./useOrderWhileOpen"; + +describe("keepOrder", () => { + it("keeps the opening order and puts new items after it", () => { + const key = (item: string) => item; + // "c" was pinned and moved to the top; "d" arrived after the list opened; "b" is gone. + expect(keepOrder(["c", "d", "a"], ["a", "b", "c"], key)).toEqual(["a", "c", "d"]); + expect(keepOrder(["c", "a"], null, key)).toEqual(["c", "a"]); + }); + + it("holds rows that arrived after opening in their first place", () => { + // "b" and "c" load from another machine after the list opened with nothing in it. + const order = withArrivedKeys(null, ["b", "c"]); + expect(order).toEqual(["b", "c"]); + // Pinning "c" moves it first in the source list; the shown order stays. + expect(keepOrder(["c", "b"], order, (item) => item)).toEqual(["b", "c"]); + expect(withArrivedKeys(order, ["b", "c"])).toBe(order); + }); +}); diff --git a/apps/web/src/hooks/useOrderWhileOpen.ts b/apps/web/src/hooks/useOrderWhileOpen.ts new file mode 100644 index 0000000000000..c925a8bbb1b5b --- /dev/null +++ b/apps/web/src/hooks/useOrderWhileOpen.ts @@ -0,0 +1,46 @@ +import { useState } from "react"; + +/** Sorts `items` into `order` by key. Items missing from `order` follow, in their own order. */ +export function keepOrder( + items: ReadonlyArray, + order: ReadonlyArray | null, + key: (item: T) => string, +): T[] { + if (order === null) return [...items]; + const rank = new Map(order.map((itemKey, index) => [itemKey, index])); + return items + .map((item, index) => ({ item, rank: rank.get(key(item)) ?? order.length + index })) + .toSorted((a, b) => a.rank - b.rank) + .map(({ item }) => item); +} + +/** `order` with keys it has not seen added at the end, or `order` itself when nothing is new. */ +export function withArrivedKeys( + order: ReadonlyArray | null, + keys: ReadonlyArray, +): ReadonlyArray | null { + const known = new Set(order); + const arrived = keys.filter((itemKey) => !known.has(itemKey)); + return arrived.length === 0 ? order : [...(order ?? []), ...arrived]; +} + +/** + * Keeps the order a list had when it opened, so a row that a pin moves does not slide out + * from under the pointer or the keyboard highlight. Rows that load later join at the end and + * then hold their place too. The new order shows on the next open. + */ +export function useOrderWhileOpen( + items: ReadonlyArray, + key: (item: T) => string, + open: boolean, +): T[] { + const [order, setOrder] = useState | null>(null); + if (!open) { + if (order !== null) setOrder(null); + return [...items]; + } + const ordered = keepOrder(items, order, key); + const nextOrder = withArrivedKeys(order, ordered.map(key)); + if (nextOrder !== order) setOrder(nextOrder); + return ordered; +} diff --git a/apps/web/src/hooks/useProjectGroupUpdate.ts b/apps/web/src/hooks/useProjectGroupUpdate.ts new file mode 100644 index 0000000000000..23194c27d6ae6 --- /dev/null +++ b/apps/web/src/hooks/useProjectGroupUpdate.ts @@ -0,0 +1,160 @@ +import { AuthOrchestrationOperateScope, type ProjectIconOverride } from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + mapAtomCommandResult, + squashAtomCommandFailure, + type AtomCommandResult, +} from "@t3tools/client-runtime/state/runtime"; +import { AsyncResult } from "effect/reactivity"; +import * as Cause from "effect/Cause"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import type { SidebarProjectGroupMember } from "../sidebarProjectGrouping"; +import { useProjects, useServerConfigs } from "../state/entities"; +import { readEnvironment } from "../state/environments"; +import { projectEnvironment } from "../state/projects"; +import { readEnvironmentScope, useEnvironmentsWithScope } from "../state/session"; +import { useOrchestrationCommand } from "../state/use-orchestration-command"; + +export type ProjectGroupUpdate = Partial<{ + title: string; + faviconPath: string | null; + projectIcon: ProjectIconOverride | null; + pinned: boolean; + archived: boolean; +}>; + +type ProjectGroupMember = Pick< + SidebarProjectGroupMember, + "environmentId" | "id" | "environmentLabel" +>; + +/** + * Writes one update to each checkout of a project, one machine at a time, and + * reports the first failure as a toast. Every machine must be connected first. + */ +export function useProjectGroupUpdate() { + const serverConfigs = useServerConfigs(); + const updateProject = useOrchestrationCommand(projectEnvironment.update, { + reportFailure: false, + }); + + const updateProjectGroup = async ( + members: ReadonlyArray, + input: ProjectGroupUpdate | ((member: TMember) => ProjectGroupUpdate), + failureTitle: string, + ): Promise> => { + const denied = checkProjectAccess(members, failureTitle); + if (denied) return denied; + const unavailable = members.find((member) => { + const environment = readEnvironment(member.environmentId); + return environment?.connection.phase !== "connected" || !environment.serverConfig; + }); + if (unavailable) { + return failWith( + failureTitle, + `Connect ${unavailable.environmentLabel ?? "the selected environment"} and try again.`, + ); + } + for (const member of members) { + // Access can be revoked between writes. + const revoked = checkProjectAccess([member], failureTitle); + if (revoked) return revoked; + const result = mapAtomCommandResult( + await updateProject({ + environmentId: member.environmentId, + input: { + projectId: member.id, + ...(typeof input === "function" ? input(member) : input), + }, + }), + () => undefined, + ); + if (result._tag === "Failure") { + reportProjectFailure( + members.length > 1 + ? `${failureTitle} on ${member.environmentLabel ?? "the current environment"}` + : failureTitle, + result, + ); + return result; + } + } + return AsyncResult.success(undefined); + }; + + /** Older servers drop pin and archive, so their projects do not offer them. */ + const canOrganizeProjectGroup = (members: ReadonlyArray) => + members.every( + (member) => + serverConfigs.get(member.environmentId)?.environment.capabilities.projectOrganization === + true, + ); + + return { updateProjectGroup, canOrganizeProjectGroup }; +} + +/** Pin state for a project group, shared by every list that can pin or unpin a project. */ +export function useProjectPin() { + const { updateProjectGroup, canOrganizeProjectGroup } = useProjectGroupUpdate(); + const environments = [...new Set(useProjects().map((project) => project.environmentId))].map( + (environmentId) => ({ environmentId }), + ); + const operableEnvironmentIds = useEnvironmentsWithScope( + environments, + AuthOrchestrationOperateScope, + ); + type PinnableGroup = { + readonly memberProjects: ReadonlyArray; + readonly pinned: boolean; + }; + const canPinProjectGroup = (group: PinnableGroup) => + canOrganizeProjectGroup(group.memberProjects) && + group.memberProjects.every((member) => operableEnvironmentIds.has(member.environmentId)); + const setProjectGroupPinned = (group: PinnableGroup, pinned: boolean) => { + void updateProjectGroup( + group.memberProjects, + { pinned }, + pinned ? "Failed to pin project" : "Failed to unpin project", + ); + }; + return { + canPinProjectGroup, + setProjectGroupPinned, + toggleProjectGroupPin: (group: PinnableGroup) => setProjectGroupPinned(group, !group.pinned), + }; +} + +/** Reports the first member this connection cannot change, and returns that failure. */ +export function checkProjectAccess( + members: ReadonlyArray, + failureTitle: string, +): AtomCommandResult | null { + const denied = members.find( + (member) => !readEnvironmentScope(member.environmentId, AuthOrchestrationOperateScope), + ); + return denied + ? failWith( + failureTitle, + `This connection cannot change projects in ${denied.environmentLabel ?? "this environment"}.`, + ) + : null; +} + +export function reportProjectFailure(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.", + }), + ); +} + +function failWith(title: string, message: string): AtomCommandResult { + const result = AsyncResult.failure(Cause.fail(new Error(message))); + reportProjectFailure(title, result); + return result; +} diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 65315f293640f..75bd468f7520d 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -45,7 +45,11 @@ function IndexDraftLanding() { const [startState, setStartState] = useState({ failed: false, retryRequest: 0 }); const mostRecentProject = - sortScopedProjectsForSidebar(projects, threads, "updated_at")[0] ?? null; + sortScopedProjectsForSidebar( + projects.filter((project) => project.archivedAt == null), + threads, + "updated_at", + )[0] ?? null; const attempt = startState.retryRequest; // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- starts a draft thread in the most recent project and navigates to it while still on the index diff --git a/apps/web/src/shortcuts/chatRouteCommands.ts b/apps/web/src/shortcuts/chatRouteCommands.ts index f0f80dfed8830..5ddb93f30e8b2 100644 --- a/apps/web/src/shortcuts/chatRouteCommands.ts +++ b/apps/web/src/shortcuts/chatRouteCommands.ts @@ -7,6 +7,7 @@ import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus"; import { useComposerDraftStore } from "../composerDraftStore"; import { dispatchPreviewAction, type PreviewAction } from "../components/preview/previewActionBus"; import { isAppModalPage } from "../components/sidebar/mainAppLocation"; +import { shouldCreateNewThreadInCurrentProject } from "../components/Sidebar.logic"; import { createNewThreadHandler, resolveDefaultProjectRef } from "../hooks/useHandleNewThread"; import { createScratchProjectActions } from "../hooks/useScratchProject"; import { getClientSettings } from "../hooks/useSettings"; @@ -76,13 +77,15 @@ export function installChatRouteCommands(router: AnyRouter): () => void { handleNewThread, }; }; - const readProjectGroupCount = () => + // Grouped like the sidebar, so the shortcut and the New thread button agree. + const readProjectGroups = () => buildSidebarProjectSnapshots({ projects: readProjects(), + groupScratchProjects: true, settings: selectProjectGroupingSettings(getClientSettings()), primaryEnvironmentId: appAtomRegistry.get(primaryEnvironmentIdAtom), resolveEnvironmentLabel: () => null, - }).length; + }); const canOperatePreview = (environmentId: EnvironmentId) => readEnvironmentScope(environmentId, AuthPreviewOperateScope); @@ -93,7 +96,10 @@ export function installChatRouteCommands(router: AnyRouter): () => void { const startNewLocal: CommandHandler = (event) => { claim(event); - void startNewThreadFromContext(readThreadContext()); + // With no current or open project, such as when every project is archived, pick one. + void startNewThreadFromContext(readThreadContext()).then((started) => { + if (!started) openCommandPalette({ open: "new-thread-in" }); + }); }; const previewCommand = @@ -120,7 +126,16 @@ export function installChatRouteCommands(router: AnyRouter): () => void { // The boring sidebar's folders already show the context, so it starts in // the current project like the legacy sidebar did. const { boringSidebarEnabled, legacySidebarEnabled } = getClientSettings(); - if (!boringSidebarEnabled && !legacySidebarEnabled && readProjectGroupCount() > 1) { + const groups = readProjectGroups(); + if ( + !boringSidebarEnabled && + !legacySidebarEnabled && + !shouldCreateNewThreadInCurrentProject( + false, + groups.filter((group) => !group.archived).length, + groups.length, + ) + ) { claim(event); openCommandPalette({ open: "new-thread-in" }); return; diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index ceef688971126..4dbd65b0abc51 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -24,6 +24,8 @@ export interface SidebarProjectSnapshot extends Project { memberProjects: readonly SidebarProjectGroupMember[]; memberProjectRefs: readonly ScopedProjectRef[]; remoteEnvironmentLabels: readonly string[]; + pinned: boolean; + archived: boolean; } export function projectGroupsSpanEnvironments( @@ -177,6 +179,8 @@ export function buildSidebarProjectSnapshots(input: { memberProjects: members, memberProjectRefs: group.memberProjectRefs, remoteEnvironmentLabels, + pinned: group.pinned, + archived: group.archived, }; }); } @@ -214,12 +218,10 @@ export function buildSidebarProjectPickerEntries(input: { return [{ group, targetProject, isPreferred }]; }); - const preferredIndex = entries.findIndex((entry) => entry.isPreferred); - if (preferredIndex <= 0) return entries; - - return [ - entries[preferredIndex]!, - ...entries.slice(0, preferredIndex), - ...entries.slice(preferredIndex + 1), - ]; + // The preferred project leads the unpinned ones; pinned projects stay first. + const preferred = entries.find((entry) => entry.isPreferred); + if (!preferred || preferred.group.pinned) return entries; + const rest = entries.filter((entry) => entry !== preferred); + const pinnedCount = rest.filter((entry) => entry.group.pinned).length; + return [...rest.slice(0, pinnedCount), preferred, ...rest.slice(pinnedCount)]; } diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 2fe8f858bf599..0b2cab287d223 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -69,6 +69,14 @@ export function readPrimaryEnvironmentId(): EnvironmentId | null { return appAtomRegistry.get(primaryEnvironmentIdAtom); } +/** The environment's state now, for handlers that should not re-render on every change. */ +export function readEnvironment(environmentId: EnvironmentId): EnvironmentPresentation | null { + const presentation = appAtomRegistry + .get(environmentPresentations.presentationsAtom) + .get(environmentId); + return presentation ? projectEnvironmentPresentation(environmentId, presentation) : null; +} + export function useEnvironment( environmentId: EnvironmentId | null, ): EnvironmentPresentation | null { diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 718f8723468a0..63d2ba04e8eab 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -46,6 +46,22 @@ Settings that are environment-wide stay read-only while a project is selected. W targets disagree, a control shows **Mixed** until you choose one value. Appearance, keyboard, and other phone-only settings ignore the filter. +## Pin and archive projects + +Pinned projects come first in the project pickers: the project picker on a new thread, the +new-thread list, the command palette, the sidebar project filter, and mobile. In the boring sidebar, pinned projects get the top +folders, even with no active threads. Pin or unpin a project from its row in any of those lists, +or press the thread pin shortcut (`mod+shift+p` by default) on the highlighted row. **Pin +project** in the command palette and **Settings → Projects** work too. A list keeps its order +while it is open, so a new pin moves up the next time you open it. + +Archive a project you are done with in **Settings → Projects**. It leaves the project pickers. +Its threads, settings, and files stay as they are, and archived projects still show in the +Settings project list, marked **Archived**. Choose one there and select **Unarchive** to bring it +back. On mobile, both are in **Settings → Projects → Organize**. + +A project on several machines must have every machine connected to pin or archive it. + ## Worktree branch names In **Settings → Worktrees → Worktree branch naming**, choose a static prefix, diff --git a/docs/user/thread-sidebar.md b/docs/user/thread-sidebar.md index 9dbaceae8ed9b..a90dabf06d8c8 100644 --- a/docs/user/thread-sidebar.md +++ b/docs/user/thread-sidebar.md @@ -148,12 +148,13 @@ cannot drag or move threads within it. Your saved order returns when you turn it ### Group threads by project (beta) Turn on **Settings → Labs → Boring sidebar (group by project)** on web and desktop to put active -threads in a folder for each project. A project with no active threads has no folder until you -pick it in the project filter. Click a folder to open or close it. A closed folder shows how -many threads it holds, and a dot when one of them needs you. Use the pen button on a folder, or -right-click it, to start a thread in that project. Pinned threads stay above the folders, and -Snoozed and Settled stay below them. In this mode you cannot drag threads, and the Working section -is off. Each device keeps its own choice. +threads in a folder for each project. Pinned projects come first. A project with no active +threads has no folder unless it is pinned or you pick it in the project filter. An archived +project's folder comes last and shows only while it has active threads. Click a folder to open or +close it. A closed folder shows how many threads it holds, and a dot when one of them +needs you. Use the pen button on a folder, or right-click it, to start a thread in that project. +Pinned threads stay above the folders, and Snoozed and Settled stay below them. In this mode you +cannot drag threads, and the Working section is off. Each device keeps its own choice. ## Settle finished work diff --git a/evals/mcp-tool-use/cases/projects.ndjson b/evals/mcp-tool-use/cases/projects.ndjson new file mode 100644 index 0000000000000..b52ee4eff30bc --- /dev/null +++ b/evals/mcp-tool-use/cases/projects.ndjson @@ -0,0 +1 @@ +{"id": "archive-project-not-delete", "area": "projects", "kind": "guard", "pattern": "archive a project by deleting it", "experience": "Projects gained an archive flag. Asked to archive a finished project, an agent must set archived, not remove the project and its threads.", "rule": "t3_project_update archived: Threads and files are kept.", "privacy": "commit-safe", "setup": {"role": "top", "mocks": {"t3_project_list": {"projects": [{"id": "project-app", "title": "app", "workspaceRoot": "/home/dev/code/app", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-09-01T00:00:00.000Z", "updatedAt": "2026-10-20T00:00:00.000Z", "deletedAt": null}, {"id": "project-old-site", "title": "old-site", "workspaceRoot": "/home/dev/code/old-site", "defaultModelSelection": null, "scripts": [], "pinnedAt": null, "archivedAt": null, "createdAt": "2026-03-01T00:00:00.000Z", "updatedAt": "2026-04-02T00:00:00.000Z", "deletedAt": null}], "nextCursor": null}}}, "prompt": "I'm done with the old-site project. Archive it in T3 Code so it stops showing up in my project pickers.", "expect": {"calls": [{"tool": "t3_project_update", "args": {"projectId": "project-old-site", "archived": true}}], "callsMustNot": [{"tool": "t3_project_delete"}, {"tool": "t3_project_update", "args": {"projectId": "project-app"}}]}, "maxSteps": 6} diff --git a/packages/client-runtime/src/operations/commands.ts b/packages/client-runtime/src/operations/commands.ts index f7ca0d575d001..e3cbecfc2b2c4 100644 --- a/packages/client-runtime/src/operations/commands.ts +++ b/packages/client-runtime/src/operations/commands.ts @@ -60,6 +60,8 @@ export interface UpdateProjectInput extends CommandMetadata { readonly faviconPath?: string | null; readonly defaultThreadEnvMode?: ThreadEnvMode | null; readonly scripts?: ReadonlyArray; + readonly pinned?: boolean; + readonly archived?: boolean; } export interface DeleteProjectInput extends CommandMetadata { @@ -375,6 +377,8 @@ export const updateProject = Effect.fn("EnvironmentCommands.updateProject")(func ? {} : { defaultThreadEnvMode: input.defaultThreadEnvMode }), ...(input.scripts === undefined ? {} : { scripts: input.scripts }), + ...(input.pinned === undefined ? {} : { pinned: input.pinned }), + ...(input.archived === undefined ? {} : { archived: input.archived }), }); }); diff --git a/packages/client-runtime/src/state/projectGrouping.test.ts b/packages/client-runtime/src/state/projectGrouping.test.ts index 824fb87e3c402..a5957d06e1b01 100644 --- a/packages/client-runtime/src/state/projectGrouping.test.ts +++ b/packages/client-runtime/src/state/projectGrouping.test.ts @@ -6,6 +6,7 @@ import { chooseLoadBalancedEnvironment } from "../load-balancing.ts"; import { buildProjectGroups, deriveLogicalProjectKeyFromSettings, + pickableProjectGroups, derivePhysicalProjectKey, resolveScratchProjectScopeKey, sortProjectsByGroupOrder, @@ -379,6 +380,31 @@ describe("buildProjectGroups", () => { }); }); +describe("project pins and archives", () => { + const at = "2026-07-02T00:00:00.000Z"; + const remote = EnvironmentId.make("remote"); + const group = (...projects: EnvironmentProject[]) => + buildProjectGroups({ projects, settings: settings("repository") })[0]!; + + it("keeps a project visible while its machines disagree", () => { + const local = makeProject("local", "/work/t3code"); + const other = makeProject("remote", "/work/t3code", { environmentId: remote }); + const partial = group({ ...local, pinnedAt: at, archivedAt: at }, other); + expect(partial).toMatchObject({ pinned: true, archived: false }); + expect(group({ ...local, archivedAt: at }, { ...other, archivedAt: at }).archived).toBe(true); + }); + + it("offers pinned projects first and leaves archived ones out", () => { + const pickable = pickableProjectGroups([ + { key: "a", pinned: false, archived: false }, + { key: "b", pinned: true, archived: true }, + { key: "c", pinned: true, archived: false }, + { key: "d", pinned: false, archived: true }, + ]); + expect(pickable.map((entry) => entry.key)).toEqual(["c", "a"]); + }); +}); + describe("sortProjectsByGroupOrder", () => { const remote = EnvironmentId.make("remote"); const ref = (projectId: string, env = environmentId) => ({ diff --git a/packages/client-runtime/src/state/projectGrouping.ts b/packages/client-runtime/src/state/projectGrouping.ts index a23fd4a79e0bc..46fdc4e3dd3ae 100644 --- a/packages/client-runtime/src/state/projectGrouping.ts +++ b/packages/client-runtime/src/state/projectGrouping.ts @@ -242,6 +242,17 @@ export interface ProjectGroup>; readonly memberProjectRefs: ReadonlyArray; + /** Clients write every member together. When members still disagree, the project stays visible. */ + readonly pinned: boolean; + readonly archived: boolean; +} + +/** The groups a project picker offers: archived groups left out, pinned ones first. */ +export function pickableProjectGroups< + TGroup extends { readonly pinned: boolean; readonly archived: boolean }, +>(groups: ReadonlyArray): TGroup[] { + const open = groups.filter((group) => !group.archived); + return [...open.filter((group) => group.pinned), ...open.filter((group) => !group.pinned)]; } function projectFreshnessTime(project: EnvironmentProject): number { @@ -363,6 +374,8 @@ export function buildProjectGroups(input: { representative, members, memberProjectRefs: projectRefsByLogicalKey.get(key) ?? [], + pinned: members.some((member) => member.project.pinnedAt != null), + archived: members.every((member) => member.project.archivedAt != null), }; }); } diff --git a/packages/contracts/src/applicationEvent.ts b/packages/contracts/src/applicationEvent.ts index 17ffdf91599fd..30150b9af5247 100644 --- a/packages/contracts/src/applicationEvent.ts +++ b/packages/contracts/src/applicationEvent.ts @@ -60,6 +60,8 @@ export const ApplicationProjectMetaUpdatedPayload = Schema.Struct({ faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), projectIcon: Schema.optional(Schema.NullOr(StoredProjectIcon)), scripts: Schema.optional(Schema.Array(ProjectScript)), + pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + archivedAt: Schema.optional(Schema.NullOr(IsoDateTime)), updatedAt: IsoDateTime, }); export type ApplicationProjectMetaUpdatedPayload = typeof ApplicationProjectMetaUpdatedPayload.Type; diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 117f75bc3d3de..9d60bec43fe5c 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -135,6 +135,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Only explicit false skips Live Activity seeding; older servers may still publish. */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), projectCloneTracking: Schema.optionalKey(Schema.Boolean), + /** Server keeps `pinned` and `archived` from project.update. Older servers drop them. */ + projectOrganization: Schema.optionalKey(Schema.Boolean), /** Server answers projects.listIgnoredFiles. */ ignoredFileListing: Schema.optionalKey(Schema.Boolean), environmentIcon: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/orchestrationProject.ts b/packages/contracts/src/orchestrationProject.ts index 4b8203c7ce0ae..d98c50e56e8d1 100644 --- a/packages/contracts/src/orchestrationProject.ts +++ b/packages/contracts/src/orchestrationProject.ts @@ -17,6 +17,9 @@ export const OrchestrationProjectShell = Schema.Struct({ faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), projectIcon: Schema.optional(Schema.NullOr(ReceivedProjectIcon)), scripts: Schema.Array(ProjectScript), + // Pinned projects lead project pickers; archived ones leave them. Optional so older servers decode. + pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + archivedAt: Schema.optional(Schema.NullOr(IsoDateTime)), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index 79e164b71e93b..39696c62e4c99 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -161,6 +161,8 @@ export const Project = Schema.Struct({ // Opt-in because background sync performs network I/O and may move the checkout. autoPull: Schema.optional(Schema.Boolean), scripts: Schema.Array(ProjectScript), + pinnedAt: Schema.optional(Schema.NullOr(IsoDateTime)), + archivedAt: Schema.optional(Schema.NullOr(IsoDateTime)), createdAt: IsoDateTime, updatedAt: IsoDateTime, deletedAt: Schema.NullOr(IsoDateTime), @@ -191,6 +193,12 @@ export const ProjectUpdatePayload = Schema.Struct({ faviconPath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), defaultThreadEnvMode: Schema.optional(Schema.NullOr(ThreadEnvMode)), scripts: Schema.optional(Schema.Array(ProjectScript)), + pinned: Schema.optional(Schema.Boolean), + archived: Schema.optional( + Schema.Boolean.annotate({ + description: "Threads and files are kept.", + }), + ), }); export type ProjectUpdatePayload = typeof ProjectUpdatePayload.Type; From a24ec905c8157f1ef6114639ff614617d98f10e9 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Sat, 31 Oct 2026 02:53:26 -0700 Subject: [PATCH 002/174] fix(web): machine label no longer shifts between one and many machines (#28070) Co-authored-by: Claude Opus 5.5 --- apps/web/src/components/BranchToolbar.tsx | 12 ++++-------- .../src/components/BranchToolbarEnvModeSelector.tsx | 2 +- .../components/BranchToolbarEnvironmentSelector.tsx | 2 +- apps/web/src/components/chat/ComposerControl.tsx | 2 +- 4 files changed, 7 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index 27639044e758c..93249bcf0c9f2 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -214,9 +214,7 @@ const RunContextSelector = memo(function RunContextSelector({ const workspaceIcon = ( }> - + {isPanel @@ -232,7 +230,7 @@ const RunContextSelector = memo(function RunContextSelector({ }> {autoEnvironmentLabel ? (