diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 61de3444c23a..f45b6049120d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -54,6 +54,7 @@ export const PREVIEW_ZOOM_IN_CHANNEL = "desktop:preview-zoom-in"; export const PREVIEW_ZOOM_OUT_CHANNEL = "desktop:preview-zoom-out"; export const PREVIEW_RESET_ZOOM_CHANNEL = "desktop:preview-reset-zoom"; export const PREVIEW_HARD_RELOAD_CHANNEL = "desktop:preview-hard-reload"; +export const PREVIEW_SET_COLOR_SCHEME_CHANNEL = "desktop:preview-set-color-scheme"; export const PREVIEW_OPEN_DEVTOOLS_CHANNEL = "desktop:preview-open-devtools"; export const PREVIEW_CLEAR_COOKIES_CHANNEL = "desktop:preview-clear-cookies"; export const PREVIEW_CLEAR_CACHE_CHANNEL = "desktop:preview-clear-cache"; diff --git a/apps/desktop/src/ipc/methods/preview.ts b/apps/desktop/src/ipc/methods/preview.ts index 2abf53ac2843..28405288f6ce 100644 --- a/apps/desktop/src/ipc/methods/preview.ts +++ b/apps/desktop/src/ipc/methods/preview.ts @@ -13,6 +13,7 @@ import { DesktopPreviewRecordingSaveInputSchema, DesktopPreviewRegisterWebviewInputSchema, DesktopPreviewScreenshotArtifactSchema, + DesktopPreviewSetColorSchemeInputSchema, DesktopPreviewTabInputSchema, DesktopPreviewWebviewConfigSchema, PreviewAnnotationPayloadSchema, @@ -138,6 +139,15 @@ export const hardReload = tabMethod( "desktop.ipc.preview.hardReload", (manager, tabId) => manager.hardReload(tabId), ); +export const setColorScheme = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, + payload: DesktopPreviewSetColorSchemeInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.preview.setColorScheme")(function* ({ tabId, colorScheme }) { + const manager = yield* PreviewManager.PreviewManager; + yield* manager.setColorScheme(tabId, colorScheme); + }), +}); export const openDevTools = tabMethod( IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, "desktop.ipc.preview.openDevTools", @@ -346,6 +356,7 @@ export const methods = [ zoomOut, resetZoom, hardReload, + setColorScheme, openDevTools, clearCookies, clearCache, diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 48a50cafaf47..af986be8d218 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -164,6 +164,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { zoomOut: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_ZOOM_OUT_CHANNEL, { tabId }), resetZoom: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_RESET_ZOOM_CHANNEL, { tabId }), hardReload: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_HARD_RELOAD_CHANNEL, { tabId }), + setColorScheme: (tabId, colorScheme) => + ipcRenderer.invoke(IpcChannels.PREVIEW_SET_COLOR_SCHEME_CHANNEL, { tabId, colorScheme }), openDevTools: (tabId) => ipcRenderer.invoke(IpcChannels.PREVIEW_OPEN_DEVTOOLS_CHANNEL, { tabId }), clearCookies: () => ipcRenderer.invoke(IpcChannels.PREVIEW_CLEAR_COOKIES_CHANNEL), diff --git a/apps/desktop/src/preview/Manager.test.ts b/apps/desktop/src/preview/Manager.test.ts index 6e2df118c34e..f1215ee7b60c 100644 --- a/apps/desktop/src/preview/Manager.test.ts +++ b/apps/desktop/src/preview/Manager.test.ts @@ -365,6 +365,79 @@ describe("PreviewManager", () => { ), ); + effectIt.effect("emulates prefers-color-scheme and re-applies it across webview swaps", () => + withManager((manager) => + Effect.gen(function* () { + const makeWebContents = (id: number) => { + const sendCommand = vi.fn(async () => undefined); + return { + sendCommand, + wc: { + id, + isDestroyed: () => false, + isDevToolsOpened: () => false, + getType: () => "webview", + getURL: () => "https://example.com", + getTitle: () => "Example", + isLoading: () => false, + getZoomFactor: () => 1, + setZoomFactor: vi.fn(), + on: vi.fn(), + off: vi.fn(), + ipc: { on: vi.fn(), off: vi.fn() }, + send: webviewSend, + navigationHistory: { canGoBack: () => false, canGoForward: () => false }, + setWindowOpenHandler: vi.fn(), + debugger: { + isAttached: () => false, + attach: vi.fn(), + sendCommand, + on: vi.fn(), + off: vi.fn(), + }, + } as never, + }; + }; + const first = makeWebContents(42); + fromId.mockReturnValue(first.wc); + const states: PreviewManager.PreviewTabState[] = []; + + yield* manager.subscribeStateChanges((_tabId, state) => + Effect.sync(() => { + states.push(state); + }), + ); + yield* manager.createTab("tab_scheme"); + yield* manager.registerWebview("tab_scheme", 42); + yield* Effect.yieldNow; + + yield* manager.setColorScheme("tab_scheme", "dark"); + + expect(first.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + expect(states.at(-1)?.colorScheme).toBe("dark"); + + const replacement = makeWebContents(43); + fromId.mockReturnValue(replacement.wc); + yield* manager.registerWebview("tab_scheme", 43); + yield* Effect.yieldNow; + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "dark" }], + }); + expect(states.at(-1)?.colorScheme).toBe("dark"); + + yield* manager.setColorScheme("tab_scheme", "system"); + + expect(replacement.sendCommand).toHaveBeenCalledWith("Emulation.setEmulatedMedia", { + features: [{ name: "prefers-color-scheme", value: "" }], + }); + expect(states.at(-1)?.colorScheme).toBe("system"); + }), + ), + ); + effectIt.effect("keeps a main-frame load failure visible until a retry starts", () => withManager((manager) => Effect.gen(function* () { diff --git a/apps/desktop/src/preview/Manager.ts b/apps/desktop/src/preview/Manager.ts index 2d0360ef72cd..6c942d4ccb9a 100644 --- a/apps/desktop/src/preview/Manager.ts +++ b/apps/desktop/src/preview/Manager.ts @@ -7,6 +7,7 @@ */ import type { DesktopPreviewAnnotationTheme, + DesktopPreviewColorScheme, DesktopPreviewPointerEvent, PreviewAnnotationPayload, PreviewAnnotationRect, @@ -84,6 +85,7 @@ export interface PreviewTabState { canGoBack: boolean; canGoForward: boolean; zoomFactor: number; + colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; updatedAt: string; } @@ -1288,6 +1290,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + colorScheme: "system", controller: "none", updatedAt, }; @@ -1320,6 +1323,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoBack: false, canGoForward: false, zoomFactor: DEFAULT_ZOOM_FACTOR, + colorScheme: "system", controller: "none", updatedAt, }; @@ -1386,7 +1390,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function wc.getZoomFactor(), ); yield* attachListeners(tabId, wc); - runFork(ensureControlSession(wc).pipe(Effect.ignore)); + runFork(restoreControlSession(tabId, wc)); const registeredAt = yield* currentIso; const registration = yield* SynchronizedRef.modify(tabsRef, (tabs) => { const current = tabs.get(tabId); @@ -1457,6 +1461,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function canGoBack: current?.canGoBack ?? false, canGoForward: current?.canGoForward ?? false, zoomFactor: current?.zoomFactor ?? DEFAULT_ZOOM_FACTOR, + colorScheme: current?.colorScheme ?? "system", controller: current?.controller ?? "none", updatedAt, }; @@ -1525,7 +1530,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* detachControlSession(wc.id); yield* attempt({ operation: "openDevTools", tabId, webContentsId: wc.id }, () => { wc.once("devtools-closed", () => { - if (!wc.isDestroyed()) runFork(ensureControlSession(wc).pipe(Effect.ignore)); + if (!wc.isDestroyed()) runFork(restoreControlSession(tabId, wc)); }); wc.openDevTools({ mode: "detach" }); }); @@ -1684,6 +1689,65 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function yield* update(tabId, { zoomFactor: next }); }); + // Emulated media lives on the CDP debugger session, not the WebContents, so + // it is lost whenever the session detaches (webview swap, DevTools + // open/close) and must be re-applied after every (re)attach. + const applyColorScheme = Effect.fn("PreviewManager.applyColorScheme")(function* ( + tabId: string, + wc: Electron.WebContents, + colorScheme: DesktopPreviewColorScheme, + ) { + yield* ensureControlSession(wc); + yield* attemptPromise({ operation: "applyColorScheme", tabId, webContentsId: wc.id }, () => + wc.debugger.sendCommand("Emulation.setEmulatedMedia", { + features: [ + { + name: "prefers-color-scheme", + // An empty value clears the override so the page follows the OS. + value: colorScheme === "system" ? "" : colorScheme, + }, + ], + }), + ); + }); + + // Re-establish the control session after a detach, restoring any + // color-scheme override the tab carries. The scheme is read after the + // session attaches so a concurrent setColorScheme is not overwritten with + // a stale snapshot. + const restoreControlSession = (tabId: string, wc: Electron.WebContents) => + ensureControlSession(wc).pipe( + Effect.andThen(SynchronizedRef.get(tabsRef)), + Effect.flatMap((tabs) => { + const colorScheme = tabs.get(tabId)?.colorScheme ?? "system"; + return colorScheme === "system" ? Effect.void : applyColorScheme(tabId, wc, colorScheme); + }), + Effect.ignore, + ); + + const setColorScheme = Effect.fn("PreviewManager.setColorScheme")(function* ( + tabId: string, + colorScheme: DesktopPreviewColorScheme, + ) { + const tab = (yield* SynchronizedRef.get(tabsRef)).get(tabId); + if (!tab) { + return yield* new PreviewTabNotFoundError({ tabId }); + } + if (tab.colorScheme !== colorScheme) { + // Record the choice even when the CDP call below can't run yet (no + // webview, DevTools holding the debugger) — it is re-applied on the + // next control-session (re)attach. + yield* update(tabId, { colorScheme }); + } + // Re-read after the update: registerWebview may have swapped the guest + // in the meantime and the override must land on the current one. + const webContentsId = (yield* SynchronizedRef.get(tabsRef)).get(tabId)?.webContentsId; + if (webContentsId == null) return; + const wc = webContents.fromId(webContentsId); + if (!wc || wc.isDestroyed()) return; + yield* applyColorScheme(tabId, wc, colorScheme); + }); + const captureScreenshot = Effect.fn("PreviewManager.captureScreenshot")(function* ( tabId: string, ) { @@ -2526,6 +2590,7 @@ const makeNativeOperations = Effect.fn("PreviewManager.makeOperations")(function revealArtifact, saveRecording, setAnnotationTheme, + setColorScheme, setMainWindow, startRecording, stopRecording, @@ -2830,6 +2895,10 @@ export class PreviewManager extends Context.Service< readonly zoomOut: (tabId: string) => Effect.Effect; readonly resetZoom: (tabId: string) => Effect.Effect; readonly hardReload: (tabId: string) => Effect.Effect; + readonly setColorScheme: ( + tabId: string, + colorScheme: DesktopPreviewColorScheme, + ) => Effect.Effect; readonly openDevTools: (tabId: string) => Effect.Effect; readonly clearCookies: () => Effect.Effect; readonly clearCache: () => Effect.Effect; @@ -2921,6 +2990,7 @@ export const make = Effect.gen(function* PreviewManagerMake() { zoomOut: operations.zoomOut, resetZoom: operations.resetZoom, hardReload: operations.hardReload, + setColorScheme: operations.setColorScheme, openDevTools: operations.openDevTools, clearCookies: Effect.fn("PreviewManager.clearCookies")(function* () { yield* browserSession diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 28ae8183b97f..06bd4bc57733 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -11,9 +11,12 @@ import { createStaticNavigation, DarkTheme, DefaultTheme } from "@react-navigati import { RegistryContext } from "@effect/atom-react"; import { ConfirmDialogHost } from "./components/ConfirmDialogHost"; import { CloudAuthProvider } from "./features/cloud/CloudAuthProvider"; -import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; -import { AppearancePreferencesProvider } from "./features/settings/appearance/AppearancePreferencesProvider"; +import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; +import { + AppearancePreferencesProvider, + useAppearancePreferences, +} from "./features/settings/appearance/AppearancePreferencesProvider"; import { RootStack } from "./Stack"; import { appAtomRegistry } from "./state/atom-registry"; import { OverlayPortalHost } from "./components/OverlayPortal"; @@ -26,6 +29,10 @@ if (process.env.EXPO_PUBLIC_SHOWCASE === "1") { prepareNativeShowcaseCapture(); } +void SplashScreen.preventAutoHideAsync().catch(() => { + // The native module can be unavailable in non-native test environments. +}); + const appLinking = { prefixes: [Linking.createURL("/"), "t3code://", "t3code-dev://", "t3code-preview://"], // The Expo dev client launches the app via @@ -40,18 +47,25 @@ const appLinking = { const Navigation = createStaticNavigation(RootStack); +function SplashScreenCoordinator() { + const { isReady } = useAppearancePreferences(); + + useEffect(() => { + if (isReady) void SplashScreen.hide(); + }, [isReady]); + + return null; +} + export default function App() { const colorScheme = useColorScheme(); const statusBarBg = useThemeColor("--color-status-bar"); - useEffect(() => { - SplashScreen.hide(); - }, []); - return ( + diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index cf9cf378edd0..36cead9f8cc3 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,8 +1,4 @@ -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; +import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; import { useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; @@ -28,7 +24,6 @@ import { } from "./home-list-filter-menu"; import { hasCustomHomeListOptions, - PROJECT_GROUPING_OPTIONS, PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, } from "./home-list-options"; @@ -43,13 +38,11 @@ export function HomeHeader(props: { readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onSearchQueryChange: (query: string) => void; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; readonly onOpenSettings: () => void; readonly onStartNewTask: () => void; }) { @@ -143,20 +136,10 @@ function AndroidHomeHeader(props: HomeHeaderProps) { state: checkedMenuState(props.threadSortOrder === option.value), })), }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - state: checkedMenuState(props.projectGroupingMode === option.value), - })), - }, ] satisfies MenuAction[])), ], [ props.environments, - props.projectGroupingMode, props.projectSortOrder, props.projects, props.selectedEnvironmentId, @@ -210,13 +193,6 @@ function AndroidHomeHeader(props: HomeHeaderProps) { props.onThreadSortOrderChange(threadSort.value); return; } - - const grouping = PROJECT_GROUPING_OPTIONS.find( - (option) => id === `project-grouping:${option.value}`, - ); - if (grouping) { - props.onProjectGroupingModeChange(grouping.value); - } }, [props], ); @@ -409,10 +385,6 @@ function IosHomeHeader(props: HomeHeaderProps) { title="Thread list options" separateBackground > - - Settings - - Environment ))} - - - Group projects - {PROJECT_GROUPING_OPTIONS.map((option) => ( - props.onProjectGroupingModeChange(option.value)} - subtitle={option.subtitle} - > - {option.label} - - ))} - diff --git a/apps/mobile/src/features/home/HomeRouteScreen.tsx b/apps/mobile/src/features/home/HomeRouteScreen.tsx index d4b19cdb0fb9..9fa179f4c76a 100644 --- a/apps/mobile/src/features/home/HomeRouteScreen.tsx +++ b/apps/mobile/src/features/home/HomeRouteScreen.tsx @@ -4,7 +4,6 @@ import { useNavigation } from "@react-navigation/native"; import { useEffect, useMemo, useState } from "react"; import { NativeHeaderToolbar, NativeStackScreenOptions } from "../../native/StackHeader"; -import { scopedProjectKey } from "../../lib/scopedEntities"; import { useProjects, useThreadShells } from "../../state/entities"; import { usePendingNewTasks } from "../../state/use-pending-new-tasks"; import { useWorkspaceState } from "../../state/workspace"; @@ -16,6 +15,7 @@ import { AndroidHomeFabLayout } from "./AndroidHomeFab"; import { HomeScreen } from "./HomeScreen"; import { HomeHeader } from "./HomeHeader"; import { useHomeListOptions } from "./home-list-options"; +import { buildHomeProjectScopes } from "./homeThreadList"; import { usePendingTaskListActions } from "./usePendingTaskListActions"; import { useThreadListActions } from "./useThreadListActions"; @@ -54,7 +54,6 @@ export function HomeRouteScreen() { const { options: listOptions, setSelectedEnvironmentId, - setProjectGroupingMode, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); @@ -62,16 +61,15 @@ export function HomeRouteScreen() { const [selectedProjectKey, setSelectedProjectKey] = useState(null); const projectFilterOptions = useMemo( () => - projects - .filter( - (project) => - selectedEnvironmentId === null || project.environmentId === selectedEnvironmentId, - ) - .map((project) => ({ - key: scopedProjectKey(project.environmentId, project.id), - label: project.title, - })), - [projects, selectedEnvironmentId], + buildHomeProjectScopes({ + projects, + environmentId: selectedEnvironmentId, + projectGroupingMode: listOptions.projectGroupingMode, + }).map((scope) => ({ + key: scope.key, + label: scope.title, + })), + [listOptions.projectGroupingMode, projects, selectedEnvironmentId], ); useEffect(() => { if ( @@ -119,11 +117,9 @@ export function HomeRouteScreen() { selectedProjectKey={selectedProjectKey} projectSortOrder={listOptions.projectSortOrder} threadSortOrder={listOptions.threadSortOrder} - projectGroupingMode={listOptions.projectGroupingMode} onEnvironmentChange={setSelectedEnvironmentId} onProjectChange={setSelectedProjectKey} onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} - onProjectGroupingModeChange={setProjectGroupingMode} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onStartNewTask={() => navigation.navigate("NewTaskSheet", { screen: "NewTask" })} @@ -146,7 +142,6 @@ export function HomeRouteScreen() { navigation.navigate("SettingsSheet", { screen: "SettingsEnvironments" }) } onOpenSettings={() => navigation.navigate("SettingsSheet", { screen: "Settings" })} - onProjectGroupingModeChange={setProjectGroupingMode} onProjectSortOrderChange={setProjectSortOrder} onSearchQueryChange={setSearchQuery} onSelectThread={(thread) => { diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 41180c486430..108a4b7056fd 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -15,17 +15,15 @@ import type { import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; -import { ActivityIndicator, FlatList, Platform, Pressable, ScrollView, View } from "react-native"; +import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; -import { ProjectFavicon } from "../../components/ProjectFavicon"; import type { WorkspaceState } from "../../state/workspaceModel"; import type { SavedRemoteConnection } from "../../lib/connection"; -import { cn } from "../../lib/cn"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; @@ -54,7 +52,12 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "./homeListItems"; -import { buildHomeThreadGroups, type HomeProjectSortOrder } from "./homeThreadList"; +import { + buildHomeProjectScopes, + buildHomeThreadGroups, + sortHomeProjectScopes, + type HomeProjectSortOrder, +} from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; @@ -79,7 +82,6 @@ interface HomeScreenProps { readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; readonly onAddConnection: () => void; readonly onOpenEnvironments: () => void; readonly onOpenSettings: () => void; @@ -243,43 +245,69 @@ export function HomeScreen(props: HomeScreenProps) { onScrollBeginDrag: handleScrollBeginDrag, }); - const scopedProject = useMemo( + const projectScopes = useMemo( + () => + buildHomeProjectScopes({ + projects: props.projects, + environmentId: props.selectedEnvironmentId, + projectGroupingMode: props.projectGroupingMode, + }), + [props.projectGroupingMode, props.projects, props.selectedEnvironmentId], + ); + const selectedProjectScope = useMemo( () => props.selectedProjectKey === null ? null - : (props.projects.find( - (project) => - scopedProjectKey(project.environmentId, project.id) === props.selectedProjectKey && - (props.selectedEnvironmentId === null || - project.environmentId === props.selectedEnvironmentId), + : (projectScopes.find( + (scope) => + scope.key === props.selectedProjectKey || + scope.projectRefs.some( + (projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId) === + props.selectedProjectKey, + ), ) ?? null), - [props.projects, props.selectedEnvironmentId, props.selectedProjectKey], + [projectScopes, props.selectedProjectKey], + ); + const selectedProjectRefKeys = useMemo( + () => + selectedProjectScope === null + ? null + : new Set( + selectedProjectScope.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + ), + ), + [selectedProjectScope], ); const scopedProjects = useMemo( - () => (scopedProject === null ? props.projects : [scopedProject]), - [props.projects, scopedProject], + () => + selectedProjectRefKeys === null + ? props.projects + : props.projects.filter((project) => + selectedProjectRefKeys.has(scopedProjectKey(project.environmentId, project.id)), + ), + [props.projects, selectedProjectRefKeys], ); const scopedThreads = useMemo( () => - scopedProject === null + selectedProjectRefKeys === null ? props.threads - : props.threads.filter( - (thread) => - thread.environmentId === scopedProject.environmentId && - thread.projectId === scopedProject.id, + : props.threads.filter((thread) => + selectedProjectRefKeys.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), - [props.threads, scopedProject], + [props.threads, selectedProjectRefKeys], ); const scopedPendingTasks = useMemo( () => - scopedProject === null + selectedProjectRefKeys === null ? props.pendingTasks - : props.pendingTasks.filter( - (pendingTask) => - pendingTask.message.environmentId === scopedProject.environmentId && - pendingTask.creation.projectId === scopedProject.id, + : props.pendingTasks.filter((pendingTask) => + selectedProjectRefKeys.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), ), - [props.pendingTasks, scopedProject], + [props.pendingTasks, selectedProjectRefKeys], ); const projectGroups = useMemo( @@ -334,23 +362,64 @@ export function HomeScreen(props: HomeScreenProps) { }, [props.projects]); const v2ProjectScopeKey = props.selectedProjectKey; - const setV2ProjectScopeKey = props.onProjectChange; const v2ScopeProjects = useMemo( () => - props.selectedEnvironmentId === null - ? props.projects - : props.projects.filter((project) => project.environmentId === props.selectedEnvironmentId), - [props.projects, props.selectedEnvironmentId], + sortHomeProjectScopes({ + scopes: projectScopes, + threads: props.threads, + pendingTasks: props.pendingTasks, + projectSortOrder: props.projectSortOrder, + }), + [ + props.pendingTasks, + props.projects, + props.projectSortOrder, + props.selectedEnvironmentId, + props.threads, + projectScopes, + ], ); - const v2ScopedProject = useMemo( + const v2ScopedProjectGroup = useMemo( () => v2ProjectScopeKey === null ? null : (v2ScopeProjects.find( - (project) => scopedProjectKey(project.environmentId, project.id) === v2ProjectScopeKey, + (scope) => + scope.key === v2ProjectScopeKey || + scope.projectRefs.some( + (projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId) === + v2ProjectScopeKey, + ), ) ?? null), [v2ProjectScopeKey, v2ScopeProjects], ); + const v2ProjectTitleByProjectKey = useMemo( + () => + new Map( + v2ScopeProjects.flatMap((scope) => + scope.projectRefs.map( + (projectRef) => + [ + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + scope.title, + ] as const, + ), + ), + ), + [v2ScopeProjects], + ); + const v2ScopedProjectKeys = useMemo( + () => + v2ScopedProjectGroup === null + ? null + : new Set( + v2ScopedProjectGroup.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + ), + ), + [v2ScopedProjectGroup], + ); // Thread List v2 (beta): one flat list in creation order, no grouping. // Settled threads collapse into a recency tail below the card block. // Settled threads stay in the live shell stream (settled ≠ archived), so @@ -431,13 +500,7 @@ export function HomeScreen(props: HomeScreenProps) { return buildThreadListV2Items({ threads: props.threads.filter((thread) => thread.archivedAt === null), environmentId: props.selectedEnvironmentId, - projectRef: - v2ScopedProject === null - ? null - : { - environmentId: v2ScopedProject.environmentId, - projectId: v2ScopedProject.id, - }, + projectRefs: v2ScopedProjectGroup === null ? null : v2ScopedProjectGroup.projectRefs, searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, @@ -453,7 +516,7 @@ export function HomeScreen(props: HomeScreenProps) { props.selectedEnvironmentId, props.threads, threadListV2Enabled, - v2ScopedProject, + v2ScopedProjectGroup, ]); const threadListV2Items = threadListV2Layout.items; @@ -467,6 +530,9 @@ export function HomeScreen(props: HomeScreenProps) { projectByKey.get(scopedProjectKey(item.thread.environmentId, item.thread.projectId)) ?? null } + projectTitle={v2ProjectTitleByProjectKey.get( + scopedProjectKey(item.thread.environmentId, item.thread.projectId), + )} providerDriver={ serverConfigs .get(item.thread.environmentId) @@ -510,6 +576,7 @@ export function HomeScreen(props: HomeScreenProps) { props.savedConnectionsById, serverConfigs, settlementEnvironmentIds, + v2ProjectTitleByProjectKey, ], ); const v2KeyExtractor = useCallback( @@ -699,9 +766,10 @@ export function HomeScreen(props: HomeScreenProps) { (pendingTask) => (props.selectedEnvironmentId === null || pendingTask.message.environmentId === props.selectedEnvironmentId) && - (v2ScopedProject === null || - (pendingTask.message.environmentId === v2ScopedProject.environmentId && - pendingTask.creation.projectId === v2ScopedProject.id)) && + (v2ScopedProjectKeys === null || + v2ScopedProjectKeys.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); // Project scoping lives in the header filter menu (no inline chip row on @@ -728,9 +796,9 @@ export function HomeScreen(props: HomeScreenProps) { const listEmpty = !hasResults ? ( hasSearchQuery ? ( - ) : scopedProject !== null ? ( + ) : selectedProjectScope !== null ? ( ) : selectedEnvironmentLabel ? ( @@ -750,9 +818,9 @@ export function HomeScreen(props: HomeScreenProps) { const v2ListEmpty = v2PendingTasks.length > 0 ? null : hasSearchQuery ? ( - ) : v2ScopedProject !== null ? ( + ) : v2ScopedProjectGroup !== null ? ( ) : ( diff --git a/apps/mobile/src/features/home/home-list-filter-menu.test.ts b/apps/mobile/src/features/home/home-list-filter-menu.test.ts index 916e32671a1a..99e3cb36c072 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.test.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.test.ts @@ -15,17 +15,16 @@ describe("buildHomeListFilterMenu", () => { selectedProjectKey: "environment-1:project-1", projectSortOrder: "updated_at", threadSortOrder: "updated_at", - projectGroupingMode: "repository", onEnvironmentChange: vi.fn(), onProjectChange, onProjectSortOrderChange: vi.fn(), onThreadSortOrderChange: vi.fn(), - onProjectGroupingModeChange: vi.fn(), }); const projectMenu = menu.items.find( (item) => item.type === "submenu" && item.title === "Project", ); + expect(menu.items.some((item) => item.title === "Settings")).toBe(false); expect(projectMenu).toMatchObject({ type: "submenu", items: [ diff --git a/apps/mobile/src/features/home/home-list-filter-menu.ts b/apps/mobile/src/features/home/home-list-filter-menu.ts index 73fda2f5d03a..edd0176f8627 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -1,15 +1,7 @@ -import type { - EnvironmentId, - SidebarProjectGroupingMode, - SidebarThreadSortOrder, -} from "@t3tools/contracts"; +import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { HomeProjectSortOrder } from "./homeThreadList"; -import { - PROJECT_GROUPING_OPTIONS, - PROJECT_SORT_OPTIONS, - THREAD_SORT_OPTIONS, -} from "./home-list-options"; +import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; @@ -47,13 +39,10 @@ export function buildHomeListFilterMenu(props: { readonly selectedProjectKey: string | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; - readonly projectGroupingMode: SidebarProjectGroupingMode; readonly onEnvironmentChange: (environmentId: EnvironmentId | null) => void; readonly onProjectChange: (projectKey: string | null) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - readonly onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; - readonly onOpenSettings?: () => void; /** False hides the sort/group submenus. Thread List v2 uses a fixed creation-order layout, so offering those controls while it silently ignores them would be a lie; the environment filter still applies. */ @@ -61,14 +50,6 @@ export function buildHomeListFilterMenu(props: { }): HomeListFilterMenu { const items: Array = []; - if (props.onOpenSettings) { - items.push({ - type: "action", - title: "Settings", - onPress: props.onOpenSettings, - }); - } - items.push({ type: "submenu", title: "Environment", @@ -136,17 +117,6 @@ export function buildHomeListFilterMenu(props: { onPress: () => props.onThreadSortOrderChange(option.value), })), }, - { - type: "submenu", - title: "Group projects", - items: PROJECT_GROUPING_OPTIONS.map((option) => ({ - type: "action", - title: option.label, - subtitle: option.subtitle, - state: props.projectGroupingMode === option.value ? "on" : "off", - onPress: () => props.onProjectGroupingModeChange(option.value), - })), - }, ); } diff --git a/apps/mobile/src/features/home/home-list-options.test.ts b/apps/mobile/src/features/home/home-list-options.test.ts index 651a64f6f83d..ac3893956ca7 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -1,5 +1,4 @@ import { - DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, } from "@t3tools/contracts"; @@ -14,7 +13,6 @@ const defaults: HomeListOptions = { ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, }; describe("home list options", () => { @@ -22,11 +20,10 @@ describe("home list options", () => { expect(hasCustomHomeListOptions(defaults)).toBe(false); }); - it("marks environment filters and grouping changes as customized", () => { + it("marks environment filters as customized", () => { expect( hasCustomHomeListOptions({ ...defaults, selectedEnvironmentId: "environment-1" as never }), ).toBe(true); - expect(hasCustomHomeListOptions({ ...defaults, projectGroupingMode: "separate" })).toBe(true); expect( hasCustomHomeListOptions({ ...defaults, selectedProjectKey: "environment-1:project-1" }), ).toBe(true); diff --git a/apps/mobile/src/features/home/home-list-options.ts b/apps/mobile/src/features/home/home-list-options.ts index 919cec55ef1f..d70e2537baec 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -4,7 +4,6 @@ import type { SidebarThreadSortOrder, } from "@t3tools/contracts"; import { - DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, DEFAULT_SIDEBAR_THREAD_SORT_ORDER, } from "@t3tools/contracts"; @@ -26,9 +25,18 @@ export interface HomeListOptions { readonly selectedEnvironmentId: EnvironmentId | null; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; +} + +export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } +export function resolveProjectGroupingMode( + projectGroupingEnabled: boolean | undefined, +): SidebarProjectGroupingMode { + return projectGroupingEnabled === false ? "separate" : "repository"; +} + export const PROJECT_SORT_OPTIONS: ReadonlyArray<{ readonly value: HomeProjectSortOrder; readonly label: string; @@ -45,28 +53,6 @@ export const THREAD_SORT_OPTIONS: ReadonlyArray<{ { value: "created_at", label: "Created at" }, ]; -export const PROJECT_GROUPING_OPTIONS: ReadonlyArray<{ - readonly value: SidebarProjectGroupingMode; - readonly label: string; - readonly subtitle: string; -}> = [ - { - value: "repository", - label: "Group by repository", - subtitle: "Combine matching repositories across environments", - }, - { - value: "repository_path", - label: "Group by repository path", - subtitle: "Combine only matching paths within a repository", - }, - { - value: "separate", - label: "Keep separate", - subtitle: "Show every project path separately", - }, -]; - function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentId: null, @@ -75,26 +61,34 @@ function defaultHomeListOptions(): HomeListOptions { ? "updated_at" : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER, threadSortOrder: DEFAULT_SIDEBAR_THREAD_SORT_ORDER, - projectGroupingMode: DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE, }; } interface HomeListOptionsContextValue { readonly options: HomeListOptions; readonly setOptions: Dispatch>; + readonly projectGroupingMode: SidebarProjectGroupingMode; } const HomeListOptionsContext = createContext(null); /** Keeps list preferences stable while the app moves between compact and split shells. */ -export function HomeListOptionsProvider({ children }: PropsWithChildren) { +export function HomeListOptionsProvider({ + children, + projectGroupingMode, +}: PropsWithChildren<{ readonly projectGroupingMode: SidebarProjectGroupingMode }>) { const [options, setOptions] = useState(defaultHomeListOptions); - const value = useMemo(() => ({ options, setOptions }), [options]); + const value = useMemo( + () => ({ options, setOptions, projectGroupingMode }), + [options, projectGroupingMode], + ); return createElement(HomeListOptionsContext, { value }, children); } export function hasCustomHomeListOptions( - options: HomeListOptions & { readonly selectedProjectKey?: string | null }, + options: HomeListOptions & { + readonly selectedProjectKey?: string | null; + }, ): boolean { const defaultProjectSortOrder = DEFAULT_SIDEBAR_PROJECT_SORT_ORDER === "manual" @@ -104,8 +98,7 @@ export function hasCustomHomeListOptions( options.selectedEnvironmentId !== null || (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || options.projectSortOrder !== defaultProjectSortOrder || - options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER || - options.projectGroupingMode !== DEFAULT_SIDEBAR_PROJECT_GROUPING_MODE + options.threadSortOrder !== DEFAULT_SIDEBAR_THREAD_SORT_ORDER ); } @@ -119,10 +112,14 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { setOptions((current) => ({ ...current, selectedEnvironmentId: value })); @@ -133,15 +130,10 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { setOptions((current) => ({ ...current, threadSortOrder: value })); }, []); - const setProjectGroupingMode = useCallback((value: SidebarProjectGroupingMode) => { - setOptions((current) => ({ ...current, projectGroupingMode: value })); - }, []); - return { options: resolvedOptions, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder, - setProjectGroupingMode, } as const; } diff --git a/apps/mobile/src/features/home/homeThreadList.test.ts b/apps/mobile/src/features/home/homeThreadList.test.ts index 46d1173b0ddc..e791cd3b36af 100644 --- a/apps/mobile/src/features/home/homeThreadList.test.ts +++ b/apps/mobile/src/features/home/homeThreadList.test.ts @@ -5,7 +5,11 @@ import type { import { EnvironmentId, ProjectId, ProviderInstanceId, ThreadId } from "@t3tools/contracts"; import { describe, expect, it } from "vite-plus/test"; -import { buildHomeThreadGroups } from "./homeThreadList"; +import { + buildHomeProjectScopes, + buildHomeThreadGroups, + sortHomeProjectScopes, +} from "./homeThreadList"; function makeProject( input: Partial & Pick, @@ -67,6 +71,346 @@ function buildGroups( } describe("buildHomeThreadGroups", () => { + it("builds one v2 scope for the same repository across environments", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }; + const projects = [ + makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-local"), + title: "t3code", + repositoryIdentity, + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-remote"), + title: "t3code", + repositoryIdentity, + }), + ]; + + const scopes = buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + }); + + expect(scopes).toHaveLength(1); + expect(scopes[0]?.title).toBe("t3code"); + expect(scopes[0]?.projects).toEqual(projects); + expect(scopes[0]?.projectRefs).toEqual( + projects.map((project) => ({ + environmentId: project.environmentId, + projectId: project.id, + })), + ); + }); + + it("routes stale duplicate project refs through the canonical repository group", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }; + const local = makeProject({ + id: ProjectId.make("project-local"), + environmentId: localEnvironmentId, + title: "t3code", + workspaceRoot: "/workspaces/t3code", + repositoryIdentity, + }); + const stale = makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-stale"), + title: "t3code", + workspaceRoot: "/remote/t3code", + updatedAt: "2026-06-01T00:00:00.000Z", + }); + const canonicalRemote = makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-canonical-remote"), + title: "t3code", + workspaceRoot: "/remote/t3code/", + repositoryIdentity, + updatedAt: "2026-06-02T00:00:00.000Z", + }); + const projects = [local, stale, canonicalRemote]; + const staleThread = makeThread({ + environmentId: remoteEnvironmentId, + id: ThreadId.make("thread-stale-project-ref"), + projectId: stale.id, + title: "Still visible", + updatedAt: "2026-06-03T00:00:00.000Z", + }); + + const scopes = buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + }); + const groups = buildGroups(projects, [staleThread]); + + expect(scopes).toHaveLength(1); + expect(scopes[0]?.projects.map((project) => project.id)).toEqual([ + local.id, + canonicalRemote.id, + ]); + expect(scopes[0]?.projectRefs.map((projectRef) => projectRef.projectId)).toEqual([ + local.id, + stale.id, + canonicalRemote.id, + ]); + expect(groups).toHaveLength(1); + expect(groups[0]?.threads.map((thread) => thread.id)).toEqual([staleThread.id]); + expect(groups[0]?.newThreadTarget?.id).toBe(canonicalRemote.id); + }); + + it("keeps repository identity from an older duplicate when the freshness winner lacks it", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }; + const projects = [ + makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-local"), + title: "t3code", + repositoryIdentity, + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-remote-with-identity"), + title: "t3code", + workspaceRoot: "/remote/t3code", + repositoryIdentity, + updatedAt: "2026-06-01T00:00:00.000Z", + }), + makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-remote-fresh"), + title: "t3code", + workspaceRoot: "/remote/t3code/", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + ]; + + const scopes = buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + }); + + expect(scopes).toHaveLength(1); + expect(scopes[0]?.representative.id).toBe(ProjectId.make("project-local")); + expect(scopes[0]?.projects.map((project) => project.id)).toContain( + ProjectId.make("project-remote-fresh"), + ); + expect(scopes[0]?.projectRefs).toHaveLength(3); + }); + + it("sorts v2 project scopes by their grouped thread activity", () => { + const environmentId = EnvironmentId.make("environment-1"); + const olderProject = makeProject({ + environmentId, + id: ProjectId.make("project-older"), + title: "Older project", + }); + const newerProject = makeProject({ + environmentId, + id: ProjectId.make("project-newer"), + title: "Newer project", + }); + const scopes = buildHomeProjectScopes({ + projects: [newerProject, olderProject], + environmentId: null, + projectGroupingMode: "separate", + }); + + expect( + sortHomeProjectScopes({ + scopes, + threads: [ + makeThread({ + environmentId, + id: ThreadId.make("thread-older-project"), + projectId: olderProject.id, + title: "Most recently active", + updatedAt: "2026-06-03T00:00:00.000Z", + }), + makeThread({ + environmentId, + id: ThreadId.make("thread-newer-project"), + projectId: newerProject.id, + title: "Less recently active", + updatedAt: "2026-06-02T00:00:00.000Z", + }), + ], + pendingTasks: [], + projectSortOrder: "updated_at", + }).map((scope) => scope.representative.id), + ).toEqual([olderProject.id, newerProject.id]); + }); + + it("sorts invalid project creation timestamps after valid ones", () => { + const environmentId = EnvironmentId.make("environment-1"); + const invalidProject = makeProject({ + environmentId, + id: ProjectId.make("project-invalid"), + title: "A invalid timestamp", + createdAt: "invalid", + }); + const validProject = makeProject({ + environmentId, + id: ProjectId.make("project-valid"), + title: "Z valid timestamp", + createdAt: "2026-06-02T00:00:00.000Z", + }); + const scopes = buildHomeProjectScopes({ + projects: [invalidProject, validProject], + environmentId: null, + projectGroupingMode: "separate", + }); + + expect( + sortHomeProjectScopes({ + scopes, + threads: [], + pendingTasks: [], + projectSortOrder: "created_at", + }).map((scope) => scope.representative.id), + ).toEqual([validProject.id, invalidProject.id]); + }); + + it("uses the freshest member when a grouped scope has no activity", () => { + const localEnvironmentId = EnvironmentId.make("environment-local"); + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const repositoryIdentity = { + canonicalKey: "github.com/pingdotgg/t3code", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }; + const olderMember = makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-older-member"), + title: "t3code", + updatedAt: "2026-06-01T00:00:00.000Z", + repositoryIdentity, + }); + const newerMember = makeProject({ + environmentId: remoteEnvironmentId, + id: ProjectId.make("project-newer-member"), + title: "t3code", + updatedAt: "2026-06-03T00:00:00.000Z", + repositoryIdentity, + }); + const otherProject = makeProject({ + environmentId: localEnvironmentId, + id: ProjectId.make("project-other"), + title: "other", + updatedAt: "2026-06-02T00:00:00.000Z", + }); + const scopes = buildHomeProjectScopes({ + projects: [olderMember, newerMember, otherProject], + environmentId: null, + projectGroupingMode: "repository", + }); + + expect( + sortHomeProjectScopes({ + scopes, + threads: [], + pendingTasks: [], + projectSortOrder: "updated_at", + })[0]?.key, + ).toBe(scopes.find((scope) => scope.projects.length === 2)?.key); + }); + + it("does not merge unrelated repositories that share a title", () => { + const environmentId = EnvironmentId.make("environment-1"); + const projects = ["one", "two"].map((name) => + makeProject({ + environmentId, + id: ProjectId.make(`project-${name}`), + title: "app", + repositoryIdentity: { + canonicalKey: `github.com/example/${name}`, + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: `git@github.com:example/${name}.git`, + }, + }, + }), + ); + + expect( + buildHomeProjectScopes({ + projects, + environmentId: null, + projectGroupingMode: "repository", + }), + ).toHaveLength(2); + }); + + it("uses the repository label for a singleton repository scope", () => { + const project = makeProject({ + environmentId: EnvironmentId.make("environment-1"), + id: ProjectId.make("project-1"), + title: "local-worktree-name", + repositoryIdentity: { + canonicalKey: "github.com/pingdotgg/t3code", + displayName: "codething-mvp", + locator: { + source: "git-remote" as const, + remoteName: "origin", + remoteUrl: "git@github.com:pingdotgg/t3code.git", + }, + }, + }); + + const scopes = buildHomeProjectScopes({ + projects: [project], + environmentId: null, + projectGroupingMode: "repository", + }); + const groups = buildGroups( + [project], + [ + makeThread({ + environmentId: project.environmentId, + id: ThreadId.make("thread-1"), + projectId: project.id, + title: "Thread", + }), + ], + ); + + expect(scopes[0]?.title).toBe("codething-mvp"); + expect(groups[0]?.title).toBe("codething-mvp"); + }); + it("sorts the newest thread first regardless of snapshot order", () => { const environmentId = EnvironmentId.make("environment-1"); const project = makeProject({ diff --git a/apps/mobile/src/features/home/homeThreadList.ts b/apps/mobile/src/features/home/homeThreadList.ts index cada956d8bb5..21084f0f5fe5 100644 --- a/apps/mobile/src/features/home/homeThreadList.ts +++ b/apps/mobile/src/features/home/homeThreadList.ts @@ -1,14 +1,20 @@ import { deriveLogicalProjectKey, + derivePhysicalProjectKey, deriveProjectGroupLabel, } from "@t3tools/client-runtime/state/project-grouping"; import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { getThreadSortTimestamp, sortThreads } from "@t3tools/client-runtime/state/thread-sort"; +import { + getThreadSortTimestamp, + sortThreads, + toSortableTimestamp, +} from "@t3tools/client-runtime/state/thread-sort"; import type { EnvironmentId, + ScopedProjectRef, SidebarProjectGroupingMode, SidebarProjectSortOrder, SidebarThreadSortOrder, @@ -22,6 +28,161 @@ import type { PendingNewTask } from "../../state/use-pending-new-tasks"; export type HomeProjectSortOrder = Exclude; +export interface HomeProjectScope { + readonly key: string; + readonly title: string; + readonly representative: EnvironmentProject; + readonly projects: ReadonlyArray; + readonly projectRefs: ReadonlyArray; +} + +function getProjectFreshnessTimestamp(project: EnvironmentProject): number { + return toSortableTimestamp(project.updatedAt) ?? toSortableTimestamp(project.createdAt) ?? 0; +} + +function getProjectSortTimestamp( + project: EnvironmentProject, + sortOrder: HomeProjectSortOrder, +): number { + return sortOrder === "created_at" + ? (toSortableTimestamp(project.createdAt) ?? Number.NEGATIVE_INFINITY) + : (toSortableTimestamp(project.updatedAt) ?? + toSortableTimestamp(project.createdAt) ?? + Number.NEGATIVE_INFINITY); +} + +export function buildHomeProjectScopes(input: { + readonly projects: ReadonlyArray; + readonly environmentId: EnvironmentId | null; + readonly projectGroupingMode: SidebarProjectGroupingMode; +}): ReadonlyArray { + const projects = input.projects.filter( + (project) => input.environmentId === null || project.environmentId === input.environmentId, + ); + const projectsByPhysicalKey = new Map(); + for (const project of projects) { + const physicalKey = derivePhysicalProjectKey(project); + const existing = projectsByPhysicalKey.get(physicalKey); + if (existing) existing.push(project); + else projectsByPhysicalKey.set(physicalKey, [project]); + } + + const winnersByPhysicalKey = new Map< + string, + { readonly key: string; readonly project: EnvironmentProject } + >(); + for (const [physicalKey, members] of projectsByPhysicalKey) { + const project = members.reduce((winner, candidate) => { + const freshnessDelta = + getProjectFreshnessTimestamp(candidate) - getProjectFreshnessTimestamp(winner); + return freshnessDelta > 0 || (freshnessDelta === 0 && candidate.id > winner.id) + ? candidate + : winner; + }); + const identitySource = members.find((member) => member.repositoryIdentity !== null) ?? project; + winnersByPhysicalKey.set(physicalKey, { + key: deriveLogicalProjectKey(identitySource, { groupingMode: input.projectGroupingMode }), + project, + }); + } + + const groups = new Map(); + for (const { key, project } of winnersByPhysicalKey.values()) { + const existing = groups.get(key); + if (existing) existing.push(project); + else groups.set(key, [project]); + } + + const projectRefsByGroup = new Map(); + const seenProjectRefs = new Set(); + for (const project of projects) { + const refKey = scopedProjectKey(project.environmentId, project.id); + if (seenProjectRefs.has(refKey)) continue; + seenProjectRefs.add(refKey); + + const key = + winnersByPhysicalKey.get(derivePhysicalProjectKey(project))?.key ?? + deriveLogicalProjectKey(project, { groupingMode: input.projectGroupingMode }); + const refs = projectRefsByGroup.get(key); + const projectRef = { environmentId: project.environmentId, projectId: project.id }; + if (refs) refs.push(projectRef); + else projectRefsByGroup.set(key, [projectRef]); + } + + return Array.from(groups, ([key, projects]) => { + const representative = projects[0]!; + return { + key, + title: deriveProjectGroupLabel({ representative, members: projects }), + representative, + projects, + projectRefs: projectRefsByGroup.get(key) ?? [], + }; + }); +} + +export function sortHomeProjectScopes(input: { + readonly scopes: ReadonlyArray; + readonly threads: ReadonlyArray; + readonly pendingTasks: ReadonlyArray; + readonly projectSortOrder: HomeProjectSortOrder; +}): ReadonlyArray { + const scopeKeyByProjectRef = new Map( + input.scopes.flatMap((scope) => + scope.projectRefs.map( + (projectRef) => + [scopedProjectKey(projectRef.environmentId, projectRef.projectId), scope.key] as const, + ), + ), + ); + const latestActivityByScope = new Map(); + const recordActivity = (scopeKey: string | undefined, timestamp: number) => { + if (!scopeKey || !Number.isFinite(timestamp)) return; + latestActivityByScope.set( + scopeKey, + Math.max(latestActivityByScope.get(scopeKey) ?? Number.NEGATIVE_INFINITY, timestamp), + ); + }; + + for (const thread of input.threads) { + if (thread.archivedAt !== null) continue; + recordActivity( + scopeKeyByProjectRef.get(scopedProjectKey(thread.environmentId, thread.projectId)), + getThreadSortTimestamp(thread, input.projectSortOrder), + ); + } + for (const pendingTask of input.pendingTasks) { + recordActivity( + scopeKeyByProjectRef.get( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), + Date.parse(pendingTask.message.createdAt), + ); + } + + return Arr.sort( + input.scopes, + Order.mapInput( + Order.Struct({ + timestamp: Order.flip(Order.Number), + title: Order.String, + key: Order.String, + }), + (scope: HomeProjectScope) => ({ + timestamp: + latestActivityByScope.get(scope.key) ?? + Math.max( + ...scope.projects.map((project) => + getProjectSortTimestamp(project, input.projectSortOrder), + ), + ), + title: scope.title, + key: scope.key, + }), + ), + ); +} + /** * Default home view only surfaces threads active within this window, to keep the * screen compact while keeping recent work visible. @@ -103,22 +264,18 @@ export function buildHomeThreadGroups(input: { const groups = new Map(); const groupKeyByProjectKey = new Map(); - for (const project of input.projects) { - if (input.environmentId !== null && project.environmentId !== input.environmentId) { - continue; - } - - const groupKey = deriveLogicalProjectKey(project, { - groupingMode: input.projectGroupingMode, + for (const scope of buildHomeProjectScopes(input)) { + groups.set(scope.key, { + key: scope.key, + projects: [...scope.projects], + pendingTasks: [], + threads: [], }); - const physicalKey = scopedProjectKey(project.environmentId, project.id); - groupKeyByProjectKey.set(physicalKey, groupKey); - - const existing = groups.get(groupKey); - if (existing) { - existing.projects.push(project); - } else { - groups.set(groupKey, { key: groupKey, projects: [project], pendingTasks: [], threads: [] }); + for (const projectRef of scope.projectRefs) { + groupKeyByProjectKey.set( + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + scope.key, + ); } } @@ -186,10 +343,7 @@ export function buildHomeThreadGroups(input: { continue; } - const title = - group.projects.length > 1 - ? deriveProjectGroupLabel({ representative, members: group.projects }) - : representative.title; + const title = deriveProjectGroupLabel({ representative, members: group.projects }); const groupMatches = query.length === 0 || title.toLocaleLowerCase().includes(query) || @@ -215,21 +369,23 @@ export function buildHomeThreadGroups(input: { ? selectRecentThreads(sortedThreads, input.threadSortOrder, now) : sortedThreads; - // Sorted newest-first, so the first thread whose project is a group member - // marks the machine the user last worked on. - const lastActiveProject = Arr.findFirst(sortedThreads, (thread) => - group.projects.some( - (project) => - project.environmentId === thread.environmentId && project.id === thread.projectId, - ), - ).pipe( + // A stale project id still resolves to the canonical member with the same + // environment/path, so quick creation follows the machine with the newest activity. + const lastActiveProject = Arr.head(sortedThreads).pipe( Option.flatMap((thread) => Arr.findFirst( - group.projects, + input.projects, (project) => project.environmentId === thread.environmentId && project.id === thread.projectId, ), ), + Option.flatMap((threadProject) => + Arr.findFirst( + group.projects, + (project) => + derivePhysicalProjectKey(project) === derivePhysicalProjectKey(threadProject), + ), + ), Option.getOrNull, ); diff --git a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx index d1327473b0d8..9c068c6249c2 100644 --- a/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx +++ b/apps/mobile/src/features/layout/AdaptiveWorkspaceLayout.tsx @@ -2,7 +2,8 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { EnvironmentId, ThreadId } from "@t3tools/contracts"; +import { EnvironmentId, ThreadId, type SidebarProjectGroupingMode } from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; import { useFocusEffect } from "@react-navigation/native"; import { NavigationContext, @@ -22,6 +23,7 @@ import { } from "react"; import { useWindowDimensions, View } from "react-native"; import Animated, { useAnimatedStyle, useSharedValue, withTiming } from "react-native-reanimated"; +import { AsyncResult } from "effect/unstable/reactivity"; import { deriveFileInspectorPaneLayout, @@ -34,11 +36,12 @@ import { } from "../../lib/layout"; import { resolveThreadSelectionNavigationAction } from "../../lib/adaptive-navigation"; import { scopedThreadKey } from "../../lib/scopedEntities"; +import { mobilePreferencesAtom } from "../../state/preferences"; import { parseActiveThreadPath, useHardwareKeyboardCommand, } from "../keyboard/hardwareKeyboardCommands"; -import { HomeListOptionsProvider } from "../home/home-list-options"; +import { HomeListOptionsProvider, resolveProjectGroupingMode } from "../home/home-list-options"; import { ThreadNavigationSidebar } from "../threads/ThreadNavigationSidebar"; import { WORKSPACE_PANE_TIMING } from "./workspace-pane-animation"; import { WorkspaceInspectorPane } from "./workspace-inspector-pane"; @@ -184,6 +187,31 @@ export function AdaptiveWorkspaceLayout(props: { readonly children: ReactNode; readonly pathname: string; }) { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + if (!AsyncResult.isSuccess(preferencesResult)) { + return AsyncResult.isFailure(preferencesResult) ? ( + + ) : null; + } + return ( + + ); +} + +function AdaptiveWorkspaceLayoutContent( + props: { + readonly children: ReactNode; + readonly pathname: string; + } & { + readonly projectGroupingMode: SidebarProjectGroupingMode; + }, +) { + const projectGroupingMode = props.projectGroupingMode; const { width, height } = useWindowDimensions(); const pathname = props.pathname; const navigation = useNavigation(); @@ -474,7 +502,7 @@ export function AdaptiveWorkspaceLayout(props: { ); return ( - + {shouldRenderPrimarySidebar && layout.listPaneWidth !== null ? ( diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 9aea392555af..4c33675f57f7 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -119,6 +119,8 @@ function LocalSettingsRouteScreen() { /> + + @@ -504,6 +506,8 @@ function ConfiguredSettingsRouteScreen() { /> + + @@ -518,6 +522,25 @@ function ConfiguredSettingsRouteScreen() { ); } +function GeneralSettingsSection() { + const preferencesResult = useAtomValue(mobilePreferencesAtom); + const savePreferences = useAtomSet(updateMobilePreferencesAtom); + const projectGroupingEnabled = AsyncResult.isSuccess(preferencesResult) + ? preferencesResult.value.projectGroupingEnabled !== false + : true; + + return ( + + savePreferences({ projectGroupingEnabled: value })} + /> + + ); +} + /** * Device-local beta toggles. Mobile has no client-settings sync, so this is * the counterpart of web's Settings → Beta backed by mobile preferences. diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 13f628351c93..01403047c8ef 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -33,7 +33,6 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { hasCustomHomeListOptions, - PROJECT_GROUPING_OPTIONS, PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, useHomeListOptions, @@ -48,7 +47,7 @@ import { type HomeGroupDisplayState, type HomeListItem, } from "../home/homeListItems"; -import { buildHomeThreadGroups } from "../home/homeThreadList"; +import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; @@ -217,36 +216,47 @@ function ThreadNavigationSidebarPane( () => new Set(environments.map((environment) => environment.environmentId)), [environments], ); - const { - options, - setSelectedEnvironmentId, - setProjectGroupingMode, - setProjectSortOrder, - setThreadSortOrder, - } = useHomeListOptions(availableEnvironmentIds); + const { options, setSelectedEnvironmentId, setProjectSortOrder, setThreadSortOrder } = + useHomeListOptions(availableEnvironmentIds); const [selectedProjectKey, setSelectedProjectKey] = useState(null); + const projectScopes = useMemo( + () => + buildHomeProjectScopes({ + projects, + environmentId: options.selectedEnvironmentId, + projectGroupingMode: options.projectGroupingMode, + }), + [options.projectGroupingMode, options.selectedEnvironmentId, projects], + ); const projectFilterOptions = useMemo( () => - projects - .filter( - (project) => - options.selectedEnvironmentId === null || - project.environmentId === options.selectedEnvironmentId, - ) - .map((project) => ({ - key: scopedProjectKey(project.environmentId, project.id), - label: project.title, - })), - [options.selectedEnvironmentId, projects], + projectScopes.map((scope) => ({ + key: scope.key, + label: scope.title, + })), + [projectScopes], ); - const selectedProject = useMemo( + const projectTitleByProjectKey = useMemo( + () => + new Map( + projectScopes.flatMap((scope) => + scope.projectRefs.map( + (projectRef) => + [ + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + scope.title, + ] as const, + ), + ), + ), + [projectScopes], + ); + const selectedProjectScope = useMemo( () => selectedProjectKey === null ? null - : (projects.find( - (project) => scopedProjectKey(project.environmentId, project.id) === selectedProjectKey, - ) ?? null), - [projects, selectedProjectKey], + : (projectScopes.find((scope) => scope.key === selectedProjectKey) ?? null), + [projectScopes, selectedProjectKey], ); useEffect(() => { if ( @@ -256,31 +266,45 @@ function ThreadNavigationSidebarPane( setSelectedProjectKey(null); } }, [projectFilterOptions, selectedProjectKey]); + const selectedProjectRefs = useMemo( + () => + selectedProjectScope === null + ? null + : new Set( + selectedProjectScope.projectRefs.map((projectRef) => + scopedProjectKey(projectRef.environmentId, projectRef.projectId), + ), + ), + [selectedProjectScope], + ); const scopedProjects = useMemo( - () => (selectedProject === null ? projects : [selectedProject]), - [projects, selectedProject], + () => + selectedProjectRefs === null + ? projects + : projects.filter((project) => + selectedProjectRefs.has(scopedProjectKey(project.environmentId, project.id)), + ), + [projects, selectedProjectRefs], ); const scopedThreads = useMemo( () => - selectedProject === null + selectedProjectRefs === null ? threads - : threads.filter( - (thread) => - thread.environmentId === selectedProject.environmentId && - thread.projectId === selectedProject.id, + : threads.filter((thread) => + selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), ), - [selectedProject, threads], + [selectedProjectRefs, threads], ); const scopedPendingTasks = useMemo( () => - selectedProject === null + selectedProjectRefs === null ? pendingTasks - : pendingTasks.filter( - (pendingTask) => - pendingTask.message.environmentId === selectedProject.environmentId && - pendingTask.creation.projectId === selectedProject.id, + : pendingTasks.filter((pendingTask) => + selectedProjectRefs.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + ), ), - [pendingTasks, selectedProject], + [pendingTasks, selectedProjectRefs], ); const groups = useMemo( () => @@ -401,13 +425,7 @@ function ThreadNavigationSidebarPane( return buildThreadListV2Items({ threads: threads.filter((thread) => thread.archivedAt === null), environmentId: options.selectedEnvironmentId, - projectRef: - selectedProject === null - ? null - : { - environmentId: selectedProject.environmentId, - projectId: selectedProject.id, - }, + projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, changeRequestStateByKey, settlementEnvironmentIds, @@ -423,7 +441,7 @@ function ThreadNavigationSidebarPane( settlementEnvironmentIds, threadListV2Enabled, threads, - selectedProject, + selectedProjectScope, ]); const listItems = useMemo(() => { if (!threadListV2Enabled) return listLayout.items; @@ -437,9 +455,10 @@ function ThreadNavigationSidebarPane( (pendingTask) => (options.selectedEnvironmentId === null || pendingTask.message.environmentId === options.selectedEnvironmentId) && - (selectedProject === null || - (pendingTask.message.environmentId === selectedProject.environmentId && - pendingTask.creation.projectId === selectedProject.id)) && + (selectedProjectRefs === null || + selectedProjectRefs.has( + scopedProjectKey(pendingTask.message.environmentId, pendingTask.creation.projectId), + )) && (v2SearchQuery.length === 0 || pendingTask.title.toLocaleLowerCase().includes(v2SearchQuery)), ); @@ -469,7 +488,7 @@ function ThreadNavigationSidebarPane( options.selectedEnvironmentId, pendingTasks, props.searchQuery, - selectedProject, + selectedProjectRefs, threadListV2Enabled, threadListV2Layout, ]); @@ -541,16 +560,6 @@ function ThreadNavigationSidebarPane( state: options.threadSortOrder === option.value ? "on" : "off", })), }, - { - id: "project-grouping", - title: "Group projects", - subactions: PROJECT_GROUPING_OPTIONS.map((option) => ({ - id: `project-grouping:${option.value}`, - title: option.label, - subtitle: option.subtitle, - state: options.projectGroupingMode === option.value ? "on" : "off", - })), - }, ] satisfies MenuAction[])), ], [environments, options, projectFilterOptions, selectedProjectKey, threadListV2Enabled], @@ -594,15 +603,10 @@ function ThreadNavigationSidebarPane( setThreadSortOrder(threadSort.value); return; } - const grouping = PROJECT_GROUPING_OPTIONS.find( - (option) => `project-grouping:${option.value}` === event, - ); - if (grouping) setProjectGroupingMode(grouping.value); }, [ environments, projectFilterOptions, - setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, @@ -739,6 +743,7 @@ function ThreadNavigationSidebarPane( variant={item.item.variant} showSettledDivider={item.item.showSettledDivider} project={projectByKey.get(scopeKey) ?? null} + projectTitle={projectTitleByProjectKey.get(scopeKey)} providerDriver={ serverConfigs .get(thread.environmentId) @@ -868,6 +873,7 @@ function ThreadNavigationSidebarPane( openPendingTask, projectByKey, projectCwdByKey, + projectTitleByProjectKey, props.onNewThreadInProject, props.selectedThreadKey, props.width, @@ -898,12 +904,10 @@ function ThreadNavigationSidebarPane( selectedProjectKey, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, - projectGroupingMode: options.projectGroupingMode, onEnvironmentChange: setSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, - onProjectGroupingModeChange: setProjectGroupingMode, listOrganization: !threadListV2Enabled, }), [ @@ -911,7 +915,6 @@ function ThreadNavigationSidebarPane( options, projectFilterOptions, selectedProjectKey, - setProjectGroupingMode, setProjectSortOrder, setSelectedEnvironmentId, setThreadSortOrder, @@ -933,8 +936,8 @@ function ThreadNavigationSidebarPane( ? "Loading threads…" : props.searchQuery.trim().length > 0 ? "No matching threads" - : selectedProject !== null - ? `No threads in ${selectedProject.title}` + : selectedProjectScope !== null + ? `No threads in ${selectedProjectScope.title}` : "No threads yet"} ); diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 63eb9a4d9f36..69729cb64690 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -90,6 +90,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { readonly variant: "card" | "slim"; readonly showSettledDivider: boolean; readonly project: EnvironmentProject | null; + readonly projectTitle?: string; readonly providerDriver: string | null; /** Which machine hosts the thread. Null when only one environment is connected — repeating the same label on every row is noise. Mirrors @@ -219,7 +220,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ) : null} @@ -230,7 +231,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { )} numberOfLines={1} > - {props.project?.title ?? ""} + {props.projectTitle ?? props.project?.title ?? ""} diff --git a/apps/mobile/src/features/threads/threadListV2.test.ts b/apps/mobile/src/features/threads/threadListV2.test.ts index 80edc86124b2..cdbf2e3c5851 100644 --- a/apps/mobile/src/features/threads/threadListV2.test.ts +++ b/apps/mobile/src/features/threads/threadListV2.test.ts @@ -166,13 +166,36 @@ describe("buildThreadListV2Items", () => { }), ], environmentId: null, - projectRef: { environmentId, projectId: ProjectId.make("project-1") }, + projectRefs: [{ environmentId, projectId: ProjectId.make("project-1") }], searchQuery: "", now: NOW, }); expect(items.map((item) => item.thread.id)).toEqual(["included"]); }); + + it("scopes the flat list to every environment member of a logical project", () => { + const remoteEnvironmentId = EnvironmentId.make("environment-remote"); + const { items } = buildThreadListV2Items({ + threads: [ + makeThread({ id: ThreadId.make("local"), title: "Local" }), + makeThread({ + environmentId: remoteEnvironmentId, + id: ThreadId.make("remote"), + title: "Remote", + }), + ], + environmentId: null, + projectRefs: [ + { environmentId, projectId: ProjectId.make("project-1") }, + { environmentId: remoteEnvironmentId, projectId: ProjectId.make("project-1") }, + ], + searchQuery: "", + now: NOW, + }); + + expect(items.map((item) => item.thread.id)).toEqual(["local", "remote"]); + }); }); describe("buildThreadListV2Items settled paging", () => { diff --git a/apps/mobile/src/features/threads/threadListV2.ts b/apps/mobile/src/features/threads/threadListV2.ts index a1604bafac45..071e1d93be05 100644 --- a/apps/mobile/src/features/threads/threadListV2.ts +++ b/apps/mobile/src/features/threads/threadListV2.ts @@ -95,10 +95,10 @@ export interface ThreadListV2Layout { export function buildThreadListV2Items(input: { readonly threads: ReadonlyArray; readonly environmentId: EnvironmentId | null; - readonly projectRef?: { + readonly projectRefs?: ReadonlyArray<{ readonly environmentId: EnvironmentId; readonly projectId: ProjectId; - } | null; + }> | null; readonly searchQuery: string; /** Per-row PR state reported up by visible rows ("env:threadId" keys). */ readonly changeRequestStateByKey?: ReadonlyMap; @@ -115,6 +115,9 @@ export function buildThreadListV2Items(input: { const now = input.now ?? new Date().toISOString(); const autoSettleAfterDays = input.autoSettleAfterDays ?? 3; const query = input.searchQuery.trim().toLocaleLowerCase(); + const projectKeys = input.projectRefs + ? new Set(input.projectRefs.map((ref) => `${ref.environmentId}:${ref.projectId}`)) + : null; const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; @@ -122,11 +125,7 @@ export function buildThreadListV2Items(input: { // Callers pass live (unarchived) shells; settled threads are among them // and partition into the tail via effectiveSettled. if (input.environmentId !== null && thread.environmentId !== input.environmentId) continue; - if ( - input.projectRef != null && - (thread.environmentId !== input.projectRef.environmentId || - thread.projectId !== input.projectRef.projectId) - ) { + if (projectKeys !== null && !projectKeys.has(`${thread.environmentId}:${thread.projectId}`)) { continue; } if (query.length > 0 && !thread.title.toLocaleLowerCase().includes(query)) continue; diff --git a/apps/mobile/src/persistence/mobile-preferences.ts b/apps/mobile/src/persistence/mobile-preferences.ts index 1138ad2b655e..4e576bb2fe13 100644 --- a/apps/mobile/src/persistence/mobile-preferences.ts +++ b/apps/mobile/src/persistence/mobile-preferences.ts @@ -22,6 +22,7 @@ export interface Preferences { readonly codeWordBreak?: boolean; readonly connectOnboardingOptOutAccounts?: ReadonlyArray; readonly collapsedProjectGroups?: readonly string[]; + readonly projectGroupingEnabled?: boolean; /** * Device-local mirror of the web beta's `sidebarV2Enabled`. Mobile has no * client-settings sync, so the flat v2 thread list is opted into per @@ -77,6 +78,7 @@ function sanitizePreferences(parsed: Preferences): Preferences { codeWordBreak?: boolean; connectOnboardingOptOutAccounts?: ReadonlyArray; collapsedProjectGroups?: readonly string[]; + projectGroupingEnabled?: boolean; threadListV2Enabled?: boolean; } = {}; @@ -104,6 +106,9 @@ function sanitizePreferences(parsed: Preferences): Preferences { (key): key is string => typeof key === "string", ); } + if (typeof parsed.projectGroupingEnabled === "boolean") { + preferences.projectGroupingEnabled = parsed.projectGroupingEnabled; + } if (typeof parsed.threadListV2Enabled === "boolean") { preferences.threadListV2Enabled = parsed.threadListV2Enabled; } diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 00b6c4cfcceb..517f633577c1 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -12,7 +12,8 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { DEVELOPMENT_ICON_OVERRIDES, - PUBLISH_ICON_OVERRIDES, + resolveWebAssetBrandForPackageVersion, + resolveWebIconOverrides, } from "../../../scripts/lib/brand-assets.ts"; import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; @@ -81,50 +82,34 @@ const runCommand = Effect.fn("runCommand")(function* (command: ChildProcess.Stan } }); -interface PublishIconBackup { - readonly targetPath: string; - readonly backupPath: string; -} - -const applyPublishIconOverrides = Effect.fn("applyPublishIconOverrides")(function* ( +const preparePublishIcons = Effect.fn("preparePublishIcons")(function* ( repoRoot: string, serverDir: string, + version: string, ) { const path = yield* Path.Path; const fs = yield* FileSystem.FileSystem; - const backups: PublishIconBackup[] = []; - - for (const override of PUBLISH_ICON_OVERRIDES) { - const sourcePath = path.join(repoRoot, override.sourceRelativePath); - const targetPath = path.join(serverDir, override.targetRelativePath); - const backupPath = `${targetPath}.publish-bak`; - - if (!(yield* fs.exists(sourcePath))) { - return yield* new ServerCliPublishIconSourceMissingError({ sourcePath }); + const brand = resolveWebAssetBrandForPackageVersion(version); + const icons = resolveWebIconOverrides(brand, "dist/client").map((override) => ({ + sourcePath: path.join(repoRoot, override.sourceRelativePath), + targetPath: path.join(serverDir, override.targetRelativePath), + })); + + for (const icon of icons) { + if (!(yield* fs.exists(icon.sourcePath))) { + return yield* new ServerCliPublishIconSourceMissingError({ sourcePath: icon.sourcePath }); } - if (!(yield* fs.exists(targetPath))) { - return yield* new ServerCliPublishIconTargetMissingError({ targetPath }); + if (!(yield* fs.exists(icon.targetPath))) { + return yield* new ServerCliPublishIconTargetMissingError({ targetPath: icon.targetPath }); } - - yield* fs.copyFile(targetPath, backupPath); - yield* fs.copyFile(sourcePath, targetPath); - backups.push({ targetPath, backupPath }); } - yield* Effect.log("[cli] Applied publish icon overrides to dist/client"); - return backups as ReadonlyArray; -}); - -const restorePublishIconOverrides = Effect.fn("restorePublishIconOverrides")(function* ( - backups: ReadonlyArray, -) { - const fs = yield* FileSystem.FileSystem; - for (const backup of backups) { - if (!(yield* fs.exists(backup.backupPath))) { - continue; - } - yield* fs.rename(backup.backupPath, backup.targetPath); - } + return yield* Effect.forEach(icons, (icon) => + Effect.all({ + original: fs.readFile(icon.targetPath), + publish: fs.readFile(icon.sourcePath), + }).pipe(Effect.map((contents) => ({ ...icon, ...contents }))), + ); }); const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides")(function* ( @@ -236,7 +221,6 @@ const publishCmd = Command.make( const repoRoot = yield* RepoRoot; const serverDir = path.join(repoRoot, "apps/server"); const packageJsonPath = path.join(serverDir, "package.json"); - const backupPath = `${packageJsonPath}.bak`; // Assert build assets exist for (const relPath of ["dist/bin.mjs", "dist/client/index.html"]) { @@ -247,7 +231,7 @@ const publishCmd = Command.make( } yield* Effect.acquireUseRelease( - // Acquire: backup package.json, resolve catalog dependencies, and strip devDependencies/scripts + // Acquire: resolve publish metadata and read every original before mutation. Effect.gen(function* () { const version = Option.getOrElse(config.appVersion, () => serverPackageJson.version); const workspaceConfig = yield* readWorkspaceConfig(); @@ -273,19 +257,22 @@ const publishCmd = Command.make( ), }; - const original = yield* fs.readFileString(packageJsonPath); - const packageJsonString = yield* encodePackageJson(pkg); - yield* fs.writeFileString(backupPath, original); - yield* fs.writeFileString(packageJsonPath, `${packageJsonString}\n`); - yield* Effect.log("[cli] Prepared package.json for publish"); - - const iconBackups = yield* applyPublishIconOverrides(repoRoot, serverDir); - return { iconBackups }; + return { + packageJsonString: yield* encodePackageJson(pkg), + originalPackageJson: yield* fs.readFile(packageJsonPath), + icons: yield* preparePublishIcons(repoRoot, serverDir, version), + }; }), // Use: pnpm publish from the workspace root so pnpm-only workspace // config, including override selectors, is interpreted correctly. - () => + (resource) => Effect.gen(function* () { + yield* fs.writeFileString(packageJsonPath, `${resource.packageJsonString}\n`); + for (const icon of resource.icons) { + yield* fs.writeFile(icon.targetPath, icon.publish); + } + yield* Effect.log("[cli] Applied package metadata and publish icon overrides"); + const args = createVpPmPublishArgs(config); const spawnCommand = yield* resolveSpawnCommand("vp", ["pm", ...args]); @@ -299,16 +286,14 @@ const publishCmd = Command.make( }), ); }), - // Release: restore - (resource: { readonly iconBackups: ReadonlyArray }) => + // Release: restore every file even if applying overrides or publishing fails. + (resource) => Effect.gen(function* () { - yield* restorePublishIconOverrides(resource.iconBackups).pipe( - Effect.catch((error) => - Effect.logError(`[cli] Failed to restore publish icon overrides: ${String(error)}`), - ), - ); - yield* fs.rename(backupPath, packageJsonPath); - if (config.verbose) yield* Effect.log("[cli] Restored original package.json"); + yield* fs.writeFile(packageJsonPath, resource.originalPackageJson); + for (const icon of resource.icons) { + yield* fs.writeFile(icon.targetPath, icon.original); + } + if (config.verbose) yield* Effect.log("[cli] Restored original publish assets"); }), ); }), diff --git a/apps/server/src/cloud/selfUpdate.test.ts b/apps/server/src/cloud/selfUpdate.test.ts index 28bcc0ffe623..9d6e3801704d 100644 --- a/apps/server/src/cloud/selfUpdate.test.ts +++ b/apps/server/src/cloud/selfUpdate.test.ts @@ -54,7 +54,7 @@ const makeRecordingRunnerLayer = ( return { stdout: options?.stdoutFor?.(input.command, input.args) ?? - (versionFromPath === undefined ? "" : `${versionFromPath}\n`), + (versionFromPath === undefined ? "" : `t3 v${versionFromPath}\n`), stderr: failed ? `${input.command} exploded` : "", code: ChildProcessSpawner.ExitCode(failed ? 1 : 0), timedOut: false, @@ -427,7 +427,7 @@ it.layer(NodeServices.layer)("ServerSelfUpdate.update", (it) => { Effect.gen(function* () { const context = yield* makeContext({ stdoutFor: (command, args) => - command === NODE_PATH && args[1] === "--version" ? "0.0.28\n" : undefined, + command === NODE_PATH && args[1] === "--version" ? "t3 v0.0.28\n" : undefined, }); const versionDir = context.path.join(context.baseDir, "runtime", "versions", "0.0.29"); diff --git a/apps/server/src/cloud/selfUpdate.ts b/apps/server/src/cloud/selfUpdate.ts index 2df49910bd5e..62bd07fbbc8b 100644 --- a/apps/server/src/cloud/selfUpdate.ts +++ b/apps/server/src/cloud/selfUpdate.ts @@ -273,8 +273,10 @@ export const make = Effect.fn("cloud.server_self_update.make")(function* (option failWith(`Could not verify the installed t3@${targetVersion}.`, cause), ), ); - const preflightVersion = preflight.stdout.trim(); - if (preflight.code !== 0 || preflightVersion !== targetVersion) { + // Effect CLI's unstable formatVersion currently emits `${name} v${version}`. + // Extract the version token so surrounding presentation changes do not break updates. + const reportedVersion = /\bv(\S+)\s*$/.exec(preflight.stdout)?.[1]; + if (preflight.code !== 0 || reportedVersion !== targetVersion) { // A completed npm install can still be unusable under this Node or on // this machine. Remove its sentinel and tree so a retry of the same // version performs a clean install instead of reusing a known-bad one. diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index 64d6ba02b1d6..8c4651dc1cfb 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -4,6 +4,7 @@ import type { PreviewAutomationRecordingArtifact, PreviewAutomationRecordingStatus, PreviewAutomationResizeResult, + PreviewAutomationSetColorSchemeResult, PreviewAutomationSnapshot, PreviewAutomationStatus, PreviewTabId, @@ -58,6 +59,8 @@ const handlers = { invokeTargeted("navigate", input, input.timeoutMs), preview_resize: (input) => invokeTargeted("resize", input, input.timeoutMs), + preview_set_appearance: (input) => + invokeTargeted("setColorScheme", input), preview_snapshot: (input) => invokeTargeted("snapshot", input ?? {}), preview_click: (input) => invokeTargeted("click", input, input.timeoutMs).pipe(Effect.as(null)), diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index c729fc20ece0..d2527fdfb395 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -10,6 +10,8 @@ import { PreviewAutomationResizeInput, PreviewAutomationResizeResult, PreviewAutomationScrollInput, + PreviewAutomationSetColorSchemeInput, + PreviewAutomationSetColorSchemeResult, PreviewAutomationSnapshot, PreviewAutomationStatus, PreviewAutomationTabTargetInput, @@ -86,6 +88,19 @@ export const PreviewResizeTool = safeBrowserTool( .annotate(Tool.Idempotent, true), ); +export const PreviewSetAppearanceTool = safeBrowserTool( + Tool.make("preview_set_appearance", { + description: + "Emulate prefers-color-scheme in a collaborative browser tab, optionally selected by tabId. Use {colorScheme:'dark'} or {colorScheme:'light'} to preview the page in that appearance, and {colorScheme:'system'} to clear the override and follow the OS appearance.", + parameters: PreviewAutomationSetColorSchemeInput, + success: PreviewAutomationSetColorSchemeResult, + failure: PreviewAutomationError, + dependencies, + }) + .annotate(Tool.Title, "Set preview appearance") + .annotate(Tool.Idempotent, true), +); + export const PreviewSnapshotTool = readonlyBrowserTool( Tool.make("preview_snapshot", { description: @@ -189,6 +204,7 @@ export const PreviewToolkit = Toolkit.make( PreviewOpenTool, PreviewNavigateTool, PreviewResizeTool, + PreviewSetAppearanceTool, PreviewSnapshotTool, PreviewClickTool, PreviewTypeTool, @@ -205,6 +221,7 @@ export const PreviewStandardToolkit = Toolkit.make( PreviewOpenTool, PreviewNavigateTool, PreviewResizeTool, + PreviewSetAppearanceTool, PreviewClickTool, PreviewTypeTool, PreviewPressTool, diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index c2db48184c7c..ffab9cbdf86a 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -248,7 +248,7 @@ export const BranchToolbar = memo(function BranchToolbar({ if (!hasActiveThread || !activeProject) return null; return ( -
+
{isMobile ? ( , - title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusText(connection)}`, + title: `${activeEnvironmentUnavailableState.label}: ${connectionStatusTitle(connection)}`, description: connection.error ?? "Reconnect this environment before sending messages or running actions.", @@ -5701,7 +5701,7 @@ function ChatViewContent(props: ChatViewProps) { : undefined } > -
+
-
-
- {isGitRepo && ( -
- -
- )} -
+
+
+
+ {isGitRepo && ( +
+ +
+ )}
ReactNode; runProject: (project: Project) => Promise; + searchTerms?: (project: Project) => ReadonlyArray; shortcutCommand?: KeybindingCommand; }): CommandPaletteActionItem[] { return input.projects.map((project) => ({ kind: "action", value: `${input.valuePrefix}:${project.environmentId}:${project.id}`, - searchTerms: [project.title, project.workspaceRoot], + searchTerms: [project.title, project.workspaceRoot, ...(input.searchTerms?.(project) ?? [])], title: project.title, description: project.workspaceRoot, icon: input.icon(project), diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index 5b04d5e275f7..3b4a6c72d3ad 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -56,9 +56,10 @@ import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { useEnvironments, usePrimaryEnvironment } from "../state/environments"; +import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { + resolveThreadActionProjectRef, startNewThreadInProjectFromContext, startNewThreadFromContext, } from "../lib/chatThreadActions"; @@ -79,7 +80,7 @@ import { } from "../lib/projectPaths"; import { onOpenCommandPalette } from "../commandPaletteBus"; import { isTerminalFocused } from "../lib/terminalFocus"; -import { getLatestThreadForProject } from "../lib/threadSort"; +import { getLatestThreadForProject, sortThreads } from "../lib/threadSort"; import { cn, isMacPlatform, isWindowsPlatform, newProjectId } from "../lib/utils"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { buildThreadRouteParams, resolveThreadRouteTarget } from "../threadRoutes"; @@ -106,6 +107,7 @@ import { ITEM_ICON_CLASS, RECENT_THREAD_LIMIT, } from "./CommandPalette.logic"; +import { orderItemsByPreferredIds, sortLogicalProjectsForSidebar } from "./Sidebar.logic"; import { resolveEnvironmentOptionLabel } from "./BranchToolbar.logic"; import { CommandPaletteResults } from "./CommandPaletteResults"; import { AzureDevOpsIcon, BitbucketIcon, GitHubIcon, GitLabIcon } from "./Icons"; @@ -128,6 +130,12 @@ import { stackedThreadToast, toastManager } from "./ui/toast"; import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip"; import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext"; import type { ChatComposerHandle } from "./chat/ChatComposer"; +import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; +import { + buildSidebarProjectPickerEntries, + buildSidebarProjectSnapshots, +} from "../sidebarProjectGrouping"; const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = []; @@ -489,10 +497,11 @@ function OpenCommandPaletteDialog(props: { }); const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); - const primaryEnvironment = usePrimaryEnvironment(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); + const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const providers = useAtomValue(primaryServerProvidersAtom); @@ -506,7 +515,93 @@ function OpenCommandPaletteDialog(props: { const [addProjectCloneFlow, setAddProjectCloneFlow] = useState(null); const [isRemoteProjectLookingUp, setIsRemoteProjectLookingUp] = useState(false); const [isRemoteProjectCloning, setIsRemoteProjectCloning] = useState(false); - const primaryEnvironmentId = primaryEnvironment?.environmentId ?? null; + const projectGroupingSettings = useMemo( + () => selectProjectGroupingSettings(clientSettings), + [clientSettings], + ); + + const environmentLabelById = useMemo( + () => + new Map( + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const orderedProjects = useMemo( + () => + orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + [projectOrder, projects], + ); + const unsortedProjectGroups = useMemo( + () => + buildSidebarProjectSnapshots({ + projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + }), + [ + clientSettings.sidebarProjectSortOrder, + environmentLabelById, + orderedProjects, + primaryEnvironmentId, + projectGroupingSettings, + projects, + ], + ); + const projectGroups = useMemo( + () => + sortLogicalProjectsForSidebar( + unsortedProjectGroups, + threads, + clientSettings.sidebarProjectSortOrder, + ), + [clientSettings.sidebarProjectSortOrder, threads, unsortedProjectGroups], + ); + const contextualProjectRef = useMemo( + () => + resolveThreadActionProjectRef({ + activeDraftThread, + activeThread: activeThread ?? undefined, + defaultProjectRef, + handleNewThread, + }), + [activeDraftThread, activeThread, defaultProjectRef, handleNewThread], + ); + const projectPickerEntries = useMemo( + () => + buildSidebarProjectPickerEntries({ + groups: projectGroups, + preferredProjectRef: contextualProjectRef, + }), + [contextualProjectRef, projectGroups], + ); + const pickerProjects = useMemo( + () => + projectPickerEntries.map(({ group, targetProject }) => ({ + ...targetProject, + title: group.displayName, + })), + [projectPickerEntries], + ); + const projectGroupByTargetKey = useMemo( + () => + new Map( + projectPickerEntries.map(({ group, targetProject }) => [ + `${targetProject.environmentId}:${targetProject.id}`, + group, + ]), + ), + [projectPickerEntries], + ); const addProjectEnvironmentOptions = useMemo(() => { const options = environments.map((environment): AddProjectEnvironmentOption => { @@ -650,11 +745,28 @@ function OpenCommandPaletteDialog(props: { const openProjectFromSearch = useMemo( () => async (project: (typeof projects)[number]) => { - const latestThread = getLatestThreadForProject( - threads.filter((thread) => thread.environmentId === project.environmentId), - project.id, - clientSettings.sidebarThreadSortOrder, - ); + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const groupedProjectKeys = group + ? new Set( + group.memberProjectRefs.map( + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, + ), + ) + : null; + const latestThread = groupedProjectKeys + ? (sortThreads( + threads.filter( + (thread) => + thread.archivedAt === null && + groupedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`), + ), + clientSettings.sidebarThreadSortOrder, + )[0] ?? null) + : getLatestThreadForProject( + threads.filter((thread) => thread.environmentId === project.environmentId), + project.id, + clientSettings.sidebarThreadSortOrder, + ); if (latestThread) { await navigate({ to: "/$environmentId/$threadId", @@ -667,14 +779,26 @@ function OpenCommandPaletteDialog(props: { await handleNewThread(scopeProjectRef(project.environmentId, project.id)); }, - [handleNewThread, navigate, clientSettings.sidebarThreadSortOrder, threads], + [ + clientSettings.sidebarThreadSortOrder, + handleNewThread, + navigate, + projectGroupByTargetKey, + threads, + ], ); const projectSearchItems = useMemo( () => buildProjectActionItems({ - projects, + projects: pickerProjects, valuePrefix: "project", + searchTerms: (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + return ( + group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + ); + }, icon: (project) => ( enumerateCommandPaletteItems( buildProjectActionItems({ - projects, + projects: pickerProjects, valuePrefix: "new-thread-in", + searchTerms: (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + return ( + group?.memberProjects.flatMap((member) => [member.title, member.workspaceRoot]) ?? [] + ); + }, icon: (project) => ( ), runProject: async (project) => { + const group = projectGroupByTargetKey.get(`${project.environmentId}:${project.id}`); + const contextualRefBelongsToGroup = + contextualProjectRef !== null && + group?.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === contextualProjectRef.environmentId && + projectRef.projectId === contextualProjectRef.projectId, + ); await startNewThreadInProjectFromContext( { activeDraftThread, @@ -708,12 +846,22 @@ function OpenCommandPaletteDialog(props: { defaultProjectRef, handleNewThread, }, - scopeProjectRef(project.environmentId, project.id), + contextualRefBelongsToGroup + ? contextualProjectRef + : scopeProjectRef(project.environmentId, project.id), ); }, }), ), - [activeDraftThread, activeThread, defaultProjectRef, handleNewThread, projects], + [ + activeDraftThread, + activeThread, + contextualProjectRef, + defaultProjectRef, + handleNewThread, + pickerProjects, + projectGroupByTargetKey, + ], ); const allThreadItems = useMemo( @@ -1020,9 +1168,9 @@ function OpenCommandPaletteDialog(props: { const actionItems: Array = []; if (projects.length > 0) { - const activeProjectTitle = currentProjectId - ? (projectTitleById.get(currentProjectId) ?? null) - : null; + const activeProjectTitle = + projectPickerEntries.find((entry) => entry.isPreferred)?.group.displayName ?? + (currentProjectId ? (projectTitleById.get(currentProjectId) ?? null) : null); if (activeProjectTitle) { actionItems.push({ diff --git a/apps/web/src/components/Sidebar.logic.test.ts b/apps/web/src/components/Sidebar.logic.test.ts index 9c71c7beaf9b..65424b830a2c 100644 --- a/apps/web/src/components/Sidebar.logic.test.ts +++ b/apps/web/src/components/Sidebar.logic.test.ts @@ -20,8 +20,12 @@ import { resolveThreadRowClassName, resolveSidebarV2Status, resolveThreadStatusPill, + resolveWorkingStartedAt, + formatWorkingDurationLabel, shouldNavigateAfterProjectRemoval, shouldClearThreadSelectionOnMouseDown, + sortLogicalProjectsForSidebar, + sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, sortProjectsForSidebar, sortScopedProjectsForSidebar, @@ -34,6 +38,7 @@ import { ProviderInstanceId, ThreadId, } from "@t3tools/contracts"; + import { DEFAULT_INTERACTION_MODE, DEFAULT_RUNTIME_MODE, @@ -207,8 +212,10 @@ function makeLatestTurn(overrides?: { state: "completed", assistantMessageId: null, requestedAt: "2026-03-09T10:00:00.000Z", - startedAt: overrides?.startedAt ?? "2026-03-09T10:00:00.000Z", - completedAt: overrides?.completedAt ?? "2026-03-09T10:05:00.000Z", + startedAt: + overrides?.startedAt !== undefined ? overrides.startedAt : "2026-03-09T10:00:00.000Z", + completedAt: + overrides?.completedAt !== undefined ? overrides.completedAt : "2026-03-09T10:05:00.000Z", }; } @@ -781,6 +788,141 @@ describe("sortThreadsForSidebarV2", () => { }); }); +describe("sortSettledThreadsForSidebarV2", () => { + const settled = (input: { + id: string; + settledAt?: string | null; + latestUserMessageAt?: string | null; + latestTurn?: OrchestrationLatestTurn | null; + updatedAt?: string; + }) => ({ + id: input.id, + settledAt: input.settledAt ?? null, + latestUserMessageAt: input.latestUserMessageAt ?? null, + latestTurn: input.latestTurn ?? null, + updatedAt: input.updatedAt ?? "2026-03-09T09:00:00.000Z", + }); + + it("orders by settle time, most recently settled first", () => { + const sorted = sortSettledThreadsForSidebarV2([ + settled({ + id: "settled-first", + settledAt: "2026-03-09T10:00:00.000Z", + // Created/active later than the other thread: settle time must win. + latestUserMessageAt: "2026-03-09T09:59:00.000Z", + }), + settled({ + id: "settled-last", + settledAt: "2026-03-09T12:00:00.000Z", + latestUserMessageAt: "2026-03-09T08:00:00.000Z", + }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["settled-last", "settled-first"]); + }); + + it("falls back to last activity for auto-settled threads without a settledAt stamp", () => { + const sorted = sortSettledThreadsForSidebarV2([ + settled({ id: "auto-old", latestUserMessageAt: "2026-03-09T08:00:00.000Z" }), + settled({ id: "explicit", settledAt: "2026-03-09T10:00:00.000Z" }), + settled({ id: "auto-recent", latestUserMessageAt: "2026-03-09T11:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["auto-recent", "explicit", "auto-old"]); + }); + + it("counts a turn completion as activity for auto-settled threads", () => { + // The message came in before the other thread's, but its turn finished + // after: completion time is the real "work ended" moment. + const sorted = sortSettledThreadsForSidebarV2([ + settled({ id: "message-only", latestUserMessageAt: "2026-03-09T10:04:00.000Z" }), + settled({ + id: "completed-later", + latestUserMessageAt: "2026-03-09T10:00:00.000Z", + latestTurn: makeLatestTurn({ completedAt: "2026-03-09T10:30:00.000Z" }), + }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["completed-later", "message-only"]); + }); + + it("breaks timestamp ties by id so the order is stable", () => { + const sorted = sortSettledThreadsForSidebarV2([ + settled({ id: "b", settledAt: "2026-03-09T10:00:00.000Z" }), + settled({ id: "a", settledAt: "2026-03-09T10:00:00.000Z" }), + ]); + + expect(sorted.map((thread) => thread.id)).toEqual(["a", "b"]); + }); +}); + +describe("resolveWorkingStartedAt", () => { + const session = { + threadId: ThreadId.make("thread-1"), + status: "running" as const, + providerName: "Codex", + providerInstanceId: ProviderInstanceId.make("codex"), + runtimeMode: DEFAULT_RUNTIME_MODE, + activeTurnId: "turn-1" as never, + lastError: null, + updatedAt: "2026-03-09T10:02:00.000Z", + }; + + it("uses the running turn's start time", () => { + expect( + resolveWorkingStartedAt({ + latestTurn: makeLatestTurn({ completedAt: null }), + session, + }), + ).toBe("2026-03-09T10:00:00.000Z"); + }); + + it("uses the request time while a turn awaits adoption", () => { + expect( + resolveWorkingStartedAt({ + latestTurn: makeLatestTurn({ startedAt: null, completedAt: null }), + session, + }), + ).toBe("2026-03-09T10:00:00.000Z"); + }); + + it("falls back to the session transition when the latest turn already completed", () => { + expect( + resolveWorkingStartedAt({ + latestTurn: makeLatestTurn(), + session, + }), + ).toBe("2026-03-09T10:02:00.000Z"); + }); + + it("skips a malformed startedAt instead of returning it", () => { + expect( + resolveWorkingStartedAt({ + latestTurn: makeLatestTurn({ startedAt: "not-a-date", completedAt: null }), + session, + }), + ).toBe("2026-03-09T10:00:00.000Z"); + }); + + it("returns null with neither a running turn nor a session", () => { + expect(resolveWorkingStartedAt({ latestTurn: null, session: null })).toBeNull(); + }); +}); + +describe("formatWorkingDurationLabel", () => { + it("formats seconds, minutes, and hours", () => { + expect(formatWorkingDurationLabel(0)).toBe("0s"); + expect(formatWorkingDurationLabel(42_000)).toBe("42s"); + expect(formatWorkingDurationLabel(5 * 60_000)).toBe("5m"); + expect(formatWorkingDurationLabel(90 * 60_000)).toBe("1h 30m"); + }); + + it("clamps negative and non-finite elapsed values to zero", () => { + expect(formatWorkingDurationLabel(-5_000)).toBe("0s"); + expect(formatWorkingDurationLabel(Number.NaN)).toBe("0s"); + }); +}); + describe("resolveThreadStatusPill", () => { const baseThread = { hasActionableProposedPlan: false, @@ -1353,3 +1495,40 @@ describe("sortScopedProjectsForSidebar", () => { ]); }); }); + +describe("sortLogicalProjectsForSidebar", () => { + it("uses saved order only in manual mode and activity order otherwise", () => { + const olderProjectId = ProjectId.make("project-older"); + const newerProjectId = ProjectId.make("project-newer"); + const projects = [ + { + ...makeProject({ id: olderProjectId, title: "Older project" }), + projectKey: "logical-older", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: olderProjectId }], + }, + { + ...makeProject({ id: newerProjectId, title: "Newer project" }), + projectKey: "logical-newer", + memberProjectRefs: [{ environmentId: localEnvironmentId, projectId: newerProjectId }], + }, + ]; + const threads = [ + makeThread({ + projectId: olderProjectId, + updatedAt: "2026-03-09T10:01:00.000Z", + }), + makeThread({ + id: ThreadId.make("thread-newer"), + projectId: newerProjectId, + updatedAt: "2026-03-09T10:05:00.000Z", + }), + ]; + + expect(sortLogicalProjectsForSidebar(projects, threads, "manual")).toEqual(projects); + expect( + sortLogicalProjectsForSidebar(projects, threads, "updated_at").map( + (project) => project.projectKey, + ), + ).toEqual(["logical-newer", "logical-older"]); + }); +}); diff --git a/apps/web/src/components/Sidebar.logic.ts b/apps/web/src/components/Sidebar.logic.ts index feb068919010..e7082ca8adad 100644 --- a/apps/web/src/components/Sidebar.logic.ts +++ b/apps/web/src/components/Sidebar.logic.ts @@ -36,6 +36,14 @@ type ScopedSidebarThread = ThreadSortInput & { archivedAt: string | null; }; +type LogicalSidebarProject = SidebarProject & { + projectKey: string; + memberProjectRefs: readonly { + environmentId: string; + projectId: string; + }[]; +}; + export type ThreadTraversalDirection = "previous" | "next"; export async function archiveSelectedThreadEntries< @@ -494,6 +502,18 @@ export function firstValidTimestampMs( return 0; } +/** String twin of firstValidTimestampMs for callers that need the ISO string + (display labels, tick anchors) rather than epoch ms. */ +export function firstValidTimestamp( + ...candidates: ReadonlyArray +): string | null { + for (const candidate of candidates) { + if (candidate == null) continue; + if (!Number.isNaN(Date.parse(candidate))) return candidate; + } + return null; +} + // v2 sort: static creation order, newest thread on top. Activity NEVER // reorders the list — a row holds its position from open until settled, so // the screen only moves at lifecycle transitions. Status (including pending @@ -508,6 +528,73 @@ export function sortThreadsForSidebarV2< ); } +type SettledTimestampInput = Pick< + SidebarThreadSummary, + "settledAt" | "latestUserMessageAt" | "latestTurn" | "updatedAt" +>; + +/** The timestamp a settled row sorts and labels by: settledAt when stamped + (explicit settles), otherwise last activity — the same candidates + threadLastActivityAt feeds the auto-settle window (user message plus all + latestTurn stamps), so a thread whose last activity was a turn completion + doesn't sort by an older message time. updatedAt is the final net. */ +export function resolveSettledTimestamp(thread: SettledTimestampInput): string | null { + const settledAt = firstValidTimestamp(thread.settledAt); + if (settledAt !== null) return settledAt; + let latest: string | null = null; + let latestMs = Number.NEGATIVE_INFINITY; + for (const candidate of [ + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + ]) { + if (candidate == null) continue; + const parsed = Date.parse(candidate); + if (!Number.isNaN(parsed) && parsed > latestMs) { + latest = candidate; + latestMs = parsed; + } + } + return latest ?? firstValidTimestamp(thread.updatedAt); +} + +// Settled rows are history, so they order by when the work ENDED, not when +// the thread was created or last touched. +export function sortSettledThreadsForSidebarV2< + T extends SettledTimestampInput & { readonly id: string }, +>(threads: readonly T[]): T[] { + const timestampMs = (thread: T) => { + const timestamp = resolveSettledTimestamp(thread); + return timestamp === null ? 0 : Date.parse(timestamp); + }; + return [...threads].toSorted( + (left, right) => timestampMs(right) - timestampMs(left) || left.id.localeCompare(right.id), + ); +} + +/** The timestamp a working thread's elapsed label counts from: the running + turn's start (request time until adoption), falling back to the session's + last transition when the turn projection lags behind. Malformed + timestamps fall through to the next candidate, not just missing ones. */ +export function resolveWorkingStartedAt( + thread: Pick, +): string | null { + const turn = thread.latestTurn; + if (turn && turn.completedAt === null) { + return firstValidTimestamp(turn.startedAt, turn.requestedAt, thread.session?.updatedAt); + } + return firstValidTimestamp(thread.session?.updatedAt); +} + +export function formatWorkingDurationLabel(elapsedMs: number): string { + const seconds = Number.isFinite(elapsedMs) ? Math.max(0, Math.floor(elapsedMs / 1000)) : 0; + if (seconds < 60) return `${seconds}s`; + const minutes = Math.floor(seconds / 60); + if (minutes < 60) return `${minutes}m`; + return `${Math.floor(minutes / 60)}h ${minutes % 60}m`; +} + export function resolveThreadStatusPill(input: { thread: ThreadStatusInput; }): ThreadStatusPill | null { @@ -727,6 +814,44 @@ export function sortProjectsForSidebar< ); } +export function sortLogicalProjectsForSidebar< + TProject extends LogicalSidebarProject, + TThread extends ScopedSidebarThread, +>( + projects: readonly TProject[], + threads: readonly TThread[], + sortOrder: SidebarProjectSortOrder, +): TProject[] { + const groupKeyByProjectRef = new Map( + projects.flatMap((project) => + project.memberProjectRefs.map( + (projectRef) => + [`${projectRef.environmentId}\0${projectRef.projectId}`, project.projectKey] as const, + ), + ), + ); + const threadsByProjectKey = new Map(); + for (const thread of threads) { + if (thread.archivedAt !== null) continue; + const projectKey = groupKeyByProjectRef.get(`${thread.environmentId}\0${thread.projectId}`); + if (!projectKey) continue; + const existing = threadsByProjectKey.get(projectKey); + if (existing) { + existing.push(thread); + } else { + threadsByProjectKey.set(projectKey, [thread]); + } + } + + return sortProjectsByActivity( + projects, + sortOrder, + (project) => threadsByProjectKey.get(project.projectKey) ?? [], + (left, right) => + left.title.localeCompare(right.title) || left.projectKey.localeCompare(right.projectKey), + ); +} + /** * Sorts the cross-environment project collection used by landing surfaces. * Project ids are only unique within an environment, and archived threads diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 35bdb451d6d9..014e629fda7d 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -145,15 +145,7 @@ import { DialogTitle, } from "./ui/dialog"; import { Input } from "./ui/input"; -import { - Menu, - MenuGroup, - MenuPopup, - MenuRadioGroup, - MenuRadioItem, - MenuSeparator, - MenuTrigger, -} from "./ui/menu"; +import { Menu, MenuGroup, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; import { NumberField, NumberFieldDecrement, @@ -2601,20 +2593,16 @@ type SortableProjectHandleProps = Pick< function ProjectSortMenu({ projectSortOrder, threadSortOrder, - projectGroupingMode, threadPreviewCount, onProjectSortOrderChange, onThreadSortOrderChange, - onProjectGroupingModeChange, onThreadPreviewCountChange, }: { projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; - projectGroupingMode: SidebarProjectGroupingMode; threadPreviewCount: SidebarThreadPreviewCount; onProjectSortOrderChange: (sortOrder: SidebarProjectSortOrder) => void; onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; - onProjectGroupingModeChange: (mode: SidebarProjectGroupingMode) => void; onThreadPreviewCountChange: (count: SidebarThreadPreviewCount) => void; }) { const handleThreadPreviewCountChange = useCallback( @@ -2718,30 +2706,6 @@ function ProjectSortMenu({
- - -
- Group projects -
- { - if (value === "repository" || value === "repository_path" || value === "separate") { - onProjectGroupingModeChange(value); - } - }} - > - {( - Object.entries(PROJECT_GROUPING_MODE_LABELS) as Array< - [SidebarProjectGroupingMode, string] - > - ).map(([value, label]) => ( - - {label} - - ))} - -
); @@ -2792,7 +2756,6 @@ interface SidebarProjectsContentProps { handleDesktopUpdateButtonClick: () => void; projectSortOrder: SidebarProjectSortOrder; threadSortOrder: SidebarThreadSortOrder; - projectGroupingMode: SidebarProjectGroupingMode; threadPreviewCount: SidebarThreadPreviewCount; updateSettings: ReturnType; openAddProject: () => void; @@ -2833,7 +2796,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( handleDesktopUpdateButtonClick, projectSortOrder, threadSortOrder, - projectGroupingMode, threadPreviewCount, updateSettings, openAddProject, @@ -2875,12 +2837,6 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( }, [updateSettings], ); - const handleProjectGroupingModeChange = useCallback( - (groupingMode: SidebarProjectGroupingMode) => { - updateSettings({ sidebarProjectGroupingMode: groupingMode }); - }, - [updateSettings], - ); const handleThreadPreviewCountChange = useCallback( (count: SidebarThreadPreviewCount) => { updateSettings({ sidebarThreadPreviewCount: count }); @@ -2944,11 +2900,9 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent( @@ -3064,7 +3018,6 @@ export default function Sidebar() { const isOnSettings = pathname.startsWith("/settings"); const sidebarThreadSortOrder = useClientSettings((s) => s.sidebarThreadSortOrder); const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); - const sidebarProjectGroupingMode = useClientSettings((s) => s.sidebarProjectGroupingMode); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const sidebarThreadPreviewCount = useClientSettings((s) => s.sidebarThreadPreviewCount); const updateSettings = useUpdateClientSettings(); @@ -3674,7 +3627,6 @@ export default function Sidebar() { handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick} projectSortOrder={sidebarProjectSortOrder} threadSortOrder={sidebarThreadSortOrder} - projectGroupingMode={sidebarProjectGroupingMode} threadPreviewCount={sidebarThreadPreviewCount} updateSettings={updateSettings} openAddProject={openAddProjectCommandPalette} diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 40e6377ede47..361c3460bfd9 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -1,25 +1,24 @@ import { autoAnimate } from "@formkit/auto-animate"; import { useAtomValue } from "@effect/atom-react"; import { effectiveSettled } from "@t3tools/client-runtime/state/thread-settled"; -import type { - EnvironmentProject, - EnvironmentThreadShell, -} from "@t3tools/client-runtime/state/models"; +import type { EnvironmentThreadShell } from "@t3tools/client-runtime/state/models"; import { scopeProjectRef, scopeThreadRef, scopedThreadKey, } from "@t3tools/client-runtime/environment"; -import type { ScopedThreadRef } from "@t3tools/contracts"; +import type { ScopedThreadRef, SidebarProjectGroupingMode } from "@t3tools/contracts"; import { CheckIcon, ChevronDownIcon, CircleAlertIcon, CircleCheckIcon, CircleDashedIcon, + CopyIcon, FolderIcon, FolderPlusIcon, GitBranchIcon, + EllipsisIcon, MessageSquareIcon, PlusIcon, SearchIcon, @@ -61,13 +60,24 @@ import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../termina import { isMacPlatform } from "~/lib/utils"; import { useOpenPrLink } from "../lib/openPullRequestLink"; import { readLocalApi } from "../localApi"; -import { useUiStateStore } from "../uiStateStore"; +import { + deriveProjectGroupingOverrideKey, + getProjectOrderKey, + selectProjectGroupingSettings, +} from "../logicalProject"; +import { + buildSidebarProjectSnapshots, + type SidebarProjectGroupMember, + type SidebarProjectSnapshot, +} from "../sidebarProjectGrouping"; +import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore"; import { useThreadSelectionStore } from "../threadSelectionStore"; import { useThreadActions } from "../hooks/useThreadActions"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { openCommandPalette } from "../commandPaletteBus"; import { startNewThreadFromContext } from "../lib/chatThreadActions"; -import { useClientSettings } from "../hooks/useSettings"; +import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; +import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; @@ -81,12 +91,17 @@ import { formatRelativeTimeLabel } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; import { - firstValidTimestampMs, + formatWorkingDurationLabel, hasUnseenCompletion, isTrailingDoubleClick, + orderItemsByPreferredIds, resolveAdjacentThreadId, + resolveSettledTimestamp, resolveSidebarV2Status, + resolveWorkingStartedAt, shouldNavigateAfterProjectRemoval, + sortLogicalProjectsForSidebar, + sortSettledThreadsForSidebarV2, sortThreadsForSidebarV2, } from "./Sidebar.logic"; import { resolveLocalCheckoutBranchMismatch } from "./BranchToolbar.logic"; @@ -98,8 +113,20 @@ import { deriveProviderInstanceEntries, type ProviderInstanceEntry } from "../pr import { primaryServerProvidersAtom } from "../state/server"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { CommandDialogTrigger } from "./ui/command"; +import { Button } from "./ui/button"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, +} from "./ui/dialog"; +import { Input } from "./ui/input"; import { Kbd } from "./ui/kbd"; import { Menu, MenuPopup, MenuRadioGroup, MenuRadioItem, MenuTrigger } from "./ui/menu"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "./ui/select"; import { SidebarContent, SidebarGroup, SidebarMenuButton, useSidebar } from "./ui/sidebar"; import { SidebarChromeFooter, SidebarChromeHeader } from "./sidebar/SidebarChrome"; import { Tooltip, TooltipPopup, TooltipProvider, TooltipTrigger } from "./ui/tooltip"; @@ -109,6 +136,11 @@ import { useComposerDraftStore } from "../composerDraftStore"; // stays behind an explicit Show more. const SETTLED_TAIL_INITIAL_COUNT = 10; const SETTLED_TAIL_PAGE_COUNT = 25; +const PROJECT_GROUPING_MODE_LABELS: Record = { + repository: "Group by repository", + repository_path: "Group by repository path", + separate: "Keep separate", +}; function compactSidebarTimeLabel(label: string): string { if (label === "just now") return "now"; @@ -120,6 +152,44 @@ function threadTimeLabel(thread: SidebarThreadSummary): string { return compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); } +// Settled rows read "how long ago did this wrap up", matching their sort +// key: both go through resolveSettledTimestamp so label and order can't +// disagree. +function settledTimeLabel(thread: SidebarThreadSummary): string { + const timestamp = resolveSettledTimestamp(thread); + return timestamp === null ? "" : compactSidebarTimeLabel(formatRelativeTimeLabel(timestamp)); +} + +// Floats at the row's right edge, vertically centered, while the jump +// modifier is held. An overlay pill instead of an inline slot: the hint +// must neither displace the status/time label (holding ⌘ used to blank +// out "Working") nor shift any layout when it appears. pointer-events-none +// so it never swallows clicks meant for the settle/un-settle buttons it +// can overlap. +function JumpHintBadge(props: { label: string }) { + return ( + + {props.label} + + ); +} + +// Self-ticking so only this span re-renders each second, not the whole row. +function WorkingDuration(props: { startedAt: string | null }) { + const startedMs = props.startedAt !== null ? Date.parse(props.startedAt) : Number.NaN; + const [, setTick] = useState(0); + useEffect(() => { + if (Number.isNaN(startedMs)) return; + const id = window.setInterval(() => setTick((tick) => tick + 1), 1_000); + return () => window.clearInterval(id); + }, [startedMs]); + if (Number.isNaN(startedMs)) return null; + return {formatWorkingDurationLabel(Date.now() - startedMs)}; +} + function SidebarV2ThreadTooltip({ thread, projectTitle, @@ -157,7 +227,7 @@ function SidebarV2ThreadTooltip({
{projectTitle}
@@ -263,7 +333,15 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { // flag must not light up every historical thread as unread. const isUnread = hasUnseenCompletion({ ...thread, lastVisitedAt }); const status = resolveSidebarV2Status(thread); - const shouldRecede = status === "ready" && !isUnread && !props.isActive && !isSelected; + // In-flight rows (working, or waiting on approval/input) fade as a whole: + // there is nothing for the user to do yet, so prominence is reserved for + // rows that need a human — done (unread), read-but-unsettled, and failed. + // The status label keeps its hue, so waiting rows stay findable. In-flight + // rows recede the same as read-ready ones (inbox-zero: working threads + // aren't your problem yet) — only the colored status label stands out. + const isInFlight = status === "working" || status === "approval" || status === "input"; + const shouldRecede = + (status === "ready" || isInFlight) && !isUnread && !props.isActive && !isSelected; // Status hues follow the system-wide convention set by sidebar v1 and the // mobile Live Activity/widgets (amber approval, indigo input, sky working) // so a thread reads the same color everywhere it surfaces. @@ -449,6 +527,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { : shouldRecede ? "text-sidebar-muted-foreground/75 hover:bg-sidebar-row-hover hover:text-sidebar-foreground" : "bg-transparent text-sidebar-foreground hover:bg-sidebar-row-hover", + isInFlight && + !props.isActive && + !isSelected && + "opacity-70 transition-opacity hover:opacity-100", ); const title = isRenaming ? ( @@ -474,10 +556,10 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "truncate", isUnread ? "text-foreground" - : status !== "ready" - ? "text-foreground/95" - : shouldRecede - ? "text-muted-foreground/80" + : shouldRecede + ? "text-muted-foreground/80" + : status === "failed" + ? "text-foreground/95" : "text-foreground/90", ) : cn( @@ -558,10 +640,9 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { - {props.jumpLabel ?? - compactSidebarTimeLabel( - formatRelativeTimeLabel(thread.latestUserMessageAt ?? thread.updatedAt), - )} + {variantAction === "unsettle" + ? settledTimeLabel(thread) + : threadTimeLabel(thread)} {!props.settlementSupported ? null : variantAction === "unsettle" ? ( @@ -569,7 +650,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Un-settle thread" onClick={handleUnsettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5" + className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > @@ -578,12 +659,13 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5" + className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > )} + {props.jumpLabel ? : null} {detailsTooltip} @@ -621,7 +703,12 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { className="size-4 shrink-0" /> {props.projectTitle ? ( - + {props.projectTitle} ) : ( @@ -629,11 +716,8 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { )} - {props.jumpLabel ? ( - props.jumpLabel - ) : topStatus ? ( + {topStatus ? ( ) : null} - {topStatus.label} + {/* The label alone is the live region: a role="status" + wrapper around the ticking duration would make + screen readers announce every second. */} + {topStatus.label} + {status === "working" ? ( + + + + ) : null} ) : ( threadTimeLabel(thread) @@ -655,7 +747,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { type="button" aria-label="Settle thread" onClick={handleSettleClick} - className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md border border-sidebar-border bg-sidebar-row-hover px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100 dark:border-transparent dark:inset-ring-1 dark:inset-ring-white/5" + className="absolute inset-y-0 right-0 inline-flex cursor-pointer items-center gap-1 rounded-md bg-transparent px-2 text-xs text-muted-foreground opacity-0 transition-opacity hover:text-foreground focus-visible:opacity-100 group-hover/v2-row:opacity-100" > Settle @@ -698,6 +790,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: {
+ {props.jumpLabel ? : null} {detailsTooltip} @@ -716,12 +809,15 @@ function latestTurnDiff( export default function SidebarV2() { const projects = useProjects(); + const projectOrder = useUiStateStore((store) => store.projectOrder); const threads = useThreadShells(); const router = useRouter(); const { isMobile, setOpenMobile } = useSidebar(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const autoSettleAfterDays = useClientSettings((s) => s.sidebarAutoSettleAfterDays); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); + const sidebarProjectSortOrder = useClientSettings((s) => s.sidebarProjectSortOrder); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const { settleThread, unsettleThread, deleteThread } = useThreadActions(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, @@ -729,6 +825,32 @@ export default function SidebarV2() { const deleteProject = useAtomCommand(projectEnvironment.delete, { reportFailure: false, }); + const updateProject = useAtomCommand(projectEnvironment.update, { + reportFailure: false, + }); + const updateSettings = useUpdateClientSettings(); + const { copyToClipboard: copyProjectPath } = useCopyToClipboard<{ path: string }>({ + onCopy: ({ path }) => { + toastManager.add({ + type: "success", + title: "Path copied", + description: path, + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to copy path", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + }, + }); + const [projectActionsTarget, setProjectActionsTarget] = useState( + null, + ); + const [projectScopeMenuOpen, setProjectScopeMenuOpen] = useState(false); const newThreadContext = useHandleNewThread(); const openAddProjectCommandPalette = useCallback( () => openCommandPalette({ open: "add-project" }), @@ -762,6 +884,40 @@ export default function SidebarV2() { ), [environments], ); + const orderedProjects = useMemo( + () => + orderItemsByPreferredIds({ + items: projects, + preferredIds: projectOrder, + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }), + [projectOrder, projects], + ); + const unsortedProjectGroups = useMemo( + () => + buildSidebarProjectSnapshots({ + projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, + }), + [ + environmentLabelById, + orderedProjects, + primaryEnvironmentId, + projectGroupingSettings, + projects, + sidebarProjectSortOrder, + ], + ); + const projectGroups = useMemo( + () => sortLogicalProjectsForSidebar(unsortedProjectGroups, threads, sidebarProjectSortOrder), + [sidebarProjectSortOrder, threads, unsortedProjectGroups], + ); const serverProviders = useAtomValue(primaryServerProvidersAtom); const providerEntryByInstanceId = useMemo( () => @@ -782,10 +938,16 @@ export default function SidebarV2() { ), [projects], ); - const projectTitleByKey = useMemo( + const projectDisplayNameByKey = useMemo( () => - new Map(projects.map((project) => [`${project.environmentId}:${project.id}`, project.title])), - [projects], + new Map( + projectGroups.flatMap((group) => + group.memberProjects.map( + (project) => [`${project.environmentId}:${project.id}`, group.displayName] as const, + ), + ), + ), + [projectGroups], ); // now is quantized to the minute so effectiveSettled memoization doesn't @@ -823,90 +985,129 @@ export default function SidebarV2() { // Project scope: one menu above the list. Scoping filters the list without // making the header width depend on the number or length of project names. const [projectScopeKey, setProjectScopeKey] = useState(null); - const scopedProject = useMemo( + const scopedProjectGroup = useMemo( () => projectScopeKey === null ? null - : (projects.find( - (project) => `${project.environmentId}:${project.id}` === projectScopeKey, - ) ?? null), - [projectScopeKey, projects], + : (projectGroups.find((project) => project.projectKey === projectScopeKey) ?? null), + [projectGroups, projectScopeKey], + ); + const scopedProjectKeys = useMemo( + () => + scopedProjectGroup === null + ? null + : new Set( + scopedProjectGroup.memberProjectRefs.map( + (projectRef) => `${projectRef.environmentId}:${projectRef.projectId}`, + ), + ), + [scopedProjectGroup], ); useEffect(() => { - if ( - projectScopeKey !== null && - !projects.some((project) => `${project.environmentId}:${project.id}` === projectScopeKey) - ) { + if (projectScopeKey !== null && scopedProjectGroup === null) { setProjectScopeKey(null); } - }, [projectScopeKey, projects]); + }, [projectScopeKey, scopedProjectGroup]); // Scope flips drop the selection: rows selected under the old scope may be // hidden now, and bulk actions must never count or touch invisible rows. useEffect(() => { clearSelection(); }, [clearSelection, projectScopeKey]); - const handleRemoveProject = useCallback( - async (project: EnvironmentProject) => { + const handleRemoveProjectMembers = useCallback( + async (projectGroup: SidebarProjectSnapshot, members: readonly SidebarProjectGroupMember[]) => { const api = readLocalApi(); if (!api) return; - const projectThreads = threads.filter( - (thread) => - thread.environmentId === project.environmentId && thread.projectId === project.id, + const memberKeys = new Set(members.map((member) => `${member.environmentId}:${member.id}`)); + const projectThreads = threads.filter((thread) => + memberKeys.has(`${thread.environmentId}:${thread.projectId}`), ); + const isWholeGroup = members.length === projectGroup.memberProjects.length; + const singleMember = members.length === 1 ? members[0]! : null; + const targetLabel = singleMember?.title ?? projectGroup.displayName; const confirmed = await settlePromise(() => api.dialogs.confirm( projectThreads.length > 0 ? [ - `Remove project "${project.title}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, - `Path: ${project.workspaceRoot}`, + `Remove project "${targetLabel}" and delete its ${projectThreads.length} thread${projectThreads.length === 1 ? "" : "s"}?`, + ...(singleMember + ? [ + `Path: ${singleMember.workspaceRoot}`, + ...(singleMember.environmentLabel + ? [`Environment: ${singleMember.environmentLabel}`] + : []), + ] + : [`This removes ${members.length} grouped project entries.`]), "This permanently clears conversation history for those threads.", - "This removes only the project entry, not the files on disk.", + isWholeGroup + ? "This removes only the project entries, not the files on disk." + : "Other entries in this grouped project are unaffected.", "This action cannot be undone.", ].join("\n") : [ - `Remove project "${project.title}"?`, - `Path: ${project.workspaceRoot}`, - "This removes only the project entry, not the files on disk.", + `Remove project "${targetLabel}"?`, + ...(singleMember + ? [ + `Path: ${singleMember.workspaceRoot}`, + ...(singleMember.environmentLabel + ? [`Environment: ${singleMember.environmentLabel}`] + : []), + ] + : [`This removes ${members.length} grouped project entries.`]), + isWholeGroup + ? "This removes only the project entries, not the files on disk." + : "Other entries in this grouped project are unaffected.", ].join("\n"), ), ); if (confirmed._tag === "Failure" || !confirmed.value) return; - const projectRef = scopeProjectRef(project.environmentId, project.id); - const result = await deleteProject({ - environmentId: project.environmentId, - input: { - projectId: project.id, - ...(projectThreads.length > 0 ? { force: true } : {}), - }, - }); - if (result._tag === "Failure") { - if (!isAtomCommandInterrupted(result)) { - const error = squashAtomCommandFailure(result); - toastManager.add( - stackedThreadToast({ - type: "error", - title: `Failed to remove "${project.title}"`, - description: error instanceof Error ? error.message : "An error occurred.", - }), - ); + const draftStore = useComposerDraftStore.getState(); + let shouldNavigate = false; + for (const project of members) { + const memberThreads = projectThreads.filter( + (thread) => + thread.environmentId === project.environmentId && thread.projectId === project.id, + ); + const projectRef = scopeProjectRef(project.environmentId, project.id); + const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); + const memberRemovalNeedsNavigation = shouldNavigateAfterProjectRemoval({ + routeTarget: routeTargetRef.current, + projectThreads: memberThreads, + projectDraftId: projectDraftThread?.draftId ?? null, + }); + + const result = await deleteProject({ + environmentId: project.environmentId, + input: { + projectId: project.id, + ...(memberThreads.length > 0 ? { force: true } : {}), + }, + }); + if (result._tag === "Failure") { + if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: `Failed to remove "${project.title}"`, + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + if (shouldNavigate) { + void router.navigate({ to: "/" }); + } + return; } - return; - } - const draftStore = useComposerDraftStore.getState(); - const projectDraftThread = draftStore.getDraftThreadByProjectRef(projectRef); - const shouldNavigate = shouldNavigateAfterProjectRemoval({ - routeTarget: routeTargetRef.current, - projectThreads, - projectDraftId: projectDraftThread?.draftId ?? null, - }); - if (projectDraftThread) { - draftStore.clearDraftThread(projectDraftThread.draftId); + shouldNavigate ||= memberRemovalNeedsNavigation; + if (projectDraftThread) { + draftStore.clearDraftThread(projectDraftThread.draftId); + } + draftStore.clearProjectDraftThreadId(projectRef); } - draftStore.clearProjectDraftThreadId(projectRef); if (shouldNavigate) { void router.navigate({ to: "/" }); @@ -915,6 +1116,56 @@ export default function SidebarV2() { [deleteProject, router, threads], ); + const renameProjectMember = useCallback( + async (member: SidebarProjectGroupMember, nextTitle: string) => { + const title = nextTitle.trim(); + if (!title) { + toastManager.add({ type: "warning", title: "Project title cannot be empty" }); + return; + } + if (title === member.title) return; + const result = await updateProject({ + environmentId: member.environmentId, + input: { projectId: member.id, title }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to rename project", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + }, + [updateProject], + ); + + const updateProjectGroupingPreference = useCallback( + (member: SidebarProjectGroupMember, selection: SidebarProjectGroupingMode | "inherit") => { + const overrideKey = deriveProjectGroupingOverrideKey(member); + const nextOverrides = { ...projectGroupingSettings.sidebarProjectGroupingOverrides }; + if (selection === "inherit") { + delete nextOverrides[overrideKey]; + } else { + nextOverrides[overrideKey] = selection; + } + updateSettings({ sidebarProjectGroupingOverrides: nextOverrides }); + }, + [projectGroupingSettings.sidebarProjectGroupingOverrides, updateSettings], + ); + + const handleProjectActions = useCallback( + (event: ReactMouseEvent, projectGroup: SidebarProjectSnapshot) => { + event.preventDefault(); + event.stopPropagation(); + setProjectScopeMenuOpen(false); + window.requestAnimationFrame(() => setProjectActionsTarget(projectGroup)); + }, + [], + ); + // Settled threads stay in the live shell stream (settled ≠ archived), so // the partition works directly off live shells: no archived-snapshot // merging, no optimistic holds. Archived threads remain hidden here — @@ -925,9 +1176,8 @@ export default function SidebarV2() { const visible = threads.filter( (thread) => thread.archivedAt === null && - (scopedProject === null || - (thread.environmentId === scopedProject.environmentId && - thread.projectId === scopedProject.id)), + (scopedProjectKeys === null || + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), ); const active: EnvironmentThreadShell[] = []; const settled: EnvironmentThreadShell[] = []; @@ -951,17 +1201,13 @@ export default function SidebarV2() { } return { activeThreads: sortThreadsForSidebarV2(active), - settledThreads: settled.toSorted( - (left, right) => - firstValidTimestampMs(right.latestUserMessageAt, right.updatedAt) - - firstValidTimestampMs(left.latestUserMessageAt, left.updatedAt), - ), + settledThreads: sortSettledThreadsForSidebarV2(settled), }; }, [ autoSettleAfterDays, changeRequestStateByKey, nowMinute, - scopedProject, + scopedProjectKeys, serverConfigs, threads, ]); @@ -1479,7 +1725,7 @@ export default function SidebarV2() { // for multi-project setups. const handleNewThreadClick = useCallback(() => { // One project: nothing to pick, create immediately. - if (projects.length <= 1) { + if (projectGroups.length <= 1) { if (isMobile) setOpenMobile(false); void startNewThreadFromContext({ activeDraftThread: newThreadContext.activeDraftThread, @@ -1491,7 +1737,7 @@ export default function SidebarV2() { } if (isMobile) setOpenMobile(false); openCommandPalette({ open: "new-thread-in" }); - }, [isMobile, newThreadContext, projects.length, setOpenMobile]); + }, [isMobile, newThreadContext, projectGroups.length, setOpenMobile]); const commandPaletteShortcutLabel = shortcutLabelForCommand(keybindings, "commandPalette.toggle"); // Same resolution as v1: prefer the local-thread binding, fall back to @@ -1553,25 +1799,25 @@ export default function SidebarV2() {
- {projects.length > 0 ? ( + {projectGroups.length > 0 ? (
- + - {scopedProject ? ( + {scopedProjectGroup ? ( ) : ( )} - {scopedProject?.title ?? "All projects"} + {scopedProjectGroup?.displayName ?? "All projects"} @@ -1590,8 +1836,8 @@ export default function SidebarV2() { All projects - {projects.map((project) => { - const scopeKey = `${project.environmentId}:${project.id}`; + {projectGroups.map((project) => { + const scopeKey = project.projectKey; return ( - {project.title} + {project.displayName} ); @@ -1698,7 +1942,8 @@ export default function SidebarV2() { projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null } projectTitle={ - projectTitleByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null + projectDisplayNameByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? + null } providerEntryByInstanceId={providerEntryByInstanceId} onThreadClick={handleThreadClick} @@ -1766,8 +2011,8 @@ export default function SidebarV2() { Add project - ) : scopedProject ? ( - `No threads in ${scopedProject.title} yet` + ) : scopedProjectGroup ? ( + `No threads in ${scopedProjectGroup.displayName} yet` ) : ( "No threads yet" )} @@ -1775,6 +2020,178 @@ export default function SidebarV2() { ) : null} + { + if (!open) setProjectActionsTarget(null); + }} + > + + + Project settings + + {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 + ? `${projectActionsTarget.displayName} has an entry in each environment. Changes apply only to the entry you choose.` + : `Manage ${projectActionsTarget?.displayName ?? "this project"} in this environment.`} + + + +
+ {projectActionsTarget?.memberProjects.map((member) => ( +
+
+ +
+
+ +

+ {member.environmentLabel ?? "Current environment"} +

+
+

+ {member.workspaceRoot} +

+
+
+
+ + +
+
+ + +
+
+ ))} +
+ {projectActionsTarget && projectActionsTarget.memberProjects.length > 1 ? ( +
+
+

+ Remove this project everywhere +

+

+ Deletes all grouped entries and their conversation history. +

+
+ +
+ ) : null} +
+ + + +
+
); diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index e8d8455637d3..cda5bc475e8d 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -18,10 +18,7 @@ import { PROVIDER_SEND_TURN_MAX_ATTACHMENTS, PROVIDER_SEND_TURN_MAX_IMAGE_BYTES, } from "@t3tools/contracts"; -import { - connectionStatusText, - type EnvironmentConnectionPresentation, -} from "@t3tools/client-runtime/connection"; +import type { EnvironmentConnectionPresentation } from "@t3tools/client-runtime/connection"; import { serializeComposerFileLink } from "@t3tools/shared/composerTrigger"; import { createModelSelection, normalizeModelSlug } from "@t3tools/shared/model"; import { @@ -2583,15 +2580,11 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) ? "Add feedback to refine the plan, or leave this blank to implement it" : projectSelectionRequired ? "Choose a project above to start a thread" - : environmentUnavailable - ? `${environmentUnavailable.label}: ${connectionStatusText( - environmentUnavailable.connection, - )}` - : noProviderAvailable - ? "Enable a provider in Settings to send a message" - : phase === "disconnected" - ? "Ask for follow-up changes or attach images" - : "Ask anything, @tag files/folders, $use skills, or / for commands" + : noProviderAvailable + ? "Enable a provider in Settings to send a message" + : phase === "disconnected" + ? "Ask for follow-up changes or attach images" + : "Ask anything, @tag files/folders, $use skills, or / for commands" } disabled={isConnecting || isComposerApprovalState || projectSelectionRequired} /> diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 3f50d3818b59..98091f9aab69 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -5,8 +5,15 @@ import { useCallback, useMemo } from "react"; import { openCommandPalette } from "~/commandPaletteBus"; import { useNewThreadHandler } from "~/hooks/useHandleNewThread"; +import { useClientSettings } from "~/hooks/useSettings"; +import { selectProjectGroupingSettings } from "~/logicalProject"; +import { + buildSidebarProjectPickerEntries, + buildSidebarProjectSnapshots, +} from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; -import { sortScopedProjectsForSidebar } from "../Sidebar.logic"; +import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Menu, MenuItem, @@ -28,29 +35,66 @@ export function DraftHeroHeadline({ }: DraftHeroHeadlineProps) { const projects = useProjects(); const threads = useThreadShells(); + const { environments } = useEnvironments(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); const handleNewThread = useNewThreadHandler(); const openAddProject = useCallback(() => openCommandPalette({ open: "add-project" }), []); - const orderedProjects = useMemo( - () => sortScopedProjectsForSidebar(projects, threads, "updated_at"), - [projects, threads], - ); - const projectByKey = useMemo( + const environmentLabelById = useMemo( () => new Map( - orderedProjects.map( - (project) => - [ - scopedProjectKey(scopeProjectRef(project.environmentId, project.id)), - project, - ] as const, - ), + environments.map((environment) => [environment.environmentId, environment.label] as const), + ), + [environments], + ); + const projectGroups = useMemo( + () => + sortLogicalProjectsForSidebar( + buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: (environmentId) => + environmentLabelById.get(environmentId) ?? null, + }), + threads, + projectSortOrder, ), - [orderedProjects], + [ + environmentLabelById, + primaryEnvironmentId, + projectGroupingSettings, + projectSortOrder, + projects, + threads, + ], + ); + const projectPickerEntries = useMemo( + () => + buildSidebarProjectPickerEntries({ + groups: projectGroups, + preferredProjectRef: activeProjectRef, + }), + [activeProjectRef, projectGroups], + ); + const projectEntryByKey = useMemo( + () => new Map(projectPickerEntries.map((entry) => [entry.group.projectKey, entry] as const)), + [projectPickerEntries], ); - const activeProjectKey = activeProjectRef === null ? "" : scopedProjectKey(activeProjectRef); + const activeProjectGroup = + activeProjectRef === null + ? null + : (projectGroups.find((group) => + group.memberProjectRefs.some( + (projectRef) => scopedProjectKey(projectRef) === scopedProjectKey(activeProjectRef), + ), + ) ?? null); + const activeProjectKey = activeProjectGroup?.projectKey ?? ""; + const activeProjectDisplayName = activeProjectGroup?.displayName ?? activeProjectTitle; const hasResolvedProject = activeProjectTitle !== null; - const canChooseProject = orderedProjects.length > 0; + const canChooseProject = projectPickerEntries.length > 0; const shouldShowProjectMenu = canChooseProject; const projectSelector = shouldShowProjectMenu ? ( @@ -59,26 +103,26 @@ export function DraftHeroHeadline({ aria-label={hasResolvedProject ? "Change project" : "Choose a project"} className="pointer-events-auto inline cursor-pointer border-current border-b border-dotted text-foreground underline-offset-8 transition-opacity hover:opacity-75 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring" > - {activeProjectTitle ?? "Choose a project"} + {activeProjectDisplayName ?? "Choose a project"} { - const project = projectByKey.get(value as string); - if (!project || value === activeProjectKey) { + const entry = projectEntryByKey.get(value as string); + if (!entry || value === activeProjectKey) { return; } + const project = entry.targetProject; void handleNewThread(scopeProjectRef(project.environmentId, project.id), { replace: true, }); }} > - {orderedProjects.map((project) => { - const key = scopedProjectKey(scopeProjectRef(project.environmentId, project.id)); + {projectPickerEntries.map(({ group }) => { return ( - - {project.title} + + {group.displayName} ); })} diff --git a/apps/web/src/components/preview/PreviewAutomationHosts.tsx b/apps/web/src/components/preview/PreviewAutomationHosts.tsx index b70e045f5a15..ed03fc226d6f 100644 --- a/apps/web/src/components/preview/PreviewAutomationHosts.tsx +++ b/apps/web/src/components/preview/PreviewAutomationHosts.tsx @@ -10,6 +10,8 @@ import { type PreviewAutomationOpenInput, type PreviewAutomationResizeInput, type PreviewAutomationResizeResult, + type PreviewAutomationSetColorSchemeInput, + type PreviewAutomationSetColorSchemeResult, type PreviewAutomationHost as PreviewAutomationHostState, type PreviewAutomationRequest, type PreviewAutomationStatus, @@ -457,6 +459,15 @@ function PreviewAutomationHost(props: { readonly environmentId: EnvironmentId }) viewport, } satisfies PreviewAutomationResizeResult; } + case "setColorScheme": { + const ready = await requireReadyTab(); + const input = request.input as PreviewAutomationSetColorSchemeInput; + await ready.bridge.setColorScheme(ready.tabId, input.colorScheme); + return { + tabId: ready.tabId, + colorScheme: input.colorScheme, + } satisfies PreviewAutomationSetColorSchemeResult; + } case "snapshot": { const ready = await requireReadyTab(); return await ready.bridge.automation.snapshot(ready.tabId); diff --git a/apps/web/src/components/preview/PreviewMoreMenu.tsx b/apps/web/src/components/preview/PreviewMoreMenu.tsx index 13ddcf57e9e1..28fc2e22232e 100644 --- a/apps/web/src/components/preview/PreviewMoreMenu.tsx +++ b/apps/web/src/components/preview/PreviewMoreMenu.tsx @@ -1,13 +1,34 @@ "use client"; +import type { DesktopPreviewColorScheme } from "@t3tools/contracts"; import { Minus, MoreVertical, Plus as PlusIcon, RotateCcw } from "lucide-react"; import { Button } from "~/components/ui/button"; -import { Menu, MenuItem, MenuPopup, MenuSeparator, MenuTrigger } from "~/components/ui/menu"; +import { + Menu, + MenuItem, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuSeparator, + MenuSub, + MenuSubPopup, + MenuSubTrigger, + MenuTrigger, +} from "~/components/ui/menu"; import { Tooltip, TooltipPopup, TooltipTrigger } from "~/components/ui/tooltip"; import { previewBridge } from "./previewBridge"; +const COLOR_SCHEME_OPTIONS: ReadonlyArray<{ + value: DesktopPreviewColorScheme; + label: string; +}> = [ + { value: "system", label: "System" }, + { value: "light", label: "Light" }, + { value: "dark", label: "Dark" }, +]; + interface Props { /** Active preview tab id. Tab-targeting actions are disabled without it. */ tabId: string | null; @@ -19,6 +40,8 @@ interface Props { hasWebContents: boolean; /** Current zoom factor as a number (1.0 = 100%). */ zoomFactor: number; + /** Emulated `prefers-color-scheme` for the guest page. */ + colorScheme: DesktopPreviewColorScheme; /** Fixed viewport modes expose the device toolbar and resize rails. */ deviceToolbarVisible: boolean; /** Switches between fill-panel mode and a fixed responsive viewport. */ @@ -34,6 +57,7 @@ export function PreviewMoreMenu({ tabId, hasWebContents, zoomFactor, + colorScheme, deviceToolbarVisible, onToggleDeviceToolbar, }: Props) { @@ -72,6 +96,26 @@ export function PreviewMoreMenu({ {deviceToolbarVisible ? "Hide device toolbar" : "Show device toolbar"} + + Appearance + + { + if (!tabId) return; + void bridge + .setColorScheme(tabId, value as DesktopPreviewColorScheme) + .catch(() => undefined); + }} + > + {COLOR_SCHEME_OPTIONS.map((option) => ( + + {option.label} + + ))} + + + {/* Zoom row: label + inline control cluster. `closeOnClick=false` diff --git a/apps/web/src/components/preview/PreviewView.test.tsx b/apps/web/src/components/preview/PreviewView.test.tsx index c0d503c75991..61111025fa4d 100644 --- a/apps/web/src/components/preview/PreviewView.test.tsx +++ b/apps/web/src/components/preview/PreviewView.test.tsx @@ -40,6 +40,7 @@ vi.mock("~/previewStateStore", () => ({ canGoForward: false, loading: false, zoomFactor: 1, + colorScheme: "system", controller: "none", }, }, diff --git a/apps/web/src/components/preview/PreviewView.tsx b/apps/web/src/components/preview/PreviewView.tsx index 5f7deeb0fcd7..daff913a6d54 100644 --- a/apps/web/src/components/preview/PreviewView.tsx +++ b/apps/web/src/components/preview/PreviewView.tsx @@ -610,6 +610,7 @@ export function PreviewView({ threadRef, tabId: requestedTabId, configuredUrls, tabId={tabId} hasWebContents={desktopOverlay !== null} zoomFactor={desktopOverlay?.zoomFactor ?? 1} + colorScheme={desktopOverlay?.colorScheme ?? "system"} deviceToolbarVisible={viewport._tag !== "fill"} onToggleDeviceToolbar={handleToggleDeviceToolbar} /> diff --git a/apps/web/src/components/preview/usePreviewBridge.ts b/apps/web/src/components/preview/usePreviewBridge.ts index 8794ff8b4870..22d17f968baa 100644 --- a/apps/web/src/components/preview/usePreviewBridge.ts +++ b/apps/web/src/components/preview/usePreviewBridge.ts @@ -77,6 +77,7 @@ function projectDesktopState(state: DesktopPreviewTabState): DesktopPreviewOverl canGoForward: state.canGoForward, loading: state.navStatus.kind === "Loading", zoomFactor: state.zoomFactor, + colorScheme: state.colorScheme, controller: state.controller, }; } diff --git a/apps/web/src/components/settings/SettingsPanels.logic.test.ts b/apps/web/src/components/settings/SettingsPanels.logic.test.ts index d783d16c7ada..077991f8de07 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.test.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.test.ts @@ -8,8 +8,25 @@ import { describe, expect, it } from "vite-plus/test"; import { buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + isProjectGroupingEnabled, + projectGroupingModeFromToggle, } from "./SettingsPanels.logic"; +describe("project grouping toggle", () => { + it("enables repository grouping and disables into separate projects", () => { + expect(isProjectGroupingEnabled("repository")).toBe(true); + expect(isProjectGroupingEnabled("repository_path")).toBe(true); + expect(isProjectGroupingEnabled("separate")).toBe(false); + expect(projectGroupingModeFromToggle(true)).toBe("repository"); + expect(projectGroupingModeFromToggle(false)).toBe("separate"); + }); + + it("restores repository path grouping when the toggle is cycled", () => { + expect(projectGroupingModeFromToggle(false, "repository_path")).toBe("separate"); + expect(projectGroupingModeFromToggle(true, "repository_path")).toBe("repository_path"); + }); +}); + describe("formatDiagnosticsDescription", () => { it("collapses trace and metric URLs that share the same OTEL base path", () => { expect( diff --git a/apps/web/src/components/settings/SettingsPanels.logic.ts b/apps/web/src/components/settings/SettingsPanels.logic.ts index 99d7052965aa..51e318225ae6 100644 --- a/apps/web/src/components/settings/SettingsPanels.logic.ts +++ b/apps/web/src/components/settings/SettingsPanels.logic.ts @@ -3,10 +3,44 @@ import type { ProviderInstanceConfig, ProviderInstanceId, ServerSettings, + SidebarProjectGroupingMode, UnifiedSettings, } from "@t3tools/contracts"; import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings"; +export function isProjectGroupingEnabled(mode: SidebarProjectGroupingMode): boolean { + return mode !== "separate"; +} + +export function projectGroupingModeFromToggle( + enabled: boolean, + lastEnabledMode: SidebarProjectGroupingMode = "repository", +): SidebarProjectGroupingMode { + if (!enabled) return "separate"; + return lastEnabledMode === "repository_path" ? "repository_path" : "repository"; +} + +const LAST_ENABLED_PROJECT_GROUPING_MODE_KEY = "t3code:last-enabled-project-grouping-mode"; + +export function readLastEnabledProjectGroupingMode(): SidebarProjectGroupingMode { + try { + return localStorage.getItem(LAST_ENABLED_PROJECT_GROUPING_MODE_KEY) === "repository_path" + ? "repository_path" + : "repository"; + } catch { + return "repository"; + } +} + +export function rememberEnabledProjectGroupingMode(mode: SidebarProjectGroupingMode): void { + if (mode === "separate") return; + try { + localStorage.setItem(LAST_ENABLED_PROJECT_GROUPING_MODE_KEY, mode); + } catch { + // Storage can be unavailable in restricted browser contexts. + } +} + function collapseOtelSignalsUrl(input: { readonly tracesUrl: string; readonly metricsUrl: string; diff --git a/apps/web/src/components/settings/SettingsPanels.tsx b/apps/web/src/components/settings/SettingsPanels.tsx index 01eba912567f..fa7c7299667e 100644 --- a/apps/web/src/components/settings/SettingsPanels.tsx +++ b/apps/web/src/components/settings/SettingsPanels.tsx @@ -11,6 +11,7 @@ import { type ProviderInstanceConfig, type ProviderInstanceId, type ScopedThreadRef, + type SidebarProjectGroupingMode, } from "@t3tools/contracts"; import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import { safeErrorLogAttributes } from "@t3tools/client-runtime/errors"; @@ -83,6 +84,10 @@ import { DRIVER_OPTIONS, getDriverOption } from "./providerDriverMeta"; import { buildProviderInstanceUpdatePatch, formatDiagnosticsDescription, + isProjectGroupingEnabled, + projectGroupingModeFromToggle, + readLastEnabledProjectGroupingMode, + rememberEnabledProjectGroupingMode, } from "./SettingsPanels.logic"; import { SettingResetButton, @@ -402,6 +407,10 @@ export function useSettingsRestore(onRestored?: () => void) { ...(settings.sidebarThreadPreviewCount !== DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount ? ["Visible threads"] : []), + ...(settings.sidebarProjectGroupingMode !== + DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode + ? ["Project Grouping"] + : []), ...(settings.wordWrap !== DEFAULT_UNIFIED_SETTINGS.wordWrap ? ["Word wrap"] : []), ...(settings.diffIgnoreWhitespace !== DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace ? ["Diff whitespace changes"] @@ -451,6 +460,7 @@ export function useSettingsRestore(onRestored?: () => void) { settings.automaticGitFetchInterval, settings.enableAssistantStreaming, settings.enableProviderUpdateChecks, + settings.sidebarProjectGroupingMode, settings.sidebarThreadPreviewCount, settings.timestampFormat, settings.wordWrap, @@ -475,6 +485,7 @@ export function useSettingsRestore(onRestored?: () => void) { diffIgnoreWhitespace: DEFAULT_UNIFIED_SETTINGS.diffIgnoreWhitespace, glassOpacity: DEFAULT_UNIFIED_SETTINGS.glassOpacity, sidebarThreadPreviewCount: DEFAULT_UNIFIED_SETTINGS.sidebarThreadPreviewCount, + sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, autoOpenPlanSidebar: DEFAULT_UNIFIED_SETTINGS.autoOpenPlanSidebar, enableAssistantStreaming: DEFAULT_UNIFIED_SETTINGS.enableAssistantStreaming, enableProviderUpdateChecks: DEFAULT_UNIFIED_SETTINGS.enableProviderUpdateChecks, @@ -499,6 +510,9 @@ export function GeneralSettingsPanel() { const { theme, setTheme } = useTheme(); const settings = usePrimarySettings(); const updateSettings = useUpdatePrimarySettings(); + const lastEnabledProjectGroupingMode = useRef( + readLastEnabledProjectGroupingMode(), + ); const observability = useAtomValue(primaryServerObservabilityAtom); const serverProviders = useAtomValue(primaryServerProvidersAtom); const glassOpacityRatio = @@ -620,6 +634,42 @@ export function GeneralSettingsPanel() { } /> + + updateSettings({ + sidebarProjectGroupingMode: DEFAULT_UNIFIED_SETTINGS.sidebarProjectGroupingMode, + }) + } + /> + ) : null + } + control={ + { + if (!checked && settings.sidebarProjectGroupingMode !== "separate") { + lastEnabledProjectGroupingMode.current = settings.sidebarProjectGroupingMode; + rememberEnabledProjectGroupingMode(settings.sidebarProjectGroupingMode); + } + updateSettings({ + sidebarProjectGroupingMode: projectGroupingModeFromToggle( + checked, + lastEnabledProjectGroupingMode.current, + ), + }); + }} + aria-label="Project Grouping" + /> + } + /> + { expect(deriveLogicalProjectKey(remote)).toBe(repositoryIdentity.canonicalKey); }); + it("counts cross-environment copies as one new-thread project choice", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + + const projectGroupCount = buildSidebarProjectSnapshots({ + projects: [primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }).length; + + expect(projectGroupCount).toBe(1); + }); + it("keeps projects without repository identity physically scoped", () => { const primary = makeProject(); const remote = makeProject({ @@ -157,11 +179,6 @@ describe("environment grouping", () => { primary.id, remote.id, ]); - expect(snapshots[0]?.memberProjectRefs.map((ref) => ref.projectId)).toEqual([ - primary.id, - duplicate.id, - remote.id, - ]); }); it("prefers the fresher project row when duplicate stale rows are ordered first", () => { @@ -187,10 +204,6 @@ describe("environment grouping", () => { expect(snapshots).toHaveLength(1); expect(snapshots[0]?.memberProjects.map((project) => project.id)).toEqual([canonical.id]); - expect(snapshots[0]?.memberProjectRefs.map((ref) => ref.projectId)).toEqual([ - staleDuplicate.id, - canonical.id, - ]); expect(snapshots[0]?.id).toBe(canonical.id); }); @@ -225,11 +238,24 @@ describe("environment grouping", () => { canonical.id, remote.id, ]); - expect(snapshots[0]?.memberProjectRefs.map((ref) => ref.projectId)).toEqual([ - staleWithoutRepositoryIdentity.id, - canonical.id, - remote.id, + expect(snapshots[0]?.memberProjectRefs).toEqual([ + { + environmentId: primaryEnvironmentId, + projectId: staleWithoutRepositoryIdentity.id, + }, + { environmentId: primaryEnvironmentId, projectId: canonical.id }, + { environmentId: remoteEnvironmentId, projectId: remote.id }, ]); + + const [pickerEntry] = buildSidebarProjectPickerEntries({ + groups: snapshots, + preferredProjectRef: { + environmentId: primaryEnvironmentId, + projectId: staleWithoutRepositoryIdentity.id, + }, + }); + expect(pickerEntry?.isPreferred).toBe(true); + expect(pickerEntry?.targetProject.id).toBe(canonical.id); }); it("routes duplicate physical project keys to the winning logical group", () => { @@ -254,4 +280,73 @@ describe("environment grouping", () => { repositoryIdentity.canonicalKey, ); }); + + it("builds one picker entry per logical project and targets the preferred environment", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const separate = makeProject({ + id: ProjectId.make("project-separate"), + title: "separate", + workspaceRoot: "/tmp/separate", + }); + const groups = buildSidebarProjectSnapshots({ + projects: [separate, primary, remote], + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + const entries = buildSidebarProjectPickerEntries({ + groups, + preferredProjectRef: { + environmentId: remoteEnvironmentId, + projectId: remote.id, + }, + }); + + expect(entries).toHaveLength(2); + expect(entries[0]?.group.projectKey).toBe(repositoryIdentity.canonicalKey); + expect(entries[0]?.targetProject).toMatchObject({ + environmentId: remoteEnvironmentId, + id: remote.id, + }); + expect(entries[0]?.isPreferred).toBe(true); + expect(entries[1]?.group.displayName).toBe("separate"); + }); + + it("keeps manual project order when building grouped sidebar entries", () => { + const primary = makeProject({ repositoryIdentity }); + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + repositoryIdentity, + }); + const separate = makeProject({ + id: ProjectId.make("project-separate"), + title: "separate", + workspaceRoot: "/tmp/separate", + }); + const orderedProjects = orderItemsByPreferredIds({ + items: [primary, remote, separate], + preferredIds: [getProjectOrderKey(separate), getProjectOrderKey(primary)], + getId: getProjectOrderKey, + getPreferenceIds: (project) => [ + getProjectOrderKey(project), + legacyProjectCwdPreferenceKey(project.workspaceRoot), + ], + }); + + const groups = buildSidebarProjectSnapshots({ + projects: orderedProjects, + settings: defaultGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }); + + expect(groups.map((group) => group.displayName)).toEqual(["separate", "shared-repo"]); + }); }); diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 98035d7a704b..abe150db588b 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -402,10 +402,39 @@ html[data-mobile-composer-route-transition="true"]::view-transition-old(t3-mobil content: ""; } + .chat-composer-context-strip { + --chat-composer-context-surface: var(--card); + + position: relative; + isolation: isolate; + } + + .chat-composer-context-strip::before { + position: absolute; + z-index: -1; + inset: 0; + border-radius: 0 0 16px 16px; + background: color-mix( + in srgb, + var(--chat-composer-context-surface) var(--glass-opacity), + transparent + ); + -webkit-mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem); + mask-image: linear-gradient(to bottom, transparent 0 1rem, black 1rem); + box-shadow: 0 12px 28px -18px rgb(0 0 0 / 40%); + content: ""; + -webkit-backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + backdrop-filter: blur(var(--glass-blur)) saturate(var(--glass-saturation)); + } + .dark .chat-composer-glass-host { - --chat-composer-glass-surface: color-mix(in srgb, var(--background) 84%, var(--color-white)); + --chat-composer-glass-surface: color-mix(in srgb, var(--background) 96%, var(--color-white)); + + box-shadow: none; + } - box-shadow: 0 0 0 1px rgb(255 255 255 / 10%); + .dark .chat-composer-context-strip::before { + box-shadow: 0 14px 32px -18px rgb(0 0 0 / 75%); } .alert-glass { diff --git a/apps/web/src/previewStateStore.test.ts b/apps/web/src/previewStateStore.test.ts index c908e23f9baa..f4a3eb732ba2 100644 --- a/apps/web/src/previewStateStore.test.ts +++ b/apps/web/src/previewStateStore.test.ts @@ -295,6 +295,7 @@ describe("previewStateStore (single-tab)", () => { canGoForward: false, loading: false, zoomFactor: 1, + colorScheme: "system", controller: "none", }); const state = readThreadPreviewState(ref); @@ -312,6 +313,7 @@ describe("previewStateStore (single-tab)", () => { canGoForward: false, loading: false, zoomFactor: 1, + colorScheme: "system", controller: "none", }); setActivePreviewTab(ref, first.tabId); @@ -357,6 +359,7 @@ describe("previewStateStore (single-tab)", () => { canGoForward: false, loading: false, zoomFactor: 1, + colorScheme: "system", controller: "none", }); diff --git a/apps/web/src/previewStateStore.ts b/apps/web/src/previewStateStore.ts index e44a464ed24c..34553d6f80b3 100644 --- a/apps/web/src/previewStateStore.ts +++ b/apps/web/src/previewStateStore.ts @@ -8,6 +8,7 @@ import { useAtomValue } from "@effect/atom-react"; import { scopedThreadKey } from "@t3tools/client-runtime/environment"; import { + type DesktopPreviewColorScheme, type PreviewEvent, type PreviewSessionSnapshot, type ScopedThreadRef, @@ -22,6 +23,7 @@ export interface DesktopPreviewOverlay { canGoForward: boolean; loading: boolean; zoomFactor: number; + colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; } diff --git a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx index 7dc6702b4ec1..ce9de113beb8 100644 --- a/apps/web/src/routes/_chat.$environmentId.$threadId.tsx +++ b/apps/web/src/routes/_chat.$environmentId.$threadId.tsx @@ -4,9 +4,14 @@ import { useEffect } from "react"; import ChatView from "../components/ChatView"; import { threadHasStarted } from "../components/ChatView.logic"; import { finalizePromotedDraftThreadByRef, useComposerDraftStore } from "../composerDraftStore"; -import { resolveThreadRouteRef } from "../threadRoutes"; +import { resolveThreadRouteRef, resolveThreadRouteRenderState } from "../threadRoutes"; import { SidebarInset } from "~/components/ui/sidebar"; -import { useEnvironmentThreadRefs, useThreadDetail, useThreadShell } from "../state/entities"; +import { + useEnvironmentThreadRefs, + useThreadDetail, + useThreadShell, + useThreadStatus, +} from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { environmentShell } from "../state/shell"; @@ -20,9 +25,9 @@ function ChatThreadRouteView() { ); const serverThreadShell = useThreadShell(threadRef); const serverThreadDetail = useThreadDetail(threadRef); + const serverThreadStatus = useThreadStatus(threadRef); const environmentThreadRefs = useEnvironmentThreadRefs(threadRef?.environmentId ?? null); const bootstrapComplete = shell.data?.snapshot._tag === "Some"; - const threadExists = serverThreadShell !== null || serverThreadDetail !== null; const environmentHasServerThreads = environmentThreadRefs.length > 0; const draftThreadExists = useComposerDraftStore((store) => threadRef ? store.getDraftThreadByRef(threadRef) !== null : false, @@ -36,7 +41,13 @@ function ChatThreadRouteView() { } return store.hasDraftThreadsInEnvironment(threadRef.environmentId); }); - const routeThreadExists = threadExists || draftThreadExists; + const renderState = resolveThreadRouteRenderState({ + bootstrapComplete, + serverThreadShellExists: serverThreadShell !== null, + serverThreadDetailExists: serverThreadDetail !== null, + serverThreadDetailDeleted: serverThreadStatus === "deleted", + draftThreadExists, + }); const serverThreadStarted = threadHasStarted(serverThreadDetail); const environmentHasAnyThreads = environmentHasServerThreads || environmentHasDraftThreads; @@ -45,10 +56,10 @@ function ChatThreadRouteView() { return; } - if (!routeThreadExists && environmentHasAnyThreads) { + if (renderState === "missing" && environmentHasAnyThreads) { void navigate({ to: "/", replace: true }); } - }, [bootstrapComplete, environmentHasAnyThreads, navigate, routeThreadExists, threadRef]); + }, [bootstrapComplete, environmentHasAnyThreads, navigate, renderState, threadRef]); useEffect(() => { if (!threadRef || !serverThreadStarted || !draftThread) { @@ -57,7 +68,7 @@ function ChatThreadRouteView() { finalizePromotedDraftThreadByRef(threadRef); }, [draftThread, serverThreadStarted, threadRef]); - if (!threadRef || !bootstrapComplete || !routeThreadExists) { + if (!threadRef || renderState !== "ready") { return null; } diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index 57824fe5188e..2e29d222b5ba 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -1,11 +1,14 @@ import { Outlet, createFileRoute, redirect } from "@tanstack/react-router"; import { useAtomValue } from "@effect/atom-react"; -import { useEffect } from "react"; +import { useEffect, useMemo } from "react"; import { isCommandPaletteOpen } from "../commandPaletteBus"; import { useClientSettings } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; +import { usePrimaryEnvironmentId } from "../state/environments"; +import { selectProjectGroupingSettings } from "../logicalProject"; +import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; import { useHandleNewThread } from "../hooks/useHandleNewThread"; import { @@ -29,7 +32,19 @@ function ChatRouteGlobalShortcuts() { useHandleNewThread(); const keybindings = useAtomValue(primaryServerKeybindingsAtom); const sidebarV2Enabled = useClientSettings((settings) => settings.sidebarV2Enabled); - const projectCount = useProjects().length; + const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); + const projects = useProjects(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const projectGroupCount = useMemo( + () => + buildSidebarProjectSnapshots({ + projects, + settings: projectGroupingSettings, + primaryEnvironmentId, + resolveEnvironmentLabel: () => null, + }).length, + [primaryEnvironmentId, projectGroupingSettings, projects], + ); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef ? selectThreadTerminalUiState(state.terminalUiStateByThreadKey, routeThreadRef).terminalOpen @@ -83,7 +98,7 @@ function ChatRouteGlobalShortcuts() { // Sidebar v2 routes creation through the command palette whenever // there is a real choice to make; v1 (and single-project setups) // keep the immediate contextual create. - if (sidebarV2Enabled && projectCount > 1) { + if (sidebarV2Enabled && projectGroupCount > 1) { openCommandPalette({ open: "new-thread-in" }); return; } @@ -152,7 +167,7 @@ function ChatRouteGlobalShortcuts() { keybindings, defaultProjectRef, previewOpen, - projectCount, + projectGroupCount, routeThreadRef, selectedThreadKeysSize, sidebarV2Enabled, diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 737f4e1c8f5d..32299b565f4b 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -31,6 +31,12 @@ export interface SidebarProjectSnapshot extends Project { remoteEnvironmentLabels: readonly string[]; } +export interface SidebarProjectPickerEntry { + group: SidebarProjectSnapshot; + targetProject: SidebarProjectGroupMember; + isPreferred: boolean; +} + interface SidebarProjectGroupCandidate { readonly logicalKey: string; readonly project: Project; @@ -133,6 +139,26 @@ export function buildSidebarProjectSnapshots(input: { } } + const projectRefsByLogicalKey = new Map(); + const seenProjectRefs = new Set(); + for (const project of input.projects) { + const physicalProjectKey = derivePhysicalProjectKey(project); + const logicalKey = + winnersByPhysicalKey.get(physicalProjectKey)?.logicalKey ?? + deriveLogicalProjectKeyFromSettings(project, input.settings); + const projectRefKey = `${project.environmentId}:${project.id}`; + if (seenProjectRefs.has(projectRefKey)) continue; + seenProjectRefs.add(projectRefKey); + + const projectRef = scopeProjectRef(project.environmentId, project.id); + const existingRefs = projectRefsByLogicalKey.get(logicalKey); + if (existingRefs) { + existingRefs.push(projectRef); + } else { + projectRefsByLogicalKey.set(logicalKey, [projectRef]); + } + } + const result: SidebarProjectSnapshot[] = []; const seen = new Set(); for (const project of input.projects) { @@ -170,13 +196,6 @@ export function buildSidebarProjectSnapshots(input: { remoteMembers.length > 0 && remoteMembers.every((member) => isDesktopLocal(member.environmentId)); - // Keep duplicate (non-winning) project ids in thread lookup refs so threads - // still attached to stale rows remain visible under the winning sidebar row. - const physicalKeysInGroup = new Set(members.map((member) => member.physicalProjectKey)); - const memberProjectRefs = input.projects - .filter((project) => physicalKeysInGroup.has(derivePhysicalProjectKey(project))) - .map((project) => scopeProjectRef(project.environmentId, project.id)); - result.push({ ...representative, projectKey: logicalKey, @@ -192,10 +211,52 @@ export function buildSidebarProjectSnapshots(input: { hasLocal && hasRemote ? "mixed" : hasRemote ? "remote-only" : "local-only", allRemoteMembersAreDesktopLocal, memberProjects: members, - memberProjectRefs, + memberProjectRefs: projectRefsByLogicalKey.get(logicalKey) ?? [], remoteEnvironmentLabels, }); } return result; } + +export function buildSidebarProjectPickerEntries(input: { + groups: ReadonlyArray; + preferredProjectRef: ScopedProjectRef | null; +}) { + const entries = input.groups.flatMap((group): SidebarProjectPickerEntry[] => { + const isPreferred = input.preferredProjectRef + ? group.memberProjectRefs.some( + (projectRef) => + projectRef.environmentId === input.preferredProjectRef?.environmentId && + projectRef.projectId === input.preferredProjectRef.projectId, + ) + : false; + const preferredProject = isPreferred + ? (group.memberProjects.find( + (project) => + project.environmentId === input.preferredProjectRef?.environmentId && + project.id === input.preferredProjectRef?.projectId, + ) ?? + group.memberProjects.find( + (project) => project.environmentId === input.preferredProjectRef?.environmentId, + )) + : null; + const targetProject = + preferredProject ?? + group.memberProjects.find( + (project) => project.environmentId === group.environmentId && project.id === group.id, + ) ?? + group.memberProjects[0]; + if (!targetProject) return []; + + 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), + ]; +} diff --git a/apps/web/src/state/entities.ts b/apps/web/src/state/entities.ts index 47e63ca2cd37..e85fc1d115ec 100644 --- a/apps/web/src/state/entities.ts +++ b/apps/web/src/state/entities.ts @@ -4,7 +4,10 @@ import type { EnvironmentThread, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; -import { mergeEnvironmentThread } from "@t3tools/client-runtime/state/threads"; +import { + type EnvironmentThreadStatus, + mergeEnvironmentThread, +} from "@t3tools/client-runtime/state/threads"; import type { OrchestrationMessage, OrchestrationProposedPlan, @@ -44,6 +47,9 @@ const EMPTY_THREAD_SHELL_ATOM = Atom.make(null).p const EMPTY_THREAD_DETAIL_ATOM = Atom.make(null).pipe( Atom.withLabel("web-thread-detail:empty"), ); +const EMPTY_THREAD_STATUS_ATOM = Atom.make("empty").pipe( + Atom.withLabel("web-thread-status:empty"), +); const EMPTY_MESSAGES_ATOM = Atom.make(EMPTY_MESSAGES).pipe( Atom.withLabel("web-thread-messages:empty"), ); @@ -140,6 +146,12 @@ export function useThreadDetail(ref: ScopedThreadRef | null): EnvironmentThread ); } +export function useThreadStatus(ref: ScopedThreadRef | null): EnvironmentThreadStatus { + return useAtomValue( + ref === null ? EMPTY_THREAD_STATUS_ATOM : environmentThreadDetails.statusAtom(ref), + ); +} + /** Detail collections composed with shell-authoritative thread/workspace metadata. */ export function useThread(ref: ScopedThreadRef | null): EnvironmentThread | null { const shell = useThreadShell(ref); diff --git a/apps/web/src/threadRoutes.test.ts b/apps/web/src/threadRoutes.test.ts index 644cdccb5c16..3edb2f38dc85 100644 --- a/apps/web/src/threadRoutes.test.ts +++ b/apps/web/src/threadRoutes.test.ts @@ -7,6 +7,7 @@ import { buildDraftThreadRouteParams, buildThreadRouteParams, resolveActiveThreadRouteRef, + resolveThreadRouteRenderState, resolveThreadRouteRef, resolveThreadRouteTarget, } from "./threadRoutes"; @@ -92,4 +93,70 @@ describe("threadRoutes", () => { }), ).toBeNull(); }); + + it("keeps shell-only server threads in the loading state", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + draftThreadExists: false, + }), + ).toBe("loading"); + }); + + it("renders server details and local drafts when they are ready", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: true, + serverThreadDetailDeleted: false, + draftThreadExists: false, + }), + ).toBe("ready"); + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: false, + serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + draftThreadExists: true, + }), + ).toBe("ready"); + }); + + it("distinguishes bootstrap loading from a missing thread", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: false, + serverThreadShellExists: false, + serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + draftThreadExists: false, + }), + ).toBe("loading"); + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: false, + serverThreadDetailExists: false, + serverThreadDetailDeleted: false, + draftThreadExists: false, + }), + ).toBe("missing"); + }); + + it("redirects deleted shell-only threads", () => { + expect( + resolveThreadRouteRenderState({ + bootstrapComplete: true, + serverThreadShellExists: true, + serverThreadDetailExists: false, + serverThreadDetailDeleted: true, + draftThreadExists: false, + }), + ).toBe("missing"); + }); }); diff --git a/apps/web/src/threadRoutes.ts b/apps/web/src/threadRoutes.ts index a4d853c0a7fc..fd5bc39d836a 100644 --- a/apps/web/src/threadRoutes.ts +++ b/apps/web/src/threadRoutes.ts @@ -18,6 +18,27 @@ type DraftThreadRouteState = { promotedTo?: ScopedThreadRef | null; }; +export type ThreadRouteRenderState = "loading" | "ready" | "missing"; + +export function resolveThreadRouteRenderState(input: { + bootstrapComplete: boolean; + serverThreadShellExists: boolean; + serverThreadDetailExists: boolean; + serverThreadDetailDeleted: boolean; + draftThreadExists: boolean; +}): ThreadRouteRenderState { + if (!input.bootstrapComplete) { + return "loading"; + } + if (input.serverThreadDetailExists || input.draftThreadExists) { + return "ready"; + } + if (input.serverThreadDetailDeleted) { + return "missing"; + } + return input.serverThreadShellExists ? "loading" : "missing"; +} + export function buildThreadRouteParams(ref: ScopedThreadRef): { environmentId: EnvironmentId; threadId: ThreadId; diff --git a/packages/client-runtime/src/connection/presentation.test.ts b/packages/client-runtime/src/connection/presentation.test.ts index 354b003a2d4a..e13638a2b41f 100644 --- a/packages/client-runtime/src/connection/presentation.test.ts +++ b/packages/client-runtime/src/connection/presentation.test.ts @@ -12,6 +12,7 @@ import { connectionCatalogDisplayUrl, connectionPhaseMessage, connectionStatusText, + connectionStatusTitle, presentEnvironmentConnection, presentConnectionState, } from "./presentation.ts"; @@ -123,13 +124,15 @@ describe("connection presentation", () => { }); it("combines reconnect progress with the latest failure", () => { - expect( - connectionStatusText({ - phase: "reconnecting", - error: "Relay request timed out.", - traceId: "trace-retry", - }), - ).toBe("Failed to connect. Reconnecting... Reason: Relay request timed out."); + const connection = { + phase: "reconnecting", + error: "Relay request timed out.", + traceId: "trace-retry", + } as const; + expect(connectionStatusText(connection)).toBe( + "Failed to connect. Reconnecting... Reason: Relay request timed out.", + ); + expect(connectionStatusTitle(connection)).toBe("Failed to connect. Reconnecting..."); }); it("presents the supervisor's offline state without consulting shell state", () => { diff --git a/packages/client-runtime/src/connection/presentation.ts b/packages/client-runtime/src/connection/presentation.ts index ec7687dfe42c..168443deceb4 100644 --- a/packages/client-runtime/src/connection/presentation.ts +++ b/packages/client-runtime/src/connection/presentation.ts @@ -76,6 +76,13 @@ export function connectionStatusText(connection: EnvironmentConnectionPresentati } } +export function connectionStatusTitle(connection: EnvironmentConnectionPresentation): string { + if (connection.phase === "reconnecting" && connection.error) { + return "Failed to connect. Reconnecting..."; + } + return connectionStatusText({ ...connection, error: null }); +} + export function presentEnvironmentConnection( state: SupervisorConnectionState, ): EnvironmentConnectionPresentation { diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index a0a857a6f35d..98e0ff71f916 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -510,6 +510,15 @@ export type DesktopPreviewNavStatus = description: string; }; +/** + * Emulated `prefers-color-scheme` for the guest page. "system" clears the + * override so the page follows the OS appearance. + */ +export type DesktopPreviewColorScheme = "system" | "light" | "dark"; + +export const DesktopPreviewColorSchemeSchema: Schema.Codec = + Schema.Literals(["system", "light", "dark"]); + export interface DesktopPreviewTabState { tabId: string; webContentsId: number | null; @@ -518,6 +527,7 @@ export interface DesktopPreviewTabState { canGoForward: boolean; /** Current zoom factor (1.0 = 100%). */ zoomFactor: number; + colorScheme: DesktopPreviewColorScheme; controller: "human" | "agent" | "none"; updatedAt: string; } @@ -554,6 +564,7 @@ export const DesktopPreviewTabStateSchema: Schema.Codec canGoBack: Schema.Boolean, canGoForward: Schema.Boolean, zoomFactor: Schema.Number, + colorScheme: DesktopPreviewColorSchemeSchema, controller: Schema.Literals(["human", "agent", "none"]), updatedAt: Schema.String, }); @@ -912,6 +923,11 @@ export const DesktopPreviewConfigInputSchema = Schema.Struct({ environmentId: EnvironmentId, }); +export const DesktopPreviewSetColorSchemeInputSchema = Schema.Struct({ + tabId: DesktopPreviewTabIdSchema, + colorScheme: DesktopPreviewColorSchemeSchema, +}); + export const DesktopPreviewAnnotationThemeInputSchema = Schema.Struct({ theme: DesktopPreviewAnnotationThemeSchema, }); @@ -1038,6 +1054,11 @@ export interface DesktopPreviewBridge { resetZoom: (tabId: string) => Promise; /** Reload bypassing the HTTP cache. */ hardReload: (tabId: string) => Promise; + /** + * Emulate `prefers-color-scheme` on the guest page ("system" clears the + * override). Persists per tab and is re-applied across webview swaps. + */ + setColorScheme: (tabId: string, colorScheme: DesktopPreviewColorScheme) => Promise; /** Open the guest webview's DevTools (detached). */ openDevTools: (tabId: string) => Promise; /** Drop cookies + storage data for the preview partition (all tabs). */ diff --git a/packages/contracts/src/previewAutomation.ts b/packages/contracts/src/previewAutomation.ts index 6431dd0dcfd5..f05623cbc992 100644 --- a/packages/contracts/src/previewAutomation.ts +++ b/packages/contracts/src/previewAutomation.ts @@ -45,6 +45,7 @@ export const PREVIEW_AUTOMATION_V1_OPERATIONS = [ export const PREVIEW_AUTOMATION_OPERATIONS = [ ...PREVIEW_AUTOMATION_V1_OPERATIONS, "resize", + "setColorScheme", ] as const; export const PreviewAutomationOperation = Schema.Literals(PREVIEW_AUTOMATION_OPERATIONS); @@ -254,6 +255,29 @@ export const PreviewAutomationResizeResult = Schema.Struct({ }); export type PreviewAutomationResizeResult = typeof PreviewAutomationResizeResult.Type; +/** Mirrors DesktopPreviewColorScheme; declared here to keep this module free of ipc.ts imports. */ +export const PreviewAutomationColorScheme = Schema.Literals(["system", "light", "dark"]); +export type PreviewAutomationColorScheme = typeof PreviewAutomationColorScheme.Type; + +export const PreviewAutomationSetColorSchemeInput = Schema.Struct({ + ...PreviewAutomationTabTargetFields, + colorScheme: PreviewAutomationColorScheme.annotate({ + description: + "Emulated prefers-color-scheme for the page: light, dark, or system to follow the OS appearance.", + }), +}).annotate({ + description: + "Emulates prefers-color-scheme in the active browser tab without changing the OS or app theme.", +}); +export type PreviewAutomationSetColorSchemeInput = typeof PreviewAutomationSetColorSchemeInput.Type; + +export const PreviewAutomationSetColorSchemeResult = Schema.Struct({ + tabId: PreviewTabId, + colorScheme: PreviewAutomationColorScheme, +}); +export type PreviewAutomationSetColorSchemeResult = + typeof PreviewAutomationSetColorSchemeResult.Type; + const Locator = TrimmedNonEmptyString.annotate({ description: "Playwright selector, preferably role/text based, for example role=button[name='Send'] or text=Continue. Use snapshot first to inspect the page.", diff --git a/scripts/lib/brand-assets.test.ts b/scripts/lib/brand-assets.test.ts index c830f3d1c64f..8265ad681802 100644 --- a/scripts/lib/brand-assets.test.ts +++ b/scripts/lib/brand-assets.test.ts @@ -4,14 +4,14 @@ import { BRAND_ASSET_PATHS, DEVELOPMENT_ICON_OVERRIDES, DEVELOPMENT_PUBLIC_ICON_OVERRIDES, - PUBLISH_ICON_OVERRIDES, resolveWebAssetBrandForChannel, + resolveWebAssetBrandForPackageVersion, resolveWebIconOverrides, } from "./brand-assets.ts"; describe("brand-assets", () => { - it("maps server publish web assets to production icons", () => { - expect(PUBLISH_ICON_OVERRIDES).toEqual([ + it("maps production web assets into the server package", () => { + expect(resolveWebIconOverrides("production", "dist/client")).toEqual([ { sourceRelativePath: BRAND_ASSET_PATHS.productionWebFaviconIco, targetRelativePath: "dist/client/favicon.ico", @@ -78,6 +78,11 @@ describe("brand-assets", () => { expect(resolveWebAssetBrandForChannel("nightly")).toBe("nightly"); }); + it("maps package versions to web asset brands", () => { + expect(resolveWebAssetBrandForPackageVersion("0.0.29")).toBe("production"); + expect(resolveWebAssetBrandForPackageVersion("0.0.29-nightly.20260723.882")).toBe("nightly"); + }); + it("keeps development, nightly, and production icon families separate", () => { expect([ BRAND_ASSET_PATHS.developmentIconComposerProject, diff --git a/scripts/lib/brand-assets.ts b/scripts/lib/brand-assets.ts index dafc23434460..de73cd511e35 100644 --- a/scripts/lib/brand-assets.ts +++ b/scripts/lib/brand-assets.ts @@ -44,6 +44,10 @@ export function resolveWebAssetBrandForChannel(channel: WebAssetChannel): WebAss return channel === "nightly" ? "nightly" : "production"; } +export function resolveWebAssetBrandForPackageVersion(version: string): WebAssetBrand { + return version.includes("-nightly.") ? "nightly" : "production"; +} + export interface IconOverride { readonly sourceRelativePath: string; readonly targetRelativePath: string; @@ -108,5 +112,3 @@ export const DEVELOPMENT_PUBLIC_ICON_OVERRIDES = resolveWebIconOverrides( "development", "apps/web/public", ); - -export const PUBLISH_ICON_OVERRIDES = resolveWebIconOverrides("production", "dist/client");