From d03033488d097a8539a5f2f607fe2e70699867b3 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:20:21 +0200 Subject: [PATCH 1/8] chore(identity): reapply identity product after upstream restack --- apps/mobile/app.config.ts | 4 +- apps/mobile/package.json | 2 +- apps/mobile/src/App.tsx | 2 + apps/mobile/src/Stack.tsx | 3 +- apps/mobile/src/components/ProjectFavicon.tsx | 57 +- .../archive/ArchivedThreadsScreen.tsx | 14 +- .../mobile/src/features/board/BoardScreen.tsx | 17 +- .../src/features/cloud/linkEnvironment.ts | 9 +- .../src/features/connection/pairing.test.ts | 21 - .../mobile/src/features/connection/pairing.ts | 17 +- .../features/files/ThreadFilesRouteScreen.tsx | 8 +- apps/mobile/src/features/home/HomeHeader.tsx | 74 ++- .../src/features/home/HomeRouteScreen.tsx | 42 +- apps/mobile/src/features/home/HomeScreen.tsx | 17 +- .../home/home-list-filter-menu.test.ts | 38 ++ .../features/home/home-list-filter-menu.ts | 18 + .../features/home/home-list-options.test.ts | 6 + .../src/features/home/home-list-options.ts | 12 + .../src/features/identity/IdentityAvatar.tsx | 37 ++ .../identity/IdentityClaimGate.test.ts | 13 + .../features/identity/IdentityClaimGate.tsx | 234 +++++++ .../features/identity/ParticipantStack.tsx | 132 ++++ .../identity/identityClaimCandidate.ts | 6 + .../identity/ownershipFilterSurface.test.ts | 21 + .../identity/participantStackSurface.test.ts | 23 + .../layout/native-mail-search-toolbar.ts | 11 - .../features/settings/SettingsRouteScreen.tsx | 117 +++- .../features/threads/NewTaskDraftScreen.tsx | 22 +- .../threads/ThreadNavigationSidebar.tsx | 39 +- .../threads/new-task-flow-provider.tsx | 49 +- .../threads/sidebar-navigation-shell.tsx | 4 +- .../features/threads/thread-list-items.tsx | 14 +- .../features/threads/thread-list-v2-items.tsx | 11 + .../src/features/threads/threadListV2.ts | 11 +- apps/mobile/src/lib/composerImages.ts | 8 + apps/mobile/src/lib/modelOptions.test.ts | 44 +- apps/mobile/src/lib/modelOptions.ts | 25 - apps/mobile/src/native/StackHeader.tsx | 12 +- .../src/native/T3ComposerEditor.native.tsx | 117 ++-- apps/mobile/src/state/identity.ts | 28 + .../src/state/use-composer-drafts.test.ts | 30 - apps/mobile/src/state/use-composer-drafts.ts | 13 +- apps/server/src/assets/AssetAccess.test.ts | 46 +- apps/server/src/assets/AssetAccess.ts | 63 +- apps/server/src/auth/RpcAuthorization.ts | 5 +- apps/server/src/bin.test.ts | 4 +- apps/server/src/bin.ts | 4 +- apps/server/src/github/GitHubPrBridge.ts | 53 ++ .../src/github/githubActorTrust.test.ts | 90 +++ apps/server/src/github/githubActorTrust.ts | 80 +++ .../src/identity/IdentityService.test.ts | 147 +++++ apps/server/src/identity/IdentityService.ts | 366 +++++++++++ apps/server/src/identity/stampSource.test.ts | 144 ++++ apps/server/src/identity/stampSource.ts | 198 ++++++ apps/server/src/jira/JiraAppClient.ts | 144 ++-- apps/server/src/jira/JiraDeliveryStore.ts | 7 + apps/server/src/jira/JiraIssueBridge.ts | 181 +++++- apps/server/src/jira/JiraThreadLookup.ts | 79 ++- apps/server/src/jira/JiraWebhook.test.ts | 63 +- apps/server/src/jira/JiraWebhookPayload.ts | 101 ++- apps/server/src/jira/jiraActorTrust.test.ts | 88 +++ apps/server/src/jira/jiraActorTrust.ts | 53 ++ .../src/jira/jiraDiscordContext.test.ts | 30 + apps/server/src/jira/jiraDiscordContext.ts | 84 +++ apps/server/src/keybindings.test.ts | 2 - apps/server/src/keybindings.ts | 21 +- .../Layers/OrchestrationEngine.test.ts | 1 + .../Layers/ProjectionPipeline.ts | 58 ++ .../Layers/ProjectionSnapshotQuery.ts | 63 +- .../Layers/ProviderCommandReactor.ts | 1 + .../decider.titleRegeneration.test.ts | 1 + apps/server/src/orchestration/decider.ts | 7 + apps/server/src/orchestration/http.ts | 48 +- apps/server/src/orchestration/projector.ts | 52 ++ apps/server/src/os-jank.ts | 19 - .../Layers/ProjectionThreadMessages.ts | 22 +- .../persistence/Layers/ProjectionThreads.ts | 71 +- apps/server/src/persistence/Migrations.ts | 4 + .../Migrations/037_SessionIdentityClaims.ts | 19 + .../038_ProjectionThreadSourceAttribution.ts | 36 + .../Services/ProjectionThreadMessages.ts | 3 + .../persistence/Services/ProjectionThreads.ts | 6 + .../src/persistence/SessionIdentityClaims.ts | 177 +++++ apps/server/src/server.test.ts | 20 +- apps/server/src/server.ts | 35 +- apps/server/src/vcs/GitVcsDriverCore.test.ts | 45 +- apps/server/src/vcs/GitVcsDriverCore.ts | 91 +-- .../src/workspace/WorkspaceEntries.test.ts | 344 +--------- apps/server/src/workspace/WorkspaceEntries.ts | 105 +-- .../workspace/WorkspaceSearchIndex.test.ts | 143 +--- .../src/workspace/WorkspaceSearchIndex.ts | 315 ++------- apps/server/src/ws.ts | 63 +- apps/web/src/cloud/linkEnvironment.ts | 9 +- apps/web/src/components/ChatMarkdown.tsx | 52 +- apps/web/src/components/ChatView.tsx | 139 ++-- .../components/CommandPalette.logic.test.ts | 129 ++-- .../src/components/CommandPalette.logic.ts | 72 +- apps/web/src/components/CommandPalette.tsx | 614 +++++++++--------- apps/web/src/components/ProjectFavicon.tsx | 61 +- .../components/ServerUpdateAction.test.tsx | 13 +- .../web/src/components/ServerUpdateAction.tsx | 96 +-- apps/web/src/components/Sidebar.tsx | 107 ++- apps/web/src/components/SidebarV2.tsx | 91 ++- apps/web/src/components/board/BoardView.tsx | 17 +- .../components/chat/ComposerBannerStack.tsx | 2 +- apps/web/src/components/chat/TraitsPicker.tsx | 8 +- .../components/desktopUpdate.logic.test.ts | 18 - .../web/src/components/desktopUpdate.logic.ts | 18 - .../src/components/files/FileBrowserPanel.tsx | 72 -- .../src/components/files/FilePreviewPanel.tsx | 188 ++---- .../files/projectFilesQueryState.ts | 27 - .../components/identity/IdentityAvatar.tsx | 41 ++ .../components/identity/IdentityClaimGate.tsx | 377 +++++++++++ .../identity/ParticipantStack.logic.test.ts | 28 + .../identity/ParticipantStack.logic.ts | 7 + .../components/identity/ParticipantStack.tsx | 140 ++++ .../settings/ConnectionsSettings.tsx | 2 + .../components/sidebar/SidebarUpdatePill.tsx | 7 +- apps/web/src/forkSurfaceExistence.test.ts | 37 ++ apps/web/src/keybindings.test.ts | 52 -- apps/web/src/routes/__root.tsx | 5 + apps/web/src/state/identity.ts | 34 + apps/web/src/state/projects.ts | 13 - apps/web/src/state/queries.ts | 97 +-- infra/relay/README.md | 14 +- .../src/environments/EnvironmentConnector.ts | 18 +- infra/relay/src/http/Api.ts | 6 +- packages/client-runtime/package.json | 4 + .../client-runtime/src/rpc/session.test.ts | 34 - .../client-runtime/src/state/identity.test.ts | 175 +++++ packages/client-runtime/src/state/identity.ts | 122 ++++ .../src/state/threadReducer.test.ts | 37 ++ .../client-runtime/src/state/threadReducer.ts | 5 + packages/contracts/src/baseSchemas.ts | 25 - packages/contracts/src/environmentHttp.ts | 3 + packages/contracts/src/identity.test.ts | 161 +++++ packages/contracts/src/identity.ts | 210 ++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/keybindings.test.ts | 77 --- packages/contracts/src/keybindings.ts | 13 +- packages/contracts/src/orchestration.test.ts | 2 + packages/contracts/src/orchestration.ts | 35 + packages/contracts/src/project.test.ts | 42 -- packages/contracts/src/project.ts | 82 +-- packages/contracts/src/relay.ts | 20 +- packages/contracts/src/rpc.ts | 52 +- packages/contracts/src/server.test.ts | 26 +- packages/contracts/src/server.ts | 9 +- packages/shared/package.json | 16 + packages/shared/src/identityAvatar.test.ts | 80 +++ packages/shared/src/identityAvatar.ts | 122 ++++ .../shared/src/identityMap.lookup.test.ts | 43 ++ packages/shared/src/identityMap.test.ts | 69 ++ packages/shared/src/identityMap.ts | 314 +++++++++ packages/shared/src/keybindings.ts | 2 - packages/shared/src/projectFavicon.test.ts | 26 +- packages/shared/src/projectFavicon.ts | 17 - packages/shared/src/schemaJson.test.ts | 9 - packages/shared/src/schemaJson.ts | 10 +- packages/shared/src/semver.test.ts | 26 +- packages/shared/src/semver.ts | 7 +- packages/shared/src/sourceAttribution.test.ts | 120 ++++ packages/shared/src/sourceAttribution.ts | 176 +++++ .../shared/src/threadAttributeSearch.test.ts | 104 +++ packages/shared/src/threadAttributeSearch.ts | 208 ++++++ pnpm-workspace.yaml | 24 +- scripts/mobile-showcase-environment.ts | 59 +- scripts/mobile-showcase.config.ts | 7 +- scripts/mobile-showcase.test.ts | 26 +- scripts/mobile-showcase.ts | 103 +-- 170 files changed, 7126 insertions(+), 3372 deletions(-) create mode 100644 apps/mobile/src/features/identity/IdentityAvatar.tsx create mode 100644 apps/mobile/src/features/identity/IdentityClaimGate.test.ts create mode 100644 apps/mobile/src/features/identity/IdentityClaimGate.tsx create mode 100644 apps/mobile/src/features/identity/ParticipantStack.tsx create mode 100644 apps/mobile/src/features/identity/identityClaimCandidate.ts create mode 100644 apps/mobile/src/features/identity/ownershipFilterSurface.test.ts create mode 100644 apps/mobile/src/features/identity/participantStackSurface.test.ts create mode 100644 apps/mobile/src/state/identity.ts create mode 100644 apps/server/src/github/githubActorTrust.test.ts create mode 100644 apps/server/src/github/githubActorTrust.ts create mode 100644 apps/server/src/identity/IdentityService.test.ts create mode 100644 apps/server/src/identity/IdentityService.ts create mode 100644 apps/server/src/identity/stampSource.test.ts create mode 100644 apps/server/src/identity/stampSource.ts create mode 100644 apps/server/src/jira/jiraActorTrust.test.ts create mode 100644 apps/server/src/jira/jiraActorTrust.ts create mode 100644 apps/server/src/jira/jiraDiscordContext.test.ts create mode 100644 apps/server/src/jira/jiraDiscordContext.ts create mode 100644 apps/server/src/persistence/Migrations/037_SessionIdentityClaims.ts create mode 100644 apps/server/src/persistence/Migrations/038_ProjectionThreadSourceAttribution.ts create mode 100644 apps/server/src/persistence/SessionIdentityClaims.ts create mode 100644 apps/web/src/components/identity/IdentityAvatar.tsx create mode 100644 apps/web/src/components/identity/IdentityClaimGate.tsx create mode 100644 apps/web/src/components/identity/ParticipantStack.logic.test.ts create mode 100644 apps/web/src/components/identity/ParticipantStack.logic.ts create mode 100644 apps/web/src/components/identity/ParticipantStack.tsx create mode 100644 apps/web/src/state/identity.ts create mode 100644 packages/client-runtime/src/state/identity.test.ts create mode 100644 packages/client-runtime/src/state/identity.ts create mode 100644 packages/contracts/src/identity.test.ts create mode 100644 packages/contracts/src/identity.ts create mode 100644 packages/shared/src/identityAvatar.test.ts create mode 100644 packages/shared/src/identityAvatar.ts create mode 100644 packages/shared/src/identityMap.lookup.test.ts create mode 100644 packages/shared/src/identityMap.test.ts create mode 100644 packages/shared/src/identityMap.ts create mode 100644 packages/shared/src/sourceAttribution.test.ts create mode 100644 packages/shared/src/sourceAttribution.ts create mode 100644 packages/shared/src/threadAttributeSearch.test.ts create mode 100644 packages/shared/src/threadAttributeSearch.ts diff --git a/apps/mobile/app.config.ts b/apps/mobile/app.config.ts index fbce5e05418..b6c3128ea5f 100644 --- a/apps/mobile/app.config.ts +++ b/apps/mobile/app.config.ts @@ -175,7 +175,7 @@ const config: ExpoConfig = { slug: "t3-code", platforms: ["ios", "android"], scheme: variant.scheme, - version: "1.0.1", + version: "0.1.0", runtimeVersion: { // Fingerprint (not appVersion) so an OTA only reaches binaries whose native // project — native deps, config plugins, AND patches/ — matches the update. @@ -296,12 +296,10 @@ const config: ExpoConfig = { "expo-camera", { cameraPermission: "Allow T3 Code to access your camera so you can scan pairing QR codes.", - microphonePermission: false, barcodeScannerEnabled: true, recordAudioAndroid: false, }, ], - ["expo-image-picker", { photosPermission: false, microphonePermission: false }], [ "expo-splash-screen", { diff --git a/apps/mobile/package.json b/apps/mobile/package.json index 653ab85f739..d2195282ef5 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -11,7 +11,7 @@ "start:dev": "APP_VARIANT=development expo start", "start:preview": "APP_VARIANT=preview expo start", "start:prod": "APP_VARIANT=production expo start", - "showcase": "APP_VARIANT=production EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code --clear", + "showcase": "APP_VARIANT=development EXPO_PUBLIC_SHOWCASE=1 expo start --dev-client --scheme t3code-dev --clear", "screenshots": "node ../../scripts/mobile-showcase.ts", "android": "EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && expo run:android", "android:dev": "APP_VARIANT=development EXPO_NO_GIT_STATUS=1 expo prebuild --clean --platform android && REACT_NATIVE_PACKAGER_HOSTNAME=localhost expo run:android", diff --git a/apps/mobile/src/App.tsx b/apps/mobile/src/App.tsx index 06bd4bc5773..c6947471a45 100644 --- a/apps/mobile/src/App.tsx +++ b/apps/mobile/src/App.tsx @@ -11,6 +11,7 @@ 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 { IdentityClaimGate } from "./features/identity/IdentityClaimGate"; import { prepareNativeShowcaseCapture } from "./features/showcase/nativeShowcaseScene"; import { IncomingShareProvider } from "./features/sharing/IncomingShareProvider"; import { @@ -88,6 +89,7 @@ export default function App() { /> + {/* Anchored-menu overlays render here — in-window, so the keyboard stays up while a dropdown is open. */} diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b083b8c1d97..661442a3098 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -15,7 +15,6 @@ import { DynamicColorIOS, Platform, Pressable, ScrollView, StyleSheet } from "re import { useResolveClassNames } from "uniwind"; import { AppText as Text } from "./components/AppText"; -import { getCompactBrandHeaderOptions } from "./components/CompactBrandTitle"; import { ArchivedThreadsRouteScreen } from "./features/archive/ArchivedThreadsRouteScreen"; import { useAgentNotificationNavigation } from "./features/agent-awareness/notificationNavigation"; import { ClerkSettingsSheetDetentProvider } from "./features/cloud/ClerkSettingsSheetDetent"; @@ -394,7 +393,7 @@ export const RootStack = createNativeStackNavigator({ ...GLASS_HEADER_OPTIONS, contentStyle: { backgroundColor: "transparent" }, headerBackVisible: false, - ...getCompactBrandHeaderOptions(), + title: "Threads", }, }), Board: createNativeStackScreen({ diff --git a/apps/mobile/src/components/ProjectFavicon.tsx b/apps/mobile/src/components/ProjectFavicon.tsx index d52aa05b446..772d5e8cc14 100644 --- a/apps/mobile/src/components/ProjectFavicon.tsx +++ b/apps/mobile/src/components/ProjectFavicon.tsx @@ -1,21 +1,14 @@ import { SymbolView } from "./AppSymbol"; import { Image } from "expo-image"; -import { useLayoutEffect, useMemo, useState } from "react"; +import { useState } from "react"; import { View } from "react-native"; import type { EnvironmentId } from "@t3tools/contracts"; -import { - getProjectFaviconCacheKey, - isProjectFaviconFallbackUrl, -} from "@t3tools/shared/projectFavicon"; +import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; import { useThemeColor } from "../lib/useThemeColor"; import { useAssetUrl } from "../state/assets"; -import { - beginProjectFaviconRequest, - createProjectFaviconRequest, - hasLoadedProjectFavicon, - markProjectFaviconFailed, - markProjectFaviconLoaded, -} from "./projectFaviconCache"; + +/* ─── Favicon cache (matches web pattern) ────────────────────────────── */ +const loadedFaviconUrls = new Set(); /* ─── Component ──────────────────────────────────────────────────────── */ export function ProjectFavicon(props: { @@ -33,15 +26,10 @@ export function ProjectFavicon(props: { : { _tag: "project-favicon", cwd: props.workspaceRoot }, ); const renderableFaviconUrl = isProjectFaviconFallbackUrl(faviconUrl) ? null : faviconUrl; - const cacheKey = - renderableFaviconUrl && props.workspaceRoot - ? getProjectFaviconCacheKey(props.environmentId, props.workspaceRoot, renderableFaviconUrl) - : null; return ( createProjectFaviconRequest(props.cacheKey, props.faviconUrl), - [props.cacheKey, props.faviconUrl], - ); - const [activeFaviconRequest, setActiveFaviconRequest] = useState(null); - useLayoutEffect(() => { - if (faviconRequest === null) return; - - const endRequest = beginProjectFaviconRequest(faviconRequest); - setActiveFaviconRequest(faviconRequest); - return endRequest; - }, [faviconRequest]); const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - hasLoadedProjectFavicon(props.cacheKey) ? "loaded" : "loading", + props.faviconUrl && loadedFaviconUrls.has(props.faviconUrl) ? "loaded" : "loading", ); - const requestIsActive = faviconRequest !== null && activeFaviconRequest === faviconRequest; - const showImage = requestIsActive && status === "loaded"; + const showImage = props.faviconUrl !== null && status === "loaded"; return ( { - if (!markProjectFaviconLoaded(faviconRequest)) return; + if (props.faviconUrl) loadedFaviconUrls.add(props.faviconUrl); setStatus("loaded"); }} - onError={() => { - if (!markProjectFaviconFailed(faviconRequest)) return; - setStatus("error"); - }} + onError={() => setStatus("error")} /> ) : null} diff --git a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx index 01440007bc6..916802e9faf 100644 --- a/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx +++ b/apps/mobile/src/features/archive/ArchivedThreadsScreen.tsx @@ -29,10 +29,7 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; -import { - createNativeMailSearchToolbarItem, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, -} from "../layout/native-mail-search-toolbar"; +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; import type { ArchivedThreadGroup, ArchivedThreadSortOrder } from "./archivedThreadList"; export interface ArchivedThreadsHeaderEnvironment { @@ -73,8 +70,7 @@ function ArchivedThreadsHeader(props: { const searchIconColor = useThemeColor("--color-icon"); const searchTextColor = useThemeColor("--color-foreground"); const usesNativeChrome = Platform.OS === "ios"; - const usesCompactMailToolbar = - Platform.OS === "ios" && width < 700 && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const usesCompactMailToolbar = Platform.OS === "ios" && width < 700; const androidFilterActions = useMemo( () => [ { @@ -276,11 +272,7 @@ function ArchivedThreadsHeader(props: { ...(usesNativeChrome ? { allowToolbarIntegration: true, - // "integratedButton" is an iOS 26 search-bar placement; - // pre-glass iOS keeps the default pull-down placement. - ...(NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED - ? { placement: "integratedButton" as const } - : null), + placement: "integratedButton" as const, } : { placement: "stacked" as const, diff --git a/apps/mobile/src/features/board/BoardScreen.tsx b/apps/mobile/src/features/board/BoardScreen.tsx index 8c73177dd07..34b845506ab 100644 --- a/apps/mobile/src/features/board/BoardScreen.tsx +++ b/apps/mobile/src/features/board/BoardScreen.tsx @@ -27,6 +27,7 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { EmptyState } from "../../components/EmptyState"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { SymbolView } from "../../components/AppSymbol"; +import { ThreadIdentityLeading } from "../identity/ParticipantStack"; import { relativeTime } from "../../lib/time"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; @@ -173,9 +174,19 @@ function BoardCard(props: { /> ) : null} - - {props.thread.title} - + + + + {props.thread.title} + + {subtitleParts.length > 0 ? ( {subtitleParts.join(" · ")} diff --git a/apps/mobile/src/features/cloud/linkEnvironment.ts b/apps/mobile/src/features/cloud/linkEnvironment.ts index 958827ee492..5d619c688f1 100644 --- a/apps/mobile/src/features/cloud/linkEnvironment.ts +++ b/apps/mobile/src/features/cloud/linkEnvironment.ts @@ -134,14 +134,7 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayEnvironmentLinkProofInvalidError": return `Relay rejected the environment link proof (${error.reason}).`; case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; + return "Relay rejected the environment connection request."; case "RelayEnvironmentEndpointUnavailableError": return `Relay could not reach the environment endpoint (${error.reason}).`; case "RelayEnvironmentEndpointTimedOutError": diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 19392768479..18b6c71a293 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -1,32 +1,11 @@ import { describe, expect, it } from "vite-plus/test"; import { - buildPairingUrl, extractPairingUrlFromQrPayload, PairingQrPayloadEmptyError, parsePairingUrl, } from "./pairing"; -describe("buildPairingUrl", () => { - it("uses HTTP for a schemeless IP address", () => { - expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( - "http://192.168.1.100:3773/#token=pairing-token", - ); - }); - - it("keeps HTTPS as the default for a schemeless hostname", () => { - expect(buildPairingUrl("remote.example.com", "pairing-token")).toBe( - "https://remote.example.com/#token=pairing-token", - ); - }); - - it("preserves an explicit scheme for an IP address", () => { - expect(buildPairingUrl("https://192.168.1.100:3773", "pairing-token")).toBe( - "https://192.168.1.100:3773/#token=pairing-token", - ); - }); -}); - describe("extractPairingUrlFromQrPayload", () => { it("trims raw pairing urls from qr payloads", () => { expect( diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 569d00cbdd3..910efa7f256 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -3,21 +3,6 @@ import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; -function isIpLiteral(host: string): boolean { - try { - const hostname = new URL(`http://${host}`).hostname.replace(/^\[|\]$/g, ""); - if (hostname.includes(":")) return true; - - const octets = hostname.split("."); - return ( - octets.length === 4 && - octets.every((octet) => /^\d{1,3}$/.test(octet) && Number(octet) <= 255) - ); - } catch { - return false; - } -} - export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( "PairingQrPayloadEmptyError", {}, @@ -34,7 +19,7 @@ export function buildPairingUrl(host: string, code: string): string { if (!c) return h; try { - const url = new URL(h.includes("://") ? h : `${isIpLiteral(h) ? "http" : "https"}://${h}`); + const url = new URL(h.includes("://") ? h : `https://${h}`); url.hash = new URLSearchParams([["token", c]]).toString(); return url.toString(); } catch { diff --git a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx index 7f5105aac17..012f99536d2 100644 --- a/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx +++ b/apps/mobile/src/features/files/ThreadFilesRouteScreen.tsx @@ -29,10 +29,7 @@ import { useAdaptiveWorkspacePaneRole, useRegisterWorkspaceInspector, } from "../layout/AdaptiveWorkspaceLayout"; -import { - createNativeMailSearchToolbarItem, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, -} from "../layout/native-mail-search-toolbar"; +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; import { ReviewHighlighterProvider } from "../review/ReviewHighlighterProvider"; import { ThreadRouteScreen } from "../threads/ThreadRouteScreen"; @@ -357,8 +354,7 @@ export function ThreadFilesTreeScreen(props: ThreadFilesRouteScreenProps) { ); } - const usesCompactMailToolbar = - Platform.OS === "ios" && !layout.usesSplitView && NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED; + const usesCompactMailToolbar = Platform.OS === "ios" && !layout.usesSplitView; return ( <> diff --git a/apps/mobile/src/features/home/HomeHeader.tsx b/apps/mobile/src/features/home/HomeHeader.tsx index f5a8e549ece..5181bea1cc5 100644 --- a/apps/mobile/src/features/home/HomeHeader.tsx +++ b/apps/mobile/src/features/home/HomeHeader.tsx @@ -1,6 +1,5 @@ import type { EnvironmentId, SidebarThreadSortOrder } from "@t3tools/contracts"; import type { MenuAction } from "@react-native-menu/menu"; -import Constants from "expo-constants"; import { NativeHeaderToolbar, NativeStackScreenOptions, @@ -14,16 +13,11 @@ import { useSafeAreaInsets } from "react-native-safe-area-context"; import { ControlPillMenu } from "../../components/ControlPill"; import { SymbolView } from "../../components/AppSymbol"; import { T3Wordmark } from "../../components/T3Wordmark"; -import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; -import { resolveMobileStageLabel } from "../../lib/mobileBranding"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { useThemeColor } from "../../lib/useThemeColor"; import { useHardwareKeyboardCommand } from "../keyboard/hardwareKeyboardCommands"; import { withNativeGlassHeaderItem } from "../layout/native-glass-header-items"; -import { - createNativeMailSearchToolbarItem, - NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED, -} from "../layout/native-mail-search-toolbar"; +import { createNativeMailSearchToolbarItem } from "../layout/native-mail-search-toolbar"; import type { HomeProjectSortOrder } from "./homeThreadList"; import { buildHomeListFilterMenu, @@ -32,6 +26,7 @@ import { } from "./home-list-filter-menu"; import { hasCustomHomeListOptions, + type OwnershipFilter, PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS, } from "./home-list-options"; @@ -60,6 +55,7 @@ export function HomeHeader(props: { readonly threadGrouping: HomeThreadGrouping; readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; + readonly ownershipFilter: OwnershipFilter; /** * Hide settled from the main Threads inbox. Recency/none default on; * project grouping defaults off at the call site. @@ -73,6 +69,7 @@ export function HomeHeader(props: { readonly onClearEnvironments: () => void; readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; + readonly onOwnershipFilterChange: (filter: OwnershipFilter) => void; readonly onHideSettledThreadsChange: (hide: boolean) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; @@ -109,6 +106,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { const alternateModes = otherHomeListModes(props.listMode); const hasCustomListOptions = props.selectedEnvironmentIds.length > 0 || + props.ownershipFilter !== "any" || props.selectedProjectKey !== null || (props.listMode === "threads" && props.hideSettledThreads !== defaultHideSettledForGrouping(props.threadGrouping)) || @@ -116,12 +114,14 @@ function AndroidHomeHeader(props: HomeHeaderProps) { (listOrganization && hasCustomHomeListOptions({ selectedEnvironmentIds: props.selectedEnvironmentIds, + ownershipFilter: props.ownershipFilter, listMode: props.listMode, threadGrouping: props.threadGrouping, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, selectedProjectKey: props.selectedProjectKey, - })); const menuActions = useMemo( + })); + const menuActions = useMemo( () => [ { id: "environment", @@ -141,6 +141,18 @@ function AndroidHomeHeader(props: HomeHeaderProps) { })), ], }, + { + id: "ownership", + title: "Ownership", + subactions: [ + { id: "ownership:any", title: "Anyone" }, + { id: "ownership:mine", title: "Mine" }, + { id: "ownership:theirs", title: "Theirs" }, + ].map((action) => ({ + ...action, + state: checkedMenuState(action.id === `ownership:${props.ownershipFilter}`), + })), + }, ...(props.projects.length === 0 || props.listMode === "board" ? [] : ([ @@ -207,6 +219,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { props.environments, props.hideSettledThreads, props.listMode, + props.ownershipFilter, props.projectSortOrder, props.projects, props.selectedEnvironmentIds, @@ -234,6 +247,14 @@ function AndroidHomeHeader(props: HomeHeaderProps) { return; } + if (id.startsWith("ownership:")) { + const ownership = id.slice("ownership:".length); + if (ownership === "any" || ownership === "mine" || ownership === "theirs") { + props.onOwnershipFilterChange(ownership); + } + return; + } + if (id.startsWith("project:")) { const projectKey = id.slice("project:".length); if (props.projects.some((project) => project.key === projectKey)) { @@ -276,9 +297,8 @@ function AndroidHomeHeader(props: HomeHeaderProps) { <> @@ -291,7 +311,7 @@ function AndroidHomeHeader(props: HomeHeaderProps) { - {stageLabel} + Alpha @@ -397,6 +417,7 @@ function IosHomeHeader(props: HomeHeaderProps) { const useSolidBoardHeader = isBoardMode && NATIVE_LIQUID_GLASS_SUPPORTED; const hasCustomListOptions = props.selectedEnvironmentIds.length > 0 || + props.ownershipFilter !== "any" || props.selectedProjectKey !== null || (props.listMode === "threads" && props.hideSettledThreads !== defaultHideSettledForGrouping(props.threadGrouping)) || @@ -404,6 +425,7 @@ function IosHomeHeader(props: HomeHeaderProps) { (listOrganization && hasCustomHomeListOptions({ selectedEnvironmentIds: props.selectedEnvironmentIds, + ownershipFilter: props.ownershipFilter, listMode: props.listMode, threadGrouping: props.threadGrouping, projectSortOrder: props.projectSortOrder, @@ -420,11 +442,13 @@ function IosHomeHeader(props: HomeHeaderProps) { projects: props.projects, selectedEnvironmentIds: props.selectedEnvironmentIds, selectedProjectKey: props.selectedProjectKey, + ownershipFilter: props.ownershipFilter, projectSortOrder: props.projectSortOrder, threadSortOrder: props.threadSortOrder, onClearEnvironments: props.onClearEnvironments, onToggleEnvironment: props.onToggleEnvironment, onProjectChange: props.onProjectChange, + onOwnershipFilterChange: props.onOwnershipFilterChange, onProjectSortOrderChange: props.onProjectSortOrderChange, onThreadSortOrderChange: props.onThreadSortOrderChange, listOrganization, @@ -493,7 +517,8 @@ function IosHomeHeader(props: HomeHeaderProps) { // Board has no thread search — hide the bottom mail search toolbar. unstable_headerToolbarItems: Platform.OS === "ios" && !isBoardMode - ? () => [ createNativeMailSearchToolbarItem({ + ? () => [ + createNativeMailSearchToolbarItem({ composeButtonId: "home-new-task", composeSystemImageName: "square.and.pencil", filterMenu, @@ -506,14 +531,14 @@ function IosHomeHeader(props: HomeHeaderProps) { placeholder: "Search", searchTextChangeId: "home-search-text", }), - ], - } - : { - // Pre-Liquid-Glass iOS: standard pull-down search in the nav - // bar; create + sort live in the plain bottom toolbar below. - headerSearchBarOptions: { + ] + : undefined, + headerSearchBarOptions: + Platform.OS === "ios" + ? undefined + : { ref: searchBarRef, - autoCapitalize: "none" as const, + allowToolbarIntegration: true, hideNavigationBar: false, placeholder: "Search", onCancelButtonPress: () => { @@ -523,7 +548,6 @@ function IosHomeHeader(props: HomeHeaderProps) { props.onSearchQueryChange(event.nativeEvent.text); }, }, - }), }} /> @@ -547,7 +571,8 @@ function IosHomeHeader(props: HomeHeaderProps) { )} - {Platform.OS === "ios" ? null : ( + {Platform.OS === "ios" ? null : ( + - ) : null} - + ) : null} + + + + { - void checkForAppUpdateOnLaunch(); - }, []); - const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = useThreadListActions(); const pendingTasks = usePendingNewTasks(); @@ -72,12 +70,31 @@ export function HomeRouteScreen() { options: listOptions, toggleSelectedEnvironmentId, clearSelectedEnvironments, + setOwnershipFilter, setListMode, setThreadGrouping, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); const selectedEnvironmentIds = listOptions.selectedEnvironmentIds; + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); + const ownershipFilteredThreads = useMemo( + () => + threads.filter((thread) => + threadMatchesMine({ + claimPersonId: claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + thread.environmentId, + ), + originPersonId: thread.originSource?.personId ?? null, + participantPersonIds: (thread.participantSummaries ?? []).map( + (participant) => participant.personId, + ), + mode: listOptions.ownershipFilter, + }), + ), + [claimPersonIdByEnvironment, listOptions.ownershipFilter, threads], + ); const preferencesResult = useAtomValue(mobilePreferencesAtom); const savePreferences = useAtomSet(updateMobilePreferencesAtom); // Recency/none default to hide settled; project grouping defaults to show. @@ -126,9 +143,7 @@ export function HomeRouteScreen() { if (layout.usesSplitView) { return ( <> - [] }} - /> + navigation.navigate("NewTaskSheet", { screen: "NewTask" })} > <> - {/* Title is owned by HomeHeader (tracks list mode). */} navigation.navigate("SettingsSheet", { screen: "Settings" })} onProjectSortOrderChange={setProjectSortOrder} @@ -230,7 +248,7 @@ export function HomeRouteScreen() { searchQuery={searchQuery} selectedEnvironmentIds={selectedEnvironmentIds} selectedProjectKey={selectedProjectKey} - threads={threads} + threads={ownershipFilteredThreads} threadSortOrder={listOptions.threadSortOrder} /> diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 16d274dfc8d..ed393fc02e3 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -132,7 +132,6 @@ interface HomeScreenProps { /* ─── Layout constants ───────────────────────────────────────────────── */ const ESTIMATED_THREAD_ROW_HEIGHT = 72; -const PRE_LIQUID_GLASS_BOTTOM_TOOLBAR_HEIGHT = 44; /** * Top spacing between the list and the Android custom header. The Android * header (AndroidHomeHeader) is rendered in-flow above this screen and @@ -234,7 +233,8 @@ export function HomeScreen(props: HomeScreenProps) { } const selected = new Set(props.selectedEnvironmentIds); return connectedIds.filter((environmentId) => selected.has(environmentId)); - }, [props.environments, props.selectedEnvironmentIds]); const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); + }, [props.environments, props.selectedEnvironmentIds]); + const threadSearch = useThreadSearch(searchEnvironmentIds, props.searchQuery); const threadSearchMatchByKey = useMemo(() => { const matches = new Map(); for (const match of threadSearch.matches) { @@ -1057,7 +1057,7 @@ export function HomeScreen(props: HomeScreenProps) { @@ -1261,7 +1261,7 @@ export function HomeScreen(props: HomeScreenProps) { contentContainerStyle={{ paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 96 + iosBottomToolbarClearance + ? Math.max(insets.bottom, 24) + 96 : Math.max(insets.bottom, 16) + 88, }} /> @@ -1321,19 +1321,16 @@ export function HomeScreen(props: HomeScreenProps) { scrollEventThrottle={16} contentContainerStyle={{ // Android reserves room for the floating new-task FAB - // (56 button + 16 gap + bottom inset). Pre-glass iOS shows a - // standard 44pt bottom toolbar that overlays the list and is not - // reflected in insets while contentInsetAdjustmentBehavior is - // "never". + // (56 button + 16 gap + bottom inset). paddingBottom: Platform.OS === "ios" - ? Math.max(insets.bottom, 24) + 24 + iosBottomToolbarClearance + ? Math.max(insets.bottom, 24) + 24 : Math.max(insets.bottom, 16) + 88, }} scrollIndicatorInsets={ Platform.OS === "ios" ? { - bottom: Math.max(insets.bottom, 16) + 24 + iosBottomToolbarClearance, + bottom: Math.max(insets.bottom, 16) + 24, top: 0, } : undefined 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 f5f860e9951..ee14ad15663 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 @@ -13,11 +13,13 @@ describe("buildHomeListFilterMenu", () => { ], selectedEnvironmentIds: [], selectedProjectKey: "environment-1:project-1", + ownershipFilter: "any", projectSortOrder: "updated_at", threadSortOrder: "updated_at", onClearEnvironments: vi.fn(), onToggleEnvironment: vi.fn(), onProjectChange, + onOwnershipFilterChange: vi.fn(), onProjectSortOrderChange: vi.fn(), onThreadSortOrderChange: vi.fn(), }); @@ -53,11 +55,13 @@ describe("buildHomeListFilterMenu", () => { projects: [], selectedEnvironmentIds: ["env-1" as never], selectedProjectKey: null, + ownershipFilter: "any", projectSortOrder: "updated_at", threadSortOrder: "updated_at", onClearEnvironments, onToggleEnvironment, onProjectChange: vi.fn(), + onOwnershipFilterChange: vi.fn(), onProjectSortOrderChange: vi.fn(), onThreadSortOrderChange: vi.fn(), }); @@ -79,4 +83,38 @@ describe("buildHomeListFilterMenu", () => { expect(onClearEnvironments).toHaveBeenCalledOnce(); expect(onToggleEnvironment).toHaveBeenCalledWith("env-2"); }); + + it("offers Anyone, Mine, and Theirs ownership filters", () => { + const onOwnershipFilterChange = vi.fn(); + const menu = buildHomeListFilterMenu({ + environments: [], + projects: [], + selectedEnvironmentIds: [], + selectedProjectKey: null, + ownershipFilter: "mine", + projectSortOrder: "updated_at", + threadSortOrder: "updated_at", + onClearEnvironments: vi.fn(), + onToggleEnvironment: vi.fn(), + onProjectChange: vi.fn(), + onOwnershipFilterChange, + onProjectSortOrderChange: vi.fn(), + onThreadSortOrderChange: vi.fn(), + }); + + const ownershipMenu = menu.items.find( + (item) => item.type === "submenu" && item.title === "Ownership", + ); + expect(ownershipMenu).toMatchObject({ + type: "submenu", + items: [ + { title: "Anyone", state: "off" }, + { title: "Mine", state: "on" }, + { title: "Theirs", state: "off" }, + ], + }); + if (ownershipMenu?.type !== "submenu") throw new Error("Expected ownership submenu"); + ownershipMenu.items[2]?.onPress(); + expect(onOwnershipFilterChange).toHaveBeenCalledWith("theirs"); + }); }); 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 8ae4699281c..ca0c6d9f92a 100644 --- a/apps/mobile/src/features/home/home-list-filter-menu.ts +++ b/apps/mobile/src/features/home/home-list-filter-menu.ts @@ -8,6 +8,7 @@ import { } from "./homeListMode"; import type { HomeProjectSortOrder } from "./homeThreadList"; import { PROJECT_SORT_OPTIONS, THREAD_SORT_OPTIONS } from "./home-list-options"; +import type { OwnershipFilter } from "./home-list-options"; export interface HomeListFilterMenuEnvironment { readonly environmentId: EnvironmentId; @@ -43,11 +44,13 @@ export function buildHomeListFilterMenu(props: { readonly projects: ReadonlyArray; readonly selectedEnvironmentIds: readonly EnvironmentId[]; readonly selectedProjectKey: string | null; + readonly ownershipFilter: OwnershipFilter; readonly projectSortOrder: HomeProjectSortOrder; readonly threadSortOrder: SidebarThreadSortOrder; readonly onClearEnvironments: () => void; readonly onToggleEnvironment: (environmentId: EnvironmentId) => void; readonly onProjectChange: (projectKey: string | null) => void; + readonly onOwnershipFilterChange: (filter: OwnershipFilter) => void; readonly onProjectSortOrderChange: (sortOrder: HomeProjectSortOrder) => void; readonly onThreadSortOrderChange: (sortOrder: SidebarThreadSortOrder) => void; /** @@ -93,6 +96,21 @@ export function buildHomeListFilterMenu(props: { ], }); + items.push({ + type: "submenu", + title: "Ownership", + items: [ + { value: "any", label: "Anyone" }, + { value: "mine", label: "Mine" }, + { value: "theirs", label: "Theirs" }, + ].map((option) => ({ + type: "action" as const, + title: option.label, + state: props.ownershipFilter === option.value ? ("on" as const) : ("off" as const), + onPress: () => props.onOwnershipFilterChange(option.value as OwnershipFilter), + })), + }); + if (props.showProjectFilter !== false && props.projects.length > 0) { items.push({ type: "submenu", 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 71a7f8f71f3..e594c4735c3 100644 --- a/apps/mobile/src/features/home/home-list-options.test.ts +++ b/apps/mobile/src/features/home/home-list-options.test.ts @@ -8,6 +8,7 @@ import { hasCustomHomeListOptions, type HomeListOptions } from "./home-list-opti const defaults: HomeListOptions = { selectedEnvironmentIds: [], + ownershipFilter: "any", listMode: "threads", threadGrouping: "project", projectSortOrder: @@ -34,6 +35,11 @@ describe("home list options", () => { ).toBe(true); }); + it("marks ownership filters as customized", () => { + expect(hasCustomHomeListOptions({ ...defaults, ownershipFilter: "mine" })).toBe(true); + expect(hasCustomHomeListOptions({ ...defaults, ownershipFilter: "theirs" })).toBe(true); + }); + it("marks non-default thread grouping as customized", () => { expect(hasCustomHomeListOptions({ ...defaults, threadGrouping: "recency" })).toBe(true); expect(hasCustomHomeListOptions({ ...defaults, threadGrouping: "none" })).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 ca56b5e4735..99d4e94da98 100644 --- a/apps/mobile/src/features/home/home-list-options.ts +++ b/apps/mobile/src/features/home/home-list-options.ts @@ -37,6 +37,7 @@ export interface HomeListOptions { * the provider is given a storage callback. */ readonly selectedEnvironmentIds: readonly EnvironmentId[]; + readonly ownershipFilter: OwnershipFilter; readonly listMode: HomeListMode; /** Organization of the Threads list (ignored on Board). */ readonly threadGrouping: HomeThreadGrouping; @@ -44,6 +45,8 @@ export interface HomeListOptions { readonly threadSortOrder: SidebarThreadSortOrder; } +export type OwnershipFilter = "any" | "mine" | "theirs"; + export interface ResolvedHomeListOptions extends HomeListOptions { readonly projectGroupingMode: SidebarProjectGroupingMode; } @@ -73,6 +76,7 @@ export const THREAD_SORT_OPTIONS: ReadonlyArray<{ function defaultHomeListOptions(): HomeListOptions { return { selectedEnvironmentIds: [], + ownershipFilter: "any", listMode: DEFAULT_HOME_LIST_MODE, threadGrouping: DEFAULT_HOME_THREAD_GROUPING, projectSortOrder: @@ -191,6 +195,7 @@ export function hasCustomHomeListOptions( : DEFAULT_SIDEBAR_PROJECT_SORT_ORDER; return ( options.selectedEnvironmentIds.length > 0 || + options.ownershipFilter !== "any" || (options.selectedProjectKey !== null && options.selectedProjectKey !== undefined) || options.threadGrouping !== DEFAULT_HOME_THREAD_GROUPING || options.projectSortOrder !== defaultProjectSortOrder || @@ -240,6 +245,12 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet { + setOptions((current) => ({ ...current, ownershipFilter: value })); + }, + [setOptions], + ); const setThreadGrouping = useCallback( (value: HomeThreadGrouping) => { setOptions((current) => ({ ...current, threadGrouping: value })); @@ -263,6 +274,7 @@ export function useHomeListOptions(availableEnvironmentIds: ReadonlySet + + {model.initials} + + + ); +} diff --git a/apps/mobile/src/features/identity/IdentityClaimGate.test.ts b/apps/mobile/src/features/identity/IdentityClaimGate.test.ts new file mode 100644 index 00000000000..00e91298c95 --- /dev/null +++ b/apps/mobile/src/features/identity/IdentityClaimGate.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { resolveIdentityClaimCandidate } from "./identityClaimCandidate"; + +describe("resolveIdentityClaimCandidate", () => { + const candidates = [{ environmentId: "first" }, { environmentId: "second" }]; + + it("advances through candidates without wrapping after the final candidate", () => { + expect(resolveIdentityClaimCandidate(candidates, 0)).toBe(candidates[0]); + expect(resolveIdentityClaimCandidate(candidates, 1)).toBe(candidates[1]); + expect(resolveIdentityClaimCandidate(candidates, 2)).toBeUndefined(); + }); +}); diff --git a/apps/mobile/src/features/identity/IdentityClaimGate.tsx b/apps/mobile/src/features/identity/IdentityClaimGate.tsx new file mode 100644 index 00000000000..7b861120890 --- /dev/null +++ b/apps/mobile/src/features/identity/IdentityClaimGate.tsx @@ -0,0 +1,234 @@ +import { + filterPeopleForTypeahead, + identityClaimRequired, +} from "@t3tools/client-runtime/state/identity"; +import { IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, IdentityUsername } from "@t3tools/contracts"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { ActivityIndicator, Modal, Pressable, TextInput, View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { useEnvironments, type EnvironmentPresentation } from "../../state/environments"; +import { identityEnvironment } from "../../state/identity"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { resolveIdentityClaimCandidate } from "./identityClaimCandidate"; + +/** + * Claim gate for multi-env mobile (e.g. local + t3vm). + * + * - Only **connected** environments are probed (never while reconnecting — + * identity RPCs on a flapping link heat the device and leave a Modal that + * steals all touches). + * - Secondary remotes are ordered before primary so t3vm (map on) wins over + * smart/desktop primary (map off). + * - At most one Modal is shown. + */ +export function IdentityClaimGate() { + const { environments } = useEnvironments(); + const candidates = useMemo(() => { + const connected = environments.filter( + (environment) => environment.connection.phase === "connected", + ); + const secondary = connected.filter( + (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget", + ); + const primary = connected.filter( + (environment) => environment.entry.target._tag === "PrimaryConnectionTarget", + ); + return [...secondary, ...primary]; + }, [environments]); + + const [activeIndex, setActiveIndex] = useState(0); + const candidateKey = candidates.map((environment) => environment.environmentId).join("\0"); + + // A changed connection set starts a fresh probe. Reaching the end of an unchanged + // set stays exhausted instead of wrapping to zero and probing forever. + useEffect(() => { + setActiveIndex(0); + }, [candidateKey]); + + const onSkip = useCallback(() => { + setActiveIndex((index) => index + 1); + }, []); + + const environment = resolveIdentityClaimCandidate(candidates, activeIndex); + if (environment === undefined) { + return null; + } + + // Probe candidates in order: each reports whether it needs a claim; we advance + // past envs that do not (map off / already claimed) without showing a Modal. + return ( + + ); +} + +function IdentityClaimGateBody(props: { + readonly environment: EnvironmentPresentation; + readonly onSkip: () => void; +}) { + const environmentId = props.environment.environmentId; + const target = useMemo(() => ({ environmentId, input: {} as const }), [environmentId]); + const snapshotQuery = useEnvironmentQuery(identityEnvironment.snapshot(target)); + const claimQuery = useEnvironmentQuery(identityEnvironment.sessionClaim(target)); + const claimCommand = useAtomCommand(identityEnvironment.claim, { + label: "identity-claim", + reportFailure: true, + }); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + const didSkipRef = useRef(false); + + const needsClaim = identityClaimRequired(snapshotQuery.data, claimQuery.data); + + // Finished loading and this env does not need a claim → try next. + useEffect(() => { + if (snapshotQuery.isPending || claimQuery.isPending) { + return; + } + // Snapshot failed or map disabled / already claimed. + if ( + snapshotQuery.error !== null || + snapshotQuery.data === null || + !snapshotQuery.data.enabled || + !needsClaim + ) { + if (didSkipRef.current) { + return; + } + didSkipRef.current = true; + props.onSkip(); + } + }, [ + claimQuery.isPending, + needsClaim, + props.onSkip, + snapshotQuery.data, + snapshotQuery.error, + snapshotQuery.isPending, + ]); + + const suggestions = useMemo(() => { + if (!snapshotQuery.data) return []; + return filterPeopleForTypeahead( + snapshotQuery.data.people, + query, + IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, + ); + }, [query, snapshotQuery.data]); + + // No modal while loading, erroring, or not required — avoids a blank + // touch-stealing overlay on reconnect. + if (snapshotQuery.isPending || claimQuery.isPending) { + return null; + } + if (!needsClaim || !snapshotQuery.data?.enabled) { + return null; + } + + const submit = async (username: string) => { + const normalized = username.trim().toLowerCase(); + const exact = snapshotQuery.data?.people.find((person) => person.username === normalized); + if (!exact) { + setError("Pick a username from the server map."); + return; + } + setSubmitting(true); + setError(null); + try { + await claimCommand({ + environmentId, + input: { + username: IdentityUsername.make(exact.username), + method: "typeahead", + }, + }); + claimQuery.refresh(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Claim failed"); + } finally { + setSubmitting(false); + } + }; + + const envLabel = props.environment.label.trim() || "this environment"; + + return ( + { + // Required claim — keep the modal. + }} + > + + + + Shared environment · {envLabel} + + Who are you? + + {envLabel} uses a closed identity map. Type at least{" "} + {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS} characters, then choose a map match. + + { + setQuery(value); + setError(null); + }} + onSubmitEditing={() => void submit(query)} + className="mt-4 rounded-lg border border-border bg-background px-3 py-2.5 text-base text-foreground" + testID="identity-claim-input" + /> + {suggestions.length > 0 ? ( + + {suggestions.map((person) => ( + { + setQuery(person.username); + void submit(person.username); + }} + className="border-b border-border px-3 py-2.5 active:bg-subtle" + > + {person.username} + {person.name ? ( + {person.name} + ) : null} + + ))} + + ) : null} + {error ? {error} : null} + void submit(query)} + className="mt-4 items-center rounded-lg bg-primary px-4 py-2.5" + > + {submitting ? ( + + ) : ( + Save identity + )} + + + + + ); +} diff --git a/apps/mobile/src/features/identity/ParticipantStack.tsx b/apps/mobile/src/features/identity/ParticipantStack.tsx new file mode 100644 index 00000000000..eda60df41f6 --- /dev/null +++ b/apps/mobile/src/features/identity/ParticipantStack.tsx @@ -0,0 +1,132 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + claimPersonIdForEnvironment, + isClaimedNonStarterParticipant, +} from "@t3tools/client-runtime/state/identity"; +import type { ThreadParticipantSummary } from "@t3tools/contracts"; +import { View } from "react-native"; + +import { AppText as Text } from "../../components/AppText"; +import { cn } from "../../lib/cn"; +import { identityClaimPersonIdByEnvironmentAtom } from "../../state/identity"; +import { IdentityAvatar } from "./IdentityAvatar"; + +/** + * Creator micro-avatar + +N for thread list rows (mirrors web ParticipantStack). + * Long-press / accessibility label carries the expanded roster; dense RN lists + * skip hover popovers. + */ +export function ParticipantStack(props: { + readonly environmentId: string; + readonly participants: ReadonlyArray; + readonly className?: string; +}) { + const people = props.participants; + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); + const claimPersonId = claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + props.environmentId, + ); + const youParticipated = isClaimedNonStarterParticipant({ + claimPersonId, + participants: people, + }); + if (people.length === 0) return null; + + const lead = people[0]!; + const extras = people.slice(1); + const label = + extras.length === 0 + ? `Started by ${lead.username}` + : `Started by ${lead.username}, ${extras.length} other participant${extras.length === 1 ? "" : "s"}`; + const accessibleLabel = youParticipated ? `${label}. You participated` : label; + + return ( + + + {extras.length > 0 ? ( + + + +{extras.length} + + + ) : null} + {youParticipated ? ( + + + + ) : null} + + ); +} + +export function SourceChannelGlyph(props: { + readonly channel: string | null | undefined; + readonly className?: string; +}) { + if (!props.channel) return null; + const short = + props.channel === "desktop" + ? "D" + : props.channel === "web" + ? "W" + : props.channel === "mobile" + ? "M" + : props.channel === "discord" + ? "Δ" + : props.channel === "jira" + ? "J" + : props.channel === "github" + ? "G" + : props.channel.slice(0, 1).toUpperCase(); + return ( + + {short} + + ); +} + +/** Leading channel glyph + participant stack for a thread shell row. */ +export function ThreadIdentityLeading(props: { + readonly environmentId: string; + readonly originChannel?: string | null | undefined; + readonly participants?: ReadonlyArray | null | undefined; + readonly className?: string; +}) { + const participants = props.participants ?? []; + const channel = props.originChannel ?? participants[0]?.firstChannel ?? null; + if (!channel && participants.length === 0) return null; + return ( + + + + + ); +} diff --git a/apps/mobile/src/features/identity/identityClaimCandidate.ts b/apps/mobile/src/features/identity/identityClaimCandidate.ts new file mode 100644 index 00000000000..15fc4fc986a --- /dev/null +++ b/apps/mobile/src/features/identity/identityClaimCandidate.ts @@ -0,0 +1,6 @@ +export function resolveIdentityClaimCandidate( + candidates: readonly T[], + activeIndex: number, +): T | undefined { + return candidates[activeIndex]; +} diff --git a/apps/mobile/src/features/identity/ownershipFilterSurface.test.ts b/apps/mobile/src/features/identity/ownershipFilterSurface.test.ts new file mode 100644 index 00000000000..4bbbffca6ce --- /dev/null +++ b/apps/mobile/src/features/identity/ownershipFilterSurface.test.ts @@ -0,0 +1,21 @@ +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +function readSource(relativePath: string): string { + return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +describe("mobile ownership filter surface", () => { + it("keeps ownership controls wired in both phone and sidebar thread lists", () => { + const homeHeader = readSource("../home/HomeHeader.tsx"); + const homeRoute = readSource("../home/HomeRouteScreen.tsx"); + const sidebar = readSource("../threads/ThreadNavigationSidebar.tsx"); + + expect(homeHeader).toContain('title: "Ownership"'); + expect(homeHeader).toContain("onOwnershipFilterChange"); + expect(homeRoute).toContain("ownershipFilteredThreads"); + expect(sidebar).toContain("threadMatchesMine"); + expect(sidebar).toContain("ownershipFilter: options.ownershipFilter"); + }); +}); diff --git a/apps/mobile/src/features/identity/participantStackSurface.test.ts b/apps/mobile/src/features/identity/participantStackSurface.test.ts new file mode 100644 index 00000000000..ef64d87a877 --- /dev/null +++ b/apps/mobile/src/features/identity/participantStackSurface.test.ts @@ -0,0 +1,23 @@ +import * as NodeFS from "node:fs"; +import * as NodeURL from "node:url"; +import { describe, expect, it } from "vite-plus/test"; + +function readSource(relativePath: string): string { + return NodeFS.readFileSync(NodeURL.fileURLToPath(new URL(relativePath, import.meta.url)), "utf8"); +} + +describe("mobile participation indicator surface", () => { + it("keeps the claimed-participant marker wired across mobile thread lists", () => { + const stack = readSource("./ParticipantStack.tsx"); + const listV1 = readSource("../threads/thread-list-items.tsx"); + const listV2 = readSource("../threads/thread-list-v2-items.tsx"); + const board = readSource("../board/BoardScreen.tsx"); + + expect(stack).toContain('testID="you-participated-indicator"'); + expect(stack).toContain("isClaimedNonStarterParticipant"); + expect(stack).toContain("You participated"); + expect(listV1).toContain("environmentId={thread.environmentId}"); + expect(listV2).toContain("environmentId={thread.environmentId}"); + expect(board).toContain("environmentId={props.thread.environmentId}"); + }); +}); diff --git a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts index 8770d96b124..820e1222243 100644 --- a/apps/mobile/src/features/layout/native-mail-search-toolbar.ts +++ b/apps/mobile/src/features/layout/native-mail-search-toolbar.ts @@ -1,16 +1,5 @@ import type { HeaderBarButtonMailSearchToolbarItem } from "react-native-screens"; -import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; - -/** - * The patched mail-style toolbar is built natively from iOS 26 Liquid Glass - * UIKit (`UIGlassEffect`) with no earlier fallback: pre-26 the native side - * silently drops the item and hides the navigation toolbar entirely. Screens - * that send it must fall back to standard search/toolbar primitives when this - * is false. - */ -export const NATIVE_MAIL_SEARCH_TOOLBAR_SUPPORTED = NATIVE_LIQUID_GLASS_SUPPORTED; - type NativeMailSearchToolbarInput = Omit< HeaderBarButtonMailSearchToolbarItem, "type" | "useFallbackSearchField" diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index 49adfe75cb2..f354bcd29ac 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -9,10 +9,19 @@ import { SymbolView } from "../../components/AppSymbol"; import * as Effect from "effect/Effect"; import { AsyncResult } from "effect/unstable/reactivity"; import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; -import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; +import { + ActivityIndicator, + Alert, + Linking, + Platform, + Pressable, + ScrollView, + View, +} from "react-native"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { + type AtomCommandResult, isAtomCommandInterrupted, reportAtomCommandResult, settleAsyncResult, @@ -38,11 +47,6 @@ import { runtime } from "../../lib/runtime"; import { useThemeColor } from "../../lib/useThemeColor"; import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; -import { - type AppUpdateCheckState, - registerHiddenUpdateTap, - runAppUpdateCheck, -} from "../updates/app-updates"; import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; @@ -574,11 +578,12 @@ function BetaSettingsSection() { ); } +type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; + function AppSettingsSection() { const icon = useThemeColor("--color-icon"); - const [updateState, setUpdateState] = useState("idle"); + const [updateState, setUpdateState] = useState("idle"); const updateInFlight = useRef(false); - const hiddenUpdateTapCount = useRef(0); const version = Constants.expoConfig?.version ?? "0.0.0"; // Fall back to "production" to match resolveAppVariant in app.config.ts, so a @@ -586,11 +591,22 @@ function AppSettingsSection() { const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; const variantLabel = variant === "production" ? "" : capitalize(variant); const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; + // Which JS is actually running: the bundle shipped in the binary, or an OTA + // update downloaded on top of it. Surfacing this makes "am I even on the + // right build?" answerable at a glance. + const bundleLabel = Updates.isEnabled + ? Updates.isEmbeddedLaunch + ? "Embedded" + : Updates.updateId + ? `OTA ${Updates.updateId.slice(0, 7)}` + : null + : null; + const busy = updateState === "checking" || updateState === "downloading" || updateState === "restarting"; // "Up to date" is a transient acknowledgement, not a state worth persisting — - // return the version row to its normal, deliberately quiet state. + // drop back to the bundle label so the row keeps answering "what am I running?". useEffect(() => { if (updateState !== "current") return; const timer = setTimeout(() => setUpdateState("idle"), 3000); @@ -603,24 +619,12 @@ function AppSettingsSection() { if (updateInFlight.current) return; updateInFlight.current = true; try { - await runAppUpdateCheck({ - onFailure: (message) => Alert.alert("Update failed", message), - onStateChange: setUpdateState, - }); + await runUpdateCheck(setUpdateState); } finally { updateInFlight.current = false; } }, []); - const handleVersionPress = useCallback(() => { - if (!Updates.isEnabled || updateInFlight.current) return; - const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current); - hiddenUpdateTapCount.current = tap.nextCount; - if (tap.shouldCheck) { - void checkForUpdate(); - } - }, [checkForUpdate]); - const statusLabel = updateState === "checking" ? "Checking…" @@ -630,7 +634,7 @@ function AppSettingsSection() { ? "Restarting…" : updateState === "current" ? "Up to date" - : null; + : bundleLabel; const versionRow = ( @@ -648,6 +652,21 @@ function AppSettingsSection() { {statusLabel} ) : null} + {Updates.isEnabled ? ( + + {busy ? ( + + ) : ( + + )} + + ) : null} ); @@ -657,10 +676,10 @@ function AppSettingsSection() { {Updates.isEnabled ? ( void checkForUpdate()} > {versionRow} @@ -671,6 +690,52 @@ function AppSettingsSection() { ); } +async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise { + setUpdateState("checking"); + const check = await settlePromise(() => Updates.checkForUpdateAsync()); + if (check._tag === "Failure") { + reportUpdateFailure(check, "Could not check for updates."); + setUpdateState("idle"); + return; + } + // A rollback directive (`eas update:rollback`) arrives as isAvailable: false + // with isRollBackToEmbedded: true — there is nothing newer to install, but the + // running OTA still has to be dropped for the embedded bundle. + if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) { + setUpdateState("current"); + return; + } + + setUpdateState("downloading"); + const fetched = await settlePromise(() => Updates.fetchUpdateAsync()); + if (fetched._tag === "Failure") { + reportUpdateFailure(fetched, "Could not download the update."); + setUpdateState("idle"); + return; + } + // isNew is always false for a rollback, so it can't be the sole gate here either. + if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) { + setUpdateState("current"); + return; + } + + setUpdateState("restarting"); + // reloadAsync never resolves on success — the JS context is torn down — so + // reaching the failure branch below is the only way this returns. + const reloaded = await settlePromise(() => Updates.reloadAsync()); + if (reloaded._tag === "Failure") { + reportUpdateFailure(reloaded, "Downloaded, but could not restart the app."); + setUpdateState("idle"); + } +} + +function reportUpdateFailure(result: AtomCommandResult, fallback: string): void { + reportAtomCommandResult(result, { label: "app update check" }); + if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; + const error = squashAtomCommandFailure(result); + Alert.alert("Update failed", error instanceof Error ? error.message : fallback); +} + function capitalize(value: string): string { return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; } diff --git a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx index ea40b30daec..aeedb4d59f9 100644 --- a/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx +++ b/apps/mobile/src/features/threads/NewTaskDraftScreen.tsx @@ -44,8 +44,7 @@ import { type ComposerDraft, type ComposerDraftWorkspaceSelection, } from "../../state/use-composer-drafts"; -import { useEnvironmentServerConfig, useProjects } from "../../state/entities"; -import { resolveSelectableModelSelection } from "../../lib/modelOptions"; +import { useProjects } from "../../state/entities"; import { deriveThreadTitleFromPrompt } from "../../lib/projectThreadStartTurn"; import { armAgentAwarenessLiveActivityForLocalWork } from "../agent-awareness/remoteRegistration"; import { enqueueThreadOutboxMessage, removeThreadOutboxMessage } from "../../state/thread-outbox"; @@ -94,9 +93,6 @@ export function NewTaskDraftScreen(props: { const controlsBottomPadding = isKeyboardVisible ? 8 : Math.max(insets.bottom, 10); const { logicalProjects, selectedProject, setProject } = flow; const { connectedEnvironments } = useRemoteConnectionStatus(); - const selectedEnvironmentServerConfig = useEnvironmentServerConfig( - selectedProject?.environmentId ?? null, - ); const environmentConnected = selectedProject !== null && connectedEnvironments.find( @@ -829,14 +825,7 @@ export function NewTaskDraftScreen(props: { return; } const draft = getComposerDraftSnapshot(draftKey); - // Snapshot read keeps just-typed selector state; the availability gate - // still applies so a stored selection on a disabled provider falls back - // to the flow's resolved model. - const modelSelection = - resolveSelectableModelSelection( - selectedEnvironmentServerConfig, - draft.modelSelection ?? null, - ) ?? flow.selectedModel; + const modelSelection = draft.modelSelection ?? flow.selectedModel; const workspaceMode = draft.workspaceSelection?.mode ?? flow.workspaceMode; const selectedBranchName = draft.workspaceSelection?.branch ?? flow.selectedBranchName; const selectedWorktreePath = @@ -888,10 +877,7 @@ export function NewTaskDraftScreen(props: { if (editingPendingTask) { flow.finishEditingPendingTask(); } else { - // Drop the workspace selection with the content: the next task should - // re-resolve mode/branch/origin from the server's configured defaults - // instead of resurrecting this task's picks. - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + clearComposerDraftContent(draftKey); } navigation.getParent()?.goBack(); return; @@ -949,7 +935,7 @@ export function NewTaskDraftScreen(props: { } flow.finishEditingPendingTask(); } else { - clearComposerDraftContent(draftKey, { clearWorkspaceSelection: true }); + clearComposerDraftContent(draftKey); } navigation.dispatch( StackActions.replace("Thread", { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 9bddf3002bf..d28f671ea7a 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -3,6 +3,10 @@ import type { EnvironmentProject, EnvironmentThreadShell, } from "@t3tools/client-runtime/state/shell"; +import { + claimPersonIdForEnvironment, + threadMatchesMine, +} from "@t3tools/client-runtime/state/identity"; import { threadSearchMatchKey, type EnvironmentThreadSearchMatch, @@ -30,6 +34,7 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; import { scopedProjectKey, scopedThreadKey } from "../../lib/scopedEntities"; import { useThemeColor } from "../../lib/useThemeColor"; import { useProjects, useThreadShells } from "../../state/entities"; +import { identityClaimPersonIdByEnvironmentAtom } from "../../state/identity"; import { resolveHideSettledOnProjects, resolveHideSettledOnRecent, @@ -235,11 +240,13 @@ function ThreadNavigationSidebarPane( options, toggleSelectedEnvironmentId, clearSelectedEnvironments, + setOwnershipFilter, setListMode, setThreadGrouping, setProjectSortOrder, setThreadSortOrder, } = useHomeListOptions(availableEnvironmentIds); + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); const searchEnvironmentIds = useMemo(() => { const connectedIds = workspaceEnvironments .filter((environment) => environment.connectionState === "connected") @@ -362,12 +369,23 @@ function ThreadNavigationSidebarPane( ); const scopedThreads = useMemo( () => - selectedProjectRefs === null - ? threads - : threads.filter((thread) => - selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId)), - ), - [selectedProjectRefs, threads], + threads.filter( + (thread) => + (selectedProjectRefs === null || + selectedProjectRefs.has(scopedProjectKey(thread.environmentId, thread.projectId))) && + threadMatchesMine({ + claimPersonId: claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + thread.environmentId, + ), + originPersonId: thread.originSource?.personId ?? null, + participantPersonIds: (thread.participantSummaries ?? []).map( + (participant) => participant.personId, + ), + mode: options.ownershipFilter, + }), + ), + [claimPersonIdByEnvironment, options.ownershipFilter, selectedProjectRefs, threads], ); const scopedPendingTasks = useMemo( () => @@ -711,7 +729,7 @@ function ThreadNavigationSidebarPane( // Always partition settled into the slim tail (web V2 / classic Recent // shelf). Hide-settled must not erase that history on mobile. return buildThreadListV2Items({ - threads: threads.filter((thread) => thread.archivedAt === null), + threads: scopedThreads.filter((thread) => thread.archivedAt === null), selectedEnvironmentIds: options.selectedEnvironmentIds, projectRefs: selectedProjectScope === null ? null : selectedProjectScope.projectRefs, searchQuery: props.searchQuery, @@ -733,7 +751,7 @@ function ThreadNavigationSidebarPane( settlementEnvironmentIds, snoozeEnvironmentIds, threadListV2Enabled, - threads, + scopedThreads, selectedProjectScope, ]); // Re-partition the moment the earliest snooze expires (clamped to the @@ -1302,6 +1320,7 @@ function ThreadNavigationSidebarPane( // light the "customized" state (sort options are hidden). const filterCustomized = options.selectedEnvironmentIds.length > 0 || + options.ownershipFilter !== "any" || selectedProjectKey !== null || options.threadGrouping !== "project" || (options.listMode === "threads" && @@ -1317,11 +1336,13 @@ function ThreadNavigationSidebarPane( projects: projectFilterOptions, selectedEnvironmentIds: options.selectedEnvironmentIds, selectedProjectKey, + ownershipFilter: options.ownershipFilter, projectSortOrder: options.projectSortOrder, threadSortOrder: options.threadSortOrder, onClearEnvironments: clearSelectedEnvironments, onToggleEnvironment: toggleSelectedEnvironmentId, onProjectChange: setSelectedProjectKey, + onOwnershipFilterChange: setOwnershipFilter, onProjectSortOrderChange: setProjectSortOrder, onThreadSortOrderChange: setThreadSortOrder, listOrganization, @@ -1341,6 +1362,7 @@ function ThreadNavigationSidebarPane( hideSettledThreads, listOrganization, options.listMode, + options.ownershipFilter, options.projectSortOrder, options.selectedEnvironmentIds, options.threadGrouping, @@ -1348,6 +1370,7 @@ function ThreadNavigationSidebarPane( projectFilterOptions, selectedProjectKey, setHideSettledThreads, + setOwnershipFilter, setProjectSortOrder, setThreadGrouping, setThreadSortOrder, diff --git a/apps/mobile/src/features/threads/new-task-flow-provider.tsx b/apps/mobile/src/features/threads/new-task-flow-provider.tsx index 8d4ce7a7fed..74fe2f4852a 100644 --- a/apps/mobile/src/features/threads/new-task-flow-provider.tsx +++ b/apps/mobile/src/features/threads/new-task-flow-provider.tsx @@ -22,11 +22,7 @@ import { useEnvironmentServerConfig, useProjects, useThreadShells } from "../../ import type { TurnCommandMetadata } from "../../lib/commandMetadata"; import type { DraftComposerImageAttachment } from "../../lib/composerImages"; import type { ModelOption, ProviderGroup } from "../../lib/modelOptions"; -import { - buildModelOptions, - groupByProvider, - resolveSelectableModelSelection, -} from "../../lib/modelOptions"; +import { buildModelOptions, groupByProvider } from "../../lib/modelOptions"; import { groupProjectsByRepository } from "../../lib/repositoryGroups"; import { scopedProjectKey } from "../../lib/scopedEntities"; import { appAtomRegistry } from "../../state/atom-registry"; @@ -351,11 +347,7 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const selectedProjectDraft = useComposerDraft(selectedProjectDraftKey); const prompt = selectedProjectDraft.text; const attachments = selectedProjectDraft.attachments; - // The server's configured default decides the mode until the user picks one - // explicitly — same resolution web uses for new draft threads. - const defaultWorkspaceMode: WorkspaceMode = - selectedEnvironmentServerConfig?.settings.defaultThreadEnvMode ?? "local"; - const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? defaultWorkspaceMode; + const workspaceMode = selectedProjectDraft.workspaceSelection?.mode ?? "local"; const selectedBranchName = selectedProjectDraft.workspaceSelection?.branch ?? null; const selectedWorktreePath = selectedProjectDraft.workspaceSelection?.worktreePath ?? null; // Keep the user's explicit choice separate from the resolved display value: @@ -369,29 +361,22 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { const runtimeMode = selectedProjectDraft.runtimeMode ?? DEFAULT_RUNTIME_MODE; const interactionMode = selectedProjectDraft.interactionMode ?? DEFAULT_PROVIDER_INTERACTION_MODE; - // Stored selections (draft and project default) only count while their - // provider is usable on the server; otherwise the server's default model - // wins instead of silently targeting a disabled provider. - const draftModelSelection = resolveSelectableModelSelection( - selectedEnvironmentServerConfig, - selectedProjectDraft.modelSelection ?? null, - ); - const projectDefaultModelSelection = resolveSelectableModelSelection( - selectedEnvironmentServerConfig, - selectedProject?.defaultModelSelection ?? null, - ); const modelOptions = useMemo( () => buildModelOptions( selectedEnvironmentServerConfig, - draftModelSelection ?? projectDefaultModelSelection, + selectedProjectDraft.modelSelection ?? selectedProject?.defaultModelSelection ?? null, ), - [selectedEnvironmentServerConfig, draftModelSelection, projectDefaultModelSelection], + [ + selectedEnvironmentServerConfig, + selectedProject?.defaultModelSelection, + selectedProjectDraft.modelSelection, + ], ); const selectedModel = - draftModelSelection ?? - projectDefaultModelSelection ?? + selectedProjectDraft.modelSelection ?? + selectedProject?.defaultModelSelection ?? modelOptions.find((option) => option.isDefault)?.selection ?? modelOptions[0]?.selection ?? null; @@ -690,20 +675,12 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { } const draft = getComposerDraftSnapshot(selectedProjectDraftKey); const text = draft.text.trim(); - // Same availability gate the composer display applies: a stored - // selection targeting a disabled provider must not ride into the queue. - const draftModelSelection = - resolveSelectableModelSelection( - selectedEnvironmentServerConfig, - draft.modelSelection ?? null, - ) ?? selectedModel; + const draftModelSelection = draft.modelSelection ?? selectedModel; if (text.length === 0 || !draftModelSelection) { return null; } const workspaceSelection = draft.workspaceSelection; - // Fall back to the resolved mode (server default) so queued tasks drain - // with the same mode the composer displayed. - const mode = workspaceSelection?.mode ?? workspaceMode; + const mode = workspaceSelection?.mode ?? "local"; // When the selection is the stand-in built from the queued snapshot, // persist the original (possibly absent) snapshot values — the // stand-in's placeholder title/workspaceRoot must never be written back @@ -745,12 +722,10 @@ export function NewTaskFlowProvider(props: React.PropsWithChildren) { [ editingPendingProject, editingPendingTask, - selectedEnvironmentServerConfig, selectedModel, selectedProject, selectedProjectDraftKey, startFromOrigin, - workspaceMode, ], ); diff --git a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx index 4be4089a54d..f0e3f89c07d 100644 --- a/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx +++ b/apps/mobile/src/features/threads/sidebar-navigation-shell.tsx @@ -11,7 +11,6 @@ import { import type { ReactNode } from "react"; import { Platform, useColorScheme } from "react-native"; -import { getCompactBrandHeaderOptions } from "../../components/CompactBrandTitle"; import { NATIVE_LIQUID_GLASS_SUPPORTED } from "../../native/native-glass"; import { nativeHeaderScrollEdgeEffects } from "../../native/StackHeader"; @@ -36,9 +35,10 @@ const SIDEBAR_SCREEN_OPTIONS: SidebarScreenOptions = { headerShadowVisible: false, headerShown: true, headerStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? { backgroundColor: "transparent" } : undefined, - ...getCompactBrandHeaderOptions({ fontSize: 18, fontWeight: "800" }), + headerTitleStyle: { fontSize: 18, fontWeight: "800" }, headerTransparent: NATIVE_LIQUID_GLASS_SUPPORTED, scrollEdgeEffects: NATIVE_LIQUID_GLASS_SUPPORTED ? SCROLL_EDGE_EFFECTS : undefined, + title: "Threads", unstable_navigationItemStyle: NATIVE_LIQUID_GLASS_SUPPORTED ? "editor" : undefined, }; diff --git a/apps/mobile/src/features/threads/thread-list-items.tsx b/apps/mobile/src/features/threads/thread-list-items.tsx index 63ed299eeed..4ddbab08684 100644 --- a/apps/mobile/src/features/threads/thread-list-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-items.tsx @@ -16,7 +16,6 @@ import { ControlPillMenu } from "../../components/ControlPill"; import { ProjectFavicon } from "../../components/ProjectFavicon"; import { ProviderUsageIcon } from "../../components/ProviderUsageIcon"; import { cn } from "../../lib/cn"; -import { HOME_HORIZONTAL_INSET } from "../../lib/layoutMetrics"; import { relativeTime } from "../../lib/time"; import { useThemeColor } from "../../lib/useThemeColor"; import { useEnvironmentServerConfig } from "../../state/entities"; @@ -28,6 +27,7 @@ import { useThreadPr, type ThreadPr } from "../../state/use-thread-pr"; import { composerDraftsAtom, hasComposerDraftMessage } from "../../state/use-composer-drafts"; import type { HomeGroupDisplayAction } from "../home/homeListItems"; import { ThreadSwipeable } from "../home/thread-swipe-actions"; +import { ThreadIdentityLeading } from "../identity/ParticipantStack"; import { resolveThreadStatus } from "./threadPresentation"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; import { @@ -46,7 +46,7 @@ import { useAtomValue } from "@effect/atom-react"; export type ThreadListVariant = "compact" | "sidebar"; /** Left inset that aligns compact secondary rows with the title column. */ -export const THREAD_LIST_COMPACT_INSET = HOME_HORIZONTAL_INSET; +export const THREAD_LIST_COMPACT_INSET = 20; const SIDEBAR_ROW_RADIUS = 12; function pullRequestTintColor( @@ -694,6 +694,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { marker={showUsageDot ? (threadUsage?.marker ?? null) : null} /> ) : null} + {thread.title} @@ -765,6 +770,11 @@ export const ThreadListRow = memo(function ThreadListRow(props: { marker={showUsageDot ? (threadUsage?.marker ?? null) : null} /> ) : null} + + + 0 && - !thread.title.toLocaleLowerCase().includes(query) && + !threadMatchesAttributeQuery( + { + title: thread.title, + branch: thread.branch, + originSource: thread.originSource ?? null, + participantSummaries: thread.participantSummaries ?? [], + }, + query, + ) && input.matchedThreadKeys?.has( threadSearchMatchKey({ environmentId: thread.environmentId, diff --git a/apps/mobile/src/lib/composerImages.ts b/apps/mobile/src/lib/composerImages.ts index f559545c04e..5c79b5b5eb8 100644 --- a/apps/mobile/src/lib/composerImages.ts +++ b/apps/mobile/src/lib/composerImages.ts @@ -65,6 +65,14 @@ export async function pickComposerImages(input: { readonly existingCount: number }; } + const permission = await imagePicker.requestMediaLibraryPermissionsAsync(); + if (!permission.granted) { + return { + images: [], + error: "Allow photo library access to attach images.", + }; + } + const result = await imagePicker.launchImageLibraryAsync({ mediaTypes: ["images"], allowsMultipleSelection: true, diff --git a/apps/mobile/src/lib/modelOptions.test.ts b/apps/mobile/src/lib/modelOptions.test.ts index f9e1e25787a..9a71640b45a 100644 --- a/apps/mobile/src/lib/modelOptions.test.ts +++ b/apps/mobile/src/lib/modelOptions.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vite-plus/test"; import { ProviderInstanceId, type ServerConfig } from "@t3tools/contracts"; -import { buildModelOptions, resolveSelectableModelSelection } from "./modelOptions"; +import { buildModelOptions } from "./modelOptions"; describe("mobile model options", () => { it("normalizes a legacy fallback selection against current capabilities", () => { @@ -49,46 +49,4 @@ describe("mobile model options", () => { expect(option?.capabilities?.optionDescriptors?.[0]?.id).toBe("serviceTier"); expect(option?.selection.options).toEqual([{ id: "serviceTier", value: "default" }]); }); - - it("rejects stored selections whose provider is not usable", () => { - const config = { - providers: [ - { - instanceId: "codex", - driver: "codex", - enabled: true, - installed: true, - auth: { status: "authenticated" }, - models: [], - }, - { - instanceId: "claudeAgent", - driver: "claudeAgent", - enabled: false, - installed: true, - auth: { status: "authenticated" }, - models: [], - }, - ], - } as unknown as ServerConfig; - - const usable = { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.6-sol", - }; - const disabled = { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-sonnet-5", - }; - const removed = { - instanceId: ProviderInstanceId.make("codex_personal"), - model: "gpt-5.6-sol", - }; - - expect(resolveSelectableModelSelection(config, usable)).toBe(usable); - expect(resolveSelectableModelSelection(config, disabled)).toBeNull(); - expect(resolveSelectableModelSelection(config, removed)).toBeNull(); - // No config (environment offline) — nothing to validate against. - expect(resolveSelectableModelSelection(null, disabled)).toBe(disabled); - }); }); diff --git a/apps/mobile/src/lib/modelOptions.ts b/apps/mobile/src/lib/modelOptions.ts index b51fa915dea..ab859c73b46 100644 --- a/apps/mobile/src/lib/modelOptions.ts +++ b/apps/mobile/src/lib/modelOptions.ts @@ -58,31 +58,6 @@ function normalizeSelectionOptions( }; } -/** - * A stored model selection is only usable when its provider instance is - * currently enabled, installed, and authenticated on the server. Returns the - * selection unchanged when usable, otherwise `null` so callers fall through to - * the server's default model. A missing config (environment offline) cannot be - * validated, so stored selections pass through untouched. - */ -export function resolveSelectableModelSelection( - config: T3ServerConfig | null | undefined, - selection: ModelSelection | null, -): ModelSelection | null { - if (!selection || !config) { - return selection; - } - const provider = config.providers.find( - (candidate) => candidate.instanceId === selection.instanceId, - ); - return provider && - provider.enabled && - provider.installed && - provider.auth.status !== "unauthenticated" - ? selection - : null; -} - export function buildModelOptions( config: T3ServerConfig | null | undefined, fallbackModelSelection: ModelSelection | null, diff --git a/apps/mobile/src/native/StackHeader.tsx b/apps/mobile/src/native/StackHeader.tsx index a524d515247..78c87119512 100644 --- a/apps/mobile/src/native/StackHeader.tsx +++ b/apps/mobile/src/native/StackHeader.tsx @@ -340,8 +340,7 @@ function convertToolbarChild(child: ReactNode): NativeStackHeaderItem | null { return { type: "spacing", spacing: typeof child.props.width === "number" ? child.props.width : 8, - flexible: Boolean(child.props.flexible), - } as NativeStackHeaderItem; + }; } return null; @@ -352,11 +351,6 @@ function collectToolbarItems(children: ReactNode): NativeStackHeaderItem[] { Children.forEach(children, (child) => { const item = convertToolbarChild(child); if (item) { - if (item.type === "spacing") { - // Native inserts spacing items at `index`, treating a missing index - // as 0 — which would move the spacer in front of earlier siblings. - (item as { index?: number }).index = items.length; - } items.push(item); } }); @@ -370,8 +364,7 @@ function NativeHeaderToolbarRoot(props: { const navigation = useNativeStackNavigation(); const items = useMemo(() => collectToolbarItems(props.children), [props.children]); - // Swap toolbar owners before paint so split and compact headers cannot clear each other. - useLayoutEffect(() => { + useEffect(() => { if (!navigation) { return; } @@ -447,7 +440,6 @@ function NativeHeaderToolbarLabel(_props: { readonly children?: ReactNode }) { NativeHeaderToolbarLabel.displayName = "NativeHeaderToolbarLabel"; function NativeHeaderToolbarSpacer(_props: { - readonly flexible?: boolean; readonly sharesBackground?: boolean; readonly width?: number; }) { diff --git a/apps/mobile/src/native/T3ComposerEditor.native.tsx b/apps/mobile/src/native/T3ComposerEditor.native.tsx index e78f90a7db9..84d1c22084f 100644 --- a/apps/mobile/src/native/T3ComposerEditor.native.tsx +++ b/apps/mobile/src/native/T3ComposerEditor.native.tsx @@ -1,6 +1,5 @@ import { collectComposerInlineTokens } from "@t3tools/shared/composerInlineTokens"; import { requireNativeView } from "expo"; -import { TextInputWrapper } from "expo-paste-input"; import { useCallback, useEffect, @@ -10,13 +9,12 @@ import { useState, type Ref, } from "react"; -import type { NativeSyntheticEvent, ViewProps } from "react-native"; +import type { NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle } from "react-native"; import { Image, StyleSheet } from "react-native"; import { markdownFileIconSource } from "@t3tools/mobile-markdown-text/file-icons"; import { resolveMarkdownFileIcon } from "@t3tools/mobile-markdown-text/links"; import { MOBILE_TYPOGRAPHY } from "../lib/typography"; -import { useNativePaste } from "../lib/useNativePaste"; import { useFontFamily } from "../lib/useFontFamily"; import { useThemeColor } from "../lib/useThemeColor"; import { @@ -119,7 +117,6 @@ export function ComposerEditor({ const skillBorder = useThemeColor("--color-inline-skill-border"); const skillText = useThemeColor("--color-inline-skill-foreground"); const fileTint = useThemeColor("--color-icon-muted"); - const handlePaste = useNativePaste((uris) => onPasteImages?.(uris)); useImperativeHandle( ref, @@ -224,63 +221,61 @@ export function ComposerEditor({ const resolvedTextStyle = StyleSheet.flatten(textStyle) ?? {}; const regularFontFamily = useFontFamily("regular"); return ( - - { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - onChangeText(event.nativeEvent.value); - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); - }} - onComposerSelectionChange={(event) => { - const acknowledgedEventCount = acceptNativeEvent( - event.nativeEvent.eventCount, - event.nativeEvent.value, - event.nativeEvent.selection, - ); - if (acknowledgedEventCount === false) return; - onSelectionChange?.(event.nativeEvent.selection); - setMostRecentEventCount(acknowledgedEventCount); - setNativeEventSequence((sequence) => sequence + 1); - }} - onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} - onComposerFocus={onFocus} - onComposerBlur={onBlur} - /> - + } + onComposerChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onChangeText(event.nativeEvent.value); + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerSelectionChange={(event) => { + const acknowledgedEventCount = acceptNativeEvent( + event.nativeEvent.eventCount, + event.nativeEvent.value, + event.nativeEvent.selection, + ); + if (acknowledgedEventCount === false) return; + onSelectionChange?.(event.nativeEvent.selection); + setMostRecentEventCount(acknowledgedEventCount); + setNativeEventSequence((sequence) => sequence + 1); + }} + onComposerPasteImages={(event) => onPasteImages?.(event.nativeEvent.uris)} + onComposerFocus={onFocus} + onComposerBlur={onBlur} + /> ); } diff --git a/apps/mobile/src/state/identity.ts b/apps/mobile/src/state/identity.ts new file mode 100644 index 00000000000..09cde6ff885 --- /dev/null +++ b/apps/mobile/src/state/identity.ts @@ -0,0 +1,28 @@ +import { createIdentityEnvironmentAtoms } from "@t3tools/client-runtime/state/identity"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const identityEnvironment = createIdentityEnvironmentAtoms(connectionAtomRuntime); + +const EMPTY_CLAIM_INPUT = {} as const; + +/** Session claim personId keyed by the thread's environment for ownership filters. */ +export const identityClaimPersonIdByEnvironmentAtom = Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + const out = new Map(); + for (const environmentId of catalog.entries.keys()) { + const result = get( + identityEnvironment.sessionClaim({ + environmentId: environmentId as EnvironmentId, + input: EMPTY_CLAIM_INPUT, + }), + ); + const claimResult = Option.getOrNull(AsyncResult.value(result)); + out.set(environmentId, claimResult?.claim?.personId ?? null); + } + return out; +}).pipe(Atom.withLabel("mobile-identity-claim-person-by-environment")); diff --git a/apps/mobile/src/state/use-composer-drafts.test.ts b/apps/mobile/src/state/use-composer-drafts.test.ts index fed97e81e08..fdabe67bc71 100644 --- a/apps/mobile/src/state/use-composer-drafts.test.ts +++ b/apps/mobile/src/state/use-composer-drafts.test.ts @@ -119,36 +119,6 @@ describe("mobile composer drafts", () => { }); }); - it("drops the workspace selection when clearing a sent new-task draft", () => { - const draftKey = "new-task:environment-1:project-1"; - const draft: ComposerDraft = { - text: "send this", - attachments: [], - modelSelection: { - instanceId: ProviderInstanceId.make("codex"), - model: "gpt-5.4", - }, - workspaceSelection: { - mode: "worktree", - branch: "main", - worktreePath: null, - startFromOrigin: false, - }, - }; - - expect( - clearComposerDraftContentState({ [draftKey]: draft }, draftKey, { - clearWorkspaceSelection: true, - }), - ).toEqual({ - [draftKey]: { - modelSelection: draft.modelSelection, - text: "", - attachments: [], - }, - }); - }); - it("reads the latest selector state synchronously for send", () => { const draftKey = "environment-1:thread-1"; const selectedDraft: ComposerDraft = { diff --git a/apps/mobile/src/state/use-composer-drafts.ts b/apps/mobile/src/state/use-composer-drafts.ts index 4c22f577184..185804680cf 100644 --- a/apps/mobile/src/state/use-composer-drafts.ts +++ b/apps/mobile/src/state/use-composer-drafts.ts @@ -381,18 +381,14 @@ export function updateComposerDraftSettings( export function clearComposerDraftContentState( current: Record, draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, ): Record { const existing = current[draftKey]; if (!existing) { return current; } - const { importedShareIds: _importedShareIds, workspaceSelection, ...retained } = existing; + const { importedShareIds: _importedShareIds, ...retained } = existing; const draft = { ...retained, - ...(options?.clearWorkspaceSelection || workspaceSelection === undefined - ? {} - : { workspaceSelection }), text: "", attachments: [], }; @@ -539,11 +535,8 @@ export async function restoreComposerDraftSnapshot( await persistenceQueue.run(() => writePersistedComposerDrafts(next)); } -export function clearComposerDraftContent( - draftKey: string, - options?: { readonly clearWorkspaceSelection?: boolean }, -): void { - updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey, options)); +export function clearComposerDraftContent(draftKey: string): void { + updateComposerDrafts((current) => clearComposerDraftContentState(current, draftKey)); } export function clearComposerDraft(draftKey: string): void { diff --git a/apps/server/src/assets/AssetAccess.test.ts b/apps/server/src/assets/AssetAccess.test.ts index 329a937c3b7..e52d9dd17a2 100644 --- a/apps/server/src/assets/AssetAccess.test.ts +++ b/apps/server/src/assets/AssetAccess.test.ts @@ -2,13 +2,11 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { ThreadId } from "@t3tools/contracts"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import { describe, expect, it } from "@effect/vitest"; -import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; -import * as TestClock from "effect/testing/TestClock"; import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; import * as ServerConfig from "../config.ts"; @@ -251,21 +249,12 @@ describe("AssetAccess", () => { prefix: "t3-asset-favicon-", }); const faviconPath = path.join(root, "favicon.svg"); - const initialFavicon = "a"; - const updatedFavicon = "b"; - expect(updatedFavicon).toHaveLength(initialFavicon.length); - yield* fileSystem.writeFileString(faviconPath, initialFavicon); + yield* fileSystem.writeFileString(faviconPath, ""); const canonicalFaviconPath = yield* fileSystem.realPath(faviconPath); const faviconResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, }); - expect(faviconResult.relativeUrl).toMatch(/\/v[0-9a-f]{64}-favicon\.svg$/); - expect( - yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }), - ).toEqual(faviconResult); const faviconSuffix = faviconResult.relativeUrl.slice(`${ASSET_ROUTE_PREFIX}/`.length); const faviconSeparatorIndex = faviconSuffix.indexOf("/"); expect( @@ -275,14 +264,6 @@ describe("AssetAccess", () => { ), ).toEqual({ kind: "file", path: canonicalFaviconPath }); - yield* fileSystem.writeFileString(faviconPath, updatedFavicon); - const updatedFaviconResult = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }); - expect( - updatedFaviconResult.relativeUrl.slice(updatedFaviconResult.relativeUrl.lastIndexOf("/")), - ).not.toBe(faviconResult.relativeUrl.slice(faviconResult.relativeUrl.lastIndexOf("/"))); - yield* fileSystem.remove(faviconPath); const fallbackResult = yield* issueAssetUrl({ resource: { _tag: "project-favicon", cwd: root }, @@ -299,31 +280,6 @@ describe("AssetAccess", () => { }).pipe(Effect.provide(testLayer)), ); - it.effect("buckets project favicon expiry after content hashing", () => - Effect.gen(function* () { - const crypto = yield* Crypto.Crypto; - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const root = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-asset-favicon-expiry-", - }); - yield* fileSystem.writeFileString(path.join(root, "favicon.svg"), ""); - - const bucketMs = 30 * 60 * 1000; - yield* TestClock.setTime(bucketMs - 1); - const crossingCrypto = Crypto.make({ - randomBytes: (size) => new Uint8Array(size), - digest: (algorithm, data) => - TestClock.adjust("2 millis").pipe(Effect.andThen(crypto.digest(algorithm, data))), - }); - const result = yield* issueAssetUrl({ - resource: { _tag: "project-favicon", cwd: root }, - }).pipe(Effect.provideService(Crypto.Crypto, crossingCrypto)); - - expect(result.expiresAt).toBe(3 * bucketMs); - }).pipe(Effect.provide(testLayer)), - ); - it.effect("preserves structured project favicon resolution causes", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/assets/AssetAccess.ts b/apps/server/src/assets/AssetAccess.ts index 03568a0bc09..ac48f0198c9 100644 --- a/apps/server/src/assets/AssetAccess.ts +++ b/apps/server/src/assets/AssetAccess.ts @@ -21,9 +21,7 @@ import { } from "@t3tools/shared/filePreview"; import { PROJECT_FAVICON_FALLBACK_MARKER } from "@t3tools/shared/projectFavicon"; import * as Clock from "effect/Clock"; -import * as Crypto from "effect/Crypto"; import * as Effect from "effect/Effect"; -import * as Encoding from "effect/Encoding"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Path from "effect/Path"; @@ -46,8 +44,6 @@ export const ASSET_ROUTE_PREFIX = "/api/assets"; const SIGNING_SECRET_NAME = "asset-access-signing-key"; const ASSET_TOKEN_TTL_MS = 60 * 60 * 1000; -const PROJECT_FAVICON_TOKEN_BUCKET_MS = 30 * 60 * 1000; -const PROJECT_FAVICON_VERSION_PREFIX = "v"; const PREVIEW_ASSET_EXTENSIONS = new Set([ ...WORKSPACE_BROWSER_PREVIEW_EXTENSIONS, ...WORKSPACE_IMAGE_PREVIEW_EXTENSIONS, @@ -173,7 +169,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const workspacePaths = yield* WorkspacePaths.WorkspacePaths; - let expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; + const expiresAt = (yield* Clock.currentTimeMillis) + ASSET_TOKEN_TTL_MS; let claims: AssetClaims; let fileName: string; @@ -316,18 +312,18 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i ), ); const relativePath = faviconPath ? path.relative(workspaceRoot, faviconPath) : null; - const canonicalFaviconPath = relativePath - ? yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - ) - : null; - if (relativePath && !canonicalFaviconPath) { + if ( + relativePath && + !(yield* resolveCanonicalWorkspaceFile({ workspaceRoot, relativePath }).pipe( + Effect.mapError( + (cause) => + new AssetProjectFaviconInspectionError({ + resource: input.resource, + cause, + }), + ), + )) + ) { return yield* new AssetProjectFaviconNotFoundError({ resource: input.resource, }); @@ -347,31 +343,7 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i relativePath, expiresAt, }; - if (relativePath && canonicalFaviconPath) { - const crypto = yield* Crypto.Crypto; - const faviconBytes = yield* fileSystem.readFile(canonicalFaviconPath).pipe( - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - ); - const revision = yield* crypto.digest("SHA-256", faviconBytes).pipe( - Effect.map(Encoding.encodeHex), - Effect.mapError( - (cause) => - new AssetProjectFaviconInspectionError({ - resource: input.resource, - cause, - }), - ), - ); - fileName = `${PROJECT_FAVICON_VERSION_PREFIX}${revision}-${path.basename(relativePath)}`; - } else { - fileName = PROJECT_FAVICON_FALLBACK_MARKER; - } + fileName = relativePath ? path.basename(relativePath) : PROJECT_FAVICON_FALLBACK_MARKER; break; } } @@ -386,13 +358,6 @@ export const issueAssetUrl = Effect.fn("AssetAccess.issueAssetUrl")(function* (i }), ), ); - if (claims.kind === "project-favicon") { - const issuedAt = yield* Clock.currentTimeMillis; - expiresAt = - (Math.floor(issuedAt / PROJECT_FAVICON_TOKEN_BUCKET_MS) + 2) * - PROJECT_FAVICON_TOKEN_BUCKET_MS; - claims = { ...claims, expiresAt }; - } const encodedPayload = base64UrlEncode(encodeAssetClaims(claims)); const token = `${encodedPayload}.${signPayload(encodedPayload, signingSecret)}`; return { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 228a2d2bb02..276ed32098c 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -52,12 +52,15 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverGetBackgroundPolicy]: AuthOrchestrationReadScope, [WS_METHODS.cloudGetRelayClientStatus]: AuthRelayReadScope, [WS_METHODS.cloudInstallRelayClient]: AuthRelayWriteScope, + [WS_METHODS.identityGetSnapshot]: AuthOrchestrationReadScope, + [WS_METHODS.identityGetSessionClaim]: AuthOrchestrationReadScope, + [WS_METHODS.identityClaim]: AuthOrchestrationOperateScope, + [WS_METHODS.identityClearClaim]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlLookupRepository]: AuthOrchestrationReadScope, [WS_METHODS.sourceControlCloneRepository]: AuthOrchestrationOperateScope, [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, - [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 5cb947ed87e..7a90b7fd0a9 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -42,8 +42,9 @@ import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { environmentAuthenticatedAuthLayer } from "./auth/http.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; -const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer, IdentityService.layer); class ProjectCliHttpApi extends HttpApi.make("environment").add(EnvironmentOrchestrationHttpApi) {} const connectCli = makeCli({ cloudEnabled: true }); @@ -125,6 +126,7 @@ const withLiveProjectCliServer = (baseDir: string, run: () => Effect.Ef }), ), Layer.provide(environmentAuthenticatedAuthLayer), + Layer.provide(IdentityService.layer), ); const appLayer = HttpRouter.serve(routesLayer, { disableListenLog: true, diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index c2a1a7ec8de..a0ca051ab85 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -15,8 +15,10 @@ import { sharedServerCommandFlags } from "./cli/config.ts"; import { projectCommand } from "./cli/project.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; -const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); +// Identity is residual-free and required by the server command graph type. +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer, IdentityService.layer); const connectPublicConfigMissingMessage = "T3 Connect commands are unavailable: this build is missing T3 Connect public configuration."; diff --git a/apps/server/src/github/GitHubPrBridge.ts b/apps/server/src/github/GitHubPrBridge.ts index d833653fa21..9a8f6db0e62 100644 --- a/apps/server/src/github/GitHubPrBridge.ts +++ b/apps/server/src/github/GitHubPrBridge.ts @@ -29,6 +29,8 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Semaphore from "effect/Semaphore"; +import * as IdentityService from "../identity/IdentityService.ts"; +import { buildIntegrationSourceRef } from "../identity/stampSource.ts"; import * as GitWorkflowService from "../git/GitWorkflowService.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; @@ -52,6 +54,7 @@ import { type GitHubPullRequestStackContext, stackBranchesForMatching, } from "./GitHubPullRequestStack.ts"; +import { classifyGitHubActorTrust } from "./githubActorTrust.ts"; const BUSY_RESPONSE = "This T3 thread is already working. Try again after the current turn finishes."; @@ -67,6 +70,8 @@ const PROVISION_FAILED_RESPONSE = "T3 could not open a thread for this pull request. Check the server logs for details."; const EMPTY_PROMPT_RESPONSE = "Provide a prompt after the mention. Conversation comments use the PR work thread; inline review reuses that discussion's session (first tag creates it). Override with `main-thread` or `sibling-thread`."; +const IDENTITY_DENIED_RESPONSE = + "Not authorized to run agent turns from this GitHub account. When the T3 identity map is enabled, only mapped operators (`github.login` / `github.id`) can invoke the bot — repository write access alone is not enough (including on public repos)."; const MAX_GITHUB_COMMENT_LENGTH = 65_536; const PERMISSION_RANK: Readonly> = { @@ -492,6 +497,7 @@ export const make = Effect.gen(function* () { const engine = yield* OrchestrationEngineService; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner; const providerRegistry = yield* ProviderRegistry; + const identity = yield* IdentityService.IdentityService; const crypto = yield* Crypto.Crypto; const provisionLock = yield* Semaphore.make(1); @@ -1212,6 +1218,38 @@ export const make = Effect.gen(function* () { return; } + // When the closed-set identity map is on, map membership is required in + // addition to the GitHub permission floor. Public-repo write / outside + // collaborators cannot drive the host unless listed. + const mapEnabled = yield* identity.isMapEnabled(); + const mapPeople = yield* identity.listMapPeople(); + const trust = classifyGitHubActorTrust({ + identityMapEnabled: mapEnabled, + actorId: input.invocation.actorId, + actorLogin: input.invocation.actorLogin, + people: mapPeople, + }); + if (trust.mode === "denied") { + yield* Effect.logWarning("Rejected GitHub PR invocation from unmapped identity", { + deliveryId: input.deliveryId, + repository: input.invocation.repository, + pullRequestNumber: input.invocation.pullRequestNumber, + actorId: input.invocation.actorId, + actorLogin: input.invocation.actorLogin, + reason: trust.reason, + }); + yield* finishDelivery(initial, IDENTITY_DENIED_RESPONSE, "rejected"); + return; + } + yield* Effect.logInfo("Classified GitHub actor trust", { + deliveryId: input.deliveryId, + actorLogin: input.invocation.actorLogin, + actorId: input.invocation.actorId, + mode: trust.mode, + reason: trust.reason, + personId: trust.person?.personId ?? null, + }); + const addAckReaction = input.invocation.commentSurface === "review" ? github.addReviewCommentReaction({ @@ -1357,6 +1395,20 @@ export const make = Effect.gen(function* () { const turnModelSelection = hasExplicitModelSelection ? yield* resolveGitHubModelSelection(turnInvocation, thread.modelSelection) : thread.modelSelection; + const sourcePeople = yield* identity.listMapPeople(); + const [repoOwner, repoName] = turnInvocation.repository.split("/"); + const source = buildIntegrationSourceRef({ + people: sourcePeople, + channel: "github", + platformId: String(turnInvocation.actorId), + displayName: turnInvocation.actorLogin, + location: { + owner: repoOwner ?? turnInvocation.repository, + repo: repoName ?? turnInvocation.repository, + number: turnInvocation.pullRequestNumber, + kind: "pr", + }, + }); const dispatched = yield* engine .dispatch({ type: "thread.turn.start", @@ -1376,6 +1428,7 @@ export const make = Effect.gen(function* () { titleSeed: turnInvocation.prompt.slice(0, 80) || "GitHub PR comment", runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, + source, createdAt: DateTime.formatIso(yield* DateTime.now), }) .pipe( diff --git a/apps/server/src/github/githubActorTrust.test.ts b/apps/server/src/github/githubActorTrust.test.ts new file mode 100644 index 00000000000..0f6696609e1 --- /dev/null +++ b/apps/server/src/github/githubActorTrust.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { classifyGitHubActorTrust, resolvePersonByGitHubActor } from "./githubActorTrust.ts"; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + github: { login: "patroza", id: "42661" }, + }, + { + personId: "julius", + username: "julius", + github: { login: "juliusmarminge" }, + }, +] as const; + +describe("resolvePersonByGitHubActor", () => { + it("prefers github id over login", () => { + const hit = resolvePersonByGitHubActor(people, { + actorId: 42661, + actorLogin: "someone-else", + }); + expect(hit?.person.username).toBe("patroza"); + expect(hit?.reason).toBe("mapped_github_id"); + }); + + it("falls back to login (case-insensitive)", () => { + const hit = resolvePersonByGitHubActor(people, { + actorId: 999, + actorLogin: "JuliusMarminge", + }); + expect(hit?.person.username).toBe("julius"); + expect(hit?.reason).toBe("mapped_github_login"); + }); + + it("returns null when unmapped", () => { + expect( + resolvePersonByGitHubActor(people, { actorId: 1, actorLogin: "random-user" }), + ).toBeNull(); + }); +}); + +describe("classifyGitHubActorTrust", () => { + it("allows full access when the identity map is disabled", () => { + expect( + classifyGitHubActorTrust({ + identityMapEnabled: false, + actorId: 1, + actorLogin: "stranger", + people: [], + }), + ).toEqual({ mode: "full", person: null, reason: "identity_map_disabled" }); + }); + + it("trusts mapped github accounts for full agent turns", () => { + const byId = classifyGitHubActorTrust({ + identityMapEnabled: true, + actorId: "42661", + actorLogin: "patroza", + people, + }); + expect(byId.mode).toBe("full"); + expect(byId.reason).toBe("mapped_github_id"); + expect(byId.person?.username).toBe("patroza"); + }); + + it("denies unmapped actors when the map is on (public write is not enough)", () => { + expect( + classifyGitHubActorTrust({ + identityMapEnabled: true, + actorId: 42, + actorLogin: "drive-by-collaborator", + people, + }), + ).toMatchObject({ mode: "denied", reason: "unmapped_github_actor", person: null }); + }); + + it("denies missing actor fields when the map is on", () => { + expect( + classifyGitHubActorTrust({ + identityMapEnabled: true, + actorId: null, + actorLogin: "", + people, + }), + ).toMatchObject({ mode: "denied", reason: "missing_github_actor" }); + }); +}); diff --git a/apps/server/src/github/githubActorTrust.ts b/apps/server/src/github/githubActorTrust.ts new file mode 100644 index 00000000000..9a2dd150ba1 --- /dev/null +++ b/apps/server/src/github/githubActorTrust.ts @@ -0,0 +1,80 @@ +/** + * GitHub actor trust relative to the closed-set identity map. + * + * When the map is off, collaborator permission alone gates agent turns + * (legacy behaviour). When the map is on, the actor must also resolve to a + * mapped person (github id or login) — so public-repo write access or + * outside collaborators cannot drive the host unless listed. + */ +import { + findPersonByGithubId, + findPersonByGithubLogin, + type IdentityMapPerson, +} from "@t3tools/shared/identityMap"; + +export type GitHubActorTrustMode = "full" | "denied"; + +export type GitHubActorTrustDecision = { + readonly mode: GitHubActorTrustMode; + readonly person: IdentityMapPerson | null; + readonly reason: + | "identity_map_disabled" + | "mapped_github_id" + | "mapped_github_login" + | "unmapped_github_actor" + | "missing_github_actor"; +}; + +export function resolvePersonByGitHubActor( + people: ReadonlyArray, + input: { + readonly actorId: number | string | null | undefined; + readonly actorLogin: string | null | undefined; + }, +): { + readonly person: IdentityMapPerson; + readonly reason: "mapped_github_id" | "mapped_github_login"; +} | null { + if (input.actorId !== null && input.actorId !== undefined && String(input.actorId).length > 0) { + const byId = findPersonByGithubId(people, input.actorId); + if (byId !== null) return { person: byId, reason: "mapped_github_id" }; + } + const login = input.actorLogin?.trim() ?? ""; + if (login.length > 0) { + const byLogin = findPersonByGithubLogin(people, login); + if (byLogin !== null) return { person: byLogin, reason: "mapped_github_login" }; + } + return null; +} + +/** + * Classify a GitHub mention actor for agent execution. + * + * - Map off → full (permission floor still enforced separately) + * - Map on + id/login in map → full + * - Map on + unmapped/missing → denied (no agent turn) + */ +export function classifyGitHubActorTrust(input: { + readonly identityMapEnabled: boolean; + readonly actorId: number | string | null | undefined; + readonly actorLogin: string | null | undefined; + readonly people: ReadonlyArray; +}): GitHubActorTrustDecision { + if (!input.identityMapEnabled) { + return { mode: "full", person: null, reason: "identity_map_disabled" }; + } + const login = input.actorLogin?.trim() ?? ""; + const hasId = + input.actorId !== null && input.actorId !== undefined && String(input.actorId).length > 0; + if (!hasId && login.length === 0) { + return { mode: "denied", person: null, reason: "missing_github_actor" }; + } + const hit = resolvePersonByGitHubActor(input.people, { + actorId: input.actorId, + actorLogin: input.actorLogin, + }); + if (hit === null) { + return { mode: "denied", person: null, reason: "unmapped_github_actor" }; + } + return { mode: "full", person: hit.person, reason: hit.reason }; +} diff --git a/apps/server/src/identity/IdentityService.test.ts b/apps/server/src/identity/IdentityService.test.ts new file mode 100644 index 00000000000..424298c305a --- /dev/null +++ b/apps/server/src/identity/IdentityService.test.ts @@ -0,0 +1,147 @@ +import { AuthSessionId, IdentityError, IdentityUsername } from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; + +import * as IdentityService from "./IdentityService.ts"; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + jira: { accountId: "712020:pat-account" }, + }, + { + personId: "julius", + username: "julius", + name: "Julius", + }, +] as const; + +const TestLayer = IdentityService.layerWithPeople([...people]); + +const isIdentityError = (error: unknown): error is IdentityError => + typeof error === "object" && error !== null && "_tag" in error && error._tag === "IdentityError"; + +describe("IdentityService", () => { + it.effect("snapshot is enabled when people are present", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const snapshot = yield* identity.getSnapshot(); + expect(snapshot.enabled).toBe(true); + expect(snapshot.claimRequired).toBe(true); + expect(snapshot.people.map((person) => person.username)).toEqual(["patroza", "julius"]); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("rejects unknown claim targets", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000aa"); + const result = yield* identity + .claim(sessionId, { username: IdentityUsername.make("nobody") }) + .pipe(Effect.exit); + expect(Exit.isFailure(result)).toBe(true); + if (Exit.isFailure(result)) { + const error = result.cause; + // Cause.fail path + const failures = + "failures" in error ? (error as { failures: ReadonlyArray }).failures : []; + const first = failures[0] ?? error; + // Prefer direct fail extraction via Cause.squash if available + void first; + } + const failed = yield* identity + .claim(sessionId, { username: IdentityUsername.make("nobody") }) + .pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(failed).toBe("identity_unknown_person"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("claims, gates operate, and clears", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000bb"); + + const before = yield* identity.requireOperateClaim(sessionId).pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(before).toBe("identity_claim_required"); + + const claimed = yield* identity.claim(sessionId, { + username: IdentityUsername.make("patroza"), + method: "typeahead", + }); + expect(claimed.claim.username).toBe("patroza"); + expect(claimed.claim.personId).toBe("patroza"); + + const allowed = yield* identity.requireOperateClaim(sessionId); + expect(allowed?.username).toBe("patroza"); + + const cleared = yield* identity.clearClaim(sessionId); + expect(cleared.cleared).toBe(true); + + const after = yield* identity.requireOperateClaim(sessionId).pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(after).toBe("identity_claim_required"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("allows overwrite claim (settings switch)", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000cc"); + yield* identity.claim(sessionId, { username: IdentityUsername.make("patroza") }); + const next = yield* identity.claim(sessionId, { + username: IdentityUsername.make("julius"), + method: "settings", + }); + expect(next.claim.username).toBe("julius"); + expect(next.claim.method).toBe("settings"); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("bot sessions skip the interactive operate claim gate", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000dd"); + const allowed = yield* identity.requireOperateClaim(sessionId, { + clientDeviceType: "bot", + }); + expect(allowed).toBeNull(); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("resolves mapped Jira account ids and reports map enabled", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + expect(yield* identity.isMapEnabled()).toBe(true); + const hit = yield* identity.resolveByJiraAccountId("accountid:712020:PAT-ACCOUNT"); + expect(hit?.username).toBe("patroza"); + const miss = yield* identity.resolveByJiraAccountId("712020:stranger"); + expect(miss).toBeNull(); + }).pipe(Effect.provide(TestLayer)), + ); + + it.effect("non-bot sessions still require a claim when map is enabled", () => + Effect.gen(function* () { + const identity = yield* IdentityService.IdentityService; + const sessionId = AuthSessionId.make("00000000-0000-4000-8000-0000000000ee"); + const code = yield* identity + .requireOperateClaim(sessionId, { clientDeviceType: "desktop" }) + .pipe( + Effect.map(() => null as string | null), + Effect.catch((error) => Effect.succeed(isIdentityError(error) ? error.code : "other")), + ); + expect(code).toBe("identity_claim_required"); + }).pipe(Effect.provide(TestLayer)), + ); +}); diff --git a/apps/server/src/identity/IdentityService.ts b/apps/server/src/identity/IdentityService.ts new file mode 100644 index 00000000000..d09ea392ee0 --- /dev/null +++ b/apps/server/src/identity/IdentityService.ts @@ -0,0 +1,366 @@ +// @effect-diagnostics preferSchemaOverJson:off +// @effect-diagnostics nodeBuiltinImport:off +// @effect-diagnostics globalConsole:off +/** + * Closed-set identity map + per-session claims. + * + * Map: T3_IDENTITY_MAP_PATH only (explicit). Missing/empty → feature off. + * Claims: process-local Ref (re-claim after restart). Persistence later. + * + * Layer residual is empty so it can sit on the server graph without polluting + * CLI typecheck (SqlClient / ServerConfig leakage). + * + * v1 trust: interactive claim is map membership only (trusted-team ops). + */ +import * as NodeFS from "node:fs"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import { + AuthSessionId, + type AuthClientMetadataDeviceType, + IdentityClaimInput, + IdentityError, + IdentitySessionClaimResult, + IdentitySnapshot, + IdentityUsername, + PersonId, + SessionIdentityClaim, + type SessionIdentityClaimMethod, +} from "@t3tools/contracts"; +import { + parseIdentityMapDocument, + resolvePersonByJiraAccountId, + toIdentityPersonPublic, + type IdentityMapPerson, + IdentityMapParseError, +} from "@t3tools/shared/identityMap"; +import { parse as parseYamlString } from "yaml"; + +import { + SessionIdentityClaimRepository, + layer as sessionIdentityClaimRepositoryLayer, +} from "../persistence/SessionIdentityClaims.ts"; + +type ClaimRecord = { + readonly sessionId: AuthSessionId; + readonly personId: PersonId; + readonly username: IdentityUsername; + readonly claimedAt: string; + readonly method: SessionIdentityClaimMethod; +}; + +export class IdentityService extends Context.Service< + IdentityService, + { + readonly getSnapshot: () => Effect.Effect; + /** In-process map people for platform SourceRef resolution (GitHub/Jira/Discord). */ + readonly listMapPeople: () => Effect.Effect>; + readonly getSessionClaim: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + readonly claim: ( + sessionId: AuthSessionId, + input: IdentityClaimInput, + ) => Effect.Effect<{ claim: SessionIdentityClaim }, IdentityError>; + readonly clearClaim: ( + sessionId: AuthSessionId, + ) => Effect.Effect<{ cleared: boolean }, IdentityError>; + /** + * When the identity map is enabled, interactive sessions must have claimed + * a person before orchestration operate. Returns the claim, or null when + * the map is off / the session is an integration bot. + * + * Bot sessions (`clientDeviceType: "bot"`) skip the claim gate: Discord/Jira + * share one long-lived auth session across many human senders, so a single + * session claim cannot impersonate each actor. Per-turn SourceRef stamping + * from the platform map is a separate path (not session claim). + */ + readonly requireOperateClaim: ( + sessionId: AuthSessionId, + options?: { + readonly clientDeviceType?: AuthClientMetadataDeviceType; + }, + ) => Effect.Effect; + /** + * Resolve a closed-set person from a Jira actor accountId. + * Returns null when the map is off, accountId is missing, or unmapped. + */ + readonly resolveByJiraAccountId: ( + accountId: string | null | undefined, + ) => Effect.Effect; + /** True when T3_IDENTITY_MAP_PATH loaded at least one person. */ + readonly isMapEnabled: () => Effect.Effect; + } +>()("t3/identity/IdentityService") {} + +function parseMapDocument(path: string, raw: string): ReadonlyArray { + const trimmed = raw.trim(); + if (trimmed.length === 0) return []; + let document: unknown; + if (path.endsWith(".json")) { + document = JSON.parse(trimmed) as unknown; + } else { + try { + document = JSON.parse(trimmed) as unknown; + } catch { + document = parseYamlString(trimmed) as unknown; + } + } + return parseIdentityMapDocument(document); +} + +function loadPeopleFromEnv(): ReadonlyArray { + const configured = process.env.T3_IDENTITY_MAP_PATH?.trim(); + if (configured === undefined || configured.length === 0) { + return []; + } + try { + if (!NodeFS.existsSync(configured)) { + console.error(`[identity] T3_IDENTITY_MAP_PATH not found: ${configured}`); + return []; + } + const raw = NodeFS.readFileSync(configured, "utf8"); + if (raw.trim().length === 0) { + console.error(`[identity] T3_IDENTITY_MAP_PATH is empty: ${configured}`); + return []; + } + const people = parseMapDocument(configured, raw); + if (people.length === 0) { + console.error(`[identity] T3_IDENTITY_MAP_PATH has no people: ${configured}`); + } + return people; + } catch (cause) { + const message = + cause instanceof IdentityMapParseError + ? cause.message + : cause instanceof Error + ? cause.message + : String(cause); + console.error(`[identity] failed to load map at ${configured}: ${message}`); + return []; + } +} + +function toPublicPeople(people: ReadonlyArray) { + return people.map((person) => { + const pub = toIdentityPersonPublic(person); + return { + personId: PersonId.make(pub.personId), + username: IdentityUsername.make(pub.username), + ...(pub.name !== undefined ? { name: pub.name } : {}), + links: pub.links, + }; + }); +} + +type ClaimStore = { + readonly get: (sessionId: AuthSessionId) => Effect.Effect; + readonly put: (record: ClaimRecord) => Effect.Effect; + readonly remove: (sessionId: AuthSessionId) => Effect.Effect; +}; + +function makeService( + people: ReadonlyArray, + store: ClaimStore, +): IdentityService["Service"] { + const byUsername = new Map(people.map((person) => [person.username, person] as const)); + const byPersonId = new Map(people.map((person) => [person.personId, person] as const)); + const enabled = people.length > 0; + + const toPublicClaim = (record: ClaimRecord): SessionIdentityClaim => ({ + sessionId: record.sessionId, + personId: record.personId, + username: record.username, + claimedAt: record.claimedAt, + method: record.method, + }); + + return { + getSnapshot: () => + Effect.succeed({ + enabled, + claimRequired: enabled, + people: toPublicPeople(people), + }), + + listMapPeople: () => Effect.succeed(people), + + getSessionClaim: (sessionId) => + store.get(sessionId).pipe( + Effect.map((record) => ({ + claim: record === null ? null : toPublicClaim(record), + })), + ), + + claim: (sessionId, input) => + Effect.gen(function* () { + if (!enabled) { + return yield* new IdentityError({ + code: "identity_map_disabled", + message: "Identity map is not configured; claims are disabled.", + }); + } + + const person = + "personId" in input + ? (byPersonId.get(input.personId) ?? null) + : (byUsername.get(input.username) ?? null); + if (person === null) { + return yield* new IdentityError({ + code: "identity_unknown_person", + message: "That identity is not in the server identity map.", + }); + } + + const method: SessionIdentityClaimMethod = + ("method" in input && input.method !== undefined ? input.method : undefined) ?? + "typeahead"; + const claimedAt = yield* DateTime.now.pipe(Effect.map((dt) => DateTime.formatIso(dt))); + const record: ClaimRecord = { + sessionId, + personId: PersonId.make(person.personId), + username: IdentityUsername.make(person.username), + claimedAt, + method, + }; + yield* store.put(record); + return { claim: toPublicClaim(record) }; + }), + + clearClaim: (sessionId) => store.remove(sessionId).pipe(Effect.map((cleared) => ({ cleared }))), + + requireOperateClaim: (sessionId, options) => + Effect.gen(function* () { + if (!enabled) return null; + // Integration bots: one auth session, many platform actors — not interactive claim. + if (options?.clientDeviceType === "bot") { + return null; + } + const existing = yield* store.get(sessionId); + if (existing === null) { + return yield* new IdentityError({ + code: "identity_claim_required", + message: + "Choose who you are (identity claim) before operating on this environment. Map membership only — trusted-team ops.", + }); + } + if (!byPersonId.has(existing.personId) || !byUsername.has(existing.username)) { + yield* store.remove(sessionId); + return yield* new IdentityError({ + code: "identity_unknown_person", + message: "Your identity claim is no longer in the server map. Claim again.", + }); + } + return toPublicClaim(existing); + }), + + resolveByJiraAccountId: (accountId) => + Effect.succeed(enabled ? resolvePersonByJiraAccountId(people, accountId) : null), + + isMapEnabled: () => Effect.succeed(enabled), + }; +} + +function makeMemoryStore(claimsRef: Ref.Ref>): ClaimStore { + return { + get: (sessionId) => + Ref.get(claimsRef).pipe(Effect.map((claims) => claims.get(sessionId) ?? null)), + put: (record) => + Ref.update(claimsRef, (claims) => { + const next = new Map(claims); + next.set(record.sessionId, record); + return next; + }), + remove: (sessionId) => + Ref.modify(claimsRef, (claims) => { + const had = claims.has(sessionId); + if (!had) return [false, claims] as const; + const next = new Map(claims); + next.delete(sessionId); + return [true, next] as const; + }), + }; +} + +export const make: Effect.Effect = Effect.gen(function* () { + const claimsRef = yield* Ref.make(new Map()); + const people = loadPeopleFromEnv(); + if (people.length > 0) { + yield* Effect.logInfo("Identity map loaded", { + path: process.env.T3_IDENTITY_MAP_PATH ?? "", + people: people.length, + }); + } + return makeService(people, makeMemoryStore(claimsRef)); +}); + +/** Residual-free in-memory layer (CLI / tests without SQL). */ +export const layer = Layer.effect(IdentityService, make); + +/** + * Server layer: SQLite-backed claims with an in-memory cache. + * Requires SessionIdentityClaimRepository (SqlClient residual). + */ +export const layerPersisted = Layer.effect( + IdentityService, + Effect.gen(function* () { + const claimsRef = yield* Ref.make(new Map()); + const memory = makeMemoryStore(claimsRef); + const repository = yield* SessionIdentityClaimRepository; + const people = loadPeopleFromEnv(); + if (people.length > 0) { + yield* Effect.logInfo("Identity map loaded", { + path: process.env.T3_IDENTITY_MAP_PATH ?? "", + people: people.length, + }); + } + + const store: ClaimStore = { + get: (sessionId) => + Effect.gen(function* () { + const cached = yield* memory.get(sessionId); + if (cached !== null) return cached; + const row = yield* repository + .getBySessionId(sessionId) + .pipe(Effect.catch(() => Effect.succeed(Option.none()))); + if (Option.isNone(row)) return null; + const record: ClaimRecord = { + sessionId: row.value.sessionId, + personId: row.value.personId, + username: row.value.username, + claimedAt: row.value.claimedAt, + method: row.value.method, + }; + yield* memory.put(record); + return record; + }), + put: (record) => + Effect.gen(function* () { + yield* memory.put(record); + yield* repository.upsert(record).pipe(Effect.catch(() => Effect.void)); + }), + remove: (sessionId) => + Effect.gen(function* () { + const cleared = yield* memory.remove(sessionId); + yield* repository.deleteBySessionId(sessionId).pipe(Effect.catch(() => Effect.void)); + return cleared; + }), + }; + + return makeService(people, store); + }), +).pipe(Layer.provide(sessionIdentityClaimRepositoryLayer)); + +/** Test helper: fixed people list. */ +export const layerWithPeople = (people: ReadonlyArray) => + Layer.effect( + IdentityService, + Effect.gen(function* () { + const claimsRef = yield* Ref.make(new Map()); + return makeService(people, makeMemoryStore(claimsRef)); + }), + ); diff --git a/apps/server/src/identity/stampSource.test.ts b/apps/server/src/identity/stampSource.test.ts new file mode 100644 index 00000000000..6356dc33332 --- /dev/null +++ b/apps/server/src/identity/stampSource.test.ts @@ -0,0 +1,144 @@ +import { + AuthSessionId, + CommandId, + IdentityUsername, + MessageId, + PersonId, + ThreadId, + type SessionIdentityClaim, +} from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + buildIntegrationSourceRef, + sourceRefFromOperateClaim, + stampOrchestrationCommandSource, +} from "./stampSource.ts"; + +const claim: SessionIdentityClaim = { + sessionId: AuthSessionId.make("00000000-0000-4000-8000-0000000000aa"), + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + claimedAt: "2026-01-01T00:00:00.000Z", + method: "typeahead", +}; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick", + discord: { id: "95218063095377920" }, + github: { login: "patroza", id: "42661" }, + jira: { accountId: "jira-pat", email: "p@example.com" }, + }, +] as const; + +describe("sourceRefFromOperateClaim", () => { + it("maps desktop deviceType to channel", () => { + expect(sourceRefFromOperateClaim({ claim, clientDeviceType: "desktop" })).toEqual({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + }); +}); + +describe("buildIntegrationSourceRef", () => { + it("resolves github actor to map person", () => { + const source = buildIntegrationSourceRef({ + people, + channel: "github", + platformId: "42661", + displayName: "patroza", + location: { owner: "pingdotgg", repo: "t3code", number: 1, kind: "pr" }, + }); + expect(source.channel).toBe("github"); + expect(source.personId).toBe("patroza"); + expect(source.username).toBe("patroza"); + expect(source.location?.number).toBe(1); + }); + + it("resolves discord snowflake", () => { + const source = buildIntegrationSourceRef({ + people, + channel: "discord", + platformId: "95218063095377920", + displayName: "patroza", + }); + expect(source.personId).toBe("patroza"); + expect(source.channel).toBe("discord"); + }); + + it("leaves person unset when unmapped", () => { + const source = buildIntegrationSourceRef({ + people, + channel: "jira", + platformId: "unknown-account", + displayName: "Ghost", + }); + expect(source.personId).toBeUndefined(); + expect(source.actor?.platformId).toBe("unknown-account"); + }); +}); + +describe("stampOrchestrationCommandSource", () => { + const baseCommand = { + type: "thread.turn.start" as const, + commandId: CommandId.make("00000000-0000-4000-8000-0000000000bb"), + threadId: ThreadId.make("00000000-0000-4000-8000-0000000000cc"), + message: { + messageId: MessageId.make("00000000-0000-4000-8000-0000000000dd"), + role: "user" as const, + text: "hello", + attachments: [], + }, + runtimeMode: "full-access" as const, + interactionMode: "default" as const, + createdAt: "2026-01-01T00:00:00.000Z", + }; + + it("stamps from claim for interactive clients", () => { + const stamped = stampOrchestrationCommandSource({ + claim, + clientDeviceType: "desktop", + command: baseCommand, + }); + expect(stamped.type).toBe("thread.turn.start"); + if (stamped.type === "thread.turn.start") { + expect(stamped.source).toEqual({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + } + }); + + it("stamps from discord sourceHint for bot sessions", () => { + const stamped = stampOrchestrationCommandSource({ + claim: null, + clientDeviceType: "bot", + people, + command: { + ...baseCommand, + sourceHint: { + channel: "discord", + actor: { + platformId: "95218063095377920", + displayName: "patroza", + }, + location: { + guildId: "1083767712431480922", + channelId: "1532311945326235829", + }, + }, + }, + }); + if (stamped.type === "thread.turn.start") { + expect(stamped.source?.personId).toBe("patroza"); + expect(stamped.source?.channel).toBe("discord"); + expect(stamped.sourceHint).toBeUndefined(); + expect(stamped.source?.location?.guildId).toBe("1083767712431480922"); + } + }); +}); diff --git a/apps/server/src/identity/stampSource.ts b/apps/server/src/identity/stampSource.ts new file mode 100644 index 00000000000..0b43e7c4d5d --- /dev/null +++ b/apps/server/src/identity/stampSource.ts @@ -0,0 +1,198 @@ +/** + * Server-only: stamp SourceRef onto operate commands from session claim + * or platform sourceHint (Discord / GitHub / Jira bots and bridges). + * Never trusts client personId/username. + */ +import type { + AuthClientMetadataDeviceType, + ClientSourceHint, + OrchestrationCommand, + SessionIdentityClaim, + SourceChannel, + SourceRef, +} from "@t3tools/contracts"; +import { IdentityUsername, PersonId } from "@t3tools/contracts"; +import { + findPersonByDiscordId, + findPersonByGithubId, + findPersonByGithubLogin, + findPersonByJiraAccountId, + findPersonByJiraEmail, + type IdentityMapPerson, +} from "@t3tools/shared/identityMap"; +import { buildSourceRefFromClaim, resolveSourceChannel } from "@t3tools/shared/sourceAttribution"; + +export function sourceRefFromOperateClaim(input: { + readonly claim: SessionIdentityClaim; + readonly clientDeviceType?: AuthClientMetadataDeviceType | undefined; + readonly channelHint?: SourceChannel | undefined; +}): SourceRef { + const built = buildSourceRefFromClaim({ + personId: input.claim.personId, + username: input.claim.username, + channel: resolveSourceChannel({ + deviceType: input.clientDeviceType, + channelHint: input.channelHint, + }), + }); + return { + channel: built.channel, + personId: input.claim.personId, + username: input.claim.username, + }; +} + +/** + * Resolve a map person from a ClientSourceHint channel + actor. + * Unmapped actors still get a channel stamp without personId (v1 policy). + */ +export function resolvePersonFromSourceHint( + people: ReadonlyArray, + hint: ClientSourceHint, +): IdentityMapPerson | null { + const channel = hint.channel; + const actor = hint.actor; + const platformId = actor?.platformId?.trim() ?? ""; + const displayName = actor?.displayName?.trim() ?? ""; + + if (channel === "discord" && platformId.length > 0) { + return findPersonByDiscordId(people, platformId); + } + if (channel === "github") { + if (platformId.length > 0) { + const byId = findPersonByGithubId(people, platformId); + if (byId) return byId; + } + if (displayName.length > 0) { + return findPersonByGithubLogin(people, displayName); + } + // Some callers put login in platformId when numeric id is unknown. + if (platformId.length > 0 && !/^\d+$/u.test(platformId)) { + return findPersonByGithubLogin(people, platformId); + } + } + if (channel === "jira") { + if (platformId.length > 0) { + const byAccount = findPersonByJiraAccountId(people, platformId); + if (byAccount) return byAccount; + } + if (displayName.includes("@")) { + return findPersonByJiraEmail(people, displayName); + } + } + return null; +} + +export function sourceRefFromHintAndMap(input: { + readonly people: ReadonlyArray; + readonly hint: ClientSourceHint; + readonly clientDeviceType?: AuthClientMetadataDeviceType | undefined; +}): SourceRef { + const channel = resolveSourceChannel({ + deviceType: input.clientDeviceType, + channelHint: input.hint.channel, + }); + const person = resolvePersonFromSourceHint(input.people, input.hint); + const base: SourceRef = { + channel, + ...(input.hint.location !== undefined ? { location: input.hint.location } : {}), + ...(input.hint.actor !== undefined ? { actor: input.hint.actor } : {}), + }; + if (person === null) { + return base; + } + return { + ...base, + personId: PersonId.make(person.personId), + username: IdentityUsername.make(person.username), + }; +} + +/** + * Attach server-authored source to turn.start commands. + * Priority: session claim → sourceHint+map (integrations) → channel-only for bots. + */ +export function stampOrchestrationCommandSource(input: { + readonly command: OrchestrationCommand; + readonly claim: SessionIdentityClaim | null; + readonly clientDeviceType?: AuthClientMetadataDeviceType | undefined; + readonly people?: ReadonlyArray | undefined; +}): OrchestrationCommand { + if (input.command.type !== "thread.turn.start") { + return input.command; + } + + const hint = input.command.sourceHint; + let source: SourceRef | undefined; + + if (input.claim !== null) { + source = sourceRefFromOperateClaim({ + claim: input.claim, + clientDeviceType: input.clientDeviceType, + channelHint: hint?.channel, + }); + // Merge optional location/actor from hint (e.g. Discord guild) onto claim stamp. + if (hint?.location !== undefined || hint?.actor !== undefined) { + source = { + ...source, + ...(hint.location !== undefined ? { location: hint.location } : {}), + ...(hint.actor !== undefined ? { actor: hint.actor } : {}), + }; + } + } else if (hint !== undefined && (input.people?.length ?? 0) > 0) { + source = sourceRefFromHintAndMap({ + people: input.people ?? [], + hint, + clientDeviceType: input.clientDeviceType, + }); + } else if (hint !== undefined) { + // Map empty/off: still stamp channel + actor for provenance. + source = { + channel: resolveSourceChannel({ + deviceType: input.clientDeviceType, + channelHint: hint.channel, + }), + ...(hint.location !== undefined ? { location: hint.location } : {}), + ...(hint.actor !== undefined ? { actor: hint.actor } : {}), + }; + } else if (input.clientDeviceType === "bot") { + source = { channel: "bot" }; + } + + // Drop sourceHint from the command stored in the engine (source is authoritative). + const { sourceHint: _dropped, ...withoutHint } = input.command; + void _dropped; + if (source === undefined) { + return withoutHint as OrchestrationCommand; + } + return { + ...withoutHint, + source, + } as OrchestrationCommand; +} + +/** Build a full SourceRef for in-process bridges (GitHub/Jira) with map resolution. */ +export function buildIntegrationSourceRef(input: { + readonly people: ReadonlyArray; + readonly channel: SourceChannel; + readonly platformId?: string | null | undefined; + readonly displayName?: string | null | undefined; + readonly location?: SourceRef["location"]; +}): SourceRef { + const hint: ClientSourceHint = { + channel: input.channel, + ...(input.platformId || input.displayName + ? { + actor: { + ...(input.platformId ? { platformId: input.platformId } : {}), + ...(input.displayName ? { displayName: input.displayName } : {}), + }, + } + : {}), + ...(input.location !== undefined ? { location: input.location } : {}), + }; + return sourceRefFromHintAndMap({ + people: input.people, + hint, + }); +} diff --git a/apps/server/src/jira/JiraAppClient.ts b/apps/server/src/jira/JiraAppClient.ts index 0a89682ebd9..d065504d86c 100644 --- a/apps/server/src/jira/JiraAppClient.ts +++ b/apps/server/src/jira/JiraAppClient.ts @@ -19,6 +19,14 @@ export class JiraAppClient extends Context.Service< * user wrote inside an existing reply thread. */ readonly parentCommentId?: string | null; + /** + * Second parent to try if `parentCommentId` is rejected (e.g. root vs mention). + * Keeps the reply inline when the first parentId is invalid. + */ + readonly fallbackParentCommentId?: string | null; + /** @-mention this Jira user at the start of the reply (normal reply style). */ + readonly mentionAccountId?: string | null; + readonly mentionDisplayName?: string | null; }) => Effect.Effect<{ readonly id: string } | null, never>; /** * Best-effort reaction on a comment (👀). Jira Cloud support varies; returns the emoji id @@ -58,18 +66,27 @@ export const make = Effect.gen(function* () { readonly issueKey: string; readonly body: string; readonly parentCommentId?: string | null; + readonly fallbackParentCommentId?: string | null; + readonly mentionAccountId?: string | null; + readonly mentionDisplayName?: string | null; }) { if (!config.enabled) return null; const url = `${config.baseUrl}/rest/api/3/issue/${encodeURIComponent(input.issueKey)}/comment`; - const parentCommentId = input.parentCommentId?.trim() || null; - const payload: Record = { body: plainTextToAdf(input.body) }; - // Undocumented but supported on Jira Cloud: parentId threads the comment under a root. - if (parentCommentId !== null) { - payload.parentId = parentCommentId; - } + const adfBody = plainTextToAdf(input.body, { + mention: input.mentionAccountId + ? { + accountId: input.mentionAccountId, + displayName: input.mentionDisplayName, + } + : null, + }); - const postOnce = (body: Record) => { + /** Jira accepts parentId as string or number; prefer numeric when pure digits. */ + const parentIdValue = (raw: string): string | number => + /^\d+$/u.test(raw) ? Number(raw) : raw; + + const postOnce = (body: Record, parentForLog: string | null) => { const request = authorize( HttpClientRequest.post(url).pipe( HttpClientRequest.acceptJson, @@ -81,7 +98,7 @@ export const make = Effect.gen(function* () { Effect.tapError((cause) => Effect.logWarning("Jira comment create request failed", { issueKey: input.issueKey, - parentCommentId, + parentCommentId: parentForLog, cause, }), ), @@ -89,74 +106,85 @@ export const make = Effect.gen(function* () { ); }; - const decodeSuccess = (success: HttpClientResponse.HttpClientResponse) => + const decodeSuccess = ( + success: HttpClientResponse.HttpClientResponse, + parentForLog: string | null, + ) => HttpClientResponse.schemaBodyJson(CommentResponse)(success).pipe( Effect.map((parsed) => ({ id: String(parsed.id) })), Effect.tapError((cause) => Effect.logWarning("Jira comment create response decode failed", { issueKey: input.issueKey, - parentCommentId, + parentCommentId: parentForLog, cause, }), ), Effect.orElseSucceed(() => null), ); - let response = yield* postOnce(payload); - if (response === null) return null; + type Attempt = + | { readonly _tag: "ok"; readonly id: string } + | { readonly _tag: "retry_next" } + | { readonly _tag: "failed" }; - const first = yield* HttpClientResponse.matchStatus(response, { - "2xx": (success) => - decodeSuccess(success).pipe( - Effect.map((parsed) => - parsed === null ? { _tag: "failed" as const } : { _tag: "ok" as const, id: parsed.id }, - ), - ), - orElse: (failed) => - Effect.gen(function* () { - const detail = yield* failed.text.pipe(Effect.orElseSucceed(() => "")); - // Threading can fail if parentId is invalid / not a root; fall back to top-level once. - if (parentCommentId !== null && (failed.status === 400 || failed.status === 404)) { - yield* Effect.logWarning( - "Jira threaded comment rejected; retrying as top-level comment", - { + const tryPost = (parentCommentId: string | null): Effect.Effect => + Effect.gen(function* () { + const payload: Record = { body: adfBody }; + // Undocumented but supported on Jira Cloud: parentId threads under a root comment. + if (parentCommentId !== null) { + payload.parentId = parentIdValue(parentCommentId); + } + const response = yield* postOnce(payload, parentCommentId); + if (response === null) return { _tag: "failed" as const }; + + return yield* HttpClientResponse.matchStatus(response, { + "2xx": (success) => + decodeSuccess(success, parentCommentId).pipe( + Effect.map((parsed) => + parsed === null + ? ({ _tag: "failed" } as const) + : ({ _tag: "ok", id: parsed.id } as const), + ), + ), + orElse: (failed) => + Effect.gen(function* () { + const detail = yield* failed.text.pipe(Effect.orElseSucceed(() => "")); + // Threading can fail if parentId is invalid / not a root — try next parent. + if (parentCommentId !== null && (failed.status === 400 || failed.status === 404)) { + yield* Effect.logWarning("Jira threaded comment rejected; trying next parent", { + issueKey: input.issueKey, + parentCommentId, + status: failed.status, + detail: detail.slice(0, 500), + }); + return { _tag: "retry_next" as const }; + } + yield* Effect.logWarning("Jira comment create rejected", { issueKey: input.issueKey, parentCommentId, status: failed.status, detail: detail.slice(0, 500), - }, - ); - return { _tag: "retry_flat" as const }; - } - yield* Effect.logWarning("Jira comment create rejected", { - issueKey: input.issueKey, - parentCommentId, - status: failed.status, - detail: detail.slice(0, 500), - }); - return { _tag: "failed" as const }; - }), - }); + }); + return { _tag: "failed" as const }; + }), + }); + }); - if (first._tag === "ok") return { id: first.id }; - if (first._tag !== "retry_flat") return null; + const primary = input.parentCommentId?.trim() || null; + const secondary = input.fallbackParentCommentId?.trim() || null; + // Prefer inline reply: primary parent → optional secondary → top-level last resort only. + const parents: Array = []; + if (primary !== null) parents.push(primary); + if (secondary !== null && secondary !== primary) parents.push(secondary); + parents.push(null); - response = yield* postOnce({ body: plainTextToAdf(input.body) }); - if (response === null) return null; - return yield* HttpClientResponse.matchStatus(response, { - "2xx": (success) => decodeSuccess(success), - orElse: (failed) => - Effect.gen(function* () { - const detail = yield* failed.text.pipe(Effect.orElseSucceed(() => "")); - yield* Effect.logWarning("Jira comment create rejected", { - issueKey: input.issueKey, - parentCommentId: null, - status: failed.status, - detail: detail.slice(0, 500), - }); - return null; - }), - }); + for (const parent of parents) { + const result = yield* tryPost(parent); + if (result._tag === "ok") return { id: result.id }; + if (result._tag === "failed") return null; + // retry_next → continue + } + return null; }); /** diff --git a/apps/server/src/jira/JiraDeliveryStore.ts b/apps/server/src/jira/JiraDeliveryStore.ts index 73b94eb42b6..8fed2fa9e86 100644 --- a/apps/server/src/jira/JiraDeliveryStore.ts +++ b/apps/server/src/jira/JiraDeliveryStore.ts @@ -23,6 +23,9 @@ export const JiraDelivery = Schema.Struct({ responseCommentId: Schema.NullOr(Schema.String), /** Emoji id for ack reaction on the source comment (e.g. 1f440), when supported. */ acknowledgmentEmojiId: Schema.optional(Schema.NullOr(Schema.String)), + /** Jira accountId of the human who mentioned the bot (for inline @ replies). */ + actorAccountId: Schema.optional(Schema.NullOr(Schema.String)), + actorDisplayName: Schema.optional(Schema.NullOr(Schema.String)), threadId: Schema.NullOr(Schema.String), previousTurnId: Schema.NullOr(Schema.String), userMessageId: Schema.NullOr(Schema.String), @@ -41,6 +44,8 @@ export type StoredJiraDelivery = { readonly commentSurface: "issue" | "reply"; readonly responseCommentId: string | null; readonly acknowledgmentEmojiId: string | null; + readonly actorAccountId: string | null; + readonly actorDisplayName: string | null; readonly threadId: ThreadId | null; readonly previousTurnId: TurnId | null; readonly userMessageId: string | null; @@ -76,6 +81,8 @@ export const make = Effect.gen(function* () { (delivery): StoredJiraDelivery => ({ ...delivery, acknowledgmentEmojiId: delivery.acknowledgmentEmojiId ?? null, + actorAccountId: delivery.actorAccountId ?? null, + actorDisplayName: delivery.actorDisplayName ?? null, threadId: delivery.threadId as ThreadId | null, previousTurnId: delivery.previousTurnId as TurnId | null, targetTurnId: delivery.targetTurnId as TurnId | null, diff --git a/apps/server/src/jira/JiraIssueBridge.ts b/apps/server/src/jira/JiraIssueBridge.ts index f665d76777c..ffae4c3363b 100644 --- a/apps/server/src/jira/JiraIssueBridge.ts +++ b/apps/server/src/jira/JiraIssueBridge.ts @@ -5,8 +5,10 @@ import { ProjectId, ThreadId, type OrchestrationThread, + type SourceRef, type TurnId, } from "@t3tools/contracts"; +import type { IdentityMapPerson } from "@t3tools/shared/identityMap"; import * as Clock from "effect/Clock"; import * as Context from "effect/Context"; import * as Crypto from "effect/Crypto"; @@ -22,6 +24,8 @@ import { githubFinalAnswerWithStats, resolveGitHubBridgeTurnOutcome, } from "../github/GitHubPrBridge.ts"; +import * as IdentityService from "../identity/IdentityService.ts"; +import { buildIntegrationSourceRef } from "../identity/stampSource.ts"; import { OrchestrationEngineService } from "../orchestration/Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import { getAutoBootstrapDefaultModelSelection } from "../serverRuntimeStartup.ts"; @@ -37,25 +41,54 @@ import { resolveT3ProjectIdForJiraKey, } from "./JiraAppConfig.ts"; import { JiraDeliveryStore, type StoredJiraDelivery } from "./JiraDeliveryStore.ts"; -import { resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; +import { resolveDiscordLinkForJiraIssue, resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; +import { classifyJiraActorTrust, type JiraActorTrustDecision } from "./jiraActorTrust.ts"; +import { + formatDiscordJiraContextNote, + postDiscordChannelMessage, + resolveDiscordBotToken, +} from "./jiraDiscordContext.ts"; import { buildJiraTurnPrompt, type JiraIssueInvocation } from "./JiraWebhookPayload.ts"; const NOT_LINKED_RESPONSE = "not yet linked. No T3 thread lists this issue, and auto-create could not pick a project (set T3CODE_JIRA_PROJECT_MAP for this Jira key, T3CODE_JIRA_DEFAULT_PROJECT_ID, or ensure exactly one T3 project exists)."; const CREATE_DISABLED_RESPONSE = - "not yet linked. Auto-create is disabled (T3CODE_JIRA_AUTO_CREATE_THREAD=false); link this issue from Discord/T3 or enable auto-create."; + "not yet linked. Auto-create is disabled; link this issue from Chat or enable auto-create."; const AMBIGUOUS_RESPONSE = - "Multiple T3 threads are linked to this Jira issue, so the bot could not pick which one to use."; + "Multiple chat threads are linked to this Jira issue, so the bot could not pick which one to use."; const BUSY_RESPONSE = - "This T3 thread is already working. Try again after the current turn finishes."; + "This chat thread is already working. Try again after the current turn finishes."; const FAILED_RESPONSE = - "T3 could not complete this request. Check the linked T3 thread for details."; + "Could not complete this request. Check the linked chat thread for details."; const EMPTY_PROMPT_RESPONSE = "Provide a prompt after the mention (for example: `@omegent investigate the packing failure`)."; const CREATE_FAILED_RESPONSE = - "T3 could not create a thread for this Jira issue. Check server logs or link an existing thread."; + "Could not create a chat thread for this Jira issue. Check server logs or link an existing thread."; +/** Untrusted-actor replies: short, no product jargon; @-mention is applied separately in ADF. */ +const CONTEXT_UNAUTHORIZED_RESPONSE = + "You're not currently authorized to run agent work from Jira. Please ask a team member who is authorized to take this forward."; +const CONTEXT_NOTED_RESPONSE = + "Thanks — noted for the team. You're not currently authorized to run agent work from Jira, so this was filed as context only. An authorized teammate can pick it up."; +const CONTEXT_FAILED_RESPONSE = + "You're not currently authorized to run agent work from Jira, and I couldn't file this as context either. Please ping an authorized teammate."; const MAX_JIRA_COMMENT_LENGTH = 32_000; +function jiraSourceRef( + invocation: JiraIssueInvocation, + people: ReadonlyArray, +): SourceRef { + return buildIntegrationSourceRef({ + people, + channel: "jira", + platformId: invocation.actorAccountId, + displayName: invocation.actorDisplayName, + location: { + ...(invocation.projectKey ? { projectKey: invocation.projectKey } : {}), + issueKey: invocation.issueKey, + }, + }); +} + export function formatJiraComment(body: string): string { const trimmed = body.trim(); if (trimmed.length <= MAX_JIRA_COMMENT_LENGTH) return trimmed; @@ -89,21 +122,54 @@ const make = Effect.gen(function* () { const jira = yield* JiraAppClient; const engine = yield* OrchestrationEngineService; const projection = yield* ProjectionSnapshotQuery; + const identity = yield* IdentityService.IdentityService; const fileSystem = yield* FileSystem.FileSystem; const crypto = yield* Crypto.Crypto; const createLock = yield* Semaphore.make(1); + const resolveActorTrust = (invocation: JiraIssueInvocation) => + Effect.gen(function* () { + const people = yield* identity.listMapPeople(); + const mapEnabled = yield* identity.isMapEnabled(); + return classifyJiraActorTrust({ + identityMapEnabled: mapEnabled, + actorAccountId: invocation.actorAccountId, + people, + }) satisfies JiraActorTrustDecision; + }); + /** - * Post a bridge response as a **threaded reply** when possible. - * Uses delivery.replyToCommentId (thread root, or the mention itself when top-level). - * Jira only allows children under top-level comments — not under existing replies. + * Post a bridge response as an **inline threaded reply** under the user's mention. + * + * Parent order: + * 1. `replyToCommentId` — thread root when the mention is a child reply (Jira only + * allows nesting under roots), or the mention itself when top-level + * 2. `sourceCommentId` — the triggering mention (always try to answer the user inline) + * + * Never intentionally posts a bare top-level comment first; flat fallback is only + * if Jira rejects every parentId (see JiraAppClient). */ - const postComment = (delivery: StoredJiraDelivery, body: string) => - jira.addIssueComment({ + const postComment = (delivery: StoredJiraDelivery, body: string) => { + const rootParent = delivery.replyToCommentId.trim(); + const mentionParent = delivery.sourceCommentId.trim(); + // Prefer the resolved reply parent, then the mention comment itself. + const parentCommentId = + rootParent.length > 0 ? rootParent : mentionParent.length > 0 ? mentionParent : null; + return jira.addIssueComment({ issueKey: delivery.issueKey, body: formatJiraComment(body), - parentCommentId: delivery.replyToCommentId || delivery.sourceCommentId || null, + parentCommentId, + // If parent was a nested reply id that Jira rejects, client retries with the + // mention id when it differs (still inline to the user), then top-level last. + fallbackParentCommentId: + rootParent.length > 0 && mentionParent.length > 0 && rootParent !== mentionParent + ? mentionParent + : null, + // Normal Jira reply style: @ the human who triggered the bot. + mentionAccountId: delivery.actorAccountId, + mentionDisplayName: delivery.actorDisplayName, }); + }; const updateDelivery = (delivery: StoredJiraDelivery, patch: Partial) => DateTime.now.pipe( @@ -381,6 +447,8 @@ const make = Effect.gen(function* () { commentSurface: input.invocation.commentSurface, responseCommentId: null, acknowledgmentEmojiId: null, + actorAccountId: input.invocation.actorAccountId, + actorDisplayName: input.invocation.actorDisplayName, threadId: null, previousTurnId: null, userMessageId: null, @@ -450,12 +518,94 @@ const make = Effect.gen(function* () { return; } + const trust = yield* resolveActorTrust(input.invocation); + yield* Effect.logInfo("Classified Jira actor trust", { + deliveryId: input.deliveryId, + issueKey: input.invocation.issueKey, + actorAccountId: input.invocation.actorAccountId, + mode: trust.mode, + reason: trust.reason, + personId: trust.person?.personId ?? null, + }); + const link = yield* resolveLinkedThreadId(input.invocation.issueKey); if (link._tag === "ambiguous") { yield* finishDelivery(acknowledged, AMBIGUOUS_RESPONSE, "rejected"); return; } + // Untrusted actors: optional chat context note only (no agent). Never auto-creates. + // Requires a unique chat-linked issue in links.json when filing context. + if (trust.mode === "context-only") { + const linksPath = config.discordLinksPath; + if (linksPath === null || linksPath.length === 0) { + yield* finishDelivery(acknowledged, CONTEXT_UNAUTHORIZED_RESPONSE, "rejected"); + return; + } + const linksRaw = yield* fileSystem + .readFileString(linksPath) + .pipe(Effect.orElseSucceed(() => "")); + const discordLink = resolveDiscordLinkForJiraIssue({ + issueKey: input.invocation.issueKey, + linksJson: linksRaw, + }); + if (discordLink._tag === "unlinked" || discordLink._tag === "ambiguous") { + yield* finishDelivery(acknowledged, CONTEXT_UNAUTHORIZED_RESPONSE, "rejected"); + return; + } + + const token = yield* Effect.promise(() => resolveDiscordBotToken()); + if (token === null) { + yield* finishDelivery(acknowledged, CONTEXT_FAILED_RESPONSE, "rejected"); + return; + } + + const requester = + input.invocation.actorDisplayName ?? input.invocation.actorAccountId ?? "unknown"; + const content = formatDiscordJiraContextNote({ + issueKey: input.invocation.issueKey, + requester, + prompt: input.invocation.prompt, + commentUrl: input.invocation.commentUrl, + }); + const posted = yield* Effect.promise(() => + postDiscordChannelMessage({ + token, + channelId: discordLink.discordThreadId, + content, + }) + .then((message) => ({ _tag: "ok" as const, message })) + .catch((cause) => ({ _tag: "err" as const, cause })), + ); + if (posted._tag === "err") { + yield* Effect.logError("Failed to post Jira context-only note to Discord", { + deliveryId: input.deliveryId, + issueKey: input.invocation.issueKey, + discordThreadId: discordLink.discordThreadId, + cause: posted.cause, + }); + yield* finishDelivery(acknowledged, CONTEXT_FAILED_RESPONSE, "rejected"); + return; + } + + yield* Effect.logInfo("Posted Jira context-only note to Discord (no agent run)", { + deliveryId: input.deliveryId, + issueKey: input.invocation.issueKey, + discordThreadId: discordLink.discordThreadId, + t3ThreadId: discordLink.t3ThreadId, + discordMessageId: posted.message.id, + }); + const notedDelivery: StoredJiraDelivery = { + ...acknowledged, + threadId: + discordLink.t3ThreadId !== null + ? (discordLink.t3ThreadId as ThreadId) + : acknowledged.threadId, + }; + yield* finishDelivery(notedDelivery, CONTEXT_NOTED_RESPONSE, "completed"); + return; + } + let thread: OrchestrationThread; if (link._tag === "linked") { const snapshot = yield* projection @@ -477,7 +627,7 @@ const make = Effect.gen(function* () { thread = snapshot.value; } } else { - // unlinked — join-or-create + // unlinked — join-or-create (trusted actors only) if (!config.enabled || !config.autoCreateThread) { yield* finishDelivery(acknowledged, CREATE_DISABLED_RESPONSE, "rejected"); return; @@ -522,8 +672,12 @@ const make = Effect.gen(function* () { threadId: thread.id, issueKey: input.invocation.issueKey, userMessageId: messageId, + trustMode: trust.mode, + personId: trust.person?.personId ?? null, }); + const mapPeople = yield* identity.listMapPeople(); + const source = jiraSourceRef(input.invocation, mapPeople); const dispatched = yield* engine .dispatch({ type: "thread.turn.start", @@ -539,6 +693,7 @@ const make = Effect.gen(function* () { titleSeed: input.invocation.prompt.slice(0, 80) || "Jira comment", runtimeMode: thread.runtimeMode, interactionMode: thread.interactionMode, + source, createdAt: DateTime.formatIso(yield* DateTime.now), }) .pipe( diff --git a/apps/server/src/jira/JiraThreadLookup.ts b/apps/server/src/jira/JiraThreadLookup.ts index 57b49714ba7..80b4c2467d9 100644 --- a/apps/server/src/jira/JiraThreadLookup.ts +++ b/apps/server/src/jira/JiraThreadLookup.ts @@ -3,6 +3,8 @@ * * Preferred resolution is the server-native {@link ThreadWorkItemStore}. This helper remains * for migration/fallback when Discord still holds associations that have not been imported yet. + * + * Discord destinations (for untrusted context notes) also come from the same links.json. */ import type { ThreadId } from "@t3tools/contracts"; @@ -11,6 +13,8 @@ import * as Schema from "effect/Schema"; const DiscordThreadLink = Schema.Struct({ discordThreadId: Schema.optional(Schema.String), t3ThreadId: Schema.String, + channelId: Schema.optional(Schema.String), + guildId: Schema.optional(Schema.String), status: Schema.optional(Schema.String), jiraIssueKeys: Schema.optional(Schema.Array(Schema.String)), }); @@ -27,26 +31,47 @@ export type JiraThreadLookupResult = | { readonly _tag: "ambiguous"; readonly threadIds: ReadonlyArray } | { readonly _tag: "linked"; readonly threadId: ThreadId }; -export function resolveThreadIdForJiraIssue(input: { - readonly issueKey: string; - readonly linksJson: string; -}): JiraThreadLookupResult { - const issueKey = input.issueKey.trim().toUpperCase(); - if (issueKey.length === 0) return { _tag: "unlinked" }; +export type JiraDiscordLinkLookupResult = + | { readonly _tag: "unlinked" } + | { + readonly _tag: "ambiguous"; + readonly discordThreadIds: ReadonlyArray; + } + | { + readonly _tag: "linked"; + readonly discordThreadId: string; + readonly t3ThreadId: string | null; + readonly channelId: string | null; + readonly guildId: string | null; + }; + +function activeLinksWithIssue( + linksJson: string, + issueKeyRaw: string, +): ReadonlyArray { + const issueKey = issueKeyRaw.trim().toUpperCase(); + if (issueKey.length === 0) return []; let links: ReadonlyArray; try { - links = decodeLinksFile(input.linksJson).links; + links = decodeLinksFile(linksJson).links; } catch { - return { _tag: "unlinked" }; + return []; } - const matches = new Set(); - for (const link of links) { - if (link.status !== undefined && link.status !== "active") continue; + return links.filter((link) => { + if (link.status !== undefined && link.status !== "active") return false; const keys = link.jiraIssueKeys ?? []; - const hit = keys.some((key) => key.trim().toUpperCase() === issueKey); - if (!hit) continue; + return keys.some((key) => key.trim().toUpperCase() === issueKey); + }); +} + +export function resolveThreadIdForJiraIssue(input: { + readonly issueKey: string; + readonly linksJson: string; +}): JiraThreadLookupResult { + const matches = new Set(); + for (const link of activeLinksWithIssue(input.linksJson, input.issueKey)) { const threadId = link.t3ThreadId.trim(); if (threadId.length > 0) matches.add(threadId); } @@ -61,3 +86,31 @@ export function resolveThreadIdForJiraIssue(input: { const [only] = matches; return { _tag: "linked", threadId: only as ThreadId }; } + +/** + * Resolve the Discord thread to post untrusted Jira context into. + * Requires a unique active links.json row with both the issue key and a discordThreadId. + */ +export function resolveDiscordLinkForJiraIssue(input: { + readonly issueKey: string; + readonly linksJson: string; +}): JiraDiscordLinkLookupResult { + const withDiscord = activeLinksWithIssue(input.linksJson, input.issueKey).filter( + (link) => (link.discordThreadId?.trim().length ?? 0) > 0, + ); + if (withDiscord.length === 0) return { _tag: "unlinked" }; + if (withDiscord.length > 1) { + return { + _tag: "ambiguous", + discordThreadIds: withDiscord.map((link) => link.discordThreadId!.trim()), + }; + } + const only = withDiscord[0]!; + return { + _tag: "linked", + discordThreadId: only.discordThreadId!.trim(), + t3ThreadId: only.t3ThreadId.trim() || null, + channelId: only.channelId?.trim() || null, + guildId: only.guildId?.trim() || null, + }; +} diff --git a/apps/server/src/jira/JiraWebhook.test.ts b/apps/server/src/jira/JiraWebhook.test.ts index fc98198a3c6..3cc10af69df 100644 --- a/apps/server/src/jira/JiraWebhook.test.ts +++ b/apps/server/src/jira/JiraWebhook.test.ts @@ -8,7 +8,7 @@ import { resolveT3ProjectIdForJiraKey, } from "./JiraAppConfig.ts"; import { formatJiraComment } from "./JiraIssueBridge.ts"; -import { resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; +import { resolveDiscordLinkForJiraIssue, resolveThreadIdForJiraIssue } from "./JiraThreadLookup.ts"; import { classifyWebhookBodyFailure, previewWebhookBody, @@ -321,11 +321,13 @@ describe("Jira thread lookup", () => { links: [ { t3ThreadId: "thread-a", + discordThreadId: "discord-a", status: "active", jiraIssueKeys: ["SA-402", "SA-409"], }, { t3ThreadId: "thread-b", + discordThreadId: "discord-b", status: "tombstone", jiraIssueKeys: ["SA-402"], }, @@ -335,6 +337,13 @@ describe("Jira thread lookup", () => { _tag: "linked", threadId: "thread-a", }); + expect(resolveDiscordLinkForJiraIssue({ issueKey: "SA-402", linksJson })).toEqual({ + _tag: "linked", + discordThreadId: "discord-a", + t3ThreadId: "thread-a", + channelId: null, + guildId: null, + }); }); it("reports unlinked and ambiguous cases", () => { @@ -356,6 +365,28 @@ describe("Jira thread lookup", () => { }), }), ).toMatchObject({ _tag: "ambiguous" }); + + expect( + resolveDiscordLinkForJiraIssue({ + issueKey: "SA-1", + linksJson: JSON.stringify({ + links: [ + { + t3ThreadId: "a", + discordThreadId: "d1", + status: "active", + jiraIssueKeys: ["SA-1"], + }, + { + t3ThreadId: "b", + discordThreadId: "d2", + status: "active", + jiraIssueKeys: ["SA-1"], + }, + ], + }), + }), + ).toMatchObject({ _tag: "ambiguous" }); }); }); @@ -366,6 +397,36 @@ describe("Jira helpers", () => { expect(isJiraProjectAllowed(new Set(["SA"]), "CFG")).toBe(false); expect(isJiraProjectAllowed(new Set(), "ANY")).toBe(true); expect(plainTextToAdf("hello\n\nworld").content).toHaveLength(2); + // Live Jira comments use bare accountId in attrs.id (no accountid: prefix). + const withMention = plainTextToAdf("hello", { + mention: { accountId: "6331c32307a27ebeff15d19d", displayName: "Armin Gebhardt" }, + }); + expect(withMention.content[0]?.content[0]).toMatchObject({ + type: "mention", + attrs: { + id: "6331c32307a27ebeff15d19d", + text: "@Armin Gebhardt", + accessLevel: "", + }, + }); + expect(withMention.content[0]?.content[1]).toMatchObject({ + type: "text", + text: " hello", + }); + // Wiki/Automation form still normalizes to bare accountId for outbound ADF. + const fromWikiForm = plainTextToAdf("ping", { + mention: { + accountId: "accountid:712020:187d3a46-cef9-4fcd-881b-b66f1a7e56ab", + displayName: "Omegent", + }, + }); + expect(fromWikiForm.content[0]?.content[0]).toMatchObject({ + type: "mention", + attrs: { + id: "712020:187d3a46-cef9-4fcd-881b-b66f1a7e56ab", + text: "@Omegent", + }, + }); expect(formatJiraComment(" ok ")).toBe("ok"); }); diff --git a/apps/server/src/jira/JiraWebhookPayload.ts b/apps/server/src/jira/JiraWebhookPayload.ts index 22033b50326..44a7ca0617b 100644 --- a/apps/server/src/jira/JiraWebhookPayload.ts +++ b/apps/server/src/jira/JiraWebhookPayload.ts @@ -384,10 +384,9 @@ export function parseJiraCommentInvocation( }; } -export function buildJiraTurnPrompt(invocation: JiraIssueInvocation): string { +function jiraPromptHeaderLines(invocation: JiraIssueInvocation): Array { const requester = invocation.actorDisplayName ?? invocation.actorAccountId ?? "unknown"; - const isUpdate = invocation.webhookEvent === "comment_updated"; - const lines = [ + return [ "", "", isUpdate @@ -445,26 +452,92 @@ function simplePromptFingerprint(prompt: string): string { return (hash >>> 0).toString(16).padStart(8, "0"); } -/** Minimal ADF document from plain text paragraphs (API v3 comment body). */ -export function plainTextToAdf(text: string): { +export type JiraAdfMention = { + readonly accountId: string; + readonly displayName?: string | null | undefined; +}; + +type AdfInlineNode = + | { readonly type: "text"; readonly text: string } + | { + readonly type: "mention"; + readonly attrs: { + readonly id: string; + readonly text: string; + readonly accessLevel: string; + }; + }; + +type AdfParagraph = { + readonly type: "paragraph"; + readonly content: ReadonlyArray; +}; + +export type JiraAdfDocument = { readonly type: "doc"; readonly version: 1; - readonly content: ReadonlyArray<{ - readonly type: "paragraph"; - readonly content: ReadonlyArray<{ readonly type: "text"; readonly text: string }>; - }>; -} { + readonly content: ReadonlyArray; +}; + +/** + * Normalize a Jira Cloud account id for an ADF **mention** node `attrs.id`. + * + * Live GET `/rest/api/3/issue/{key}/comment` returns bare Atlassian account ids + * (e.g. `6331c32307a27ebeff15d19d` or `712020:uuid`) — **not** the wiki form + * `accountid:…`. Official ADF docs: attrs.id = “Atlassian Account ID”. + * Strip a leading `accountid:` if present so inbound wiki/Automation forms still work. + */ +export function jiraMentionAccountId(accountId: string): string { + const trimmed = accountId.trim(); + if (trimmed.length === 0) return trimmed; + return trimmed.replace(/^accountid:/iu, ""); +} + +/** + * Minimal ADF document from plain text paragraphs (API v3 comment body). + * Optional leading @mention of the human requester (normal Jira reply style). + * + * Mention shape (matches real comments on this site): + * `{ type: "mention", attrs: { id: "", text: "@Name", accessLevel: "" } }` + */ +export function plainTextToAdf( + text: string, + options?: { readonly mention?: JiraAdfMention | null | undefined }, +): JiraAdfDocument { const paragraphs = text .split(/\n{2,}/u) .map((block) => block.trim()) .filter((block) => block.length > 0); const blocks = paragraphs.length > 0 ? paragraphs : [text.trim() || " "]; + const mention = options?.mention; + const accountId = jiraMentionAccountId(mention?.accountId?.trim() ?? ""); + const display = mention?.displayName?.trim().replace(/^@/u, "") || accountId; + const mentionNode: AdfInlineNode | null = + accountId.length > 0 + ? { + type: "mention", + attrs: { + id: accountId, + text: `@${display}`, + accessLevel: "", + }, + } + : null; + return { type: "doc", version: 1, - content: blocks.map((block) => ({ - type: "paragraph" as const, - content: [{ type: "text" as const, text: block }], - })), + content: blocks.map((block, index) => { + if (index === 0 && mentionNode !== null) { + return { + type: "paragraph" as const, + content: [mentionNode, { type: "text" as const, text: ` ${block}` }], + }; + } + return { + type: "paragraph" as const, + content: [{ type: "text" as const, text: block }], + }; + }), }; } diff --git a/apps/server/src/jira/jiraActorTrust.test.ts b/apps/server/src/jira/jiraActorTrust.test.ts new file mode 100644 index 00000000000..b0955aa2a13 --- /dev/null +++ b/apps/server/src/jira/jiraActorTrust.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { + classifyJiraActorTrust, + normalizeJiraAccountId, + resolvePersonByJiraAccountId, +} from "./jiraActorTrust.ts"; + +const people = [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + jira: { accountId: "712020:abc-trusted" }, + }, + { + personId: "julius", + username: "julius", + jira: { accountId: "accountid:712020:def-other" }, + }, +] as const; + +describe("normalizeJiraAccountId", () => { + it("strips accountid: prefix and lowercases", () => { + expect(normalizeJiraAccountId("accountid:712020:ABC")).toBe("712020:abc"); + expect(normalizeJiraAccountId("712020:ABC")).toBe("712020:abc"); + }); + + it("returns null for empty", () => { + expect(normalizeJiraAccountId(null)).toBeNull(); + expect(normalizeJiraAccountId(" ")).toBeNull(); + }); +}); + +describe("resolvePersonByJiraAccountId", () => { + it("matches mapped account ids with prefix variants", () => { + expect(resolvePersonByJiraAccountId(people, "712020:abc-trusted")?.username).toBe("patroza"); + expect(resolvePersonByJiraAccountId(people, "accountid:712020:ABC-TRUSTED")?.username).toBe( + "patroza", + ); + expect(resolvePersonByJiraAccountId(people, "712020:def-other")?.username).toBe("julius"); + }); + + it("returns null when unmapped", () => { + expect(resolvePersonByJiraAccountId(people, "unknown")).toBeNull(); + }); +}); + +describe("classifyJiraActorTrust", () => { + it("allows full access when the identity map is disabled", () => { + expect( + classifyJiraActorTrust({ + identityMapEnabled: false, + actorAccountId: "stranger", + people: [], + }), + ).toEqual({ mode: "full", person: null, reason: "identity_map_disabled" }); + }); + + it("trusts mapped Jira account ids for full agent turns", () => { + const decision = classifyJiraActorTrust({ + identityMapEnabled: true, + actorAccountId: "712020:abc-trusted", + people, + }); + expect(decision.mode).toBe("full"); + expect(decision.reason).toBe("mapped_jira_account"); + expect(decision.person?.username).toBe("patroza"); + }); + + it("restricts unmapped and missing account ids to context-only", () => { + expect( + classifyJiraActorTrust({ + identityMapEnabled: true, + actorAccountId: "712020:stranger", + people, + }), + ).toMatchObject({ mode: "context-only", reason: "unmapped_jira_account", person: null }); + + expect( + classifyJiraActorTrust({ + identityMapEnabled: true, + actorAccountId: null, + people, + }), + ).toMatchObject({ mode: "context-only", reason: "missing_jira_account_id", person: null }); + }); +}); diff --git a/apps/server/src/jira/jiraActorTrust.ts b/apps/server/src/jira/jiraActorTrust.ts new file mode 100644 index 00000000000..454d2026386 --- /dev/null +++ b/apps/server/src/jira/jiraActorTrust.ts @@ -0,0 +1,53 @@ +/** + * Jira actor trust relative to the closed-set identity map. + * + * When the map is off, all actors keep full agent turns (legacy behaviour). + * When the map is on, only people with a mapped Jira accountId may run the + * agent; everyone else may only append context to an already-linked thread. + */ +import { + normalizeJiraAccountId, + resolvePersonByJiraAccountId, + type IdentityMapPerson, +} from "@t3tools/shared/identityMap"; + +export type JiraActorTrustMode = "full" | "context-only"; + +export type JiraActorTrustDecision = { + readonly mode: JiraActorTrustMode; + /** Mapped person when trusted; null when map is off or actor is unmapped. */ + readonly person: IdentityMapPerson | null; + readonly reason: + | "identity_map_disabled" + | "mapped_jira_account" + | "unmapped_jira_account" + | "missing_jira_account_id"; +}; + +export { normalizeJiraAccountId, resolvePersonByJiraAccountId }; + +/** + * Classify a Jira mention actor for agent execution. + * + * - Map off → full (backward compatible) + * - Map on + accountId in map → full + * - Map on + missing/unmapped accountId → context-only + */ +export function classifyJiraActorTrust(input: { + readonly identityMapEnabled: boolean; + readonly actorAccountId: string | null | undefined; + readonly people: ReadonlyArray; +}): JiraActorTrustDecision { + if (!input.identityMapEnabled) { + return { mode: "full", person: null, reason: "identity_map_disabled" }; + } + const normalized = normalizeJiraAccountId(input.actorAccountId); + if (normalized === null) { + return { mode: "context-only", person: null, reason: "missing_jira_account_id" }; + } + const person = resolvePersonByJiraAccountId(input.people, input.actorAccountId); + if (person === null) { + return { mode: "context-only", person: null, reason: "unmapped_jira_account" }; + } + return { mode: "full", person, reason: "mapped_jira_account" }; +} diff --git a/apps/server/src/jira/jiraDiscordContext.test.ts b/apps/server/src/jira/jiraDiscordContext.test.ts new file mode 100644 index 00000000000..dd96703a667 --- /dev/null +++ b/apps/server/src/jira/jiraDiscordContext.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "@effect/vitest"; + +import { formatDiscordJiraContextNote } from "./jiraDiscordContext.ts"; + +describe("formatDiscordJiraContextNote", () => { + it("formats a short context note", () => { + const text = formatDiscordJiraContextNote({ + issueKey: "SA-402", + requester: "Ada", + prompt: "packing fails on stage", + commentUrl: "https://example.atlassian.net/browse/SA-402?focusedCommentId=1", + }); + expect(text).toContain("SA-402"); + expect(text).toContain("Ada"); + expect(text).toContain("no agent run"); + expect(text).toContain("packing fails on stage"); + expect(text).toContain("focusedCommentId=1"); + }); + + it("truncates long prompts to Discord limits", () => { + const prompt = "x".repeat(3000); + const text = formatDiscordJiraContextNote({ + issueKey: "SA-1", + requester: "Bob", + prompt, + }); + expect(text.length).toBeLessThanOrEqual(2000); + expect(text).toContain("…(truncated)"); + }); +}); diff --git a/apps/server/src/jira/jiraDiscordContext.ts b/apps/server/src/jira/jiraDiscordContext.ts new file mode 100644 index 00000000000..946666bc5c6 --- /dev/null +++ b/apps/server/src/jira/jiraDiscordContext.ts @@ -0,0 +1,84 @@ +// @effect-diagnostics nodeBuiltinImport:off globalFetch:off globalFetchInEffect:off +/** + * Minimal Discord post helper for untrusted Jira context notes. + * Intentionally small — does not depend on MCP tools or the full Discord bot. + */ +import * as NodeFSP from "node:fs/promises"; + +const DISCORD_API_BASE_URL = "https://discord.com/api/v10"; +const DISCORD_DEFAULT_ENV_FILE = "/run/secrets/discord-bot.env"; + +function extractEnvAssignment(raw: string, key: string): string | undefined { + for (const line of raw.split(/\r?\n/u)) { + if (!line.startsWith(`${key}=`)) continue; + const value = line.slice(key.length + 1).trim(); + if (value.length === 0) return undefined; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; + } + return undefined; +} + +export async function resolveDiscordBotToken(): Promise { + const fromEnv = process.env.DISCORD_BOT_TOKEN?.trim(); + if (fromEnv) return fromEnv; + try { + const envFile = await NodeFSP.readFile(DISCORD_DEFAULT_ENV_FILE, "utf8"); + const fromFile = extractEnvAssignment(envFile, "DISCORD_BOT_TOKEN")?.trim(); + return fromFile && fromFile.length > 0 ? fromFile : null; + } catch { + return null; + } +} + +/** Discord message body length limit (UTF-16 code units; we treat as chars). */ +const DISCORD_CONTENT_MAX = 2000; + +export function formatDiscordJiraContextNote(input: { + readonly issueKey: string; + readonly requester: string; + readonly prompt: string; + readonly commentUrl?: string | null; +}): string { + const header = `**Jira context** (no agent run) · \`${input.issueKey}\` · ${input.requester}`; + const link = input.commentUrl ? `\n${input.commentUrl}` : ""; + const body = input.prompt.trim(); + const combined = `${header}${link}\n\n${body}`; + if (combined.length <= DISCORD_CONTENT_MAX) return combined; + const budget = DISCORD_CONTENT_MAX - header.length - link.length - 20; + const clipped = body.slice(0, Math.max(0, budget)); + return `${header}${link}\n\n${clipped}\n…(truncated)`; +} + +export async function postDiscordChannelMessage(input: { + readonly token: string; + readonly channelId: string; + readonly content: string; +}): Promise<{ readonly id: string; readonly channelId: string }> { + const url = `${DISCORD_API_BASE_URL}/channels/${input.channelId}/messages`; + const response = await fetch(url, { + method: "POST", + headers: { + Authorization: `Bot ${input.token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + content: input.content, + allowed_mentions: { parse: [] as string[] }, + }), + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`Discord create message failed (${response.status}): ${body}`); + } + const json = (await response.json()) as { id?: string; channel_id?: string }; + return { + id: json.id ?? "", + channelId: json.channel_id ?? input.channelId, + }; +} diff --git a/apps/server/src/keybindings.test.ts b/apps/server/src/keybindings.test.ts index d929012fc57..af47ae338d5 100644 --- a/apps/server/src/keybindings.test.ts +++ b/apps/server/src/keybindings.test.ts @@ -198,8 +198,6 @@ it.layer(NodeServices.layer)("keybindings", (it) => { assert.equal(defaultsByCommand.get("thread.jump.1"), "mod+1"); assert.equal(defaultsByCommand.get("thread.jump.9"), "mod+9"); assert.equal(defaultsByCommand.get("modelPicker.toggle"), "mod+shift+m"); - assert.equal(defaultsByCommand.get("filePicker.toggle"), "mod+p"); - assert.equal(defaultsByCommand.get("projectSearch.toggle"), "mod+shift+f"); assert.equal(defaultsByCommand.get("board.open"), "mod+t"); assert.equal(defaultsByCommand.get("sidebar.toggle"), "mod+b"); assert.equal(defaultsByCommand.get("rightPanel.toggle"), "mod+alt+b"); diff --git a/apps/server/src/keybindings.ts b/apps/server/src/keybindings.ts index 53f329e8512..18a78fe3623 100644 --- a/apps/server/src/keybindings.ts +++ b/apps/server/src/keybindings.ts @@ -547,24 +547,19 @@ const make = Effect.gen(function* () { }); } - // Startup backfill must never evict persisted user rules: append only - // the defaults that fit and skip the rest. - const availableSlots = Math.max(0, MAX_KEYBINDINGS_COUNT - customConfig.length); - const defaultsToAppend = missingDefaults.slice(0, availableSlots); - const skippedDefaults = missingDefaults.slice(availableSlots); - if (skippedDefaults.length > 0) { - yield* Effect.logWarning("skipping default keybinding backfill at max entries", { + const nextConfig = [...customConfig, ...missingDefaults]; + const cappedConfig = + nextConfig.length > MAX_KEYBINDINGS_COUNT + ? nextConfig.slice(-MAX_KEYBINDINGS_COUNT) + : nextConfig; + if (nextConfig.length > MAX_KEYBINDINGS_COUNT) { + yield* Effect.logWarning("truncating keybindings config to max entries", { path: keybindingsConfigPath, maxEntries: MAX_KEYBINDINGS_COUNT, - commands: skippedDefaults.map((rule) => rule.command), }); } - if (defaultsToAppend.length === 0) { - yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); - return; - } - yield* writeConfigAtomically([...customConfig, ...defaultsToAppend]); + yield* writeConfigAtomically(cappedConfig); yield* Cache.invalidate(resolvedConfigCache, resolvedConfigCacheKey); }), ); diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index beb1991e21e..c1f504618b4 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -459,6 +459,7 @@ describe("OrchestrationEngine", () => { threadId: ThreadId.make("thread-archive"), requestId: CommandId.make("cmd-thread-archive-title-regeneration"), title: "Stale generated title", + createdAt: now(), }), ); expect( diff --git a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts index f95882f1ea2..fd2fab744ea 100644 --- a/apps/server/src/orchestration/Layers/ProjectionPipeline.ts +++ b/apps/server/src/orchestration/Layers/ProjectionPipeline.ts @@ -566,6 +566,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ]); let latestUserMessageAt: string | null = null; + // Rebuild origin + participants from projected user messages so shell stays + // consistent after resync/replay (not only first-write stamps). + type ParticipantRow = NonNullable<(typeof existingRow.value)["participantSummaries"]>[number]; + const rebuiltParticipants: Array = []; + let rebuiltOrigin = existingRow.value.originSource ?? null; for (const message of messages) { if ( message.role === "user" && @@ -573,7 +578,53 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti ) { latestUserMessageAt = message.createdAt; } + if (message.role !== "user" || message.source === undefined) { + continue; + } + if (rebuiltOrigin === null || rebuiltOrigin === undefined) { + rebuiltOrigin = message.source; + } + if (message.source.personId !== undefined && message.source.username !== undefined) { + const personId = message.source.personId; + const existingParticipantIndex = rebuiltParticipants.findIndex( + (entry) => entry.personId === personId, + ); + if (existingParticipantIndex === -1) { + rebuiltParticipants.push({ + personId, + username: message.source.username, + firstChannel: message.source.channel, + channels: [message.source.channel], + firstParticipatedAt: message.createdAt, + }); + } else { + const existingParticipant = rebuiltParticipants[existingParticipantIndex]!; + if (!existingParticipant.channels?.includes(message.source.channel)) { + rebuiltParticipants[existingParticipantIndex] = { + ...existingParticipant, + channels: [ + ...(existingParticipant.channels ?? + (existingParticipant.firstChannel === undefined + ? [] + : [existingParticipant.firstChannel])), + message.source.channel, + ], + }; + } + } + } + } + // Origin person first when present. + if (rebuiltOrigin?.personId !== undefined) { + const originId = rebuiltOrigin.personId; + rebuiltParticipants.sort((left, right) => { + if (left.personId === originId) return -1; + if (right.personId === originId) return 1; + return left.firstParticipatedAt.localeCompare(right.firstParticipatedAt); + }); } + const originSource = rebuiltOrigin; + const participantSummaries = rebuiltParticipants; const pendingApprovalCount = pendingApprovals.filter( (approval) => approval.status === "pending", @@ -590,6 +641,8 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti pendingApprovalCount, pendingUserInputCount, hasActionableProposedPlan: hasActionableProposedPlan ? 1 : 0, + originSource: originSource ?? null, + participantSummaries, }); }); @@ -937,6 +990,11 @@ const makeOrchestrationProjectionPipeline = Effect.fn("makeOrchestrationProjecti text: nextText, ...(nextAttachments !== undefined ? { attachments: [...nextAttachments] } : {}), isStreaming: event.payload.streaming, + ...(event.payload.source !== undefined + ? { source: event.payload.source } + : previousMessage?.source !== undefined + ? { source: previousMessage.source } + : {}), createdAt: previousMessage?.createdAt ?? event.payload.createdAt, updatedAt: event.payload.updatedAt, }); diff --git a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts index b687082eec7..1c02b15f770 100644 --- a/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts +++ b/apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts @@ -26,7 +26,9 @@ import { type OrchestrationThreadShell, ModelSelection, ProjectId, + SourceRef, ThreadId, + ThreadParticipantSummary, } from "@t3tools/contracts"; import * as Arr from "effect/Array"; import * as Effect from "effect/Effect"; @@ -77,6 +79,7 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + source: Schema.NullOr(Schema.fromJsonString(Schema.NullOr(SourceRef))), }), ); const ProjectionThreadProposedPlanDbRowSchema = ProjectionThreadProposedPlan; @@ -89,6 +92,11 @@ const ProjectionQueuedMessageDbRowSchema = ProjectionQueuedMessage.mapFields( const ProjectionThreadDbRowSchema = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + // JSON columns may be SQL NULL or the string "null" from JSON.stringify(null). + originSource: Schema.NullOr(Schema.fromJsonString(Schema.NullOr(SourceRef))), + participantSummaries: Schema.NullOr( + Schema.fromJsonString(Schema.NullOr(Schema.Array(ThreadParticipantSummary))), + ), }), ); const ProjectionThreadActivityDbRowSchema = ProjectionThreadActivity.mapFields( @@ -462,7 +470,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads ORDER BY created_at ASC, thread_id ASC `, @@ -496,7 +506,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NULL @@ -532,7 +544,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE deleted_at IS NULL AND archived_at IS NOT NULL @@ -553,6 +567,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source_json AS "source", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -1065,7 +1080,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE thread_id = ${threadId} AND deleted_at IS NULL @@ -1104,6 +1121,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source_json AS "source", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -1497,6 +1515,9 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { ...(row.attachments !== null ? { attachments: row.attachments } : {}), turnId: row.turnId, streaming: row.isStreaming === 1, + ...(row.source !== null && row.source !== undefined + ? { source: row.source } + : {}), createdAt: row.createdAt, updatedAt: row.updatedAt, }); @@ -1644,6 +1665,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { hasMoreActivities: false, checkpoints: checkpointsByThread.get(row.threadId) ?? [], session: sessionsByThread.get(row.threadId) ?? null, + ...(row.originSource !== null && row.originSource !== undefined + ? { originSource: row.originSource } + : {}), + ...(row.participantSummaries !== null && row.participantSummaries !== undefined + ? { participantSummaries: row.participantSummaries } + : {}), })); const snapshot = { @@ -2032,6 +2059,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { hasPendingApprovals: row.pendingApprovalCount > 0, hasPendingUserInput: row.pendingUserInputCount > 0, hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + ...(row.originSource !== null && row.originSource !== undefined + ? { originSource: row.originSource } + : {}), + ...(row.participantSummaries !== null && + row.participantSummaries !== undefined + ? { participantSummaries: row.participantSummaries } + : {}), } satisfies OrchestrationThreadShell) : Result.failVoid, ), @@ -2171,6 +2205,12 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { hasPendingApprovals: row.pendingApprovalCount > 0, hasPendingUserInput: row.pendingUserInputCount > 0, hasActionableProposedPlan: row.hasActionableProposedPlan > 0, + ...(row.originSource !== null && row.originSource !== undefined + ? { originSource: row.originSource } + : {}), + ...(row.participantSummaries !== null && row.participantSummaries !== undefined + ? { participantSummaries: row.participantSummaries } + : {}), }), ), updatedAt: updatedAt ?? "1970-01-01T00:00:00.000Z", @@ -2473,6 +2513,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { hasPendingApprovals: threadRow.value.pendingApprovalCount > 0, hasPendingUserInput: threadRow.value.pendingUserInputCount > 0, hasActionableProposedPlan: threadRow.value.hasActionableProposedPlan > 0, + ...(threadRow.value.originSource !== null && threadRow.value.originSource !== undefined + ? { originSource: threadRow.value.originSource } + : {}), + ...(threadRow.value.participantSummaries !== null && + threadRow.value.participantSummaries !== undefined + ? { participantSummaries: threadRow.value.participantSummaries } + : {}), } satisfies OrchestrationThreadShell); }); @@ -2593,6 +2640,7 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { text: row.text, turnId: row.turnId, streaming: row.isStreaming === 1, + ...(row.source !== null && row.source !== undefined ? { source: row.source } : {}), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -2627,6 +2675,13 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () { completedAt: row.completedAt, })), session: Option.isSome(sessionRow) ? mapSessionRow(sessionRow.value) : null, + ...(threadRow.value.originSource !== null && threadRow.value.originSource !== undefined + ? { originSource: threadRow.value.originSource } + : {}), + ...(threadRow.value.participantSummaries !== null && + threadRow.value.participantSummaries !== undefined + ? { participantSummaries: threadRow.value.participantSummaries } + : {}), }; return Option.some( diff --git a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts index 88d38d88664..b33c247559d 100644 --- a/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts +++ b/apps/server/src/orchestration/Layers/ProviderCommandReactor.ts @@ -945,6 +945,7 @@ const make = Effect.gen(function* () { threadId: input.threadId, requestId: input.requestId, ...(input.title !== undefined ? { title: input.title } : {}), + createdAt: yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)), }); }); const clearInterruptedThreadTitleRegenerations = Effect.fn( diff --git a/apps/server/src/orchestration/decider.titleRegeneration.test.ts b/apps/server/src/orchestration/decider.titleRegeneration.test.ts index dbf67acc14d..bf75ca52b88 100644 --- a/apps/server/src/orchestration/decider.titleRegeneration.test.ts +++ b/apps/server/src/orchestration/decider.titleRegeneration.test.ts @@ -52,6 +52,7 @@ it.layer(NodeServices.layer)("title regeneration decider", (it) => { threadId: ThreadId.make("thread-1"), requestId: CommandId.make("cmd-old-regeneration-request"), title: "Generated title", + createdAt: UPDATED_AT, }, readModel, }); diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 012bbca6ce5..29f2f8caa32 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -4,6 +4,7 @@ import { type OrchestrationEvent, type OrchestrationQueuedMessage, type OrchestrationThread, + type SourceRef, } from "@t3tools/contracts"; import * as DateTime from "effect/DateTime"; import * as Crypto from "effect/Crypto"; @@ -239,6 +240,8 @@ interface TurnStartMessageInput { readonly modelSelection?: OrchestrationQueuedMessage["modelSelection"]; readonly titleSeed?: string; readonly sourceProposedPlan?: OrchestrationQueuedMessage["sourceProposedPlan"]; + /** Server-stamped SourceRef for this user message (absent when identity off). */ + readonly source?: SourceRef; } /** @@ -276,6 +279,7 @@ const planTurnStartEvents = Effect.fn("planTurnStartEvents")(function* ({ attachments: message.attachments, turnId: null, streaming: false, + ...(message.source !== undefined ? { source: message.source } : {}), createdAt: occurredAt, updatedAt: occurredAt, }, @@ -402,6 +406,7 @@ const planQueuedMessageDispatch = Effect.fn("planQueuedMessageDispatch")(functio ? { modelSelection: queuedMessage.modelSelection } : {}), ...(sourcePlanStillValid ? { sourceProposedPlan } : {}), + ...(queuedMessage.source !== undefined ? { source: queuedMessage.source } : {}), }, occurredAt, }); @@ -1047,6 +1052,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { modelSelection: command.modelSelection } : {}), ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), + ...(command.source !== undefined ? { source: command.source } : {}), queuedAt: command.createdAt, }, }; @@ -1064,6 +1070,7 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" : {}), ...(command.titleSeed !== undefined ? { titleSeed: command.titleSeed } : {}), ...(sourceProposedPlan !== undefined ? { sourceProposedPlan } : {}), + ...(command.source !== undefined ? { source: command.source } : {}), }, occurredAt: command.createdAt, }); diff --git a/apps/server/src/orchestration/http.ts b/apps/server/src/orchestration/http.ts index cf1b84364fc..1ac59119fef 100644 --- a/apps/server/src/orchestration/http.ts +++ b/apps/server/src/orchestration/http.ts @@ -2,6 +2,8 @@ import { AuthOrchestrationOperateScope, AuthOrchestrationReadScope, EnvironmentHttpApi, + type EnvironmentRequestInvalidReason, + IdentityError, } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; @@ -16,10 +18,25 @@ import { failEnvironmentNotFound, requireEnvironmentScope, } from "../auth/http.ts"; +import * as SessionStore from "../auth/SessionStore.ts"; import { GrokTranscriptResync } from "../externalSessions/GrokTranscriptResync.ts"; +import * as IdentityService from "../identity/IdentityService.ts"; +import { stampOrchestrationCommandSource } from "../identity/stampSource.ts"; import { OrchestrationEngineService } from "./Services/OrchestrationEngine.ts"; import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; +const identityErrorToHttpReason = (error: IdentityError): EnvironmentRequestInvalidReason => { + switch (error.code) { + case "identity_claim_required": + case "identity_claim_missing": + return "identity_claim_required"; + case "identity_unknown_person": + return "identity_unknown_person"; + default: + return "identity_map_invalid"; + } +}; + export const orchestrationHttpApiLayer = HttpApiBuilder.group( EnvironmentHttpApi, "orchestration", @@ -27,6 +44,8 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( const projectionSnapshotQuery = yield* ProjectionSnapshotQuery; const orchestrationEngine = yield* OrchestrationEngineService; const grokTranscriptResync = yield* GrokTranscriptResync; + const identity = yield* IdentityService.IdentityService; + const sessions = yield* SessionStore.SessionStore; return handlers .handle( @@ -88,10 +107,33 @@ export const orchestrationHttpApiLayer = HttpApiBuilder.group( "dispatch", Effect.fn("environment.orchestration.dispatch")(function* (args) { yield* annotateEnvironmentRequest(args.endpoint.name); - yield* requireEnvironmentScope(AuthOrchestrationOperateScope); - const normalizedCommand = yield* normalizeDispatchCommand(args.payload).pipe( - Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), + const session = yield* requireEnvironmentScope(AuthOrchestrationOperateScope); + const clientDeviceType = yield* sessions.listActive().pipe( + Effect.map( + (active) => + active.find((entry) => entry.sessionId === session.sessionId)?.client.deviceType, + ), + Effect.orElseSucceed(() => undefined), ); + const operateClaim = yield* identity + .requireOperateClaim( + session.sessionId, + clientDeviceType !== undefined ? { clientDeviceType } : {}, + ) + .pipe( + Effect.catchTag("IdentityError", (error) => + failEnvironmentInvalidRequest(identityErrorToHttpReason(error)), + ), + ); + const mapPeople = yield* identity.listMapPeople(); + const normalizedCommand = stampOrchestrationCommandSource({ + command: yield* normalizeDispatchCommand(args.payload).pipe( + Effect.catch(() => failEnvironmentInvalidRequest("invalid_command")), + ), + claim: operateClaim, + clientDeviceType, + people: mapPeople, + }); return yield* orchestrationEngine .dispatch(normalizedCommand) .pipe( diff --git a/apps/server/src/orchestration/projector.ts b/apps/server/src/orchestration/projector.ts index 36748f247c3..6ce6f9b5d18 100644 --- a/apps/server/src/orchestration/projector.ts +++ b/apps/server/src/orchestration/projector.ts @@ -4,7 +4,10 @@ import { OrchestrationMessage, OrchestrationSession, OrchestrationThread, + type SourceRef, + type ThreadParticipantSummary, } from "@t3tools/contracts"; +import { mergeParticipantSummaries, nextOriginSource } from "@t3tools/shared/sourceAttribution"; import * as Effect from "effect/Effect"; import * as HashMap from "effect/HashMap"; import * as HashSet from "effect/HashSet"; @@ -487,6 +490,7 @@ export function projectEvent( ...(payload.attachments !== undefined ? { attachments: payload.attachments } : {}), turnId: payload.turnId, streaming: payload.streaming, + ...(payload.source !== undefined ? { source: payload.source } : {}), createdAt: payload.createdAt, updatedAt: payload.updatedAt, }, @@ -523,17 +527,64 @@ export function projectEvent( ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + // Preserve source on first write; streaming deltas don't re-stamp. + ...(entry.source === undefined && message.source !== undefined + ? { source: message.source } + : {}), } : entry, ) : [...thread.messages, message]; const cappedMessages = messages.slice(-MAX_THREAD_MESSAGES); + const messageSource: SourceRef | undefined = + existingMessage === undefined + ? message.source + : (existingMessage.source ?? message.source); + const nextOrigin = nextOriginSource({ + current: thread.originSource ?? null, + messageSource, + role: message.role, + }); + // Preserve branded SourceRef from the event when setting origin. + const originSource: SourceRef | null | undefined = + nextOrigin === null || nextOrigin === undefined + ? nextOrigin + : messageSource !== undefined && + (thread.originSource === undefined || thread.originSource === null) + ? messageSource + : (thread.originSource ?? null); + const existingSummaries = thread.participantSummaries ?? []; + const participantSummaries: ReadonlyArray = + message.role === "user" && + messageSource?.personId !== undefined && + messageSource.username !== undefined + ? (mergeParticipantSummaries({ + existing: existingSummaries, + source: { + personId: messageSource.personId, + username: messageSource.username, + channel: messageSource.channel, + }, + participatedAt: message.createdAt, + originPersonId: originSource?.personId ?? null, + }).map((entry) => ({ + personId: entry.personId as ThreadParticipantSummary["personId"], + username: entry.username as ThreadParticipantSummary["username"], + ...(entry.name !== undefined ? { name: entry.name } : {}), + ...(entry.firstChannel !== undefined ? { firstChannel: entry.firstChannel } : {}), + ...(entry.channels !== undefined ? { channels: [...entry.channels] } : {}), + firstParticipatedAt: entry.firstParticipatedAt, + })) as ReadonlyArray) + : existingSummaries; + return { ...nextBase, threads: updateThread(nextBase.threads, payload.threadId, { messages: cappedMessages, updatedAt: event.occurredAt, + ...(originSource !== undefined ? { originSource } : {}), + ...(participantSummaries.length > 0 ? { participantSummaries } : {}), }), }; }); @@ -561,6 +612,7 @@ export function projectEvent( ...(payload.sourceProposedPlan !== undefined ? { sourceProposedPlan: payload.sourceProposedPlan } : {}), + ...(payload.source !== undefined ? { source: payload.source } : {}), queuedAt: payload.queuedAt, }, ]; diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index 18ddbc66c0c..bc72758bc71 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -36,18 +36,6 @@ function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): vo } } -export function hydratePosixHome( - env: NodeJS.ProcessEnv, - resolveHomeDir = () => NodeOS.userInfo().homedir, -): void { - if ((env.HOME?.trim() ?? "").length > 0) return; - - const homeDir = resolveHomeDir(); - if (homeDir.length > 0) { - env.HOME = homeDir; - } -} - export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< void, never, @@ -75,13 +63,6 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< if (platform !== "darwin" && platform !== "linux") return; - yield* Effect.sync(() => hydratePosixHome(env)).pipe( - Effect.catchDefect((defect) => - Effect.sync(() => { - logPathHydrationWarning("Failed to hydrate HOME from the user account.", defect); - }), - ), - ); yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( Effect.catchDefect((defect) => Effect.sync(() => { diff --git a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts index 71919166886..368eb0d2f6b 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreadMessages.ts @@ -5,7 +5,7 @@ import * as Layer from "effect/Layer"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; -import { ChatAttachment } from "@t3tools/contracts"; +import { ChatAttachment, SourceRef } from "@t3tools/contracts"; import { toPersistenceSqlError } from "../Errors.ts"; import { @@ -21,6 +21,8 @@ const ProjectionThreadMessageDbRowSchema = ProjectionThreadMessage.mapFields( Struct.assign({ isStreaming: Schema.Number, attachments: Schema.NullOr(Schema.fromJsonString(Schema.Array(ChatAttachment))), + // JSON column may be SQL NULL or the string "null" from JSON.stringify(null). + source: Schema.NullOr(Schema.fromJsonString(Schema.NullOr(SourceRef))), }), ); @@ -37,6 +39,7 @@ function toProjectionThreadMessage( createdAt: row.createdAt, updatedAt: row.updatedAt, ...(row.attachments !== null ? { attachments: row.attachments } : {}), + ...(row.source !== null && row.source !== undefined ? { source: row.source } : {}), }; } @@ -48,6 +51,8 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { execute: (row) => { const nextAttachmentsJson = row.attachments !== undefined ? JSON.stringify(row.attachments) : null; + // Avoid JSON.stringify(null) → "null" which fails SourceRef decode. + const nextSourceJson = row.source != null ? JSON.stringify(row.source) : null; return sql` INSERT INTO projection_thread_messages ( message_id, @@ -57,6 +62,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json, is_streaming, + source_json, created_at, updated_at ) @@ -75,6 +81,14 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { ) ), ${row.isStreaming ? 1 : 0}, + COALESCE( + ${nextSourceJson}, + ( + SELECT source_json + FROM projection_thread_messages + WHERE message_id = ${row.messageId} + ) + ), ${row.createdAt}, ${row.updatedAt} ) @@ -89,6 +103,10 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { projection_thread_messages.attachments_json ), is_streaming = excluded.is_streaming, + source_json = COALESCE( + excluded.source_json, + projection_thread_messages.source_json + ), created_at = excluded.created_at, updated_at = excluded.updated_at `; @@ -108,6 +126,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source_json AS "source", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages @@ -129,6 +148,7 @@ const makeProjectionThreadMessageRepository = Effect.gen(function* () { text, attachments_json AS "attachments", is_streaming AS "isStreaming", + source_json AS "source", created_at AS "createdAt", updated_at AS "updatedAt" FROM projection_thread_messages diff --git a/apps/server/src/persistence/Layers/ProjectionThreads.ts b/apps/server/src/persistence/Layers/ProjectionThreads.ts index 7bbe78ea3b7..c6876e6e6a3 100644 --- a/apps/server/src/persistence/Layers/ProjectionThreads.ts +++ b/apps/server/src/persistence/Layers/ProjectionThreads.ts @@ -2,6 +2,7 @@ import * as SqlClient from "effect/unstable/sql/SqlClient"; import * as SqlSchema from "effect/unstable/sql/SqlSchema"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as Struct from "effect/Struct"; @@ -15,15 +16,53 @@ import { ProjectionThreadWorktreeReference, type ProjectionThreadRepositoryShape, } from "../Services/ProjectionThreads.ts"; -import { ModelSelection } from "@t3tools/contracts"; +import { ModelSelection, SourceRef, ThreadParticipantSummary } from "@t3tools/contracts"; +// JSON columns may be SQL NULL or the string "null" (from JSON.stringify(null)). +// Decode with NullOr inside fromJsonString so both forms become null. const ProjectionThreadDbRow = ProjectionThread.mapFields( Struct.assign({ modelSelection: Schema.fromJsonString(ModelSelection), + originSource: Schema.NullOr(Schema.fromJsonString(Schema.NullOr(SourceRef))), + participantSummaries: Schema.NullOr( + Schema.fromJsonString(Schema.NullOr(Schema.Array(ThreadParticipantSummary))), + ), }), ); type ProjectionThreadDbRow = typeof ProjectionThreadDbRow.Type; +function toProjectionThread(row: ProjectionThreadDbRow): ProjectionThread { + return { + threadId: row.threadId, + projectId: row.projectId, + title: row.title, + modelSelection: row.modelSelection, + runtimeMode: row.runtimeMode, + interactionMode: row.interactionMode, + branch: row.branch, + worktreePath: row.worktreePath, + latestTurnId: row.latestTurnId, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + archivedAt: row.archivedAt, + settledOverride: row.settledOverride, + settledAt: row.settledAt, + snoozedUntil: row.snoozedUntil, + snoozedAt: row.snoozedAt, + latestUserMessageAt: row.latestUserMessageAt, + pendingApprovalCount: row.pendingApprovalCount, + pendingUserInputCount: row.pendingUserInputCount, + hasActionableProposedPlan: row.hasActionableProposedPlan, + deletedAt: row.deletedAt, + ...(row.originSource !== null && row.originSource !== undefined + ? { originSource: row.originSource } + : {}), + ...(row.participantSummaries !== null && row.participantSummaries !== undefined + ? { participantSummaries: row.participantSummaries } + : {}), + }; +} + const makeProjectionThreadRepository = Effect.gen(function* () { const sql = yield* SqlClient.SqlClient; @@ -54,7 +93,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count, pending_user_input_count, has_actionable_proposed_plan, - deleted_at + deleted_at, + origin_source_json, + participant_summaries_json ) VALUES ( ${row.threadId}, @@ -79,7 +120,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { ${row.pendingApprovalCount}, ${row.pendingUserInputCount}, ${row.hasActionableProposedPlan}, - ${row.deletedAt} + ${row.deletedAt}, + ${row.originSource != null ? JSON.stringify(row.originSource) : null}, + ${row.participantSummaries != null ? JSON.stringify(row.participantSummaries) : null} ) ON CONFLICT (thread_id) DO UPDATE SET @@ -104,7 +147,15 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count = excluded.pending_approval_count, pending_user_input_count = excluded.pending_user_input_count, has_actionable_proposed_plan = excluded.has_actionable_proposed_plan, - deleted_at = excluded.deleted_at + deleted_at = excluded.deleted_at, + origin_source_json = COALESCE( + excluded.origin_source_json, + projection_threads.origin_source_json + ), + participant_summaries_json = COALESCE( + excluded.participant_summaries_json, + projection_threads.participant_summaries_json + ) `, }); @@ -136,7 +187,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE thread_id = ${threadId} `, @@ -170,7 +223,9 @@ const makeProjectionThreadRepository = Effect.gen(function* () { pending_approval_count AS "pendingApprovalCount", pending_user_input_count AS "pendingUserInputCount", has_actionable_proposed_plan AS "hasActionableProposedPlan", - deleted_at AS "deletedAt" + deleted_at AS "deletedAt", + origin_source_json AS "originSource", + participant_summaries_json AS "participantSummaries" FROM projection_threads WHERE project_id = ${projectId} ORDER BY created_at ASC, thread_id ASC @@ -211,11 +266,15 @@ const makeProjectionThreadRepository = Effect.gen(function* () { const getById: ProjectionThreadRepositoryShape["getById"] = (input) => getProjectionThreadRow(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.getById:query")), + Effect.map((row) => + Option.isNone(row) ? Option.none() : Option.some(toProjectionThread(row.value)), + ), ); const listByProjectId: ProjectionThreadRepositoryShape["listByProjectId"] = (input) => listProjectionThreadRows(input).pipe( Effect.mapError(toPersistenceSqlError("ProjectionThreadRepository.listByProjectId:query")), + Effect.map((rows) => rows.map(toProjectionThread)), ); const deleteById: ProjectionThreadRepositoryShape["deleteById"] = (input) => diff --git a/apps/server/src/persistence/Migrations.ts b/apps/server/src/persistence/Migrations.ts index 3be51de130a..237d98cf770 100644 --- a/apps/server/src/persistence/Migrations.ts +++ b/apps/server/src/persistence/Migrations.ts @@ -49,6 +49,8 @@ import Migration0033 from "./Migrations/033_ProjectionThreadsSettled.ts"; import Migration0034 from "./Migrations/034_ProjectionThreadsSnoozed.ts"; import Migration0035 from "./Migrations/035_ProjectionThreadTitleRegeneration.ts"; import Migration0036 from "./Migrations/036_ProjectionQueuedMessages.ts"; +import Migration0037 from "./Migrations/037_SessionIdentityClaims.ts"; +import Migration0038 from "./Migrations/038_ProjectionThreadSourceAttribution.ts"; import Migration0039 from "./Migrations/039_RepairProjectionThreadTitleRegeneration.ts"; /** @@ -98,6 +100,8 @@ export const migrationEntries = [ [34, "ProjectionThreadsSnoozed", Migration0034], [35, "ProjectionThreadTitleRegeneration", Migration0035], [36, "ProjectionQueuedMessages", Migration0036], + [37, "SessionIdentityClaims", Migration0037], + [38, "ProjectionThreadSourceAttribution", Migration0038], [39, "RepairProjectionThreadTitleRegeneration", Migration0039], ] as const; diff --git a/apps/server/src/persistence/Migrations/037_SessionIdentityClaims.ts b/apps/server/src/persistence/Migrations/037_SessionIdentityClaims.ts new file mode 100644 index 00000000000..b778ee3bcb3 --- /dev/null +++ b/apps/server/src/persistence/Migrations/037_SessionIdentityClaims.ts @@ -0,0 +1,19 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + yield* sql` + CREATE TABLE IF NOT EXISTS session_identity_claims ( + session_id TEXT PRIMARY KEY NOT NULL, + person_id TEXT NOT NULL, + username TEXT NOT NULL, + claimed_at TEXT NOT NULL, + method TEXT NOT NULL + ) + `; + yield* sql` + CREATE INDEX IF NOT EXISTS idx_session_identity_claims_person + ON session_identity_claims(person_id) + `; +}); diff --git a/apps/server/src/persistence/Migrations/038_ProjectionThreadSourceAttribution.ts b/apps/server/src/persistence/Migrations/038_ProjectionThreadSourceAttribution.ts new file mode 100644 index 00000000000..9da2f972805 --- /dev/null +++ b/apps/server/src/persistence/Migrations/038_ProjectionThreadSourceAttribution.ts @@ -0,0 +1,36 @@ +import * as Effect from "effect/Effect"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; + +/** + * Source attribution denormalized onto projected messages and thread shells. + * JSON columns stay optional for legacy rows (NULL = absent). + */ +export default Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const messageColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_thread_messages) + `; + if (!messageColumns.some((column) => column.name === "source_json")) { + yield* sql` + ALTER TABLE projection_thread_messages + ADD COLUMN source_json TEXT + `; + } + + const threadColumns = yield* sql<{ readonly name: string }>` + PRAGMA table_info(projection_threads) + `; + if (!threadColumns.some((column) => column.name === "origin_source_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN origin_source_json TEXT + `; + } + if (!threadColumns.some((column) => column.name === "participant_summaries_json")) { + yield* sql` + ALTER TABLE projection_threads + ADD COLUMN participant_summaries_json TEXT + `; + } +}); diff --git a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts index d50ff320256..af7f3cfcdd7 100644 --- a/apps/server/src/persistence/Services/ProjectionThreadMessages.ts +++ b/apps/server/src/persistence/Services/ProjectionThreadMessages.ts @@ -10,6 +10,7 @@ import { ChatAttachment, MessageId, OrchestrationMessageRole, + SourceRef, ThreadId, TurnId, IsoDateTime, @@ -29,6 +30,8 @@ export const ProjectionThreadMessage = Schema.Struct({ text: Schema.String, attachments: Schema.optional(Schema.Array(ChatAttachment)), isStreaming: Schema.Boolean, + /** Server-authored provenance; absent on legacy / assistant rows. */ + source: Schema.optional(SourceRef), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); diff --git a/apps/server/src/persistence/Services/ProjectionThreads.ts b/apps/server/src/persistence/Services/ProjectionThreads.ts index c8206272db0..2948e33d500 100644 --- a/apps/server/src/persistence/Services/ProjectionThreads.ts +++ b/apps/server/src/persistence/Services/ProjectionThreads.ts @@ -14,7 +14,9 @@ import { ProjectId, ProviderInteractionMode, RuntimeMode, + SourceRef, ThreadId, + ThreadParticipantSummary, TurnId, } from "@t3tools/contracts"; import * as Option from "effect/Option"; @@ -48,6 +50,10 @@ export const ProjectionThread = Schema.Struct({ pendingUserInputCount: NonNegativeInt, hasActionableProposedPlan: NonNegativeInt, deletedAt: Schema.NullOr(IsoDateTime), + /** First user-message SourceRef; null/absent on legacy threads. */ + originSource: Schema.optional(Schema.NullOr(SourceRef)), + /** Ordered participant stack data for list UI. */ + participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)), }); export type ProjectionThread = typeof ProjectionThread.Type; diff --git a/apps/server/src/persistence/SessionIdentityClaims.ts b/apps/server/src/persistence/SessionIdentityClaims.ts new file mode 100644 index 00000000000..b7eafb4b4c3 --- /dev/null +++ b/apps/server/src/persistence/SessionIdentityClaims.ts @@ -0,0 +1,177 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; +import * as SqlClient from "effect/unstable/sql/SqlClient"; +import * as SqlSchema from "effect/unstable/sql/SqlSchema"; +import { + AuthSessionId, + IdentityUsername, + PersonId, + SessionIdentityClaimMethod, +} from "@t3tools/contracts"; + +import { + PersistenceDecodeError, + PersistenceSqlError, + type PersistenceErrorCorrelation, +} from "./Errors.ts"; + +export const SessionIdentityClaimRecord = Schema.Struct({ + sessionId: AuthSessionId, + personId: PersonId, + username: IdentityUsername, + claimedAt: Schema.String, + method: SessionIdentityClaimMethod, +}); +export type SessionIdentityClaimRecord = typeof SessionIdentityClaimRecord.Type; + +type ClaimRepoError = PersistenceSqlError | PersistenceDecodeError; + +export class SessionIdentityClaimRepository extends Context.Service< + SessionIdentityClaimRepository, + { + readonly getBySessionId: ( + sessionId: AuthSessionId, + ) => Effect.Effect, ClaimRepoError>; + readonly upsert: (record: SessionIdentityClaimRecord) => Effect.Effect; + readonly deleteBySessionId: ( + sessionId: AuthSessionId, + ) => Effect.Effect; + } +>()("t3/persistence/SessionIdentityClaims/SessionIdentityClaimRepository") {} + +const toError = + (sqlOp: string, decodeOp: string, correlation?: PersistenceErrorCorrelation) => + (cause: unknown): ClaimRepoError => + Schema.isSchemaError(cause) + ? PersistenceDecodeError.fromSchemaError(decodeOp, cause, correlation) + : new PersistenceSqlError({ + operation: sqlOp, + ...(correlation === undefined ? {} : { correlation }), + cause, + }); + +const ClaimDbRow = Schema.Struct({ + sessionId: AuthSessionId, + personId: PersonId, + username: IdentityUsername, + claimedAt: Schema.String, + method: SessionIdentityClaimMethod, +}); + +const decodeClaim = Schema.decodeUnknownEffect(ClaimDbRow); + +export const make = Effect.gen(function* () { + const sql = yield* SqlClient.SqlClient; + + const getRow = SqlSchema.findOneOption({ + Request: Schema.Struct({ sessionId: AuthSessionId }), + Result: Schema.Struct({ + sessionId: Schema.String, + personId: Schema.Unknown, + username: Schema.Unknown, + claimedAt: Schema.Unknown, + method: Schema.Unknown, + }), + execute: ({ sessionId }) => + sql` + SELECT + session_id AS "sessionId", + person_id AS "personId", + username AS "username", + claimed_at AS "claimedAt", + method AS "method" + FROM session_identity_claims + WHERE session_id = ${sessionId} + `, + }); + + const upsertRow = SqlSchema.void({ + Request: SessionIdentityClaimRecord, + execute: (input) => + sql` + INSERT INTO session_identity_claims ( + session_id, + person_id, + username, + claimed_at, + method + ) + VALUES ( + ${input.sessionId}, + ${input.personId}, + ${input.username}, + ${input.claimedAt}, + ${input.method} + ) + ON CONFLICT(session_id) DO UPDATE SET + person_id = excluded.person_id, + username = excluded.username, + claimed_at = excluded.claimed_at, + method = excluded.method + `, + }); + + const deleteRow = SqlSchema.void({ + Request: Schema.Struct({ sessionId: AuthSessionId }), + execute: ({ sessionId }) => + sql` + DELETE FROM session_identity_claims + WHERE session_id = ${sessionId} + `, + }); + + return { + getBySessionId: (sessionId) => + getRow({ sessionId }).pipe( + Effect.mapError( + toError( + "SessionIdentityClaimRepository.getBySessionId:query", + "SessionIdentityClaimRepository.getBySessionId:decode", + { sessionId }, + ), + ), + Effect.flatMap((rowOption) => + Option.match(rowOption, { + onNone: () => Effect.succeed(Option.none()), + onSome: (row) => + decodeClaim(row).pipe( + Effect.mapError((cause) => + PersistenceDecodeError.fromSchemaError( + "SessionIdentityClaimRepository.getBySessionId:decode", + cause, + { sessionId }, + ), + ), + Effect.map((decoded) => Option.some(decoded)), + ), + }), + ), + ), + upsert: (record) => + upsertRow(record).pipe( + Effect.mapError( + toError( + "SessionIdentityClaimRepository.upsert:query", + "SessionIdentityClaimRepository.upsert:encode", + { sessionId: record.sessionId }, + ), + ), + ), + deleteBySessionId: (sessionId) => + deleteRow({ sessionId }).pipe( + Effect.mapError( + toError( + "SessionIdentityClaimRepository.deleteBySessionId:query", + "SessionIdentityClaimRepository.deleteBySessionId:encode", + { sessionId }, + ), + ), + Effect.as(true), + ), + } satisfies SessionIdentityClaimRepository["Service"]; +}); + +export const layer = Layer.effect(SessionIdentityClaimRepository, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index eeeb4796676..219b2192578 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -80,6 +80,7 @@ import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import * as HttpResponseCompression from "./httpCompression/HttpResponseCompression.ts"; import { makeRoutesLayer } from "./server.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; import { resolveAvailableEditorsForConfig } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GrokTranscriptResync from "./externalSessions/GrokTranscriptResync.ts"; @@ -570,15 +571,20 @@ const buildAppUnderTest = (options?: { disableListenLog: true, disableLogger: true, }).pipe( + // Identity gate is off when map is empty; still must be provided for WS/HTTP. + // Merged with keybindings so Layer.pipe stays under the 20-arg limit. Layer.provide( - Layer.mock(Keybindings.Keybindings)({ - loadConfigState: Effect.succeed({ - keybindings: [], - issues: [], + Layer.mergeAll( + IdentityService.layerWithPeople([]), + Layer.mock(Keybindings.Keybindings)({ + loadConfigState: Effect.succeed({ + keybindings: [], + issues: [], + }), + streamChanges: Stream.empty, + ...options?.layers?.keybindings, }), - streamChanges: Stream.empty, - ...options?.layers?.keybindings, - }), + ), ), Layer.provide( Layer.mock(ProviderRegistry.ProviderRegistry)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index d89279a14cf..afd389ba6ea 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -1,8 +1,6 @@ import { EnvironmentHttpApi } from "@t3tools/contracts"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; -import * as Schedule from "effect/Schedule"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -82,6 +80,7 @@ import { ObservabilityLive } from "./observability/Layers/Observability.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import { authHttpApiLayer, environmentAuthenticatedAuthLayer } from "./auth/http.ts"; import * as ServerSecretStore from "./auth/ServerSecretStore.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { connectHttpApiLayer, @@ -257,6 +256,11 @@ const ProviderLayerLive = ProviderServiceLive.pipe( const PersistenceLayerLive = Layer.empty.pipe(Layer.provideMerge(SqlitePersistenceLayerLive)); +/** Durable identity claims — residual-free once Persistence/SqlClient is in the graph. */ +const IdentityLayerLive = IdentityService.layerPersisted.pipe( + Layer.provideMerge(PersistenceLayerLive), +); + const VcsDriverRegistryLayerLive = VcsDriverRegistry.layer.pipe( Layer.provide(VcsProjectConfig.layer), ); @@ -307,6 +311,7 @@ const GitHubAppDependenciesLive = Layer.mergeAll( const GitHubPrBridgeLive = GitHubPrBridge.layer.pipe( Layer.provideMerge(GitHubAppDependenciesLive), Layer.provideMerge(ThreadWorkItemStoreLive), + Layer.provideMerge(IdentityLayerLive), ); const JiraAppDependenciesLive = Layer.mergeAll(JiraAppClient.layer, JiraDeliveryStore.layer).pipe( @@ -317,6 +322,8 @@ const JiraIssueBridgeLive = JiraIssueBridge.layer.pipe( Layer.provideMerge(JiraAppDependenciesLive), // Prefer the instance already provided by GitHubPrBridgeLive when merged below. Layer.provideMerge(ThreadWorkItemStoreLive), + // Closed-set map for trusted vs context-only Jira actors. + Layer.provideMerge(IdentityLayerLive), ); const VcsLayerLive = Layer.empty.pipe( @@ -493,6 +500,8 @@ export const makeRoutesLayer = Layer.mergeAll( ), McpHttpServer.layer.pipe(Layer.provide(McpSessionRegistry.layer)), ).pipe( + // IdentityService is residual here — tests provide layerWithPeople / memory; + // makeServerLayer provides layerPersisted (SQLite claims) once SqlClient is live. Layer.provide(PreviewAutomationBroker.layer), Layer.provide(ServerSelfUpdate.layer), Layer.provide(browserApiCorsLayer), @@ -620,25 +629,7 @@ export const makeServerLayer = Layer.unwrap( yield* Effect.forkScoped( Effect.sleep("250 millis").pipe( Effect.andThen(reconcileDesiredCloudLink(`http://127.0.0.1:${address.port}`)), - // On reboot this races NIC/DNS bring-up, so back off exponentially - // (capped at 30s) instead of burning all retries in a second. - // Bounded overall so a permanently broken setup still surfaces the - // warning below. Bad-request/unauthorized/conflict are - // deterministic failures (malformed origin, not linked yet, linked - // to a different cloud account) that no amount of retrying - // converges. - Effect.retry({ - while: (error) => - error._tag !== "EnvironmentHttpBadRequestError" && - error._tag !== "EnvironmentHttpUnauthorizedError" && - error._tag !== "EnvironmentHttpConflictError", - schedule: Schedule.exponential("1 second").pipe( - Schedule.modifyDelay(({ duration }) => - Effect.succeed(Duration.min(duration, Duration.seconds(30))), - ), - Schedule.upTo({ duration: "10 minutes" }), - ), - }), + Effect.retry({ times: 4 }), Effect.tap(() => Effect.logInfo("T3 Connect desired link reconciled on startup")), Effect.catch((cause) => Effect.logWarning("Failed to reconcile T3 Connect desired link on startup", { @@ -662,6 +653,8 @@ export const makeServerLayer = Layer.unwrap( return serverApplicationLayer.pipe( Layer.provideMerge(RuntimeServicesLive), + // Durable claims for HTTP/WS routes (residual-free; SQL from Persistence). + Layer.provideMerge(IdentityLayerLive), Layer.provideMerge(serverRelayBrokerTracingLayer), Layer.provideMerge(HttpResponseCompressionLive), Layer.provideMerge(HttpServerLive), diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 3474c6f522a..aab69de6a12 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -153,50 +153,6 @@ it.effect("uses stable diagnostics for every parsed non-repository command", () }).pipe(Effect.provide(layer)); }); -it.effect("invalidates origin remote cache when a driver mutation adds origin", () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - - const before = yield* driver.statusDetailsLocal(cwd); - assert.equal(before.hasOriginRemote, false); - - yield* driver.ensureRemote({ cwd, preferredName: "origin", url: remote }); - - const after = yield* driver.statusDetailsLocal(cwd); - assert.equal(after.hasOriginRemote, true); - }).pipe(Effect.provide(TestLayer)), -); - -it.effect("re-reads origin remote status after cache TTL expiry and bypassed invalidation", () => - Effect.gen(function* () { - const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const remote = yield* makeTmpDir("git-vcs-driver-remote-"); - yield* initRepoWithCommit(cwd); - yield* git(remote, ["init", "--bare"]); - - // First call caches hasOriginRemote = false (5-min TTL) - assert.equal((yield* driver.statusDetailsLocal(cwd)).hasOriginRemote, false); - - // Add origin via raw git (bypasses invalidation hook) - yield* git(cwd, ["remote", "add", "origin", remote]); - - // Cache still has the stale false (TTL not yet expired) - const stillCached = yield* driver.statusDetailsLocal(cwd); - assert.equal(stillCached.hasOriginRemote, false); - - // Advance past the 5-minute TTL so the cache entry expires - yield* TestClock.adjust("6 minutes"); - - // After expiry, the next call re-executes and picks up the remote - const afterExpiry = yield* driver.statusDetailsLocal(cwd); - assert.equal(afterExpiry.hasOriginRemote, true); - }).pipe(Effect.provide(TestLayer)), -); it.effect("coalesces concurrent ref pages into one repository snapshot", () => Effect.scoped( Effect.gen(function* () { @@ -619,6 +575,7 @@ it.effect("backs off failed upstream refreshes across linked worktrees", () => }), ).pipe(Effect.provide(ServerConfigLayer.pipe(Layer.provideMerge(NodeServices.layer)))), ); + it.layer(TestLayer)("GitVcsDriver core integration", (it) => { describe("process environment", () => { it.effect("preserves the caller locale for general Git subprocesses", () => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index daa07e30bbf..190e9ec69a5 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -70,8 +70,6 @@ const LIST_REFS_SNAPSHOT_CACHE_CAPACITY = 64; const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); -const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); -const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -1186,63 +1184,6 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return Cache.get(refresh ? repositoryPathsRefreshCache : repositoryPathsCache, cacheKey); }; - const defaultBranchCache = yield* Cache.makeWith( - (gitCommonDir: string) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fetchCwd = - path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return yield* executeGit( - "GitVcsDriver.statusDetails.defaultBranch", - fetchCwd, - ["--git-dir", gitCommonDir, "symbolic-ref", "refs/remotes/origin/HEAD"], - { allowNonZeroExit: true }, - ).pipe( - Effect.map((result) => { - if (result.exitCode !== 0) return null; - return parseDefaultBranchFromRemoteHeadRef(result.stdout, "origin"); - }), - ); - }), - { - capacity: 2_048, - timeToLive: Exit.match({ - onSuccess: () => STATUS_DEFAULT_BRANCH_CACHE_TTL, - onFailure: () => Duration.zero, - }), - }, - ); - const originExistsCache = yield* Cache.makeWith( - (gitCommonDir: string) => - Effect.gen(function* () { - const path = yield* Path.Path; - const fetchCwd = - path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return yield* executeGit( - "GitVcsDriver.statusDetails.originExists", - fetchCwd, - ["--git-dir", gitCommonDir, "remote", "get-url", "origin"], - { allowNonZeroExit: true }, - ).pipe(Effect.map((result) => result.exitCode === 0)); - }), - { - capacity: 2_048, - timeToLive: Exit.match({ - onSuccess: () => STATUS_ORIGIN_EXISTS_CACHE_TTL, - onFailure: () => Duration.zero, - }), - }, - ); - const invalidateStatusStaticCaches = (cwd: string) => - Effect.gen(function* () { - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), - ); - const cacheKey = repositoryPaths?.gitCommonDir ?? normalizeRepositoryPathsCacheKey(cwd); - yield* Cache.invalidate(defaultBranchCache, cacheKey); - yield* Cache.invalidate(originExistsCache, cacheKey); - }); - const resolveGitCommonDir = Effect.fn("resolveGitCommonDir")(function* (cwd: string) { const repositoryPaths = yield* resolveRepositoryPaths(cwd); if (repositoryPaths !== null) { @@ -1641,11 +1582,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); } - const repositoryPaths = yield* resolveRepositoryPaths(cwd).pipe( - Effect.catchTags({ GitCommandError: () => Effect.succeed(null) }), - ); - const statusCacheKey = repositoryPaths?.gitCommonDir; - const [numstatStdout, defaultBranch, hasPrimaryRemote] = yield* Effect.all( + const [numstatStdout, defaultRefResult, hasPrimaryRemote] = yield* Effect.all( [ executeGitWithStableDiagnostics( "GitVcsDriver.statusDetails.numstat", @@ -1702,16 +1639,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ); }), ), - statusCacheKey - ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) - : resolveDefaultBranchName(cwd, "origin").pipe(Effect.orElseSucceed(() => null)), - statusCacheKey - ? Cache.get(originExistsCache, statusCacheKey).pipe(Effect.orElseSucceed(() => false)) - : originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), + executeGit( + "GitVcsDriver.statusDetails.defaultRef", + cwd, + ["symbolic-ref", "refs/remotes/origin/HEAD"], + { allowNonZeroExit: true }, + ), + originRemoteExists(cwd).pipe(Effect.orElseSucceed(() => false)), ], { concurrency: "unbounded" }, ); const statusStdout = statusResult.stdout; + const defaultBranch = + defaultRefResult.exitCode === 0 + ? defaultRefResult.stdout.trim().replace(/^refs\/remotes\/origin\//, "") + : null; let refName: string | null = null; let upstreamRef: string | null = null; @@ -2960,14 +2902,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, effect: Effect.Effect, ): Effect.Effect => - effect.pipe( - Effect.ensuring( - Effect.all([ - invalidateListRefsSnapshot(cwd).pipe(Effect.ignore), - invalidateStatusStaticCaches(cwd).pipe(Effect.ignore), - ]), - ), - ); + effect.pipe(Effect.ensuring(invalidateListRefsSnapshot(cwd).pipe(Effect.ignore))); const initRepoWithListRefsInvalidation: GitVcsDriver.GitVcsDriver["Service"]["initRepo"] = ( input, ) => diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index d47aaaec826..a08350ed959 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -72,12 +72,7 @@ const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) return result.stdout.trim(); }); -const searchWorkspaceEntries = (input: { - cwd: string; - query: string; - limit: number; - kind?: "file" | "directory"; -}) => +const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; return yield* workspaceEntries.search(input); @@ -205,62 +200,6 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); - it.effect("applies the file filter before limiting search results", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" }); - yield* writeTextFile(cwd, "src/index.ts"); - yield* writeTextFile(cwd, "src/internal.ts"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "src", - limit: 1, - kind: "file", - }); - - expect(result.entries).toEqual([{ path: "src/index.ts", kind: "file" }]); - expect(result.truncated).toBe(true); - }), - ); - - it.effect("answers an empty file-filtered query with a bounded file listing", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" }); - yield* writeTextFile(cwd, "src/index.ts"); - yield* writeTextFile(cwd, "README.md"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "", - limit: 10, - kind: "file", - }); - - const paths = result.entries.map((entry) => entry.path); - expect(paths).toHaveLength(2); - expect(paths).toContain("src/index.ts"); - expect(paths).toContain("README.md"); - expect(result.entries.every((entry) => entry.kind === "file")).toBe(true); - }), - ); - - it.effect("returns only directories for the directory filter", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" }); - yield* writeTextFile(cwd, "src/index.ts"); - - const result = yield* searchWorkspaceEntries({ - cwd, - query: "src", - limit: 10, - kind: "directory", - }); - - expect(result.entries).toEqual([{ path: "src", kind: "directory" }]); - expect(result.truncated).toBe(false); - }), - ); - it.effect("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); @@ -353,287 +292,6 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); }); - describe("searchContents", () => { - it.effect("returns content matches with file paths, line numbers, and ranges", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" }); - yield* writeTextFile( - cwd, - "src/shapes.ts", - "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n", - ); - yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "Square", - limit: 100, - caseSensitive: false, - wholeWord: true, - useRegex: false, - }); - - expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([ - ["src/shapes.ts", 1], - ["src/shapes.ts", 2], - ]); - expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]); - expect(result.truncated).toBe(false); - }), - ); - - it.effect("honors case sensitivity and gitignore rules", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true }); - yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); - yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n"); - yield* writeTextFile(cwd, "ignored.txt", "Square\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "Square", - limit: 100, - caseSensitive: true, - wholeWord: false, - useRegex: false, - }); - - expect(result.matches).toHaveLength(1); - expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 }); - }), - ); - - it.effect("filters whole-word matches by word boundaries without widening ranges", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" }); - yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "note", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - // "notes", "denote", and "footnote" are word-adjacent and excluded; - // ranges cover exactly the query, never boundary characters. - expect(result.matches).toEqual([ - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [{ start: 0, end: 4 }], - }), - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 2, - matchRanges: [{ start: 9, end: 13 }], - }), - ]); - }), - ); - - it.effect("finds later whole-word matches in a file after rejected raw matches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" }); - yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo", - limit: 1, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - expect(result.matches).toEqual([ - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 11, - matchRanges: [{ start: 0, end: 3 }], - }), - ]); - }), - ); - - it.effect("treats astral-plane letters as whole word characters", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" }); - yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - expect(result.matches).toEqual([ - expect.objectContaining({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [{ start: 6, end: 9 }], - }), - ]); - }), - ); - - it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" }); - yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "-foo-", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - // Consuming-boundary regex would swallow the separating spaces and - // drop the middle occurrence; boundary post-filtering keeps all three. - expect(result.matches).toHaveLength(1); - expect(result.matches[0]).toMatchObject({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [ - { start: 0, end: 5 }, - { start: 6, end: 11 }, - { start: 12, end: 17 }, - ], - }); - }), - ); - - it.effect("matches punctuation-edged regex queries as whole words", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" }); - yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo-", - limit: 100, - caseSensitive: true, - wholeWord: true, - useRegex: true, - }); - - // wholeWord + useRegex must not silently drop non-word-edged patterns - // like "foo-", and "afoo-" is excluded because 'a'/'f' are both word - // characters at the match's left edge. - expect(result.matches).toHaveLength(1); - expect(result.matches[0]).toMatchObject({ - path: "src/words.ts", - lineNumber: 1, - matchRanges: [ - { start: 0, end: 4 }, - { start: 5, end: 9 }, - ], - }); - }), - ); - - it.effect("caps matches per file so one dense file cannot fill the page", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" }); - yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300)); - yield* writeTextFile(cwd, "src/other.ts", "needle\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "needle", - limit: 500, - caseSensitive: true, - wholeWord: false, - useRegex: false, - }); - - const byPath = new Map(); - for (const match of result.matches) { - byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1); - } - expect(byPath.get("src/dense.ts")).toBe(100); - expect(byPath.get("src/other.ts")).toBe(1); - }), - ); - - it.effect("preserves regex escapes during case-insensitive searches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" }); - yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "\\SQUARE", - limit: 100, - caseSensitive: false, - wholeWord: false, - useRegex: true, - }); - - expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]); - }), - ); - - it.effect("preserves invalid regex errors during case-insensitive searches", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" }); - yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n"); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "foo)bar(", - limit: 100, - caseSensitive: false, - wholeWord: false, - useRegex: true, - }); - - expect(result.regexFallbackError).toBeDefined(); - expect(result.matches).toEqual([]); - }), - ); - - it.effect("maps multi-byte lines to string-indexed ranges", () => - Effect.gen(function* () { - const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" }); - yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n'); - - const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; - const result = yield* workspaceEntries.searchContents({ - cwd, - query: "wörld", - limit: 100, - caseSensitive: true, - wholeWord: false, - useRegex: false, - }); - - expect(result.matches).toHaveLength(1); - const match = result.matches[0]!; - const range = match.matchRanges[0]!; - expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); - }), - ); - }); - describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index bb2113dac37..7501cbe0eab 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -14,14 +14,11 @@ import type { FilesystemBrowseResult, ProjectListEntriesInput, ProjectListEntriesResult, - ProjectSearchContentsInput, - ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; -import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -96,9 +93,6 @@ export class WorkspaceEntries extends Context.Service< readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; - readonly searchContents: ( - input: ProjectSearchContentsInput, - ) => Effect.Effect; readonly refresh: (cwd: string) => Effect.Effect; } >()("t3/workspace/WorkspaceEntries") {} @@ -154,37 +148,33 @@ export const make = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( Effect.orElseSucceed(() => cwd), ); - for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) { - const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant); - if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { - continue; - } - const recoverRefreshFailure = ( - cause: - | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed - | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut - | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, - ) => - Effect.gen(function* () { - yield* Effect.logWarning("Failed to refresh workspace search index", { - cwd, - variant, - cause, - }); - yield* workspaceSearchIndexes.invalidate(indexKey); - }); - yield* Effect.gen(function* () { - const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - yield* searchIndex.refresh(); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(indexKey)), - Effect.catchTags({ - WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, - WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, - WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, - }), - ); + if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { + return; } + const recoverRefreshFailure = ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + cause, + }); + yield* workspaceSearchIndexes.invalidate(normalizedCwd); + }); + yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + yield* searchIndex.refresh(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), + Effect.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), + ); }, ); @@ -240,55 +230,28 @@ export const make = Effect.gen(function* () { const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - const normalizedQuery = normalizeSearchQuery(input.query, { - trimLeadingPattern: /^[@./]+/, - }); + const normalizedQuery = input.query + .trim() + .toLowerCase() + .replace(/^[@./]+/, ""); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); - }).pipe( - Effect.provide( - workspaceSearchIndexes.get( - WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), - ), - ), - ); + return yield* searchIndex.search(normalizedQuery, input.limit); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); - const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn( - "WorkspaceEntries.searchContents", - )(function* (input) { - const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - return yield* Effect.gen(function* () { - const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.searchContents(input); - }).pipe( - Effect.provide( - workspaceSearchIndexes.get( - WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"), - ), - ), - ); - }); - const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); - }).pipe( - Effect.provide( - workspaceSearchIndexes.get( - WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), - ), - ), - ); + }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); }, ); - return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); + return WorkspaceEntries.of({ browse, list, refresh, search }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 15572837030..9b7ed4e2453 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,4 @@ -import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; +import { FileFinder } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -51,41 +51,6 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain }), ); -it.effect("waits for the full content index warmup before returning", () => - Effect.gen(function* () { - const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); - const finder = { - destroy: vi.fn(), - waitForIndexReady, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); - - expect(waitForIndexReady).toHaveBeenCalledWith(15_000); - }), -); - -it.effect("preserves a full-index warmup timeout as a structured error", () => - Effect.gen(function* () { - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const error = yield* Effect.flip( - Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), - ); - - expect(error).toMatchObject({ - _tag: "WorkspaceSearchIndexScanTimedOut", - cwd: "/workspace/project", - timeout: "15 seconds", - }); - }), -); - it.effect("preserves FileFinder destroy failures as structured defects", () => Effect.gen(function* () { const cause = new Error("native destroy failed"); @@ -93,7 +58,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => destroy: vi.fn(() => { throw cause; }), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: vi.fn(() => false), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -120,16 +85,12 @@ it.effect("preserves search and refresh failures with operation context", () => Effect.gen(function* () { const searchCause = new Error("native search failed"); const refreshCause = new Error("native scan failed"); - const contentSearchCause = new Error("native grep failed"); const finder = { destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: vi.fn(() => false), mixedSearch: vi.fn(() => { throw searchCause; }), - grep: vi.fn(() => { - throw contentSearchCause; - }), scanFiles: vi.fn(() => { throw refreshCause; }), @@ -139,15 +100,6 @@ it.effect("preserves search and refresh failures with operation context", () => const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); const query = "authorization: Bearer secret-token"; const searchError = yield* Effect.flip(searchIndex.search(query, 3)); - const contentSearchError = yield* Effect.flip( - searchIndex.searchContents({ - query, - limit: 3, - caseSensitive: false, - wholeWord: false, - useRegex: false, - }), - ); const refreshError = yield* Effect.flip(searchIndex.refresh()); expect(searchError).toMatchObject({ @@ -160,16 +112,6 @@ it.effect("preserves search and refresh failures with operation context", () => }); expect(searchError).not.toHaveProperty("query"); expect(searchError.message).not.toMatch(/Bearer|secret-token/); - expect(contentSearchError).toMatchObject({ - _tag: "WorkspaceSearchIndexSearchFailed", - cwd: "/workspace/project", - queryLength: query.length, - pageSize: 3, - reason: "FileFinder.grep threw unexpectedly.", - cause: contentSearchCause, - }); - expect(contentSearchError).not.toHaveProperty("query"); - expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); expect(refreshError).toMatchObject({ _tag: "WorkspaceSearchIndexRefreshFailed", cwd: "/workspace/project", @@ -185,7 +127,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => Effect.gen(function* () { const finder = { destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + isScanning: vi.fn(() => false), mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), } as unknown as FileFinder; @@ -215,80 +157,3 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => }), ), ); - -it.effect("continues whole-word searches after a filtered grep page", () => - Effect.scoped( - Effect.gen(function* () { - const nextCursor = { - __brand: "GrepCursor", - _offset: 1, - } as GrepCursor; - const grepResult = ( - lineContent: string, - matchRanges: Array<[number, number]>, - cursor: GrepCursor | null, - ): GrepResult => ({ - items: [ - { - relativePath: "src/words.ts", - fileName: "words.ts", - gitStatus: "unmodified", - size: lineContent.length, - modified: 0, - isBinary: false, - totalFrecencyScore: 0, - accessFrecencyScore: 0, - modificationFrecencyScore: 0, - lineNumber: 1, - col: 0, - byteOffset: 0, - lineContent, - matchRanges, - }, - ], - totalMatched: 1, - totalFilesSearched: 1, - totalFiles: 1, - filteredFileCount: 1, - nextCursor: cursor, - }); - const grep = vi.fn((_query: string, options?: GrepOptions) => - options?.cursor - ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } - : { - ok: true as const, - value: grepResult("needleSuffix", [[0, 6]], nextCursor), - }, - ); - const finder = { - destroy: vi.fn(), - waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), - grep, - } as unknown as FileFinder; - vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); - - const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); - const result = yield* searchIndex.searchContents({ - query: "needle", - limit: 1, - caseSensitive: true, - wholeWord: true, - useRegex: false, - }); - - expect(result).toEqual({ - matches: [ - { - path: "src/words.ts", - lineNumber: 1, - lineContent: "needle", - matchRanges: [{ start: 0, end: 6 }], - }, - ], - truncated: false, - }); - expect(grep).toHaveBeenCalledTimes(2); - expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor); - }), - ), -); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index 8bf36b7a80a..db4d46851e7 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,36 +1,22 @@ -import { - type DirItem, - type DirSearchResult, - type FileItem, - FileFinder, - type GrepCursor, - type MixedItem, - type MixedSearchResult, - type Result, - type SearchResult, -} from "@ff-labs/fff-node"; +import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; +import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type { ProjectEntry, - ProjectEntryKind, ProjectListEntriesResult, - ProjectSearchContentsInput, - ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; -const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; -const CONTENT_SEARCH_TIME_BUDGET_MS = 250; -const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; +const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( "WorkspaceSearchIndexCreateFailed", @@ -110,11 +96,7 @@ export class WorkspaceSearchIndex extends Context.Service< readonly search: ( query: string, limit: number, - kind?: ProjectEntryKind, ) => Effect.Effect; - readonly searchContents: ( - input: Omit, - ) => Effect.Effect; readonly refresh: () => Effect.Effect< void, WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut @@ -147,43 +129,6 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { }; } -function toFileEntry(item: FileItem): ProjectEntry | null { - const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); - return normalizedPath ? { path: normalizedPath, kind: "file" } : null; -} - -function toDirectoryEntry(item: DirItem): ProjectEntry | null { - const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); - return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; -} - -function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { - return { - entries: result.items - .flatMap((item) => { - const entry = toFileEntry(item); - return entry ? [entry] : []; - }) - .slice(0, limit), - truncated: result.totalMatched > limit, - }; -} - -function mapDirectorySearchResult( - result: DirSearchResult, - limit: number, -): ProjectSearchEntriesResult { - const entries = result.items.flatMap((item) => { - const entry = toDirectoryEntry(item); - return entry ? [entry] : []; - }); - const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0; - return { - entries: entries.slice(0, limit), - truncated: result.totalMatched - rootDirectoryCount > limit, - }; -} - function mapMixedSearchResult( result: MixedSearchResult, limit: number, @@ -210,74 +155,6 @@ function mapMixedSearchResult( }; } -const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u; - -function codePointAt(line: string, index: number): string | undefined { - const codePoint = line.codePointAt(index); - return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); -} - -function codePointBefore(line: string, index: number): string | undefined { - if (index <= 0) return undefined; - const previousCodeUnit = line.charCodeAt(index - 1); - const previousIndex = - previousCodeUnit >= 0xdc00 && previousCodeUnit <= 0xdfff ? index - 2 : index - 1; - return codePointAt(line, previousIndex); -} - -function buildContentSearchQuery(input: Omit): { - readonly searchQuery: string; - readonly regexMode: boolean; -} { - if (input.caseSensitive) { - return { searchQuery: input.query, regexMode: input.useRegex }; - } - // Plain mode relies on smart case: an all-lowercase needle matches - // case-insensitively. Regex mode needs an explicit inline flag instead. - return input.useRegex - ? { searchQuery: `(?i)${input.query}`, regexMode: true } - : { searchQuery: input.query.toLowerCase(), regexMode: false }; -} - -function mapContentMatchRanges( - line: string, - byteRanges: ReadonlyArray, -): Array<{ readonly start: number; readonly end: number }> { - const lineBytes = Buffer.from(line); - const toStringIndex = (byteOffset: number) => lineBytes.subarray(0, byteOffset).toString().length; - return byteRanges.map(([startByte, endByte]) => ({ - start: toStringIndex(startByte), - end: toStringIndex(endByte), - })); -} - -/** - * Whole-word filtering happens after the grep rather than by wrapping the - * pattern in boundary regex: consuming boundaries such as `(?:^|\W)` swallow - * the separator between adjacent matches and widen the reported ranges, and - * `\b` cannot match punctuation-edged queries at all. Matching VS Code, a - * match edge is a word boundary when it touches the line edge, the - * neighbouring character is not a word character, or the match's own edge - * character is not a word character. - */ -function isWholeWordRange( - line: string, - range: { readonly start: number; readonly end: number }, -): boolean { - if (range.end <= range.start) return false; - const isWord = (character: string | undefined) => - character !== undefined && WORD_CHARACTER.test(character); - const leftIsBoundary = - range.start === 0 || - !isWord(codePointBefore(line, range.start)) || - !isWord(codePointAt(line, range.start)); - const rightIsBoundary = - range.end >= line.length || - !isWord(codePointAt(line, range.end)) || - !isWord(codePointBefore(line, range.end)); - return leftIsBoundary && rightIsBoundary; -} - function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); for (const entry of entries) { @@ -292,19 +169,13 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } -const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( - cwd: string, - variant: WorkspaceSearchIndexVariant, -) { +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { const result = yield* Effect.try({ try: () => FileFinder.create({ basePath: cwd, disableMmapCache: true, - // Content indexing costs scan CPU and memory, so only the on-demand - // content-search index pays for it; path-only consumers (file tree, - // composer path search, file picker) keep the lightweight index. - disableContentIndexing: variant !== "content", + disableContentIndexing: true, aiMode: false, enableFsRootScanning: true, enableHomeDirScanning: true, @@ -323,65 +194,53 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( }); }); -const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* ( - cwd: string, - finder: FileFinder, - onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, -): Effect.fn.Return { - const result = yield* Effect.tryPromise({ - try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS), - catch: (cause) => - onFailure({ - reason: "FileFinder.waitForIndexReady rejected unexpectedly.", - cause, - }), - }); - if (!result.ok) { - return yield* Effect.fail(onFailure({ reason: result.error })); - } - if (!result.value) { - return yield* new WorkspaceSearchIndexScanTimedOut({ - cwd, - timeout: WORKSPACE_INDEX_SCAN_TIMEOUT, - }); - } -}); +const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => + Effect.try({ + try: () => finder.isScanning(), + catch: onFailure, + }).pipe( + Effect.repeat({ + while: (scanning) => scanning, + schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), + }), + Effect.timeoutOrElse({ + duration: WORKSPACE_INDEX_SCAN_TIMEOUT, + orElse: () => + new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), + }), + Effect.withSpan("WorkspaceSearchIndex.waitForScan"), + ); -export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( - cwd: string, - variant: WorkspaceSearchIndexVariant = "paths", -) { - const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => +export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { + const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => Effect.try({ try: () => finder.destroy(), catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), }).pipe(Effect.orDie), ); - yield* waitForIndexReady( + yield* waitForScan( cwd, finder, - ({ reason, cause }) => + (cause) => new WorkspaceSearchIndexCreateFailed({ cwd, - reason, + reason: "FileFinder.isScanning threw while creating the index.", cause, }), ); - const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function* ( + const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( query: string, pageSize: number, - operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", - execute: () => Result, - ): Effect.fn.Return { + ) { const result = yield* Effect.try({ - try: execute, + try: () => finder.mixedSearch(query, { pageSize }), catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: `FileFinder.${operation} threw unexpectedly.`, + reason: "FileFinder.mixedSearch threw unexpectedly.", cause, }), }); @@ -414,13 +273,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( reason: result.error, }); } - yield* waitForIndexReady( + yield* waitForScan( cwd, finder, - ({ reason, cause }) => + (cause) => new WorkspaceSearchIndexRefreshFailed({ cwd, - reason, + reason: "FileFinder.isScanning threw while refreshing the index.", cause, }), ); @@ -428,9 +287,7 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => - finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), - ); + const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => left.path.localeCompare(right.path), @@ -445,112 +302,20 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit, kind) { - const pageSize = Math.max(1, limit + 1); - if (kind === "file") { - const result = yield* runSearch(query, pageSize, "fileSearch", () => - finder.fileSearch(query, { pageSize }), - ); - return mapFileSearchResult(result, limit); - } - if (kind === "directory") { - const result = yield* runSearch(query, pageSize, "directorySearch", () => - finder.directorySearch(query, { pageSize }), - ); - return mapDirectorySearchResult(result, limit); - } - const result = yield* runSearch(query, pageSize, "mixedSearch", () => - finder.mixedSearch(query, { pageSize }), - ); + )(function* (query, limit) { + const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); return mapMixedSearchResult(result, limit); }); - const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( - "WorkspaceSearchIndex.searchContents", - )(function* (input) { - const { searchQuery, regexMode } = buildContentSearchQuery(input); - const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; - // Grep cursors advance by file, so whole-word post-filtering needs enough - // raw candidates from the current file before moving to the next one. - const rawPageSize = input.wholeWord - ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) - : input.limit; - const matches: Array = []; - let nextCursor: GrepCursor | null = null; - let regexFallbackError: string | undefined; - - do { - const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); - const result = yield* runSearch(input.query, input.limit, "grep", () => - finder.grep(searchQuery, { - mode: regexMode ? "regex" : "plain", - smartCase: !input.caseSensitive && !regexMode, - // A single dense file must not consume the whole result page. - maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), - pageSize: rawPageSize, - cursor: nextCursor, - timeBudgetMs: remainingTimeBudgetMs, - }), - ); - - for (const match of result.items) { - const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( - (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), - ); - if (matchRanges.length === 0) continue; - matches.push({ - path: toPosixPath(match.relativePath), - lineNumber: match.lineNumber, - lineContent: match.lineContent, - matchRanges, - }); - } - nextCursor = result.nextCursor; - regexFallbackError ??= result.regexFallbackError; - } while (matches.length < input.limit && nextCursor !== null && performance.now() < deadline); - - return { - matches: matches.slice(0, input.limit), - truncated: matches.length > input.limit || nextCursor !== null, - ...(regexFallbackError !== undefined ? { regexFallbackError } : {}), - }; - }); - - return WorkspaceSearchIndex.of({ list, refresh, search, searchContents }); + return WorkspaceSearchIndex.of({ list, refresh, search }); }); -export const WORKSPACE_SEARCH_INDEX_VARIANTS = ["paths", "content"] as const; -export type WorkspaceSearchIndexVariant = (typeof WORKSPACE_SEARCH_INDEX_VARIANTS)[number]; - -/** - * Composite LayerMap key so the lightweight path index and the on-demand - * content-search index of the same workspace are separate resources with - * independent lifecycles. "\n" cannot appear in a filesystem path. - */ -export const workspaceSearchIndexKey = (cwd: string, variant: WorkspaceSearchIndexVariant) => - `${variant}\n${cwd}`; - -function parseWorkspaceSearchIndexKey(key: string): { - readonly cwd: string; - readonly variant: WorkspaceSearchIndexVariant; -} { - const separatorIndex = key.indexOf("\n"); - return { - variant: key.slice(0, separatorIndex) as WorkspaceSearchIndexVariant, - cwd: key.slice(separatorIndex + 1), - }; -} - /** * A layer factory is required because every index is scoped to a concrete - * workspace root and variant. WorkspaceSearchIndexMap owns memoization and - * idle cleanup; using a default cwd here would mix resources from different - * workspaces. + * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; + * using a default cwd here would mix resources from different workspaces. */ -export const layer = (key: string) => { - const { cwd, variant } = parseWorkspaceSearchIndexKey(key); - return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); -}; +export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd)); export class WorkspaceSearchIndexMap extends LayerMap.Service()( "t3/workspace/WorkspaceSearchIndexMap", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 37ff00a989a..3fff5a95bf1 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -42,7 +42,6 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, - ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -108,6 +107,8 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import { requiredScopeForRpcMethod } from "./auth/RpcAuthorization.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; +import * as IdentityService from "./identity/IdentityService.ts"; +import { stampOrchestrationCommandSource } from "./identity/stampSource.ts"; import * as ProcessDiagnostics from "./diagnostics/ProcessDiagnostics.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as HostResourceProbe from "./diagnostics/HostResourceProbe.ts"; @@ -448,6 +449,7 @@ const makeWsRpcLayer = ( yield* SourceControlRepositoryService.SourceControlRepositoryService; const bootstrapCredentials = yield* PairingGrantStore.PairingGrantStore; const sessions = yield* SessionStore.SessionStore; + const identity = yield* IdentityService.IdentityService; const processDiagnostics = yield* ProcessDiagnostics.ProcessDiagnostics; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; @@ -1215,11 +1217,51 @@ const makeWsRpcLayer = ( .pipe(Effect.ignoreCause({ log: true }), Effect.forkDetach, Effect.asVoid); return WsRpcGroup.of({ + [WS_METHODS.identityGetSnapshot]: () => + observeRpcEffect(WS_METHODS.identityGetSnapshot, identity.getSnapshot()), + [WS_METHODS.identityGetSessionClaim]: () => + observeRpcEffect( + WS_METHODS.identityGetSessionClaim, + identity.getSessionClaim(currentSessionId), + ), + [WS_METHODS.identityClaim]: (payload) => + observeRpcEffect(WS_METHODS.identityClaim, identity.claim(currentSessionId, payload)), + [WS_METHODS.identityClearClaim]: () => + observeRpcEffect(WS_METHODS.identityClearClaim, identity.clearClaim(currentSessionId)), [ORCHESTRATION_WS_METHODS.dispatchCommand]: (command) => observeRpcEffect( ORCHESTRATION_WS_METHODS.dispatchCommand, Effect.gen(function* () { - const normalizedCommand = yield* normalizeDispatchCommand(command); + // Resolve deviceType so bot/integration sessions skip the interactive claim gate. + // Discord/Jira share one bot session across many humans; session claim cannot impersonate. + const clientDeviceType = yield* sessions.listActive().pipe( + Effect.map( + (active) => + active.find((entry) => entry.sessionId === currentSessionId)?.client.deviceType, + ), + Effect.orElseSucceed(() => undefined), + ); + const operateClaim = yield* identity + .requireOperateClaim( + currentSessionId, + clientDeviceType !== undefined ? { clientDeviceType } : {}, + ) + .pipe( + Effect.mapError( + (error) => + new OrchestrationDispatchCommandError({ + message: error.message, + code: error.code, + }), + ), + ); + const mapPeople = yield* identity.listMapPeople(); + const normalizedCommand = stampOrchestrationCommandSource({ + command: yield* normalizeDispatchCommand(command), + claim: operateClaim, + clientDeviceType, + people: mapPeople, + }); const shouldStopSessionAfterArchive = normalizedCommand.type === "thread.archive" ? yield* projectionSnapshotQuery @@ -1932,23 +1974,6 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), - [WS_METHODS.projectsSearchContents]: (input) => - observeRpcEffect( - WS_METHODS.projectsSearchContents, - workspaceEntries.searchContents(input).pipe( - Effect.mapError( - (cause) => - new ProjectSearchContentsError({ - cwd: input.cwd, - queryLength: input.query.length, - limit: input.limit, - ...projectEntriesFailureContext(cause), - cause, - }), - ), - ), - { "rpc.aggregate": "workspace" }, - ), [WS_METHODS.projectsListEntries]: (input) => observeRpcEffect( WS_METHODS.projectsListEntries, diff --git a/apps/web/src/cloud/linkEnvironment.ts b/apps/web/src/cloud/linkEnvironment.ts index a245cbc54db..22ff986afbd 100644 --- a/apps/web/src/cloud/linkEnvironment.ts +++ b/apps/web/src/cloud/linkEnvironment.ts @@ -145,14 +145,7 @@ function relayProtectedErrorMessage(error: RelayProtectedErrorType): string { case "RelayEnvironmentLinkProofInvalidError": return `Relay rejected the environment link proof (${error.reason}).`; case "RelayEnvironmentConnectNotAuthorizedError": - // "Not authorized" covers non-auth causes too; surface the reason so a - // missing link doesn't read as a credential problem. - if (error.reason === "environment_link_not_found") { - return "Relay has no active link for this environment. The environment server may not have re-established its link yet."; - } - return error.reason - ? `Relay rejected the environment connection request (${error.reason}).` - : "Relay rejected the environment connection request."; + return "Relay rejected the environment connection request."; case "RelayEnvironmentEndpointUnavailableError": return `Relay could not reach the environment endpoint (${error.reason}).`; case "RelayEnvironmentEndpointTimedOutError": diff --git a/apps/web/src/components/ChatMarkdown.tsx b/apps/web/src/components/ChatMarkdown.tsx index 625bf3377f2..9b9c84a3a1b 100644 --- a/apps/web/src/components/ChatMarkdown.tsx +++ b/apps/web/src/components/ChatMarkdown.tsx @@ -1,4 +1,5 @@ import { useAtomValue } from "@effect/atom-react"; +import { DiffsHighlighter, getSharedHighlighter, SupportedLanguages } from "@pierre/diffs"; import { CheckIcon, ChevronRightIcon, @@ -56,8 +57,6 @@ import { useOpenInPreferredEditor } from "../editorPreferences"; import { resolveDiffThemeName, type DiffThemeName } from "../lib/diffRendering"; import { fnv1a32 } from "../lib/diffRendering"; import { LRUCache } from "../lib/lruCache"; -import { getSyntaxHighlighterPromise } from "../lib/syntaxHighlighting"; -import { RenderErrorBoundary } from "./RenderErrorBoundary"; import { useTheme } from "../hooks/useTheme"; import { getClientSettings } from "../hooks/useSettings"; import { @@ -93,6 +92,27 @@ import { import { resolveDiscoveredServerUrl, resolveNavigableUrl } from "../browser/browserTargetResolver"; import { useAssetUrl } from "../assets/assetUrls"; +class CodeHighlightErrorBoundary extends React.Component< + { fallback: ReactNode; children: ReactNode }, + { hasError: boolean } +> { + constructor(props: { fallback: ReactNode; children: ReactNode }) { + super(props); + this.state = { hasError: false }; + } + + static getDerivedStateFromError() { + return { hasError: true }; + } + + override render() { + if (this.state.hasError) { + return this.props.fallback; + } + return this.props.children; + } +} + interface ChatMarkdownProps { text: string; cwd: string | undefined; @@ -128,6 +148,7 @@ const highlightedCodeCache = new LRUCache( MAX_HIGHLIGHT_CACHE_ENTRIES, MAX_HIGHLIGHT_CACHE_MEMORY_BYTES, ); +const highlighterPromiseCache = new Map>(); function findTaskListMarkerOffset(markdown: string, listItemStart: number): number | null { const firstLineEnd = markdown.indexOf("\n", listItemStart); @@ -278,6 +299,27 @@ function estimateHighlightedSize(html: string, code: string): number { return Math.max(html.length * 2, code.length * 3); } +function getHighlighterPromise(language: string): Promise { + const cached = highlighterPromiseCache.get(language); + if (cached) return cached; + + const promise = getSharedHighlighter({ + themes: [resolveDiffThemeName("dark"), resolveDiffThemeName("light")], + langs: [language as SupportedLanguages], + preferredHighlighter: "shiki-js", + }).catch((err) => { + highlighterPromiseCache.delete(language); + if (language === "text") { + // "text" itself failed — Shiki cannot initialize at all, surface the error + throw err; + } + // Language not supported by Shiki — fall back to "text" + return getHighlighterPromise("text"); + }); + highlighterPromiseCache.set(language, promise); + return promise; +} + function readInitialWordWrapSetting(): boolean { return getClientSettings().wordWrap; } @@ -667,7 +709,7 @@ function UncachedShikiCodeBlock({ cacheKey, isStreaming, }: UncachedShikiCodeBlockProps) { - const highlighter = use(getSyntaxHighlighterPromise(language)); + const highlighter = use(getHighlighterPromise(language)); const highlightedHtml = useMemo(() => { try { return highlighter.codeToHtml(code, { lang: language, theme: themeName }); @@ -1566,7 +1608,7 @@ function ChatMarkdown({ fenceTitle={fenceTitle} theme={resolvedTheme} > - {children}}> + {children}}> {children}}> - + ); }, diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5b05898553e..b421bd558a4 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -72,6 +72,10 @@ import { import * as Cause from "effect/Cause"; import { AsyncResult } from "effect/unstable/reactivity"; import { isTransportConnectionErrorMessage } from "@t3tools/client-runtime/errors"; +import { + isIdentityClaimRequiredMessage, + requestIdentityClaimGate, +} from "./identity/IdentityClaimGate"; import { isElectron } from "../env"; import { readLocalApi } from "../localApi"; import { useDiffPanelStore } from "../diffPanelStore"; @@ -2017,75 +2021,46 @@ function ChatViewContent(props: ChatViewProps) { ); const systemComposerBannerItems = useMemo(() => { const items: ComposerBannerStackItem[] = []; - const updateRunning = serverUpdateState.status === "running"; - const unavailableConnection = activeEnvironmentUnavailableState?.connection ?? null; - const environmentReconnecting = - unavailableConnection !== null && - (unavailableConnection.phase === "connecting" || - unavailableConnection.phase === "reconnecting"); - // Reconnecting to a version-skewed server with no update in flight - // usually means the server is restarting mid-update and a refresh wiped - // the in-memory update state. Fold the reconnect and version banners - // into one calm line instead of stacking "Failed to connect" on - // "versions differ". A failed update never folds: its error and retry - // action must stay visible. - const reconnectingThroughVersionSkew = - serverUpdateState.status === "idle" && environmentReconnecting && versionMismatch !== null; - // While an update runs, transient connect blips are expected (the server - // restarts) and the update banner already shows progress. Hard failure - // phases still surface so the Reconnect action stays reachable. - const suppressUnavailableBanner = updateRunning && environmentReconnecting; - if (activeEnvironmentUnavailableState && unavailableConnection && !suppressUnavailableBanner) { - if (reconnectingThroughVersionSkew) { - items.push({ - id: `environment-unavailable:${activeEnvironmentUnavailableState.environmentId}`, - variant: "default", - icon: ( - + } + /> + + {thread.title} + + + )} {hasDraft ? : null} {prStatus && pr ? ( @@ -2318,12 +2335,16 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec clicked === "settle" ? await settleThread(threadRef) : await unsettleThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(threadRef.environmentId); + } toastManager.add( stackedThreadToast({ type: "error", title: clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", + description: message, }), ); } @@ -3268,12 +3289,16 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { : await props.unsettleThread(threadRef); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(threadRef.environmentId); + } toastManager.add( stackedThreadToast({ type: "error", title: clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", - description: error instanceof Error ? error.message : "An error occurred.", + description: message, }), ); } @@ -3463,14 +3488,27 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { onBlur={() => void commitRename()} /> ) : ( - - {thread.title} - + <> + + + + {thread.title} + + )} {hasDraft ? : null} @@ -3583,7 +3621,20 @@ const SidebarRecentThreadRow = memo(function SidebarRecentThreadRow(props: { onBlur={() => void commitRename()} /> ) : ( - {thread.title} + <> + + + {thread.title} + )} {hasDraft ? : null} {prStatus && pr ? ( @@ -5598,7 +5649,11 @@ export default function Sidebar() { .downloadUpdate() .then((result) => { if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state); + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index ec9f11410c3..b717c0ded47 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -110,6 +110,16 @@ import { import { formatRelativeTimeLabel, parseTimestampDate } from "../timestampFormat"; import type { SidebarThreadSummary } from "../types"; import { cn } from "~/lib/utils"; +import { ParticipantStack, SourceChannelGlyph } from "./identity/ParticipantStack"; +import { + isIdentityClaimRequiredMessage, + requestIdentityClaimGate, +} from "./identity/IdentityClaimGate"; +import { + claimPersonIdForEnvironment, + threadMatchesMine, +} from "@t3tools/client-runtime/state/identity"; +import { identityClaimPersonIdByEnvironmentAtom } from "../state/identity"; import { SETTLED_TAIL_INITIAL_COUNT, SETTLED_TAIL_PAGE_COUNT, @@ -805,8 +815,17 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { "opacity-70 transition-opacity hover:opacity-100", ); + const participants = thread.participantSummaries ?? []; + const originChannel = thread.originSource?.channel ?? participants[0]?.firstChannel ?? null; + const title = (
+ {!isRenaming ? ( + <> + + + + ) : null} {isRenaming ? ( (() => { + try { + const raw = window.localStorage.getItem("t3.sidebar.ownershipFilter"); + if (raw === "mine" || raw === "theirs" || raw === "any") return raw; + } catch { + // ignore + } + return "any"; + }); + // Per-environment claims (not primary-only): smart has no map while t3vm does. + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); + const listOptionsActive = !isAllEnvironmentsSelected(selectedEnvironmentIds) || storedThreadGrouping !== DEFAULT_WEB_THREAD_GROUPING || settledRecencyHeadersEnabled !== DEFAULT_SIDEBAR_V2_SETTLED_RECENCY_HEADERS || - settledShelfExpanded !== DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED; + settledShelfExpanded !== DEFAULT_SIDEBAR_V2_SETTLED_SHELF_EXPANDED || + ownershipFilter !== "any"; const orderedProjects = useMemo( () => orderItemsByPreferredIds({ @@ -1611,7 +1643,18 @@ export default function SidebarV2() { thread.archivedAt === null && matchesEnvironmentFilter(thread.environmentId, selectedEnvironmentIds) && (scopedProjectKeys === null || - scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)), + scopedProjectKeys.has(`${thread.environmentId}:${thread.projectId}`)) && + threadMatchesMine({ + claimPersonId: claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + thread.environmentId, + ), + originPersonId: thread.originSource?.personId ?? null, + participantPersonIds: (thread.participantSummaries ?? []).map( + (participant) => participant.personId, + ), + mode: ownershipFilter, + }), ); const active: EnvironmentThreadShell[] = []; const snoozed: EnvironmentThreadShell[] = []; @@ -1661,7 +1704,9 @@ export default function SidebarV2() { }, [ autoSettleAfterDays, changeRequestStateByKey, + claimPersonIdByEnvironment, nowMinute, + ownershipFilter, scopedProjectKeys, selectedEnvironmentIds, serverConfigs, @@ -1977,11 +2022,15 @@ export default function SidebarV2() { // Never navigate away from a thread that did not settle. if (!isAtomCommandInterrupted(result)) { const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(threadRef.environmentId); + } toastManager.add( stackedThreadToast({ type: "error", title: "Failed to settle thread", - description: error instanceof Error ? error.message : "An error occurred.", + description: message, }), ); } @@ -2757,6 +2806,42 @@ export default function SidebarV2() { + +
+ Ownership +
+ { + if (value !== "any" && value !== "mine" && value !== "theirs") return; + setOwnershipFilter(value); + try { + window.localStorage.setItem("t3.sidebar.ownershipFilter", value); + } catch { + // ignore + } + }} + > + {( + [ + ["any", "Anyone"], + ["mine", "Mine"], + ["theirs", "Theirs"], + ] as const + ).map(([value, label]) => ( + + {label} + + ))} + +
+
Settled shelf diff --git a/apps/web/src/components/board/BoardView.tsx b/apps/web/src/components/board/BoardView.tsx index b148d812736..6544783b220 100644 --- a/apps/web/src/components/board/BoardView.tsx +++ b/apps/web/src/components/board/BoardView.tsx @@ -28,6 +28,10 @@ import { useNavigate } from "@tanstack/react-router"; import * as Schema from "effect/Schema"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + isIdentityClaimRequiredMessage, + requestIdentityClaimGate, +} from "../identity/IdentityClaimGate"; import { isDesktopLocalConnectionTarget } from "../../connection/desktopLocal"; import { isElectron } from "../../env"; import { useNewThreadHandler } from "../../hooks/useHandleNewThread"; @@ -119,16 +123,24 @@ interface BoardThreadGitContext { } /** Error toast for a failed thread action; interruptions and successes are silent. */ -function reportThreadActionFailure(result: AtomCommandResult, title: string) { +function reportThreadActionFailure( + result: AtomCommandResult, + title: string, + environmentId?: EnvironmentId | null, +) { if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) { return; } const error = squashAtomCommandFailure(result); + const message = error instanceof Error ? error.message : "An error occurred."; + if (isIdentityClaimRequiredMessage(message)) { + requestIdentityClaimGate(environmentId); + } toastManager.add( stackedThreadToast({ type: "error", title, - description: error instanceof Error ? error.message : "An error occurred.", + description: message, }), ); } @@ -763,6 +775,7 @@ function BoardContent() { reportThreadActionFailure( result, clicked === "settle" ? "Failed to settle thread" : "Failed to un-settle thread", + threadRef.environmentId, ); return; } diff --git a/apps/web/src/components/chat/ComposerBannerStack.tsx b/apps/web/src/components/chat/ComposerBannerStack.tsx index 75d81aa03da..697bbdb9062 100644 --- a/apps/web/src/components/chat/ComposerBannerStack.tsx +++ b/apps/web/src/components/chat/ComposerBannerStack.tsx @@ -25,7 +25,7 @@ const exitTransitionStyle = { export interface ComposerBannerStackItem { readonly id: string; - readonly variant: "default" | "error" | "info" | "success" | "warning"; + readonly variant: "error" | "info" | "success" | "warning"; readonly icon: ReactNode; readonly title: ReactNode; readonly description?: ReactNode; diff --git a/apps/web/src/components/chat/TraitsPicker.tsx b/apps/web/src/components/chat/TraitsPicker.tsx index 6593acd8e1f..a9f910ec064 100644 --- a/apps/web/src/components/chat/TraitsPicker.tsx +++ b/apps/web/src/components/chat/TraitsPicker.tsx @@ -476,13 +476,7 @@ export const TraitsPicker = memo(function TraitsPicker({ }); const fastModeIcon = showFastModeIcon ? ( <> - + Fast mode on ) : null; diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index 8d24b34a433..b07ae99c058 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -7,7 +7,6 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, - getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -159,23 +158,6 @@ describe("getDesktopUpdateActionError", () => { }); describe("desktop update UI helpers", () => { - it("builds the stable release URL for a downloaded version", () => { - expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe( - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", - ); - }); - - it("builds the nightly release URL without dropping its version suffix", () => { - expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe( - "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931", - ); - }); - - it("omits the release URL when the updater does not report a version", () => { - expect(getDesktopUpdateReleaseUrl(null)).toBeNull(); - expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); - }); - it("toasts only for actionable updater errors", () => { expect( shouldToastDesktopUpdateActionResult({ diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index dc09d7ca877..11c34777a41 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -3,24 +3,6 @@ import { isWindowsPlatform } from "../lib/utils"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; -const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; - -/** - * The main process fills `downloadedVersion` from the updater's `update-downloaded` - * event, which is dispatched on its own fiber. A download RPC can therefore resolve - * before that write lands, so fall back to the version the download was started for. - */ -export function getDesktopUpdateDownloadedVersion(state: DesktopUpdateState): string | null { - return state.downloadedVersion ?? state.availableVersion; -} - -/** Release notes for an exact downloaded build; nightly suffixes are part of the tag. */ -export function getDesktopUpdateReleaseUrl(version: string | null): string | null { - const normalizedVersion = version?.trim(); - if (!normalizedVersion) return null; - return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; -} - export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index ff658693a70..307d4413751 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -26,10 +26,6 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; - /** File currently open in the preview pane; revealed and selected in the tree. */ - selectedPath: string | null; - /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ - selectedPathRevealId: number; onOpenFile: (relativePath: string) => void; } @@ -102,8 +98,6 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, - selectedPath, - selectedPathRevealId, onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); @@ -117,9 +111,6 @@ export default function FileBrowserPanel({ const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); - const syncingSelectionRef = useRef(false); - const treeSelectionPathRef = useRef(null); - const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); // The tree renders rows in shadow DOM and its anchor rect is unreliable, so // capture the right-click position ourselves; contextmenu is a composed @@ -225,12 +216,7 @@ export default function FileBrowserPanel({ initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { - // The drag controller's selection cache must track every change, - // including reveal-driven ones, or drags act on a stale selection. dragMention.handleSelectionChange(selectedPaths); - // Selection changes driven by the reveal sync below are echoes of an - // already-open file, not a request to open it again. - if (syncingSelectionRef.current) return; // Starting a drag selects the dragged row; that selection is a side // effect of the gesture, not a request to open the file. if (dragMention.isDragInProgress()) { @@ -238,7 +224,6 @@ export default function FileBrowserPanel({ } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { - treeSelectionPathRef.current = selectedPath; onOpenFile(selectedPath); } }, @@ -262,63 +247,6 @@ export default function FileBrowserPanel({ model.resetPaths(treePaths); }, [entryKinds, model, treePaths]); - useEffect(() => { - if (!selectedPath) { - handledRevealRef.current = null; - return; - } - const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; - const handledReveal = handledRevealRef.current; - // Entry refreshes rebuild treePaths while the same preview stays open. - // Replaying a handled reveal would close an active tree search and steal focus. - if ( - handledReveal?.path === revealRequest.path && - handledReveal.revealId === revealRequest.revealId - ) { - return; - } - if (entryKinds.get(selectedPath) !== "file") return; - const selectedItem = model.getItem(selectedPath); - if (!selectedItem) return; - - // A selection that originated inside the tree (clicking a row, possibly - // in an active tree search) is already visible; re-revealing it would - // close the search and clobber the user's context. Only sync external - // opens (file picker, content search, chat links). - const selectedInTree = model - .getSelectedPaths() - .some((path) => path.replace(/\/$/, "") === selectedPath); - if (selectedInTree && treeSelectionPathRef.current === selectedPath) { - treeSelectionPathRef.current = null; - handledRevealRef.current = revealRequest; - return; - } - treeSelectionPathRef.current = null; - handledRevealRef.current = revealRequest; - - syncingSelectionRef.current = true; - model.closeSearch(); - for (const path of model.getSelectedPaths()) { - model.getItem(path)?.deselect(); - } - - // Directory rows are registered with a trailing slash (see treePath), so - // ancestor lookups must use the same form to expand them. - const segments = selectedPath.split("/"); - let ancestorPath = ""; - for (const segment of segments.slice(0, -1)) { - ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment; - const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath); - if (item && "expand" in item) item.expand(); - } - - selectedItem.select(); - model.scrollToPath(selectedPath, { focus: true, offset: "center" }); - queueMicrotask(() => { - syncingSelectionRef.current = false; - }); - }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); - // Tag tree drags with the composer mention payload. The row is read from // the composed event path (the tree's shadow root is open), so this does // not depend on running after the tree's own dragstart handler; the drag diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index a736cf96cd3..24e63a6d8ea 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -51,7 +51,6 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; -import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; @@ -183,53 +182,25 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); } -/** - * Frames to keep retrying while the file contents or line metrics are not - * available yet (fresh mounts hydrate asynchronously). - */ -const REVEAL_MAX_ATTEMPTS = 30; -/** - * After scrolling to the target, hold it for a short window so late - * programmatic scroll resets (editable-editor focus and state restoration) - * cannot silently snap the file back to the top. Real user input cancels the - * guard immediately. - */ -const REVEAL_GUARD_FRAMES = 20; -const REVEAL_GUARD_TOLERANCE_PX = 2; - -interface FileRevealState { - frameId: number | null; - cancelGuard: (() => void) | null; - handledRequestId: number | null; - latestRequestId: number | null; -} - function useFileLineReveal( relativePath: string | null, revealLine: number | null, revealRequestId: number, ): FilePostRender { - const [revealStatesByPath] = useState(() => new Map()); + const [handledRequestIdsByPath] = useState(() => new Map()); + const [latestRequestIdsByPath] = useState(() => new Map()); + const [pendingFramesByPath] = useState(() => new Map()); return useCallback( (fileContainer, instance, phase) => { if (relativePath === null) return; - const existingState = revealStatesByPath.get(relativePath); - const state: FileRevealState = existingState ?? { - frameId: null, - cancelGuard: null, - handledRequestId: null, - latestRequestId: null, - }; - if (!existingState) revealStatesByPath.set(relativePath, state); - const cancelPendingReveal = () => { - if (state.frameId !== null) { - cancelAnimationFrame(state.frameId); - state.frameId = null; + const frameId = pendingFramesByPath.get(relativePath); + if (frameId !== undefined) { + cancelAnimationFrame(frameId); + pendingFramesByPath.delete(relativePath); } - state.cancelGuard?.(); }; if (phase === "unmount") { @@ -237,20 +208,18 @@ function useFileLineReveal( return; } - const contents = instance.file?.contents; const targetLine = - revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); + revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine); updateFileLinkReveal(fileContainer, targetLine); if (!(instance instanceof VirtualizedFile)) return; - if (state.latestRequestId !== revealRequestId) { + if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) { cancelPendingReveal(); - state.latestRequestId = revealRequestId; - state.handledRequestId = null; + latestRequestIdsByPath.set(relativePath, revealRequestId); } - if (revealLine === null) { + if (targetLine === null) { fileContainer.style.minHeight = ""; return; } @@ -261,113 +230,54 @@ function useFileLineReveal( Math.max(instance.height, scrollContainer.clientHeight), )}px`; - if (state.handledRequestId === revealRequestId || state.frameId !== null) { + if ( + handledRequestIdsByPath.get(relativePath) === revealRequestId || + pendingFramesByPath.has(relativePath) + ) { return; } - const resolveScrollTarget = (line: number): number | null => { - const linePosition = instance.getLinePosition(line); - if (!linePosition) return null; + const reveal = () => { + pendingFramesByPath.delete(relativePath); + if ( + latestRequestIdsByPath.get(relativePath) !== revealRequestId || + !fileContainer.isConnected + ) { + return; + } + + const linePosition = instance.getLinePosition(targetLine); + if (!linePosition) return; - const scrollContainerRect = scrollContainer.getBoundingClientRect(); const fileTop = scrollContainer.scrollTop + fileContainer.getBoundingClientRect().top - - scrollContainerRect.top; - const root = fileContainer.shadowRoot ?? fileContainer; - const renderedLineElement = root.querySelector(`[data-line="${line}"]`); - const renderedLineRect = renderedLineElement?.getBoundingClientRect(); - - return resolveCenteredFileLineScrollTop({ - scrollTop: scrollContainer.scrollTop, - scrollHeight: scrollContainer.scrollHeight, - viewportTop: scrollContainerRect.top, - viewportHeight: scrollContainer.clientHeight, - fileTop, - estimatedLine: linePosition, - ...(renderedLineRect && renderedLineRect.height > 0 - ? { - renderedLine: { - top: renderedLineRect.top, - height: renderedLineRect.height, - }, - } - : {}), - }); - }; - - const guardScrollTarget = (line: number) => { - let framesLeft = REVEAL_GUARD_FRAMES; - let guardFrameId: number | null = null; - const cancelGuard = () => { - if (guardFrameId !== null) { - cancelAnimationFrame(guardFrameId); - guardFrameId = null; - } - scrollContainer.removeEventListener("wheel", cancelGuard); - scrollContainer.removeEventListener("touchstart", cancelGuard); - scrollContainer.removeEventListener("pointerdown", cancelGuard, true); - window.removeEventListener("keydown", cancelGuard, true); - if (state.cancelGuard === cancelGuard) state.cancelGuard = null; - }; - scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); - scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); - // Pierre stops gutter pointer events from bubbling. Listen in capture - // so starting a comment cancels the reveal guard before the row expands. - scrollContainer.addEventListener("pointerdown", cancelGuard, { - passive: true, - capture: true, - }); - window.addEventListener("keydown", cancelGuard, true); - const holdTarget = () => { - guardFrameId = null; - framesLeft -= 1; - if (framesLeft <= 0 || !scrollContainer.isConnected) { - cancelGuard(); - return; - } - const targetTop = resolveScrollTarget(line); - if ( - targetTop !== null && - Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX - ) { - scrollContainer.scrollTop = targetTop; - } - guardFrameId = requestAnimationFrame(holdTarget); - }; - guardFrameId = requestAnimationFrame(holdTarget); - state.cancelGuard = cancelGuard; - }; - - const scheduleReveal = (attempt: number) => { - state.frameId = requestAnimationFrame(() => { - state.frameId = null; - if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) { - return; - } - - // Contents and line metrics can lag the first post-render on fresh - // mounts; clamping against missing contents would scroll to line 1 - // and wrongly mark the request handled. - const currentContents = instance.file?.contents; - const line = - currentContents === undefined ? null : clampFileLine(currentContents, revealLine); - const targetTop = line === null ? null : resolveScrollTarget(line); - if (line === null || targetTop === null) { - if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); - return; - } - updateFileLinkReveal(fileContainer, line); + scrollContainer.getBoundingClientRect().top; + const centeredTop = Math.max( + 0, + fileTop + + linePosition.top - + Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2), + ); + const maxScrollTop = Math.max( + 0, + scrollContainer.scrollHeight - scrollContainer.clientHeight, + ); - scrollContainer.scrollTop = targetTop; - state.handledRequestId = revealRequestId; - guardScrollTarget(line); - }); + scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); + handledRequestIdsByPath.set(relativePath, revealRequestId); }; - scheduleReveal(0); + pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal)); }, - [revealStatesByPath, relativePath, revealLine, revealRequestId], + [ + handledRequestIdsByPath, + latestRequestIdsByPath, + pendingFramesByPath, + relativePath, + revealLine, + revealRequestId, + ], ); } @@ -1053,8 +963,6 @@ export default function FilePreviewPanel({ environmentId={environmentId} cwd={cwd} projectName={projectName} - selectedPath={relativePath} - selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} /> diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index d165c1d1a7a..0d3fb8dd941 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -11,7 +11,6 @@ import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { projectEnvironment } from "~/state/projects"; -import { useProjectPathSearch } from "~/state/queries"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; @@ -137,32 +136,6 @@ export function useProjectEntriesQuery( }; } -/** - * Backing query for the project file picker: a debounced, bounded, file-only - * server search. An empty query is a valid request — the index answers it - * with frecency-ordered files, so the picker's initial view is recent files - * without transferring the full workspace listing. `matchedQuery` is the - * query the returned entries were computed for, so the caller can highlight - * against results instead of half-typed input. - */ -export function useProjectFilePickerQuery( - environmentId: EnvironmentId, - cwd: string, - query: string, - limit: number, -) { - const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, { - allowEmptyQuery: true, - }); - - return { - entries: search.isPending ? [] : search.entries, - error: search.error, - isPending: search.isPending, - matchedQuery: search.searchedQuery, - }; -} - export function useProjectFileQuery( environmentId: EnvironmentId, cwd: string, diff --git a/apps/web/src/components/identity/IdentityAvatar.tsx b/apps/web/src/components/identity/IdentityAvatar.tsx new file mode 100644 index 00000000000..d41462c5b9b --- /dev/null +++ b/apps/web/src/components/identity/IdentityAvatar.tsx @@ -0,0 +1,41 @@ +import { identityAvatar } from "@t3tools/shared/identityAvatar"; +import { cn } from "~/lib/utils"; + +export function IdentityAvatar(props: { + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; + readonly size?: "micro" | "sm" | "md"; + readonly className?: string; + /** `null` suppresses the native title when a parent owns richer tooltip content. */ + readonly title?: string | null; +}) { + const model = identityAvatar({ + personId: props.personId, + username: props.username, + name: props.name, + }); + const sizeClass = + props.size === "md" + ? "size-7 text-[11px]" + : props.size === "sm" + ? "size-6 text-[10px]" + : "size-3.5 text-[8px]"; + + const title = props.title === null ? undefined : (props.title ?? model.label); + + return ( + + {model.initials} + + ); +} diff --git a/apps/web/src/components/identity/IdentityClaimGate.tsx b/apps/web/src/components/identity/IdentityClaimGate.tsx new file mode 100644 index 00000000000..11710c4556e --- /dev/null +++ b/apps/web/src/components/identity/IdentityClaimGate.tsx @@ -0,0 +1,377 @@ +import { + filterPeopleForTypeahead, + identityClaimRequired, +} from "@t3tools/client-runtime/state/identity"; +import { + IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, + IdentityUsername, + type EnvironmentId, + type IdentityPersonPublic, +} from "@t3tools/contracts"; +import { useEffect, useMemo, useState } from "react"; + +import { useActiveEnvironmentId } from "../../state/entities"; +import { useEnvironments, usePrimaryEnvironmentId } from "../../state/environments"; +import { identityEnvironment } from "../../state/identity"; +import { useEnvironmentQuery } from "../../state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { IdentityAvatar } from "./IdentityAvatar"; + +/** + * Force-open the claim modal (e.g. after a dispatch error). Optionally target + * the environment that rejected the operate (critical for multi-env: primary + * smart has no map while secondary t3vm requires claim). + */ +let forceClaimOpen = false; +let forceClaimEnvironmentId: EnvironmentId | null = null; +const forceClaimListeners = new Set<() => void>(); + +export function requestIdentityClaimGate(environmentId?: EnvironmentId | null): void { + forceClaimOpen = true; + forceClaimEnvironmentId = environmentId ?? null; + for (const listener of forceClaimListeners) { + listener(); + } +} + +function useForceClaimState(): { + readonly open: boolean; + readonly environmentId: EnvironmentId | null; +} { + const [state, setState] = useState({ + open: forceClaimOpen, + environmentId: forceClaimEnvironmentId, + }); + useEffect(() => { + const listener = () => + setState({ open: forceClaimOpen, environmentId: forceClaimEnvironmentId }); + forceClaimListeners.add(listener); + return () => { + forceClaimListeners.delete(listener); + }; + }, []); + return state; +} + +function clearForceClaimOpen(): void { + forceClaimOpen = false; + forceClaimEnvironmentId = null; + for (const listener of forceClaimListeners) { + listener(); + } +} + +/** + * Full-screen "Who are you?" gate when *any* connected environment has a + * closed identity map and this auth session has not claimed there yet. + * + * Multi-env: primary may be smart (no map) while secondary is t3vm (map on). + * Gate every environment that requires a claim, not only primary/active. + */ +export function IdentityClaimGate() { + const activeEnvironmentId = useActiveEnvironmentId(); + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const { environments } = useEnvironments(); + const force = useForceClaimState(); + + const orderedEnvironmentIds = useMemo(() => { + const ids: EnvironmentId[] = []; + const add = (id: EnvironmentId | null | undefined) => { + if (id !== null && id !== undefined && !ids.includes(id)) { + ids.push(id); + } + }; + // Forced env first so settle/send errors open the right dialog. + add(force.environmentId); + add(activeEnvironmentId); + add(primaryEnvironmentId); + for (const environment of environments) { + add(environment.environmentId); + } + return ids; + }, [activeEnvironmentId, environments, force.environmentId, primaryEnvironmentId]); + + if (orderedEnvironmentIds.length === 0) { + return null; + } + + // One gate body per env (hooks). Only the first that needs claim / force + // renders a modal (others return null). + return ( + <> + {orderedEnvironmentIds.map((environmentId) => { + const label = + environments.find((env) => env.environmentId === environmentId)?.label ?? null; + const forceOpen = + force.open && (force.environmentId === null || force.environmentId === environmentId); + return ( + + ); + })} + + ); +} + +function IdentityClaimGateForEnvironment(props: { + readonly environmentId: EnvironmentId; + readonly environmentLabel: string | null; + readonly forceOpen: boolean; +}) { + const target = useMemo( + () => ({ environmentId: props.environmentId, input: {} as const }), + [props.environmentId], + ); + const snapshotQuery = useEnvironmentQuery(identityEnvironment.snapshot(target)); + const claimQuery = useEnvironmentQuery(identityEnvironment.sessionClaim(target)); + const claimCommand = useAtomCommand(identityEnvironment.claim, { + label: "identity-claim", + reportFailure: true, + }); + + const needsClaim = identityClaimRequired(snapshotQuery.data, claimQuery.data); + const [query, setQuery] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const suggestions = useMemo(() => { + if (!snapshotQuery.data) return [] as ReadonlyArray; + return filterPeopleForTypeahead( + snapshotQuery.data.people, + query, + IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, + ); + }, [query, snapshotQuery.data]); + + // Show when: map requires claim, or user forced open after a dispatch error. + // Keep showing while loading if forceOpen (so the error path isn't silent). + const showGate = + needsClaim || + (props.forceOpen && (needsClaim || snapshotQuery.isPending || snapshotQuery.data !== null)) || + (props.forceOpen && snapshotQuery.error !== null); + + if (!showGate) { + return null; + } + + // Still loading map/claim — block operate with a clear panel, not a toast. + if (snapshotQuery.isPending && snapshotQuery.data === null) { + return ( +
+
+

+ Checking identity… +

+

+ Loading this server’s identity map before you can send turns. +

+
+
+ ); + } + + if (snapshotQuery.error !== null && snapshotQuery.data === null) { + return ( +
+
+

+ Could not load identity +

+

{snapshotQuery.error}

+
+ +
+
+
+ ); + } + + if (!snapshotQuery.data?.enabled) { + // Map off on *this* env (e.g. smart). Do NOT clear forceOpen — a sibling + // env (t3vm) may still need the claim dialog. + return null; + } + + if (!needsClaim) { + // Already claimed on this env; clear force only when we targeted it. + if (props.forceOpen) clearForceClaimOpen(); + return null; + } + + const snapshot = snapshotQuery.data; + const envLabel = props.environmentLabel?.trim() || "this environment"; + + const submitUsername = async (username: string) => { + const normalized = username.trim().toLowerCase(); + if (normalized.length === 0) { + setError("Type your username, then pick a match from the list."); + return; + } + const exact = snapshot.people.find((person) => person.username === normalized); + if (!exact) { + setError("That identity is not on this server’s map. Keep typing to see matches."); + return; + } + setSubmitting(true); + setError(null); + try { + const result = await claimCommand({ + environmentId: props.environmentId, + input: { + username: IdentityUsername.make(exact.username), + method: "typeahead", + }, + }); + if (result._tag === "Failure") { + setError("Could not claim identity on this server."); + return; + } + claimQuery.refresh(); + snapshotQuery.refresh(); + clearForceClaimOpen(); + } catch (cause) { + setError(cause instanceof Error ? cause.message : "Could not claim identity."); + } finally { + setSubmitting(false); + } + }; + + return ( +
+
+

+ Shared environment · {envLabel} +

+

+ Who are you? +

+

+ {envLabel} uses a closed identity + map. Type at least {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS} characters of your username (for + example pat + …), then choose a match. Free-form names are not allowed. +

+ + + { + setQuery(event.currentTarget.value); + setError(null); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void submitUsername(query); + } + }} + /> + + {suggestions.length > 0 ? ( +
    + {suggestions.map((person) => ( +
  • + +
  • + ))} +
+ ) : query.trim().length >= IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS ? ( +

No map matches for that query.

+ ) : ( +

+ Type {IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS}+ characters to search the map ( + {snapshot.people.length} people listed). +

+ )} + + {error ?

{error}

: null} + +
+ +
+
+
+ ); +} + +/** Detect dispatch / operate failures that mean the user must claim. */ +export function isIdentityClaimRequiredMessage(message: string | null | undefined): boolean { + if (!message) return false; + const lower = message.toLowerCase(); + return ( + lower.includes("identity_claim_required") || + lower.includes("choose who you are") || + lower.includes("identity claim") + ); +} diff --git a/apps/web/src/components/identity/ParticipantStack.logic.test.ts b/apps/web/src/components/identity/ParticipantStack.logic.test.ts new file mode 100644 index 00000000000..2f9e4af99ba --- /dev/null +++ b/apps/web/src/components/identity/ParticipantStack.logic.test.ts @@ -0,0 +1,28 @@ +import { IdentityUsername, PersonId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; +import { participantDisplayLabel } from "./ParticipantStack.logic"; + +describe("participantDisplayLabel", () => { + it("shows all devices for one person in first-seen order", () => { + expect( + participantDisplayLabel({ + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + firstChannel: "desktop", + channels: ["desktop", "discord"], + firstParticipatedAt: "2026-07-30T12:00:00.000Z", + }), + ).toBe("patroza@desktop,discord"); + }); + + it("supports summaries persisted before channel lists were added", () => { + expect( + participantDisplayLabel({ + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + firstChannel: "discord", + firstParticipatedAt: "2026-07-30T12:00:00.000Z", + }), + ).toBe("patroza@discord"); + }); +}); diff --git a/apps/web/src/components/identity/ParticipantStack.logic.ts b/apps/web/src/components/identity/ParticipantStack.logic.ts new file mode 100644 index 00000000000..0a2a9024810 --- /dev/null +++ b/apps/web/src/components/identity/ParticipantStack.logic.ts @@ -0,0 +1,7 @@ +import type { ThreadParticipantSummary } from "@t3tools/contracts"; + +export function participantDisplayLabel(person: ThreadParticipantSummary): string { + const channels = + person.channels ?? (person.firstChannel === undefined ? [] : [person.firstChannel]); + return channels.length === 0 ? person.username : `${person.username}@${channels.join(",")}`; +} diff --git a/apps/web/src/components/identity/ParticipantStack.tsx b/apps/web/src/components/identity/ParticipantStack.tsx new file mode 100644 index 00000000000..760fa81b147 --- /dev/null +++ b/apps/web/src/components/identity/ParticipantStack.tsx @@ -0,0 +1,140 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + claimPersonIdForEnvironment, + isClaimedNonStarterParticipant, +} from "@t3tools/client-runtime/state/identity"; +import type { ThreadParticipantSummary } from "@t3tools/contracts"; +import { CheckIcon } from "lucide-react"; +import { identityClaimPersonIdByEnvironmentAtom } from "../../state/identity"; +import { IdentityAvatar } from "./IdentityAvatar"; +import { participantDisplayLabel } from "./ParticipantStack.logic"; +import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; +import { cn } from "~/lib/utils"; + +/** + * Creator face + +N extras for thread list rows. + * Hover/focus expands remaining participants (design: participant stack). + */ +export function ParticipantStack(props: { + readonly environmentId: string; + readonly participants: ReadonlyArray; + readonly className?: string; +}) { + const people = props.participants; + const claimPersonIdByEnvironment = useAtomValue(identityClaimPersonIdByEnvironmentAtom); + const claimPersonId = claimPersonIdForEnvironment( + claimPersonIdByEnvironment, + props.environmentId, + ); + const youParticipated = isClaimedNonStarterParticipant({ + claimPersonId, + participants: people, + }); + if (people.length === 0) return null; + + const lead = people[0]!; + const extras = people.slice(1); + const label = + extras.length === 0 + ? `Started by ${lead.username}` + : `Started by ${lead.username}, ${extras.length} other participant${extras.length === 1 ? "" : "s"}`; + const accessibleLabel = youParticipated ? `${label}. You participated` : label; + + const stack = ( + + + {extras.length > 0 ? ( + + +{extras.length} + + ) : null} + {youParticipated ? ( + + + + ) : null} + + ); + + return ( + + + + {people.map((person) => ( + + + + {participantDisplayLabel(person)} + {person.personId === claimPersonId ? ( + · You + ) : null} + + + ))} + + + ); +} + +export function SourceChannelGlyph(props: { + readonly channel: string | null | undefined; + readonly className?: string; +}) { + if (!props.channel) return null; + const short = + props.channel === "desktop" + ? "D" + : props.channel === "web" + ? "W" + : props.channel === "mobile" + ? "M" + : props.channel === "discord" + ? "Δ" + : props.channel === "jira" + ? "J" + : props.channel === "github" + ? "G" + : props.channel.slice(0, 1).toUpperCase(); + return ( + + {short} + + ); +} diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 4ec6004ad76..4fc968b3207 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1444,6 +1444,7 @@ function SavedBackendListRow({
@@ -3020,6 +3021,7 @@ export function ConnectionsSettings() { primaryServerUpdateState.status !== "idle" ? ( ) : primaryVersionMismatch ? ( diff --git a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx index c5c35ad4d35..f59226bb5a8 100644 --- a/apps/web/src/components/sidebar/SidebarUpdatePill.tsx +++ b/apps/web/src/components/sidebar/SidebarUpdatePill.tsx @@ -14,7 +14,6 @@ import { shouldShowDesktopUpdateButton, shouldToastDesktopUpdateActionResult, } from "../desktopUpdate.logic"; -import { showDesktopUpdateDownloadedToast } from "../desktopUpdate.toast"; import { Alert, AlertDescription, AlertTitle } from "../ui/alert"; import { Separator } from "../ui/separator"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; @@ -81,7 +80,11 @@ export function SidebarUpdatePill() { .downloadUpdate() .then((result) => { if (result.completed) { - showDesktopUpdateDownloadedToast(bridge, result.state); + toastManager.add({ + type: "success", + title: "Update downloaded", + description: "Restart the app from the update button to install it.", + }); } if (!shouldToastDesktopUpdateActionResult(result)) return; const actionError = getDesktopUpdateActionError(result); diff --git a/apps/web/src/forkSurfaceExistence.test.ts b/apps/web/src/forkSurfaceExistence.test.ts index b67ed4cd067..22dbe806de8 100644 --- a/apps/web/src/forkSurfaceExistence.test.ts +++ b/apps/web/src/forkSurfaceExistence.test.ts @@ -119,4 +119,41 @@ describe("fork surface existence (anti stack-drop)", () => { expect(chips).toContain('aria-label="Edit queued message"'); expect(chips).toContain("Steer: send now, interrupting the current step"); }); + + it("identity claim gate and participant stack surfaces exist", () => { + const gate = readSrc("components/identity/IdentityClaimGate.tsx"); + expect(gate).toContain('data-testid="identity-claim-gate"'); + expect(gate).toContain("Who are you?"); + expect(gate).toContain("identity-claim-suggestions"); + expect(gate).toContain("Save identity"); + expect(gate).toContain("requestIdentityClaimGate"); + expect(gate).toContain("isIdentityClaimRequiredMessage"); + // Multi-env: claim gate must not only target primary (smart-without-map + t3vm). + expect(gate).toContain("forceClaimEnvironmentId"); + expect(gate).toContain("orderedEnvironmentIds"); + const stack = readSrc("components/identity/ParticipantStack.tsx"); + expect(stack).toContain('data-testid="participant-stack"'); + expect(stack).toContain('data-testid="participant-stack-popup"'); + expect(stack).toContain(" { shortcutLabelForCommand(DEFAULT_BINDINGS, "commandPalette.toggle", "MacIntel"), "⌘K", ); - assert.strictEqual( - shortcutLabelForCommand(DEFAULT_BINDINGS, "filePicker.toggle", "MacIntel"), - "⌘P", - ); - assert.strictEqual( - shortcutLabelForCommand(DEFAULT_BINDINGS, "projectSearch.toggle", "MacIntel"), - "⇧⌘F", - ); assert.strictEqual( shortcutLabelForCommand(DEFAULT_BINDINGS, "modelPicker.toggle", "Linux"), "Ctrl+Shift+M", @@ -542,40 +524,6 @@ describe("chat/editor shortcuts", () => { ); }); - it("matches filePicker.toggle shortcut outside terminal focus", () => { - assert.strictEqual( - resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: false }, - }), - "filePicker.toggle", - ); - assert.notStrictEqual( - resolveShortcutCommand(event({ key: "p", metaKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: true }, - }), - "filePicker.toggle", - ); - }); - - it("matches projectSearch.toggle shortcut outside terminal focus", () => { - assert.strictEqual( - resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: false }, - }), - "projectSearch.toggle", - ); - assert.notStrictEqual( - resolveShortcutCommand(event({ key: "f", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, { - platform: "MacIntel", - context: { terminalFocus: true }, - }), - "projectSearch.toggle", - ); - }); - it("matches diff.toggle shortcut outside terminal focus", () => { assert.isTrue( isDiffToggleShortcut(event({ key: "d", metaKey: true }), DEFAULT_BINDINGS, { diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..267912eec9d 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -17,6 +17,7 @@ import { CommandPalette } from "../components/CommandPalette"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; +import { IdentityClaimGate } from "../components/identity/IdentityClaimGate"; import { ProviderUpdateLaunchNotification } from "../components/ProviderUpdateLaunchNotification"; import { SlowRpcRequestToastCoordinator } from "../components/SlowRpcRequestToastCoordinator"; import { Button } from "../components/ui/button"; @@ -136,6 +137,10 @@ function RootRouteView() { {primaryEnvironmentAuthenticated ? : null} {primaryEnvironmentAuthenticated ? : null} + {/* Claim gate: primary auth OR hosted-static (paired remotes still need identity). */} + {primaryEnvironmentAuthenticated || authGateState.status === "hosted-static" ? ( + + ) : null} {appShell} diff --git a/apps/web/src/state/identity.ts b/apps/web/src/state/identity.ts new file mode 100644 index 00000000000..2f808489cba --- /dev/null +++ b/apps/web/src/state/identity.ts @@ -0,0 +1,34 @@ +import { createIdentityEnvironmentAtoms } from "@t3tools/client-runtime/state/identity"; +import type { EnvironmentId } from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; + +import { environmentCatalog } from "../connection/catalog"; +import { connectionAtomRuntime } from "../connection/runtime"; + +export const identityEnvironment = createIdentityEnvironmentAtoms(connectionAtomRuntime); + +const EMPTY_CLAIM_INPUT = {} as const; + +/** + * Session claim personId per connected environment. + * + * Ownership filters must key by the *thread's* environment, not primary. + * Desktop primary=smart (no map) while secondary=t3vm (claimed) must still + * Mine-filter t3vm threads correctly. + */ +export const identityClaimPersonIdByEnvironmentAtom = Atom.make((get) => { + const catalog = get(environmentCatalog.catalogValueAtom); + const out = new Map(); + for (const environmentId of catalog.entries.keys()) { + const result = get( + identityEnvironment.sessionClaim({ + environmentId: environmentId as EnvironmentId, + input: EMPTY_CLAIM_INPUT, + }), + ); + const claimResult = Option.getOrNull(AsyncResult.value(result)); + out.set(environmentId, claimResult?.claim?.personId ?? null); + } + return out; +}).pipe(Atom.withLabel("web-identity-claim-person-by-environment")); diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index d4e1098a364..7a879988328 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -1,24 +1,11 @@ import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; -import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime"; -import { WS_METHODS } from "@t3tools/contracts"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); -/** - * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile - * surface, so the atom family lives here instead of the shared client-runtime - * project atoms consumed by the mobile app. - */ -export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { - label: "environment-data:projects:search-contents", - tag: WS_METHODS.projectsSearchContents, - staleTimeMs: 5_000, - idleTtlMs: 60_000, -}); export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index 2a095b8f584..a9564c2fd64 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -12,8 +12,6 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, - ProjectContentMatch, - ProjectEntryKind, ThreadId, VcsListRefsResult, VcsRef, @@ -26,19 +24,16 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; -import { projectContentSearch, projectEnvironment } from "./projects"; +import { projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; -const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; +const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; -const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; -const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; -const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ @@ -234,50 +229,26 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } -type ProjectPathSearchTarget = ComposerPathSearchTarget & { - readonly kind?: ProjectEntryKind | undefined; -}; - -export function areProjectPathSearchTargetsEqual( - left: ProjectPathSearchTarget, - right: ProjectPathSearchTarget, -): boolean { - return ( - left.environmentId === right.environmentId && - left.cwd === right.cwd && - left.query === right.query && - left.kind === right.kind - ); -} - -export function useProjectPathSearch( - target: ProjectPathSearchTarget, - limit: number, - options?: { readonly allowEmptyQuery?: boolean }, -) { - const allowEmptyQuery = options?.allowEmptyQuery === true; +export function useComposerPathSearch(target: ComposerPathSearchTarget) { const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, cwd: target.cwd, - query: target.query == null ? null : target.query.trim(), - kind: target.kind, + query: target.query?.trim() ?? "", }), - [target.cwd, target.environmentId, target.kind, target.query], + [target.cwd, target.environmentId, target.query], ); - const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); + const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && - debouncedTarget.query !== null && - (allowEmptyQuery || debouncedTarget.query.length > 0) + debouncedTarget.query.length > 0 ? projectEnvironment.searchEntries({ environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, - limit, - ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), + limit: COMPOSER_PATH_SEARCH_LIMIT, }, }) : null, @@ -286,61 +257,11 @@ export function useProjectPathSearch( return { entries: result.data?.entries ?? [], error: result.error, - isPending: - !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, - searchedQuery: debouncedTarget.query ?? "", + isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, refresh: result.refresh, }; } -export function useComposerPathSearch(target: ComposerPathSearchTarget) { - return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); -} - -interface ProjectContentSearchTarget { - readonly environmentId: EnvironmentId | null; - readonly cwd: string | null; - readonly query: string; - readonly caseSensitive: boolean; - readonly wholeWord: boolean; - readonly useRegex: boolean; -} - -export function useProjectContentSearch(target: ProjectContentSearchTarget) { - // Whitespace is significant in content queries; trimming is only used to - // decide whether the input is blank. - const query = target.query; - const hasQuery = query.trim().length > 0; - const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); - const result = useEnvironmentQuery( - target.environmentId !== null && - target.cwd !== null && - hasQuery && - debouncedQuery.trim().length > 0 - ? projectContentSearch({ - environmentId: target.environmentId, - input: { - cwd: target.cwd, - query: debouncedQuery, - limit: PROJECT_CONTENT_SEARCH_LIMIT, - caseSensitive: target.caseSensitive, - wholeWord: target.wholeWord, - useRegex: target.useRegex, - }, - }) - : null, - ); - - return { - matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, - error: result.error, - isPending: hasQuery && (query !== debouncedQuery || result.isPending), - hasQuery, - truncated: result.data?.truncated ?? false, - invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, - }; -} - export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/infra/relay/README.md b/infra/relay/README.md index 0085c9c5b6b..114d5e9b07f 100644 --- a/infra/relay/README.md +++ b/infra/relay/README.md @@ -1,7 +1,7 @@ # T3 Connect Relay -> [!NOTE] -> Sign in to T3 Connect from the app under Settings > Connections. +> [!WARNING] +> T3 Connect is currently in private beta. Join the waitlist in the app under Settings > T3 Connect. The relay is the hosted control plane for T3 Connect. It helps clients discover and connect to remote environments, manages the cloud-side records needed for those connections, and delivers @@ -9,7 +9,7 @@ optional mobile notifications and Live Activities. The relay is intentionally not in the hot path for normal T3 Code traffic. After a client connects, regular API and WebSocket traffic goes directly between that client and the selected environment. -See the [T3 Connect architecture overview](../../docs/internals/t3-code-connect-auth-flow.html) for the larger system +See the [T3 Connect architecture overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the larger system design. ## Responsibilities @@ -25,7 +25,7 @@ The relay currently owns: - Persisting relay state and exposing relay-specific traces for diagnostics. The environment server and relay have separate credentials and trust boundaries. Read -[Environment Authentication Profile](../../docs/internals/environment-auth.md) before changing token, +[Environment Authentication Profile](../../docs/environment-auth.md) before changing token, credential, or authorization behavior. ## Code Map @@ -159,8 +159,8 @@ and hosted web builds. See: -- [T3 Connect Clerk Setup](../../docs/internals/t3-connect.md) for Clerk keys, JWT templates, and sign-up restrictions +- [T3 Connect Clerk Setup](../../docs/cloud/t3-connect-clerk.md) for Clerk keys, JWT templates, and waitlist setup. -- [Relay Observability](../../docs/operations/relay-observability.md) for deployment tracing and diagnostics. -- [T3 Connect Architecture Overview](../../docs/internals/t3-code-connect-auth-flow.html) for the full link, +- [Relay Observability](../../docs/relay-observability.md) for deployment tracing and diagnostics. +- [T3 Connect Architecture Overview](../../docs/cloud/t3-code-connect-auth-flow.html) for the full link, connect, endpoint, and notification flows. diff --git a/infra/relay/src/environments/EnvironmentConnector.ts b/infra/relay/src/environments/EnvironmentConnector.ts index d840f809e5a..db662aee94d 100644 --- a/infra/relay/src/environments/EnvironmentConnector.ts +++ b/infra/relay/src/environments/EnvironmentConnector.ts @@ -13,7 +13,6 @@ import { RelayEnvironmentMintResponse, RelayEnvironmentMintResponseProofPayload, RelayCloudMintCredentialProofPayload, - RelayEnvironmentConnectNotAuthorizedReason, type RelayEnvironmentConnectResponse, type RelayEnvironmentStatusResponse, } from "@t3tools/contracts/relay"; @@ -45,8 +44,21 @@ import * as ManagedEndpointAllocations from "./ManagedEndpointAllocations.ts"; import * as RelayConfiguration from "../Config.ts"; import { isManagedEndpointHostname } from "../deploymentConfig.ts"; +export const EnvironmentConnectNotAuthorizedReason = Schema.Literals([ + "client_proof_key_thumbprint_missing", + "environment_link_not_found", + "endpoint_provider_not_managed", + "managed_endpoint_allocation_not_found", + "managed_endpoint_base_domain_not_configured", + "managed_endpoint_allocation_not_ready", + "managed_endpoint_hostname_invalid", + "managed_endpoint_mismatch", +]); +export type EnvironmentConnectNotAuthorizedReason = + typeof EnvironmentConnectNotAuthorizedReason.Type; + function environmentConnectNotAuthorizedReasonMessage( - reason: RelayEnvironmentConnectNotAuthorizedReason, + reason: EnvironmentConnectNotAuthorizedReason, ): string { switch (reason) { case "client_proof_key_thumbprint_missing": @@ -73,7 +85,7 @@ export class EnvironmentConnectNotAuthorized extends Schema.TaggedErrorClass + EnvironmentConnectNotAuthorized: (_error, traceId) => new RelayEnvironmentConnectNotAuthorizedError({ code: "environment_connect_not_authorized", - reason: error.reason, traceId, }), EnvironmentMintRequestFailed: (_error, traceId) => @@ -821,10 +820,9 @@ export const dpopClientApi = HttpApiBuilder.group( }, mapRelayCommonApiErrors("invalid_dpop"), mapErrorTags({ - EnvironmentConnectNotAuthorized: (error, traceId) => + EnvironmentConnectNotAuthorized: (_error, traceId) => new RelayEnvironmentConnectNotAuthorizedError({ code: "environment_connect_not_authorized", - reason: error.reason, traceId, }), EnvironmentMintRequestFailed: (_error, traceId) => diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index 6889fe64248..695a92dc772 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -47,6 +47,10 @@ "types": "./src/state/aiUsagePresentation.ts", "default": "./src/state/aiUsagePresentation.ts" }, + "./state/identity": { + "types": "./src/state/identity.ts", + "default": "./src/state/identity.ts" + }, "./state/auth": { "types": "./src/state/auth.ts", "default": "./src/state/auth.ts" diff --git a/packages/client-runtime/src/rpc/session.test.ts b/packages/client-runtime/src/rpc/session.test.ts index 147843eb589..21f25c75699 100644 --- a/packages/client-runtime/src/rpc/session.test.ts +++ b/packages/client-runtime/src/rpc/session.test.ts @@ -336,40 +336,6 @@ describe("RpcSessionFactory", () => { }), ); - it.effect("reaches ready when a newer server sends unknown config members", () => - Effect.gen(function* () { - const { factory, sockets } = yield* makeFactory(); - const session = yield* factory.connect(PREPARED); - const readyFiber = yield* Effect.forkChild(session.ready); - const socket = yield* awaitSocket(sockets); - socket.open(); - - const shortcut = { - key: "p", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, - }; - yield* completeInitialConfig(socket, { - ...ENCODED_SERVER_CONFIG, - keybindings: [ - { command: "someFuture.toggle", shortcut }, - { command: "terminal.toggle", shortcut }, - ], - issues: [{ kind: "keybindings.future-issue", message: "From a newer server" }], - availableEditors: ["some-future-editor", "zed"], - }); - yield* Fiber.join(readyFiber); - - const config = yield* session.initialConfig; - expect(config.keybindings).toEqual([{ command: "terminal.toggle", shortcut }]); - expect(config.issues).toEqual([]); - expect(config.availableEditors).toEqual(["zed"]); - }), - ); - it.effect("uses the legacy config RPC for probes when the server lacks the capability", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/identity.test.ts b/packages/client-runtime/src/state/identity.test.ts new file mode 100644 index 00000000000..8165b4f595f --- /dev/null +++ b/packages/client-runtime/src/state/identity.test.ts @@ -0,0 +1,175 @@ +import { IdentityUsername, PersonId, type ThreadParticipantSummary } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + claimPersonIdForEnvironment, + filterPeopleForTypeahead, + identityClaimRequired, + isClaimedNonStarterParticipant, + threadMatchesMine, +} from "./identity.ts"; + +describe("identityClaimRequired", () => { + it("is false when identity is off", () => { + expect( + identityClaimRequired({ enabled: false, claimRequired: false, people: [] }, { claim: null }), + ).toBe(false); + }); + + it("is true when map enabled and no claim", () => { + expect( + identityClaimRequired( + { + enabled: true, + claimRequired: true, + people: [ + { + personId: "patroza" as never, + username: "patroza" as never, + links: {}, + }, + ], + }, + { claim: null }, + ), + ).toBe(true); + }); +}); + +describe("filterPeopleForTypeahead", () => { + const people = [ + { + personId: "patroza" as never, + username: "patroza" as never, + name: "Patrick Roza", + links: {}, + }, + { + personId: "julius" as never, + username: "julius" as never, + name: "Julius", + links: {}, + }, + ]; + + it("requires min chars", () => { + expect(filterPeopleForTypeahead(people, "pa", 3)).toEqual([]); + }); + + it("matches username and name after min chars", () => { + expect(filterPeopleForTypeahead(people, "pat", 3).map((p) => p.username)).toEqual(["patroza"]); + expect(filterPeopleForTypeahead(people, "roza", 3).map((p) => p.username)).toEqual(["patroza"]); + }); +}); + +describe("threadMatchesMine", () => { + it("filters mine vs theirs", () => { + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "patroza", + mode: "mine", + }), + ).toBe(true); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + mode: "mine", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: "patroza", + originPersonId: "julius", + mode: "theirs", + }), + ).toBe(true); + }); + + it("excludes both mine and theirs when there is no claim for the env", () => { + expect( + threadMatchesMine({ + claimPersonId: null, + originPersonId: "patroza", + mode: "mine", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: null, + originPersonId: "patroza", + mode: "theirs", + }), + ).toBe(false); + expect( + threadMatchesMine({ + claimPersonId: null, + originPersonId: "patroza", + mode: "any", + }), + ).toBe(true); + }); +}); + +describe("claimPersonIdForEnvironment", () => { + it("returns the claim for the thread environment only", () => { + const map = new Map([ + ["smart", null], + ["t3vm", "patroza"], + ]); + expect(claimPersonIdForEnvironment(map, "t3vm")).toBe("patroza"); + expect(claimPersonIdForEnvironment(map, "smart")).toBeNull(); + expect(claimPersonIdForEnvironment(map, "missing")).toBeNull(); + }); +}); + +describe("isClaimedNonStarterParticipant", () => { + const participants = [ + { + personId: PersonId.make("joshua"), + username: IdentityUsername.make("joshuadima"), + firstChannel: "discord", + firstParticipatedAt: "2026-07-30T12:00:00.000Z", + }, + { + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + firstChannel: "desktop", + firstParticipatedAt: "2026-07-30T12:01:00.000Z", + }, + ] satisfies ReadonlyArray; + + it("marks a claimed person hidden among later participants", () => { + expect( + isClaimedNonStarterParticipant({ + claimPersonId: "PATROZA", + participants, + }), + ).toBe(true); + }); + + it("does not redundantly mark the visible starter", () => { + expect( + isClaimedNonStarterParticipant({ + claimPersonId: "joshua", + participants, + }), + ).toBe(false); + }); + + it("does not mark an unclaimed or absent person", () => { + expect( + isClaimedNonStarterParticipant({ + claimPersonId: null, + participants, + }), + ).toBe(false); + expect( + isClaimedNonStarterParticipant({ + claimPersonId: "someone-else", + participants, + }), + ).toBe(false); + }); +}); diff --git a/packages/client-runtime/src/state/identity.ts b/packages/client-runtime/src/state/identity.ts new file mode 100644 index 00000000000..b52a4b886b8 --- /dev/null +++ b/packages/client-runtime/src/state/identity.ts @@ -0,0 +1,122 @@ +/** + * Per-environment session identity (closed-set claim against the server map). + */ +import { + WS_METHODS, + type IdentityClaimInput, + type IdentitySnapshot, + type IdentitySessionClaimResult, + type SessionIdentityClaim, + type ThreadParticipantSummary, +} from "@t3tools/contracts"; +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { Atom } from "effect/unstable/reactivity"; +import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily } from "./runtime.ts"; + +export function createIdentityEnvironmentAtoms( + runtime: Atom.AtomRuntime, +) { + const snapshot = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "identity-snapshot", + tag: WS_METHODS.identityGetSnapshot, + staleTimeMs: 30_000, + idleTtlMs: 60_000, + }); + + const sessionClaim = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "identity-session-claim", + tag: WS_METHODS.identityGetSessionClaim, + staleTimeMs: 5_000, + idleTtlMs: 60_000, + }); + + const claim = createEnvironmentRpcCommand(runtime, { + label: "identity-claim", + tag: WS_METHODS.identityClaim, + }); + + const clearClaim = createEnvironmentRpcCommand(runtime, { + label: "identity-clear-claim", + tag: WS_METHODS.identityClearClaim, + }); + + return { + snapshot, + sessionClaim, + claim, + clearClaim, + }; +} + +export type IdentityEnvironmentAtoms = ReturnType; + +export function identityClaimRequired( + snapshot: IdentitySnapshot | null | undefined, + claimResult: IdentitySessionClaimResult | null | undefined, +): boolean { + if (snapshot === null || snapshot === undefined) return false; + if (!snapshot.enabled || !snapshot.claimRequired) return false; + return claimResult?.claim == null; +} + +export function filterPeopleForTypeahead( + people: IdentitySnapshot["people"], + query: string, + minChars: number, +): IdentitySnapshot["people"] { + const q = query.trim().toLowerCase(); + if (q.length < minChars) return []; + return people.filter((person) => { + if (person.username.includes(q)) return true; + if (person.name?.toLowerCase().includes(q)) return true; + return false; + }); +} + +/** Match a thread as "mine" against the session claim personId. */ +export function threadMatchesMine(input: { + readonly claimPersonId: string | null | undefined; + readonly originPersonId?: string | null | undefined; + readonly participantPersonIds?: ReadonlyArray | null | undefined; + readonly mode: "mine" | "theirs" | "any"; +}): boolean { + if (input.mode === "any") return true; + const claimId = input.claimPersonId?.trim().toLowerCase() ?? ""; + // No claim for this environment (map off, or user never signed up there): + // ownership is unclassifiable — hide from both Mine and Theirs. Multi-env + // clients with primary=smart (no map) previously used a single empty claim + // and treated every thread as Theirs, which made Mine look broken for t3vm. + if (claimId.length === 0) return false; + const people = new Set(); + if (input.originPersonId) people.add(input.originPersonId.trim().toLowerCase()); + for (const id of input.participantPersonIds ?? []) { + people.add(id.trim().toLowerCase()); + } + const isMine = people.has(claimId); + return input.mode === "mine" ? isMine : !isMine; +} + +/** Whether the claimed person participated after someone else started the thread. */ +export function isClaimedNonStarterParticipant(input: { + readonly claimPersonId: string | null | undefined; + readonly participants: ReadonlyArray; +}): boolean { + const claimId = input.claimPersonId?.trim().toLowerCase() ?? ""; + if (claimId.length === 0) return false; + return input.participants + .slice(1) + .some((participant) => participant.personId.trim().toLowerCase() === claimId); +} + +/** Look up the claim person for a thread's environment (multi-env clients). */ +export function claimPersonIdForEnvironment( + claimPersonIdByEnvironment: ReadonlyMap, + environmentId: string, +): string | null { + const value = claimPersonIdByEnvironment.get(environmentId); + if (value === undefined || value === null) return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export type { IdentityClaimInput, IdentitySnapshot, SessionIdentityClaim }; diff --git a/packages/client-runtime/src/state/threadReducer.test.ts b/packages/client-runtime/src/state/threadReducer.test.ts index 11ab7df36d7..553491c4b1b 100644 --- a/packages/client-runtime/src/state/threadReducer.test.ts +++ b/packages/client-runtime/src/state/threadReducer.test.ts @@ -4,7 +4,9 @@ import { CheckpointRef, CommandId, EventId, + IdentityUsername, MessageId, + PersonId, ProjectId, ProviderInstanceId, ThreadId, @@ -304,6 +306,41 @@ describe("applyThreadDetailEvent", () => { } }); + it("preserves server-authored source attribution on live messages", () => { + const result = applyThreadDetailEvent(baseThread, { + ...baseEventFields, + sequence: 7, + occurredAt: "2026-04-01T06:01:00.000Z", + aggregateKind: "thread", + aggregateId: ThreadId.make("thread-1"), + type: "thread.message-sent", + payload: { + threadId: ThreadId.make("thread-1"), + messageId: MessageId.make("msg-sourced"), + role: "user", + text: "Sent from desktop", + turnId: null, + streaming: false, + source: { + channel: "desktop", + personId: PersonId.make("patroza"), + username: IdentityUsername.make("patroza"), + }, + createdAt: "2026-04-01T06:01:00.000Z", + updatedAt: "2026-04-01T06:01:00.000Z", + }, + }); + + expect(result.kind).toBe("updated"); + if (result.kind === "updated") { + expect(result.thread.messages[0]?.source).toEqual({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + } + }); + it("appends text for streaming messages", () => { const threadWithMessage: OrchestrationThread = { ...baseThread, diff --git a/packages/client-runtime/src/state/threadReducer.ts b/packages/client-runtime/src/state/threadReducer.ts index 69a2964a8e6..3b6dc3e6f9a 100644 --- a/packages/client-runtime/src/state/threadReducer.ts +++ b/packages/client-runtime/src/state/threadReducer.ts @@ -288,6 +288,7 @@ export function applyThreadDetailEvent( ...(event.payload.attachments !== undefined ? { attachments: event.payload.attachments } : {}), + ...(event.payload.source !== undefined ? { source: event.payload.source } : {}), turnId: event.payload.turnId, streaming: event.payload.streaming, createdAt: event.payload.createdAt, @@ -312,6 +313,9 @@ export function applyThreadDetailEvent( ...(message.attachments !== undefined ? { attachments: message.attachments } : {}), + ...(entry.source === undefined && message.source !== undefined + ? { source: message.source } + : {}), }, ) : Arr.append(thread.messages, message); @@ -389,6 +393,7 @@ export function applyThreadDetailEvent( ...(event.payload.sourceProposedPlan !== undefined ? { sourceProposedPlan: event.payload.sourceProposedPlan } : {}), + ...(event.payload.source !== undefined ? { source: event.payload.source } : {}), queuedAt: event.payload.queuedAt, }; return { diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index 9a63f22c9ef..a8fa565cef4 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -1,5 +1,4 @@ import * as Effect from "effect/Effect"; -import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -21,30 +20,6 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; -/** - * Wire codec for server→client arrays whose element unions grow over time - * (new literal members, new struct variants). Decoding drops elements the - * current build cannot decode instead of failing the whole payload — a client - * has to keep decoding configs sent by servers newer than itself, and - * rejecting the payload would take down the connection over data the client - * couldn't act on anyway. Encoding is the plain array encoding. - */ -export const ForwardCompatibleArray = (element: Element) => { - const decodeElement = Schema.decodeUnknownOption(element as never); - return Schema.Array(Schema.Unknown).pipe( - Schema.decodeTo( - Schema.Array(element), - SchemaTransformation.transform, ReadonlyArray>({ - decode: (values) => - values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< - Element["Encoded"] - >, - encode: (values) => values, - }), - ), - ); -}; - /** * Construct a branded identifier. Enforces non-empty trimmed strings */ diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..f95fb82808a 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -56,6 +56,9 @@ export const EnvironmentRequestInvalidReason = Schema.Literals([ "invalid_scope", "scope_not_granted", "invalid_command", + "identity_claim_required", + "identity_unknown_person", + "identity_map_invalid", ]); export type EnvironmentRequestInvalidReason = typeof EnvironmentRequestInvalidReason.Type; diff --git a/packages/contracts/src/identity.test.ts b/packages/contracts/src/identity.test.ts new file mode 100644 index 00000000000..5c7d50231bf --- /dev/null +++ b/packages/contracts/src/identity.test.ts @@ -0,0 +1,161 @@ +import { describe, expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { + IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS, + IDENTITY_HANDLE_SOFT_MAX_LENGTH, + ClientSourceHint, + IdentityClaimInput, + IdentityError, + IdentityPersonPublic, + IdentitySnapshot, + IdentityUsername, + PersonId, + SessionIdentityClaim, + SourceRef, + ThreadParticipantSummary, +} from "./identity.ts"; +import { AuthSessionId } from "./baseSchemas.ts"; + +const decodeUsername = Schema.decodeUnknownSync(IdentityUsername); +const decodePersonId = Schema.decodeUnknownSync(PersonId); +const decodeSourceRef = Schema.decodeUnknownSync(SourceRef); +const decodeClientHint = Schema.decodeUnknownSync(ClientSourceHint); +const decodeSnapshot = Schema.decodeUnknownSync(IdentitySnapshot); +const decodeClaimInput = Schema.decodeUnknownSync(IdentityClaimInput); +const decodeClaim = Schema.decodeUnknownSync(SessionIdentityClaim); +const decodePerson = Schema.decodeUnknownSync(IdentityPersonPublic); +const decodeParticipant = Schema.decodeUnknownSync(ThreadParticipantSummary); + +describe("IdentityUsername / PersonId handles", () => { + it.each(["a", "pat", "patroza", "a_b-c", "julius", "user.name", "x1"])("accepts %s", (value) => { + expect(decodeUsername(value)).toBe(value.toLowerCase()); + expect(decodePersonId(value)).toBe(value.toLowerCase()); + }); + + it("normalizes case to lowercase", () => { + expect(decodeUsername("PatRoza")).toBe("patroza"); + expect(decodePersonId("PatRoza")).toBe("patroza"); + }); + + it("accepts usernames longer than 16 chars within soft max", () => { + const long = `a${"b".repeat(40)}`; + expect(decodeUsername(long)).toBe(long); + }); + + it.each([ + ["empty", ""], + ["spaces", "pat roza"], + ["control char", "foo\nbar"], + ["leading dash", "-pat"], + ["leading underscore", "_pat"], + ["at-sign", "pat@roza"], + ["leading dot", ".pat"], + ])("rejects %s", (_label, value) => { + expect(() => decodeUsername(value)).toThrow(); + expect(() => decodePersonId(value)).toThrow(); + }); + + it("rejects past soft max", () => { + expect(() => decodeUsername("a".repeat(IDENTITY_HANDLE_SOFT_MAX_LENGTH + 1))).toThrow(); + }); + + it("exports typeahead threshold of 3 characters", () => { + expect(IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS).toBe(3); + }); +}); + +describe("SourceRef vs ClientSourceHint", () => { + it("decodes a server stamp with person", () => { + const parsed = decodeSourceRef({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + expect(parsed.channel).toBe("desktop"); + expect(parsed.personId).toBe("patroza"); + }); + + it("client hint has no person fields", () => { + const hint = decodeClientHint({ + channel: "discord", + location: { guildId: "1", channelId: "2" }, + actor: { platformId: "9", displayName: "Patrick" }, + }); + expect(hint.channel).toBe("discord"); + expect("personId" in hint).toBe(false); + }); + + it("rejects unknown channel", () => { + expect(() => decodeSourceRef({ channel: "irc" })).toThrow(); + }); +}); + +describe("IdentitySnapshot + claim", () => { + it("decodes an enabled map snapshot", () => { + const parsed = decodeSnapshot({ + enabled: true, + claimRequired: true, + people: [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + links: { + discordId: "95218063095377920", + githubLogin: "patroza", + }, + }, + ], + }); + expect(parsed.enabled).toBe(true); + expect(parsed.people[0]?.username).toBe("patroza"); + }); + + it("defaults empty links on person", () => { + const person = decodePerson({ + personId: PersonId.make("julius"), + username: "julius", + }); + expect(person.links).toEqual({}); + }); + + it("accepts claim by username or personId with optional method", () => { + expect(decodeClaimInput({ username: "patroza" })).toEqual({ username: "patroza" }); + expect(decodeClaimInput({ personId: "patroza", method: "settings" })).toEqual({ + personId: "patroza", + method: "settings", + }); + }); + + it("decodes a session claim", () => { + const claim = decodeClaim({ + sessionId: AuthSessionId.make("00000000-0000-4000-8000-000000000001"), + personId: "patroza", + username: "patroza", + claimedAt: "2026-07-30T12:00:00.000Z", + method: "typeahead", + }); + expect(claim.method).toBe("typeahead"); + }); + + it("decodes participant summary", () => { + const row = decodeParticipant({ + personId: "patroza", + username: "patroza", + firstChannel: "discord", + channels: ["discord", "desktop"], + firstParticipatedAt: "2026-07-30T12:00:00.000Z", + }); + expect(row.firstChannel).toBe("discord"); + expect(row.channels).toEqual(["discord", "desktop"]); + }); + + it("constructs IdentityError codes", () => { + const err = new IdentityError({ + code: "identity_unknown_person", + message: "not in map", + }); + expect(err.code).toBe("identity_unknown_person"); + }); +}); diff --git a/packages/contracts/src/identity.ts b/packages/contracts/src/identity.ts new file mode 100644 index 00000000000..e9d578ac4af --- /dev/null +++ b/packages/contracts/src/identity.ts @@ -0,0 +1,210 @@ +/** + * Session identity + message/thread source attribution. + * + * Closed-set people come from a server identity map file. Interactive clients + * claim a map person on their auth session; free-form usernames are rejected. + * + * Trust note (v1): interactive claim is **map membership only** — any paired + * session can claim any listed person. That is intentional for trusted-team + * shared environments, not anti-impersonation. “Mine” is claim-based and + * spoofable by peers with a session. Discord/Jira auto-claim binds via platform id. + * + * See docs/architecture/source-and-identity.md + */ +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; +import * as SchemaTransformation from "effect/SchemaTransformation"; +import { AuthSessionId, TrimmedNonEmptyString, IsoDateTime } from "./baseSchemas.ts"; + +// ── Username / person ────────────────────────────────────────── + +/** + * Soft max for wire abuse only — not a product length rule. + * Charset keeps handles safe for `user@channel` display and logs. + */ +export const IDENTITY_HANDLE_SOFT_MAX_LENGTH = 128; + +/** Minimum typed characters before the claim UI shows map suggestions. */ +export const IDENTITY_CLAIM_TYPEAHEAD_MIN_CHARS = 3; + +/** + * Handle charset: leading alnum, then alnum / `.` / `_` / `-`. + * No spaces or control chars. No minimum length product rule (single char OK). + */ +export const IDENTITY_HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; + +const normalizeHandle = (value: string) => value.trim().toLowerCase(); + +const IdentityHandleString = TrimmedNonEmptyString.pipe( + Schema.decodeTo( + Schema.String, + SchemaTransformation.transformOrFail({ + decode: (value) => Effect.succeed(normalizeHandle(value)), + encode: (value) => Effect.succeed(value), + }), + ), +).check( + Schema.isMaxLength(IDENTITY_HANDLE_SOFT_MAX_LENGTH), + Schema.isPattern(IDENTITY_HANDLE_PATTERN), +); + +export const IdentityUsername = IdentityHandleString.pipe(Schema.brand("IdentityUsername")); +export type IdentityUsername = typeof IdentityUsername.Type; + +/** Same normalization as username so mine/theirs compares stay case-stable. */ +export const PersonId = IdentityHandleString.pipe(Schema.brand("PersonId")); +export type PersonId = typeof PersonId.Type; + +// ── Channels / SourceRef ─────────────────────────────────────── + +export const SourceChannel = Schema.Literals([ + "desktop", + "vscode", + "web", + "mobile", + "discord", + "github", + "jira", + "slack", + "teams", + "bot", + "unknown", +]); +export type SourceChannel = typeof SourceChannel.Type; + +export const SourceLocation = Schema.Struct({ + guildId: Schema.optionalKey(TrimmedNonEmptyString), + channelId: Schema.optionalKey(TrimmedNonEmptyString), + threadId: Schema.optionalKey(TrimmedNonEmptyString), + owner: Schema.optionalKey(TrimmedNonEmptyString), + repo: Schema.optionalKey(TrimmedNonEmptyString), + number: Schema.optionalKey(Schema.Int), + kind: Schema.optionalKey(Schema.Literals(["pr", "issue"])), + projectKey: Schema.optionalKey(TrimmedNonEmptyString), + issueKey: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type SourceLocation = typeof SourceLocation.Type; + +export const SourceActor = Schema.Struct({ + platformId: Schema.optionalKey(TrimmedNonEmptyString), + displayName: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type SourceActor = typeof SourceActor.Type; + +/** + * Client may only hint non-person fields. Server stamps person from the + * session claim (or platform map for bots). Never trust client personId/username. + */ +export const ClientSourceHint = Schema.Struct({ + channel: Schema.optionalKey(SourceChannel), + location: Schema.optionalKey(SourceLocation), + actor: Schema.optionalKey(SourceActor), +}); +export type ClientSourceHint = typeof ClientSourceHint.Type; + +/** + * Server-authored provenance for a user-originated message / thread origin. + * personId/username absent only when an external actor is unmapped. + */ +export const SourceRef = Schema.Struct({ + channel: SourceChannel, + personId: Schema.optionalKey(PersonId), + username: Schema.optionalKey(IdentityUsername), + location: Schema.optionalKey(SourceLocation), + actor: Schema.optionalKey(SourceActor), +}); +export type SourceRef = typeof SourceRef.Type; + +/** Ordered participant on a thread shell (origin first when known). */ +export const ThreadParticipantSummary = Schema.Struct({ + personId: PersonId, + username: IdentityUsername, + name: Schema.optionalKey(TrimmedNonEmptyString), + firstChannel: Schema.optionalKey(SourceChannel), + channels: Schema.optionalKey(Schema.Array(SourceChannel)), + firstParticipatedAt: IsoDateTime, +}); +export type ThreadParticipantSummary = typeof ThreadParticipantSummary.Type; + +// ── Public identity map (client-safe) ────────────────────────── + +export const IdentityPlatformLinkPublic = Schema.Struct({ + discordId: Schema.optionalKey(TrimmedNonEmptyString), + discordUsername: Schema.optionalKey(TrimmedNonEmptyString), + githubLogin: Schema.optionalKey(TrimmedNonEmptyString), + jiraAccountId: Schema.optionalKey(TrimmedNonEmptyString), +}); +export type IdentityPlatformLinkPublic = typeof IdentityPlatformLinkPublic.Type; + +export const IdentityPersonPublic = Schema.Struct({ + personId: PersonId, + username: IdentityUsername, + name: Schema.optionalKey(TrimmedNonEmptyString), + links: IdentityPlatformLinkPublic.pipe(Schema.withDecodingDefault(Effect.succeed({}))), +}); +export type IdentityPersonPublic = typeof IdentityPersonPublic.Type; + +/** + * Snapshot of the closed identity set. + * v1: `claimRequired === enabled` (both true when map has people). + * Full people[] is intentional roster share for typeahead (not privacy isolation). + */ +export const IdentitySnapshot = Schema.Struct({ + /** False when map file missing/empty — no claim gate. */ + enabled: Schema.Boolean, + people: Schema.Array(IdentityPersonPublic), + /** v1 always equals `enabled`. */ + claimRequired: Schema.Boolean, +}); +export type IdentitySnapshot = typeof IdentitySnapshot.Type; + +export const SessionIdentityClaimMethod = Schema.Literals([ + "typeahead", + "settings", + "auto-discord", + "auto-jira", + "bootstrap", +]); +export type SessionIdentityClaimMethod = typeof SessionIdentityClaimMethod.Type; + +export const SessionIdentityClaim = Schema.Struct({ + sessionId: AuthSessionId, + personId: PersonId, + username: IdentityUsername, + claimedAt: IsoDateTime, + method: SessionIdentityClaimMethod, +}); +export type SessionIdentityClaim = typeof SessionIdentityClaim.Type; + +export const IdentityClaimInput = Schema.Union([ + Schema.Struct({ + personId: PersonId, + method: Schema.optionalKey(Schema.Literals(["typeahead", "settings", "bootstrap"])), + }), + Schema.Struct({ + username: IdentityUsername, + method: Schema.optionalKey(Schema.Literals(["typeahead", "settings", "bootstrap"])), + }), +]); +export type IdentityClaimInput = typeof IdentityClaimInput.Type; + +export const IdentityClaimResult = Schema.Struct({ + claim: SessionIdentityClaim, +}); +export type IdentityClaimResult = typeof IdentityClaimResult.Type; + +export const IdentitySessionClaimResult = Schema.Struct({ + claim: Schema.NullOr(SessionIdentityClaim), +}); +export type IdentitySessionClaimResult = typeof IdentitySessionClaimResult.Type; + +export class IdentityError extends Schema.TaggedErrorClass()("IdentityError", { + code: Schema.Literals([ + "identity_map_disabled", + "identity_unknown_person", + "identity_claim_required", + "identity_claim_missing", + "identity_map_invalid", + ]), + message: Schema.String, +}) {} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index d7064a7cf62..579815e8ad4 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -1,5 +1,6 @@ export * from "./baseSchemas.ts"; export * from "./auth.ts"; +export * from "./identity.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; export * from "./relayClient.ts"; diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index ec8c839be95..33ecd38039f 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -20,7 +20,6 @@ const decode = ( >; const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); -const encodeResolvedKeybindings = Schema.encodeEffect(ResolvedKeybindingsConfig); it.effect("parses keybinding rules", () => Effect.gen(function* () { @@ -60,18 +59,6 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); - const parsedFilePicker = yield* decode(KeybindingRule, { - key: "mod+p", - command: "filePicker.toggle", - }); - assert.strictEqual(parsedFilePicker.command, "filePicker.toggle"); - - const parsedProjectSearch = yield* decode(KeybindingRule, { - key: "mod+shift+f", - command: "projectSearch.toggle", - }); - assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); - const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", @@ -186,70 +173,6 @@ it.effect("parses resolved keybindings arrays", () => }), ); -const shortcut = { - key: "p", - metaKey: false, - ctrlKey: false, - shiftKey: false, - altKey: false, - modKey: true, -}; - -it.effect("drops resolved rules with commands this build does not know", () => - Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingsConfig, [ - { command: "terminal.toggle", shortcut }, - { command: "someFuture.toggle", shortcut }, - { command: "filePicker.toggle", shortcut }, - ]); - assert.deepEqual( - parsed.map((rule) => rule.command), - ["terminal.toggle", "filePicker.toggle"], - ); - }), -); - -it.effect("drops resolved rules with unknown when-node types", () => - Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingsConfig, [ - { - command: "terminal.toggle", - shortcut, - whenAst: { type: "xor", left: 1, right: 2 }, - }, - { command: "terminal.split", shortcut }, - ]); - assert.deepEqual( - parsed.map((rule) => rule.command), - ["terminal.split"], - ); - }), -); - -it.effect("drops malformed resolved rule entries", () => - Effect.gen(function* () { - const parsed = yield* decode(ResolvedKeybindingsConfig, [ - "garbage", - { command: "terminal.toggle", shortcut }, - null, - ]); - assert.deepEqual( - parsed.map((rule) => rule.command), - ["terminal.toggle"], - ); - }), -); - -it.effect("encodes resolved keybindings to the plain wire shape", () => - Effect.gen(function* () { - const rules = [{ command: "terminal.toggle" as const, shortcut }]; - const encoded = yield* encodeResolvedKeybindings(rules); - assert.deepEqual(encoded, rules); - const roundTripped = yield* decode(ResolvedKeybindingsConfig, encoded); - assert.deepEqual(roundTripped, rules); - }), -); - it.effect("drops unknown fields in resolved keybinding rules", () => decodeResolvedRule({ command: "terminal.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index eba8f8ef170..f000648d236 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; +import { TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; export const MAX_KEYBINDING_WHEN_LENGTH = 256; @@ -63,8 +63,6 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", - "filePicker.toggle", - "projectSearch.toggle", "composer.stash", "board.open", "chat.new", @@ -156,14 +154,7 @@ export const ResolvedKeybindingRule = Schema.Struct({ }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; -/** - * The command set grows over time, so a client may receive rules it cannot - * represent (a command or `when` node added after that client shipped). - * Decoding drops those rules instead of failing the whole payload — - * rejecting the config would take down the connection over a shortcut the - * client couldn't dispatch anyway. - */ -export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check( +export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), ); export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; diff --git a/packages/contracts/src/orchestration.test.ts b/packages/contracts/src/orchestration.test.ts index ecf7afa0610..cc35dc4e415 100644 --- a/packages/contracts/src/orchestration.test.ts +++ b/packages/contracts/src/orchestration.test.ts @@ -659,11 +659,13 @@ it.effect("accepts an internal title regeneration completion", () => threadId: "thread-1", requestId: "cmd-title-regenerate", title: "Updated title", + createdAt: "2026-01-01T00:00:00.000Z", }); assert.strictEqual(parsed.type, "thread.title.regeneration.complete"); if (parsed.type === "thread.title.regeneration.complete") { assert.strictEqual(parsed.requestId, "cmd-title-regenerate"); assert.strictEqual(parsed.title, "Updated title"); + assert.strictEqual(parsed.createdAt, "2026-01-01T00:00:00.000Z"); } }), ); diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index b9440e12051..5601fd7b90d 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -22,6 +22,7 @@ import { TurnId, } from "./baseSchemas.ts"; import { ProviderInstanceId } from "./providerInstance.ts"; +import { ClientSourceHint, SourceRef, ThreadParticipantSummary } from "./identity.ts"; export const ORCHESTRATION_WS_METHODS = { dispatchCommand: "orchestration.dispatchCommand", @@ -234,6 +235,8 @@ export const OrchestrationMessage = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + /** Server-authored provenance; absent on legacy / assistant messages. */ + source: Schema.optional(SourceRef), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -361,6 +364,8 @@ export const OrchestrationQueuedMessage = Schema.Struct({ attachments: Schema.Array(ChatAttachment), modelSelection: Schema.optional(ModelSelection), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + /** Server-stamped at enqueue; preserved when the queue drains to message-sent. */ + source: Schema.optional(SourceRef), queuedAt: IsoDateTime, }); export type OrchestrationQueuedMessage = typeof OrchestrationQueuedMessage.Type; @@ -420,6 +425,9 @@ export const OrchestrationThread = Schema.Struct({ hasMoreActivities: Schema.optional(Schema.Boolean), checkpoints: Schema.Array(OrchestrationCheckpointSummary), session: Schema.NullOr(OrchestrationSession), + originSource: Schema.optional(Schema.NullOr(SourceRef)), + // Optional without default so legacy fixtures omit the field; clients use ?? []. + participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)), }); export type OrchestrationThread = typeof OrchestrationThread.Type; @@ -470,6 +478,13 @@ export const OrchestrationThreadShell = Schema.Struct({ hasPendingApprovals: Schema.Boolean, hasPendingUserInput: Schema.Boolean, hasActionableProposedPlan: Schema.Boolean, + /** First user message SourceRef; null/absent on legacy threads. */ + originSource: Schema.optional(Schema.NullOr(SourceRef)), + /** + * Distinct people on user messages: origin person first, then first-participation order. + * Used for creator + +N participant stack. Absent on legacy shells (clients use ?? []). + */ + participantSummaries: Schema.optional(Schema.Array(ThreadParticipantSummary)), }); export type OrchestrationThreadShell = typeof OrchestrationThreadShell.Type; @@ -739,6 +754,17 @@ export const ThreadTurnStartCommand = Schema.Struct({ ), bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + /** + * Server-authored only. Gate layer stamps from session claim + deviceType + * or resolves platform actors from `sourceHint` (bots / integrations). + * Clients must not send trusted person fields. + */ + source: Schema.optional(SourceRef), + /** + * Non-person hints from trusted integrations (Discord bot, etc.). + * Server resolves personId via the identity map; never trusts client person fields. + */ + sourceHint: Schema.optional(ClientSourceHint), createdAt: IsoDateTime, }); @@ -758,6 +784,8 @@ const ClientThreadTurnStartCommand = Schema.Struct({ interactionMode: ProviderInteractionMode, bootstrap: Schema.optional(ThreadTurnStartBootstrap), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + /** Platform actor/location only — server stamps person from the identity map. */ + sourceHint: Schema.optional(ClientSourceHint), createdAt: IsoDateTime, }); @@ -982,6 +1010,7 @@ const ThreadTitleRegenerationCompleteCommand = Schema.Struct({ threadId: ThreadId, requestId: CommandId, title: Schema.optional(TrimmedNonEmptyString), + createdAt: IsoDateTime, }); /** @@ -1184,6 +1213,8 @@ export const ThreadMessageSentPayload = Schema.Struct({ attachments: Schema.optional(Schema.Array(ChatAttachment)), turnId: Schema.NullOr(TurnId), streaming: Schema.Boolean, + /** Server-authored only; clients must not invent person fields. */ + source: Schema.optional(SourceRef), createdAt: IsoDateTime, updatedAt: IsoDateTime, }); @@ -1195,6 +1226,8 @@ export const ThreadMessageQueuedPayload = Schema.Struct({ attachments: Schema.Array(ChatAttachment), modelSelection: Schema.optional(ModelSelection), sourceProposedPlan: Schema.optional(SourceProposedPlanReference), + /** Server-stamped provenance for the queued user message. */ + source: Schema.optional(SourceRef), queuedAt: IsoDateTime, }); @@ -1704,6 +1737,8 @@ export class OrchestrationDispatchCommandError extends Schema.TaggedErrorClass { - it("allows an empty entries query for bounded frecency browsing", () => { - const decoded = decodeSearchEntriesInput({ - cwd: "/workspace", - query: " ", - limit: 10, - kind: "file", - }); - expect(decoded.query).toBe(""); - }); - - it("preserves whitespace in content search queries", () => { - const decoded = decodeSearchContentsInput({ - cwd: "/workspace", - query: " foo ", - limit: 10, - caseSensitive: false, - wholeWord: false, - useRegex: false, - }); - expect(decoded.query).toBe(" foo "); - }); -}); - describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); @@ -69,18 +39,6 @@ describe("project RPC errors", () => { expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); expect(readError.message).not.toContain(cause.message); expect(readError.cause).toBe(cause); - - const contentSearchError = new ProjectSearchContentsError({ - cwd: "/workspace", - queryLength: "authorization: Bearer secret-token".length, - limit: 100, - failure: "search_index_search_failed", - cause, - }); - expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'."); - expect(contentSearchError.message).not.toContain(cause.message); - expect(contentSearchError).not.toHaveProperty("query"); - expect(contentSearchError.cause).toBe(cause); }); it("decodes legacy message-only errors during rolling upgrades", () => { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index a1b11df73b2..d59b9770ad3 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,29 +1,19 @@ import * as Schema from "effect/Schema"; -import { - NonNegativeInt, - PositiveInt, - TrimmedNonEmptyString, - TrimmedString, -} from "./baseSchemas.ts"; +import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; -const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; -export const ProjectEntryKind = Schema.Literals(["file", "directory"]); -export type ProjectEntryKind = typeof ProjectEntryKind.Type; - export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - // An empty query is a bounded browse: the index returns frecency-ordered - // entries, which the file picker uses for its initial results. - query: TrimmedString.check(Schema.isMaxLength(256)), + query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), - kind: Schema.optional(ProjectEntryKind), }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; +const ProjectEntryKind = Schema.Literals(["file", "directory"]); + export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, @@ -36,39 +26,6 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; -export const ProjectSearchContentsInput = Schema.Struct({ - cwd: TrimmedNonEmptyString, - // Whitespace is significant in content queries (" foo", regex trailing - // spaces), so the query is deliberately not trimmed on the wire. - query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), - limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)), - caseSensitive: Schema.Boolean, - wholeWord: Schema.Boolean, - useRegex: Schema.Boolean, -}); -export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type; - -export const ProjectContentMatchRange = Schema.Struct({ - start: NonNegativeInt, - end: NonNegativeInt, -}); -export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type; - -export const ProjectContentMatch = Schema.Struct({ - path: TrimmedNonEmptyString, - lineNumber: PositiveInt, - lineContent: Schema.String, - matchRanges: Schema.Array(ProjectContentMatchRange), -}); -export type ProjectContentMatch = typeof ProjectContentMatch.Type; - -export const ProjectSearchContentsResult = Schema.Struct({ - matches: Schema.Array(ProjectContentMatch), - truncated: Schema.Boolean, - regexFallbackError: Schema.optional(Schema.String), -}); -export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type; - export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, }); @@ -137,37 +94,6 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( - "ProjectSearchContentsError", - { - cwd: Schema.optional(TrimmedNonEmptyString), - queryLength: Schema.optional(NonNegativeInt), - limit: Schema.optional(PositiveInt), - failure: Schema.optional(ProjectEntriesFailure), - normalizedCwd: Schema.optional(TrimmedNonEmptyString), - timeout: Schema.optional(TrimmedNonEmptyString), - detail: Schema.optional(TrimmedNonEmptyString), - message: TrimmedNonEmptyString, - cause: Schema.optional(Schema.Defect()), - }, -) { - // @effect-diagnostics-next-line overriddenSchemaConstructor:off - constructor( - props: ProjectEntriesFailureContext & { - readonly cwd: string; - readonly queryLength: number; - readonly limit: number; - }, - ) { - super({ - ...props, - message: - decodedProjectErrorMessage(props) ?? - `Failed to search workspace contents in '${props.cwd}'.`, - } as any); - } -} - export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { diff --git a/packages/contracts/src/relay.ts b/packages/contracts/src/relay.ts index 52f7d7d4355..ff9a9e3ac61 100644 --- a/packages/contracts/src/relay.ts +++ b/packages/contracts/src/relay.ts @@ -371,34 +371,16 @@ export class RelayEnvironmentLinkProofInvalidError extends Schema.TaggedErrorCla } } -export const RelayEnvironmentConnectNotAuthorizedReason = Schema.Literals([ - "client_proof_key_thumbprint_missing", - "environment_link_not_found", - "endpoint_provider_not_managed", - "managed_endpoint_allocation_not_found", - "managed_endpoint_base_domain_not_configured", - "managed_endpoint_allocation_not_ready", - "managed_endpoint_hostname_invalid", - "managed_endpoint_mismatch", -]); -export type RelayEnvironmentConnectNotAuthorizedReason = - typeof RelayEnvironmentConnectNotAuthorizedReason.Type; - export class RelayEnvironmentConnectNotAuthorizedError extends Schema.TaggedErrorClass()( "RelayEnvironmentConnectNotAuthorizedError", { code: Schema.Literal("environment_connect_not_authorized"), - // Optional so responses from relays deployed before the reason was - // threaded through still decode. - reason: Schema.optional(RelayEnvironmentConnectNotAuthorizedReason), traceId: TrimmedNonEmptyString, }, { httpApiStatus: 403 }, ) { override get message(): string { - return this.reason - ? `Relay environment connection is not authorized: ${this.reason}` - : "Relay environment connection is not authorized"; + return "Relay environment connection is not authorized"; } } diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ad7afc32277..917d320f856 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -8,6 +8,13 @@ import { AuthAccessStreamEvent, EnvironmentAuthorizationError, } from "./auth.ts"; +import { + IdentityClaimInput, + IdentityClaimResult, + IdentityError, + IdentitySessionClaimResult, + IdentitySnapshot, +} from "./identity.ts"; import { BackgroundPolicySnapshot, ClientActivityReportInput, @@ -87,9 +94,6 @@ import { ProjectReadFileError, ProjectReadFileInput, ProjectReadFileResult, - ProjectSearchContentsError, - ProjectSearchContentsInput, - ProjectSearchContentsResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -183,7 +187,6 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", projectsReadFile: "projects.readFile", - projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", @@ -259,6 +262,12 @@ export const WS_METHODS = { cloudGetRelayClientStatus: "cloud.getRelayClientStatus", cloudInstallRelayClient: "cloud.installRelayClient", + // Session identity (closed-set map claim) + identityGetSnapshot: "identity.getSnapshot", + identityGetSessionClaim: "identity.getSessionClaim", + identityClaim: "identity.claim", + identityClearClaim: "identity.clearClaim", + // Source control methods sourceControlLookupRepository: "sourceControl.lookupRepository", sourceControlCloneRepository: "sourceControl.cloneRepository", @@ -426,6 +435,30 @@ export const WsCloudInstallRelayClientRpc = Rpc.make(WS_METHODS.cloudInstallRela stream: true, }); +export const WsIdentityGetSnapshotRpc = Rpc.make(WS_METHODS.identityGetSnapshot, { + payload: Schema.Struct({}), + success: IdentitySnapshot, + error: Schema.Union([IdentityError, EnvironmentAuthorizationError]), +}); + +export const WsIdentityGetSessionClaimRpc = Rpc.make(WS_METHODS.identityGetSessionClaim, { + payload: Schema.Struct({}), + success: IdentitySessionClaimResult, + error: Schema.Union([IdentityError, EnvironmentAuthorizationError]), +}); + +export const WsIdentityClaimRpc = Rpc.make(WS_METHODS.identityClaim, { + payload: IdentityClaimInput, + success: IdentityClaimResult, + error: Schema.Union([IdentityError, EnvironmentAuthorizationError]), +}); + +export const WsIdentityClearClaimRpc = Rpc.make(WS_METHODS.identityClearClaim, { + payload: Schema.Struct({}), + success: Schema.Struct({ cleared: Schema.Boolean }), + error: Schema.Union([IdentityError, EnvironmentAuthorizationError]), +}); + export const WsServerReportClientActivityRpc = Rpc.make(WS_METHODS.serverReportClientActivity, { payload: ClientActivityReportInput, error: EnvironmentAuthorizationError, @@ -472,12 +505,6 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); -export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { - payload: ProjectSearchContentsInput, - success: ProjectSearchContentsResult, - error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), -}); - export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { payload: ProjectListEntriesInput, success: ProjectListEntriesResult, @@ -880,12 +907,15 @@ export const WsRpcGroup = RpcGroup.make( WsServerGetBackgroundPolicyRpc, WsCloudGetRelayClientStatusRpc, WsCloudInstallRelayClientRpc, + WsIdentityGetSnapshotRpc, + WsIdentityGetSessionClaimRpc, + WsIdentityClaimRpc, + WsIdentityClearClaimRpc, WsSourceControlLookupRepositoryRpc, WsSourceControlCloneRepositoryRpc, WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, - WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, diff --git a/packages/contracts/src/server.test.ts b/packages/contracts/src/server.test.ts index 078e9fcbf33..c906f86f4dc 100644 --- a/packages/contracts/src/server.test.ts +++ b/packages/contracts/src/server.test.ts @@ -1,11 +1,9 @@ import * as Schema from "effect/Schema"; import { describe, expect, it } from "vite-plus/test"; -import { ServerConfig, ServerProvider, ServerUpsertKeybindingResult } from "./server.ts"; +import { ServerProvider } from "./server.ts"; const decodeServerProvider = Schema.decodeUnknownSync(ServerProvider); -const decodeUpsertKeybindingResult = Schema.decodeUnknownSync(ServerUpsertKeybindingResult); -const decodeAvailableEditors = Schema.decodeUnknownSync(ServerConfig.fields.availableEditors); describe("ServerProvider", () => { it("defaults capability arrays when decoding provider snapshots", () => { @@ -74,25 +72,3 @@ describe("ServerProvider", () => { expect(parsed.continuation?.groupKey).toBe("codex:home:/Users/julius/.codex"); }); }); - -describe("server config forward compatibility", () => { - it("drops config issues with kinds this build does not know", () => { - const parsed = decodeUpsertKeybindingResult({ - keybindings: [], - issues: [ - { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, - { kind: "keybindings.future-issue", message: "From a newer server" }, - ], - }); - - expect(parsed.issues).toEqual([ - { kind: "keybindings.invalid-entry", message: "Bad entry", index: 2 }, - ]); - }); - - it("drops editor ids this build does not know", () => { - const parsed = decodeAvailableEditors(["zed", "some-future-editor", "vscode"]); - - expect(parsed).toEqual(["zed", "vscode"]); - }); -}); diff --git a/packages/contracts/src/server.ts b/packages/contracts/src/server.ts index cff26e0a3ba..6ddfa3cdb67 100644 --- a/packages/contracts/src/server.ts +++ b/packages/contracts/src/server.ts @@ -3,7 +3,6 @@ import * as Schema from "effect/Schema"; import { ExecutionEnvironmentDescriptor, ServerSelfUpdateMethod } from "./environment.ts"; import { ServerAuthDescriptor } from "./auth.ts"; import { - ForwardCompatibleArray, IsoDateTime, NonNegativeInt, PositiveInt, @@ -39,9 +38,7 @@ export const ServerConfigIssue = Schema.Union([ ]); export type ServerConfigIssue = typeof ServerConfigIssue.Type; -// Issue kinds grow over time; older clients must not fail the whole config -// decode over a kind they cannot render. -const ServerConfigIssues = ForwardCompatibleArray(ServerConfigIssue); +const ServerConfigIssues = Schema.Array(ServerConfigIssue); export const ServerProviderState = Schema.Literals(["ready", "warning", "error", "disabled"]); export type ServerProviderState = typeof ServerProviderState.Type; @@ -420,9 +417,7 @@ export const ServerConfig = Schema.Struct({ keybindings: ResolvedKeybindingsConfig, issues: ServerConfigIssues, providers: ServerProviders, - // Editor ids grow over time; drop ones this build does not know rather than - // failing the whole config decode. - availableEditors: ForwardCompatibleArray(EditorId), + availableEditors: Schema.Array(EditorId), observability: ServerObservability, settings: ServerSettings, /** Whether shell subscriptions can emit an opt-in catch-up completion marker. */ diff --git a/packages/shared/package.json b/packages/shared/package.json index 4214d57b953..ff115b6e9b7 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -99,6 +99,22 @@ "types": "./src/String.ts", "import": "./src/String.ts" }, + "./identityAvatar": { + "types": "./src/identityAvatar.ts", + "import": "./src/identityAvatar.ts" + }, + "./identityMap": { + "types": "./src/identityMap.ts", + "import": "./src/identityMap.ts" + }, + "./sourceAttribution": { + "types": "./src/sourceAttribution.ts", + "import": "./src/sourceAttribution.ts" + }, + "./threadAttributeSearch": { + "types": "./src/threadAttributeSearch.ts", + "import": "./src/threadAttributeSearch.ts" + }, "./projectScripts": { "types": "./src/projectScripts.ts", "import": "./src/projectScripts.ts" diff --git a/packages/shared/src/identityAvatar.test.ts b/packages/shared/src/identityAvatar.test.ts new file mode 100644 index 00000000000..d7480c89a24 --- /dev/null +++ b/packages/shared/src/identityAvatar.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + identityAvatar, + identityAvatarColors, + identityInitials, + IDENTITY_AVATAR_PALETTE, +} from "./identityAvatar.ts"; + +describe("identityInitials", () => { + it("uses two words from display name", () => { + expect(identityInitials({ username: "patroza", name: "Patrick Roza" })).toBe("PR"); + }); + + it("uses first two letters of a single name token", () => { + expect(identityInitials({ name: "Julius" })).toBe("JU"); + }); + + it("falls back to username", () => { + expect(identityInitials({ username: "patroza" })).toBe("PA"); + }); + + it("handles short username", () => { + expect(identityInitials({ username: "ab" })).toBe("AB"); + expect(identityInitials({ username: "x" })).toBe("X"); + }); + + it("returns ? when empty", () => { + expect(identityInitials({})).toBe("?"); + expect(identityInitials({ username: " ", name: "" })).toBe("?"); + }); + + it("handles CJK name without surrogate splits", () => { + expect(identityInitials({ name: "田中 太郎" })).toBe("田太"); + }); + + it("handles CJK username", () => { + expect(identityInitials({ username: "田中" })).toBe("田中"); + }); + + it("skips emoji-only name to username when possible", () => { + // emoji has no L/N letters — falls through to code points of name + const initials = identityInitials({ name: "😀😀", username: "pat" }); + expect(initials.length).toBeGreaterThan(0); + expect(initials).not.toMatch(/[\uD800-\uDFFF]/u); + }); +}); + +describe("identityAvatarColors", () => { + it("is deterministic for the same seed", () => { + expect(identityAvatarColors("patroza")).toEqual(identityAvatarColors("patroza")); + }); + + it("varies across different seeds when possible", () => { + const a = identityAvatarColors("patroza"); + const b = identityAvatarColors("julius"); + expect(IDENTITY_AVATAR_PALETTE).toContainEqual(a); + expect(IDENTITY_AVATAR_PALETTE).toContainEqual(b); + }); +}); + +describe("identityAvatar", () => { + it("combines initials, label, and colors", () => { + const avatar = identityAvatar({ + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + }); + expect(avatar.initials).toBe("PR"); + expect(avatar.label).toBe("Patrick Roza"); + expect(avatar.backgroundColor).toMatch(/^#/); + expect(avatar.color).toBe("#FFFFFF"); + }); + + it("keeps color seed on personId when username changes", () => { + const a = identityAvatar({ personId: "p1", username: "old" }); + const b = identityAvatar({ personId: "p1", username: "new" }); + expect(a.backgroundColor).toBe(b.backgroundColor); + }); +}); diff --git a/packages/shared/src/identityAvatar.ts b/packages/shared/src/identityAvatar.ts new file mode 100644 index 00000000000..ab817efe3b0 --- /dev/null +++ b/packages/shared/src/identityAvatar.ts @@ -0,0 +1,122 @@ +/** + * Deterministic micro-avatars from identity usernames (initials + color). + * + * Pure presentation helpers for web/mobile — no network, no assets. + * Real photo URLs can replace these later; seed stays `personId` / `username`. + * + * See docs/architecture/source-and-identity.md + */ + +export type IdentityAvatarColors = { + readonly backgroundColor: string; + readonly color: string; +}; + +export type IdentityAvatarModel = IdentityAvatarColors & { + /** 1–2 uppercase letters for the chip. */ + readonly initials: string; + /** Accessible label, usually the username or display name. */ + readonly label: string; +}; + +/** + * Fixed palette (background + readable foreground). Indexed by a stable hash of + * the person key so the same user always gets the same chip across clients. + * Colors are slightly muted so dense lists stay calm on dark/light UIs. + */ +export const IDENTITY_AVATAR_PALETTE: ReadonlyArray = [ + { backgroundColor: "#3B5BDB", color: "#FFFFFF" }, + { backgroundColor: "#0CA678", color: "#FFFFFF" }, + { backgroundColor: "#E67700", color: "#FFFFFF" }, + { backgroundColor: "#9C36B5", color: "#FFFFFF" }, + { backgroundColor: "#0B7285", color: "#FFFFFF" }, + { backgroundColor: "#C2255C", color: "#FFFFFF" }, + { backgroundColor: "#2F9E44", color: "#FFFFFF" }, + { backgroundColor: "#364FC7", color: "#FFFFFF" }, + { backgroundColor: "#D9480F", color: "#FFFFFF" }, + { backgroundColor: "#5F3DC4", color: "#FFFFFF" }, + { backgroundColor: "#087F5B", color: "#FFFFFF" }, + { backgroundColor: "#A61E4D", color: "#FFFFFF" }, +] as const; + +/** FNV-1a 32-bit — fast, stable, no deps. Not part of the public chip API. */ +function hashIdentitySeed(seed: string): number { + let hash = 0x811c9dc5; + for (let i = 0; i < seed.length; i++) { + hash ^= seed.charCodeAt(i); + hash = Math.imul(hash, 0x01000193); + } + return hash >>> 0; +} + +export function identityAvatarColors(seed: string): IdentityAvatarColors { + const index = hashIdentitySeed(seed) % IDENTITY_AVATAR_PALETTE.length; + return IDENTITY_AVATAR_PALETTE[index]!; +} + +/** First up to `count` Unicode code points (not UTF-16 units). */ +function takeCodePoints(value: string, count: number): string { + const points: Array = []; + for (const point of value) { + if (point.trim().length === 0) continue; + points.push(point); + if (points.length >= count) break; + } + return points.join(""); +} + +/** + * Initials from display name when present, otherwise username. + * Uses code points so non-BMP / CJK handles do not split surrogates. + * - "Patrick Roza" → "PR" + * - "patroza" → "PA" + * - "田中" → "田中" + * - empty → "?" + */ +export function identityInitials(input: { + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; +}): string { + const name = input.name?.trim() ?? ""; + if (name.length > 0) { + const words = name.replace(/[_-]+/gu, " ").split(/\s+/u).filter(Boolean); + if (words.length >= 2) { + const a = takeCodePoints(words[0]!, 1); + const b = takeCodePoints(words[1]!, 1); + const pair = `${a}${b}`; + if (pair.length > 0) return pair.toLocaleUpperCase(); + } + if (words.length === 1) { + const two = takeCodePoints(words[0]!, 2); + if (two.length > 0) return two.toLocaleUpperCase(); + } + } + + const username = input.username?.trim() ?? ""; + if (username.length === 0) return "?"; + // Prefer letter/number-like code points; fall back to raw username points. + const alnumLike = [...username].filter((ch) => /[\p{L}\p{N}]/u.test(ch)).join(""); + const source = alnumLike.length > 0 ? alnumLike : username; + const two = takeCodePoints(source, 2); + return two.length > 0 ? two.toLocaleUpperCase() : "?"; +} + +/** + * Build a micro-avatar model. Prefer `personId` as color seed when available so + * renames keep the same chip; fall back to username. + */ +export function identityAvatar(input: { + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; +}): IdentityAvatarModel { + const username = input.username?.trim() ?? ""; + const name = input.name?.trim() ?? ""; + const seed = (input.personId?.trim() || username || name || "?").toLowerCase(); + const colors = identityAvatarColors(seed); + return { + initials: identityInitials({ username, name }), + label: name.length > 0 ? name : username.length > 0 ? username : "Unknown", + ...colors, + }; +} diff --git a/packages/shared/src/identityMap.lookup.test.ts b/packages/shared/src/identityMap.lookup.test.ts new file mode 100644 index 00000000000..475e2490627 --- /dev/null +++ b/packages/shared/src/identityMap.lookup.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + findPersonByDiscordId, + findPersonByGithubId, + findPersonByGithubLogin, + findPersonByJiraAccountId, + findPersonByJiraEmail, + parseIdentityMapDocument, +} from "./identityMap.ts"; + +const people = parseIdentityMapDocument({ + people: { + patroza: { + username: "patroza", + name: "Patrick", + discord: { id: "95218063095377920" }, + github: { login: "patroza", id: "42661" }, + jira: { accountId: "jira-pat", email: "patrick@example.com" }, + }, + julius: { + username: "julius", + github: { login: "juliusmarminge" }, + }, + }, +}); + +describe("identity map platform lookups", () => { + it("finds by discord id", () => { + expect(findPersonByDiscordId(people, "95218063095377920")?.username).toBe("patroza"); + expect(findPersonByDiscordId(people, "0")).toBeNull(); + }); + + it("finds by github login and id", () => { + expect(findPersonByGithubLogin(people, "Patroza")?.username).toBe("patroza"); + expect(findPersonByGithubId(people, 42661)?.username).toBe("patroza"); + }); + + it("finds by jira account and email", () => { + expect(findPersonByJiraAccountId(people, "jira-pat")?.username).toBe("patroza"); + expect(findPersonByJiraEmail(people, "patrick@example.com")?.username).toBe("patroza"); + }); +}); diff --git a/packages/shared/src/identityMap.test.ts b/packages/shared/src/identityMap.test.ts new file mode 100644 index 00000000000..dda0239d851 --- /dev/null +++ b/packages/shared/src/identityMap.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + IdentityMapParseError, + normalizeJiraAccountId, + parseIdentityMapDocument, + resolvePersonByJiraAccountId, +} from "./identityMap.ts"; + +describe("parseIdentityMapDocument", () => { + it("parses people map with usernames", () => { + const people = parseIdentityMapDocument({ + people: { + patroza: { + username: "patroza", + name: "Patrick Roza", + discord: { id: "95218063095377920" }, + github: { login: "patroza", id: "42661" }, + }, + julius: { + username: "Julius", + name: "Julius", + }, + }, + }); + expect(people).toHaveLength(2); + expect(people[0]?.username).toBe("patroza"); + expect(people[1]?.username).toBe("julius"); + expect(people[1]?.personId).toBe("julius"); + }); + + it("rejects free-form invalid usernames", () => { + expect(() => + parseIdentityMapDocument({ + people: [{ username: "pat roza", name: "Bad" }], + }), + ).toThrow(IdentityMapParseError); + }); + + it("rejects duplicate usernames", () => { + expect(() => + parseIdentityMapDocument({ + people: [ + { username: "a", personId: "a" }, + { username: "a", personId: "b" }, + ], + }), + ).toThrow(/duplicate username/); + }); + + it("returns empty for empty document", () => { + expect(parseIdentityMapDocument({})).toEqual([]); + expect(parseIdentityMapDocument({ people: [] })).toEqual([]); + }); + + it("resolves people by Jira accountId", () => { + const people = parseIdentityMapDocument({ + people: { + patroza: { + username: "patroza", + jira: { accountId: "712020:abc" }, + }, + }, + }); + expect(normalizeJiraAccountId("accountid:712020:ABC")).toBe("712020:abc"); + expect(resolvePersonByJiraAccountId(people, "712020:abc")?.username).toBe("patroza"); + expect(resolvePersonByJiraAccountId(people, "nope")).toBeNull(); + }); +}); diff --git a/packages/shared/src/identityMap.ts b/packages/shared/src/identityMap.ts new file mode 100644 index 00000000000..9ba48aedf29 --- /dev/null +++ b/packages/shared/src/identityMap.ts @@ -0,0 +1,314 @@ +/** + * Parse closed-set identity map documents (YAML/JSON). + * Shared by server (and later Discord bot) so ops keep one file format. + * + * See docs/architecture/source-and-identity.md + */ +import * as Schema from "effect/Schema"; + +export type IdentityMapDiscordRef = { + readonly id: string; + readonly username?: string | undefined; +}; + +export type IdentityMapGitHubRef = { + readonly login: string; + readonly id?: string | undefined; + readonly email?: string | undefined; + readonly name?: string | undefined; +}; + +export type IdentityMapJiraRef = { + readonly accountId?: string | undefined; + readonly email?: string | undefined; + readonly displayName?: string | undefined; +}; + +export type IdentityMapPerson = { + readonly personId: string; + readonly username: string; + readonly name?: string | undefined; + readonly discord?: IdentityMapDiscordRef | undefined; + readonly github?: IdentityMapGitHubRef | undefined; + readonly jira?: IdentityMapJiraRef | undefined; +}; + +export class IdentityMapParseError extends Error { + readonly _tag = "IdentityMapParseError"; + readonly pathLabel: string; + constructor(pathLabel: string, message: string) { + super(message); + this.name = "IdentityMapParseError"; + this.pathLabel = pathLabel; + } +} + +const HANDLE_PATTERN = /^[a-z0-9][a-z0-9._-]*$/; +const HANDLE_MAX = 128; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function asNonEmptyString(value: unknown): string | undefined { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function normalizeHandle(value: string, field: string, label: string): string { + const normalized = value.trim().toLowerCase(); + if ( + normalized.length === 0 || + normalized.length > HANDLE_MAX || + !HANDLE_PATTERN.test(normalized) + ) { + throw new IdentityMapParseError( + label, + `${field} must be a non-empty handle (max ${HANDLE_MAX}, pattern ${HANDLE_PATTERN}): got ${JSON.stringify(value)}`, + ); + } + return normalized; +} + +function asDiscordSnowflake(value: unknown): string | undefined { + const raw = asNonEmptyString(value); + if (raw === undefined) return undefined; + if (!/^\d{1,32}$/u.test(raw)) return undefined; + return raw; +} + +function normalizeLogin(value: unknown): string | undefined { + const raw = asNonEmptyString(value); + if (raw === undefined) return undefined; + const login = raw.replace(/^@/u, "").trim(); + if (login.length === 0) return undefined; + if (!/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/u.test(login)) return undefined; + return login; +} + +function parsePerson(raw: unknown, indexLabel: string, keyHint?: string): IdentityMapPerson { + if (!isRecord(raw)) { + throw new IdentityMapParseError(indexLabel, "person entry must be an object"); + } + + const discordNested = isRecord(raw.discord) ? raw.discord : undefined; + const githubNested = isRecord(raw.github) ? raw.github : undefined; + const jiraNested = isRecord(raw.jira) ? raw.jira : undefined; + + const usernameRaw = + asNonEmptyString(raw.username) ?? + asNonEmptyString(raw.userName) ?? + (keyHint !== undefined && !/^\d+$/u.test(keyHint) ? keyHint : undefined); + if (usernameRaw === undefined) { + throw new IdentityMapParseError(indexLabel, 'missing required "username"'); + } + const username = normalizeHandle(usernameRaw, "username", indexLabel); + + const personIdRaw = asNonEmptyString(raw.personId) ?? asNonEmptyString(raw.id) ?? username; + const personId = normalizeHandle(personIdRaw, "personId", indexLabel); + + const name = asNonEmptyString(raw.name); + + const discordId = + asDiscordSnowflake(discordNested?.id) ?? + asDiscordSnowflake(raw.discordId) ?? + asDiscordSnowflake(raw.discord_id) ?? + (keyHint !== undefined ? asDiscordSnowflake(keyHint) : undefined); + const discordUsername = + asNonEmptyString(discordNested?.username) ?? + asNonEmptyString(raw.discordUsername) ?? + asNonEmptyString(raw.discord_username); + + const githubLogin = + normalizeLogin(githubNested?.login) ?? + normalizeLogin(raw.githubLogin) ?? + normalizeLogin(raw.github_login) ?? + normalizeLogin(raw.github); + const githubId = + asNonEmptyString(githubNested?.id)?.replace(/\D/gu, "") || + asNonEmptyString(raw.githubId)?.replace(/\D/gu, "") || + asNonEmptyString(raw.github_id)?.replace(/\D/gu, "") || + undefined; + const githubEmail = + asNonEmptyString(githubNested?.email) ?? + asNonEmptyString(raw.githubEmail) ?? + asNonEmptyString(raw.github_email); + const githubName = + asNonEmptyString(githubNested?.name) ?? + asNonEmptyString(raw.githubName) ?? + asNonEmptyString(raw.github_name); + + const jiraAccountId = + asNonEmptyString(jiraNested?.accountId) ?? + asNonEmptyString(raw.jiraAccountId) ?? + asNonEmptyString(raw.jira_account_id); + const jiraEmail = + asNonEmptyString(jiraNested?.email) ?? + asNonEmptyString(raw.jiraEmail) ?? + asNonEmptyString(raw.jira_email); + const jiraDisplayName = + asNonEmptyString(jiraNested?.displayName) ?? + asNonEmptyString(raw.jiraDisplayName) ?? + asNonEmptyString(raw.jira_display_name); + + return { + personId, + username, + ...(name !== undefined ? { name } : {}), + ...(discordId !== undefined + ? { + discord: { + id: discordId, + ...(discordUsername !== undefined ? { username: discordUsername } : {}), + }, + } + : {}), + ...(githubLogin !== undefined + ? { + github: { + login: githubLogin, + ...(githubId !== undefined && githubId.length > 0 ? { id: githubId } : {}), + ...(githubEmail !== undefined ? { email: githubEmail } : {}), + ...(githubName !== undefined ? { name: githubName } : {}), + }, + } + : {}), + ...(jiraAccountId !== undefined || jiraEmail !== undefined + ? { + jira: { + ...(jiraAccountId !== undefined ? { accountId: jiraAccountId } : {}), + ...(jiraEmail !== undefined ? { email: jiraEmail } : {}), + ...(jiraDisplayName !== undefined ? { displayName: jiraDisplayName } : {}), + }, + } + : {}), + }; +} + +/** + * Parse identity map document object (already JSON/YAML-parsed). + */ +export function parseIdentityMapDocument(document: unknown): ReadonlyArray { + if (document === null || document === undefined) return []; + if (!isRecord(document)) { + throw new IdentityMapParseError("root", "Identity map root must be an object."); + } + + const peopleNode = document.people; + let people: ReadonlyArray; + + if (Array.isArray(peopleNode)) { + people = peopleNode.map((entry, index) => parsePerson(entry, `[${index}]`)); + } else if (isRecord(peopleNode)) { + people = Object.entries(peopleNode).map(([key, value]) => + parsePerson(value, `people["${key}"]`, key), + ); + } else { + const reserved = new Set(["version", "schema", "$schema"]); + const entries = Object.entries(document).filter(([key]) => !reserved.has(key)); + if (entries.length === 0) return []; + people = entries.map(([key, value]) => parsePerson(value, `["${key}"]`, key)); + } + + const usernames = new Set(); + const personIds = new Set(); + for (const person of people) { + if (usernames.has(person.username)) { + throw new IdentityMapParseError(person.username, `duplicate username "${person.username}"`); + } + if (personIds.has(person.personId)) { + throw new IdentityMapParseError(person.personId, `duplicate personId "${person.personId}"`); + } + usernames.add(person.username); + personIds.add(person.personId); + } + + return people; +} + +export function toIdentityPersonPublic(person: IdentityMapPerson) { + return { + personId: person.personId, + username: person.username, + ...(person.name !== undefined ? { name: person.name } : {}), + links: { + ...(person.discord?.id !== undefined ? { discordId: person.discord.id } : {}), + ...(person.discord?.username !== undefined + ? { discordUsername: person.discord.username } + : {}), + ...(person.github?.login !== undefined ? { githubLogin: person.github.login } : {}), + ...(person.jira?.accountId !== undefined ? { jiraAccountId: person.jira.accountId } : {}), + }, + }; +} + +/** Closed-set platform lookups (case-insensitive where appropriate). */ +export function findPersonByDiscordId( + people: ReadonlyArray, + discordId: string, +): IdentityMapPerson | null { + const id = discordId.trim(); + if (id.length === 0) return null; + return people.find((person) => person.discord?.id === id) ?? null; +} + +export function findPersonByGithubLogin( + people: ReadonlyArray, + login: string, +): IdentityMapPerson | null { + const normalized = login.trim().replace(/^@/u, "").toLowerCase(); + if (normalized.length === 0) return null; + return people.find((person) => person.github?.login.toLowerCase() === normalized) ?? null; +} + +export function findPersonByGithubId( + people: ReadonlyArray, + githubId: string | number, +): IdentityMapPerson | null { + const id = String(githubId).replace(/\D/gu, ""); + if (id.length === 0) return null; + return people.find((person) => person.github?.id === id) ?? null; +} + +/** Normalize Atlassian account ids for map lookup (`accountid:` prefix, case). */ +export function normalizeJiraAccountId(accountId: string | null | undefined): string | null { + if (accountId === null || accountId === undefined) return null; + const trimmed = accountId.trim(); + if (trimmed.length === 0) return null; + const withoutPrefix = trimmed.replace(/^accountid:/iu, ""); + return withoutPrefix.length > 0 ? withoutPrefix.toLowerCase() : null; +} + +/** Resolve a closed-set person by Jira Cloud accountId (prefix/case-insensitive). */ +export function resolvePersonByJiraAccountId( + people: ReadonlyArray, + accountId: string | null | undefined, +): IdentityMapPerson | null { + const normalized = normalizeJiraAccountId(accountId); + if (normalized === null) return null; + for (const person of people) { + const mapped = normalizeJiraAccountId(person.jira?.accountId); + if (mapped !== null && mapped === normalized) return person; + } + return null; +} + +export function findPersonByJiraAccountId( + people: ReadonlyArray, + accountId: string, +): IdentityMapPerson | null { + return resolvePersonByJiraAccountId(people, accountId); +} + +export function findPersonByJiraEmail( + people: ReadonlyArray, + email: string, +): IdentityMapPerson | null { + const normalized = email.trim().toLowerCase(); + if (normalized.length === 0) return null; + return people.find((person) => person.jira?.email?.toLowerCase() === normalized) ?? null; +} + +/** Schema re-export helper for tests that want branded contracts after parse. */ +export const IdentityMapPersonCount = Schema.Number; diff --git a/packages/shared/src/keybindings.ts b/packages/shared/src/keybindings.ts index 21f8dd47dc5..81793bcf88b 100644 --- a/packages/shared/src/keybindings.ts +++ b/packages/shared/src/keybindings.ts @@ -35,8 +35,6 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray = [ { key: "mod+-", command: "preview.zoomOut", when: "previewFocus" }, { key: "mod+0", command: "preview.resetZoom", when: "previewFocus" }, { key: "mod+k", command: "commandPalette.toggle", when: "!terminalFocus" }, - { key: "mod+p", command: "filePicker.toggle", when: "!terminalFocus" }, - { key: "mod+shift+f", command: "projectSearch.toggle", when: "!terminalFocus" }, { key: "mod+s", command: "composer.stash", when: "!terminalFocus" }, { key: "mod+t", command: "board.open" }, { key: "mod+n", command: "chat.new", when: "!terminalFocus" }, diff --git a/packages/shared/src/projectFavicon.test.ts b/packages/shared/src/projectFavicon.test.ts index 1df17cc7fe5..0011b2fc7c9 100644 --- a/packages/shared/src/projectFavicon.test.ts +++ b/packages/shared/src/projectFavicon.test.ts @@ -1,32 +1,8 @@ import { describe, expect, it } from "vite-plus/test"; -import { - getProjectFaviconCacheKey, - isProjectFaviconFallbackUrl, - PROJECT_FAVICON_FALLBACK_MARKER, -} from "./projectFavicon.ts"; +import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts"; describe("project favicon", () => { - it("uses the project and versioned filename as the cache identity", () => { - const firstUrl = "https://environment.example/api/assets/first-signed-token/v1-20-favicon.svg"; - const refreshedUrl = - "https://environment.example/api/assets/refreshed-signed-token/v1-20-favicon.svg"; - - expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).toBe( - getProjectFaviconCacheKey("environment-1", "/workspace", refreshedUrl), - ); - expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( - getProjectFaviconCacheKey( - "environment-1", - "/workspace", - "https://environment.example/api/assets/refreshed-signed-token/v2-20-favicon.svg", - ), - ); - expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( - getProjectFaviconCacheKey("environment-2", "/workspace", firstUrl), - ); - }); - it("identifies fallback asset URLs by their dedicated filename", () => { expect( isProjectFaviconFallbackUrl( diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index eebc1a8a1b6..2e46429b6c1 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,22 +1,5 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; -export function getProjectFaviconCacheKey( - environmentId: string, - workspaceRoot: string, - url: string, -) { - let revision = url; - - try { - const pathname = new URL(url, "https://t3.invalid").pathname; - revision = pathname.slice(pathname.lastIndexOf("/") + 1); - } catch { - // Keep the full value as a safe fallback for malformed URLs. - } - - return JSON.stringify([environmentId, workspaceRoot, revision]); -} - export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { if (!url) return false; diff --git a/packages/shared/src/schemaJson.test.ts b/packages/shared/src/schemaJson.test.ts index 4a4d16da0b3..c808a9b7c51 100644 --- a/packages/shared/src/schemaJson.test.ts +++ b/packages/shared/src/schemaJson.test.ts @@ -57,15 +57,6 @@ Done.`), expect(() => decodeLenientJson('{ "enabled": true,, }')).toThrow(); }); - it("preserves commas before brackets inside string values", () => { - // A comma inside a string value that happens to precede `}`/`]` must not - // be stripped as if it were a trailing comma. - expect(decodeLenientJson('{"note":"a,]"}')).toEqual({ note: "a,]" }); - expect(decodeLenientJson('{"list":["x,}"]}')).toEqual({ list: ["x,}"] }); - // Genuine trailing commas are still removed. - expect(decodeLenientJson('{"values":[1, 2,],}')).toEqual({ values: [1, 2] }); - }); - it("formats schema failures with paths without exposing invalid values", () => { const decodeCredential = decodeJsonResult(Schema.Struct({ token: Schema.Number })); const decoded = decodeCredential('{"token":"credential=secret-value"}'); diff --git a/packages/shared/src/schemaJson.ts b/packages/shared/src/schemaJson.ts index 77b1fa5d548..04d26d9c229 100644 --- a/packages/shared/src/schemaJson.ts +++ b/packages/shared/src/schemaJson.ts @@ -190,14 +190,8 @@ const parseLenientJsonGetter = SchemaGetter.onSome((input: string) => { (match, stringLiteral: string | undefined) => (stringLiteral ? match : ""), ); - // Strip trailing commas before `}` or `]`. The alternation preserves quoted - // strings so a comma inside a string value (e.g. `{"note":"a,]"}`) is not - // mistaken for a trailing comma and removed. - stripped = stripped.replace( - /("(?:[^"\\]|\\.)*")|,(\s*[}\]])/g, - (match, stringLiteral: string | undefined, bracket: string | undefined) => - stringLiteral ? match : (bracket ?? ""), - ); + // Strip trailing commas before `}` or `]`. + stripped = stripped.replace(/,(\s*[}\]])/g, "$1"); return decodeJsonString(stripped).pipe( Effect.map(Option.some), diff --git a/packages/shared/src/semver.test.ts b/packages/shared/src/semver.test.ts index ed3e1896aaf..8cbbc150fc9 100644 --- a/packages/shared/src/semver.test.ts +++ b/packages/shared/src/semver.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it } from "vite-plus/test"; -import { - compareSemverVersions, - normalizeSemverVersion, - parseSemver, - satisfiesSemverRange, -} from "./semver.ts"; +import { compareSemverVersions, normalizeSemverVersion, satisfiesSemverRange } from "./semver.ts"; describe("semver helpers", () => { it("matches supported range groups", () => { @@ -23,25 +18,6 @@ describe("semver helpers", () => { expect(normalizeSemverVersion("2.1")).toBe("2.1.0"); }); - it("normalizes and parses shorthand major-only versions", () => { - expect(normalizeSemverVersion("20")).toBe("20.0.0"); - expect(normalizeSemverVersion("v18")).toBe("v18.0.0"); - expect(normalizeSemverVersion("20-rc.1")).toBe("20.0.0-rc.1"); - expect(parseSemver("20")).toEqual({ major: 20, minor: 0, patch: 0, prerelease: [] }); - }); - - it("compares shorthand versions numerically instead of lexically", () => { - // Regression: "20" vs "9" previously fell back to string comparison, which - // ordered "20" before "9" ("2" < "9"). - expect(compareSemverVersions("20", "9")).toBeGreaterThan(0); - expect(compareSemverVersions("18", "18.0.0")).toBe(0); - }); - - it("still rejects non-numeric shorthand and keeps empty input empty", () => { - expect(parseSemver("abc")).toBeNull(); - expect(normalizeSemverVersion("")).toBe(""); - }); - it("compares prerelease versions before stable versions", () => { expect(compareSemverVersions("2.1.111-beta.1", "2.1.111")).toBeLessThan(0); }); diff --git a/packages/shared/src/semver.ts b/packages/shared/src/semver.ts index a765b065fc6..1a73e33042f 100644 --- a/packages/shared/src/semver.ts +++ b/packages/shared/src/semver.ts @@ -17,12 +17,7 @@ export function normalizeSemverVersion(version: string): string { } } - // Pad shorthand versions ("20" or "20.1") up to three segments so major-only - // and minor-only inputs parse and compare numerically. This matches - // satisfiesSemverRange, which already treats a missing minor/patch as 0. The - // length > 0 guard keeps empty/garbage input empty (parseSemver still - // rejects it), and inputs with more than three segments are left untouched. - while (segments.length > 0 && segments.length < 3) { + if (segments.length === 2) { segments.push("0"); } diff --git a/packages/shared/src/sourceAttribution.test.ts b/packages/shared/src/sourceAttribution.test.ts new file mode 100644 index 00000000000..ce8f831673e --- /dev/null +++ b/packages/shared/src/sourceAttribution.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildSourceRefFromClaim, + mergeParticipantSummaries, + nextOriginSource, + resolveSourceChannel, + sourceChannelFromDeviceType, +} from "./sourceAttribution.ts"; + +describe("sourceChannelFromDeviceType", () => { + it("maps known device types", () => { + expect(sourceChannelFromDeviceType("desktop")).toBe("desktop"); + expect(sourceChannelFromDeviceType("mobile")).toBe("mobile"); + expect(sourceChannelFromDeviceType("tablet")).toBe("mobile"); + expect(sourceChannelFromDeviceType("bot")).toBe("bot"); + expect(sourceChannelFromDeviceType("unknown")).toBe("unknown"); + expect(sourceChannelFromDeviceType(undefined)).toBe("unknown"); + }); +}); + +describe("resolveSourceChannel", () => { + it("accepts the VS Code integration channel hint", () => { + expect(resolveSourceChannel({ deviceType: "desktop", channelHint: "vscode" })).toBe("vscode"); + }); + + it("prefers explicit channel hints", () => { + expect(resolveSourceChannel({ deviceType: "desktop", channelHint: "discord" })).toBe("discord"); + }); + + it("falls back to device type", () => { + expect(resolveSourceChannel({ deviceType: "mobile" })).toBe("mobile"); + }); +}); + +describe("buildSourceRefFromClaim", () => { + it("stamps person fields from claim", () => { + expect( + buildSourceRefFromClaim({ + personId: "patroza", + username: "patroza", + channel: "desktop", + }), + ).toEqual({ + channel: "desktop", + personId: "patroza", + username: "patroza", + }); + }); +}); + +describe("nextOriginSource", () => { + it("sets origin from first user source only", () => { + const first = buildSourceRefFromClaim({ + personId: "patroza", + username: "patroza", + channel: "web", + }); + const second = buildSourceRefFromClaim({ + personId: "julius", + username: "julius", + channel: "desktop", + }); + expect(nextOriginSource({ current: null, messageSource: first, role: "user" })).toEqual(first); + expect(nextOriginSource({ current: first, messageSource: second, role: "user" })).toEqual( + first, + ); + expect(nextOriginSource({ current: null, messageSource: first, role: "assistant" })).toBeNull(); + }); +}); + +describe("mergeParticipantSummaries", () => { + it("appends distinct people and keeps origin first", () => { + const origin = { + personId: "patroza", + username: "patroza", + firstChannel: "discord" as const, + firstParticipatedAt: "2026-01-01T00:00:00.000Z", + }; + const merged = mergeParticipantSummaries({ + existing: [origin], + source: { personId: "julius", username: "julius", channel: "desktop" }, + participatedAt: "2026-01-01T00:01:00.000Z", + originPersonId: "patroza", + }); + expect(merged.map((entry) => entry.personId)).toEqual(["patroza", "julius"]); + }); + + it("ignores unmapped sources and folds a person's channels into one summary", () => { + const existing = [ + { + personId: "patroza", + username: "patroza", + firstChannel: "discord" as const, + firstParticipatedAt: "2026-01-01T00:00:00.000Z", + }, + ]; + expect( + mergeParticipantSummaries({ + existing, + source: { channel: "discord" }, + participatedAt: "2026-01-01T00:01:00.000Z", + }), + ).toEqual(existing); + const folded = mergeParticipantSummaries({ + existing, + source: { personId: "patroza", username: "patroza", channel: "desktop" }, + participatedAt: "2026-01-01T00:02:00.000Z", + }); + expect(folded).toHaveLength(1); + expect(folded[0]?.channels).toEqual(["discord", "desktop"]); + expect( + mergeParticipantSummaries({ + existing: folded, + source: { personId: "patroza", username: "patroza", channel: "desktop" }, + participatedAt: "2026-01-01T00:03:00.000Z", + }), + ).toBe(folded); + }); +}); diff --git a/packages/shared/src/sourceAttribution.ts b/packages/shared/src/sourceAttribution.ts new file mode 100644 index 00000000000..ec9cbd8284e --- /dev/null +++ b/packages/shared/src/sourceAttribution.ts @@ -0,0 +1,176 @@ +/** + * Server-side SourceRef helpers and thread participant denormalization. + * + * Clients never invent personId/username — those come from session claim or + * platform map resolution. Channel is derived from auth client device type + * (or an explicit SourceChannel for integrations). + * + * See docs/architecture/source-and-identity.md + */ +import type { AuthClientMetadataDeviceType, SourceChannel } from "@t3tools/contracts"; + +export type SourceRefLike = { + readonly channel: SourceChannel; + readonly personId?: string | undefined; + readonly username?: string | undefined; + readonly location?: { + readonly guildId?: string | undefined; + readonly channelId?: string | undefined; + readonly threadId?: string | undefined; + readonly owner?: string | undefined; + readonly repo?: string | undefined; + readonly number?: number | undefined; + readonly kind?: "pr" | "issue" | undefined; + readonly projectKey?: string | undefined; + readonly issueKey?: string | undefined; + }; + readonly actor?: { + readonly platformId?: string | undefined; + readonly displayName?: string | undefined; + }; +}; + +/** Map auth client deviceType → SourceChannel. */ +export function sourceChannelFromDeviceType( + deviceType: AuthClientMetadataDeviceType | undefined | null, +): SourceChannel { + switch (deviceType) { + case "desktop": + return "desktop"; + case "mobile": + case "tablet": + return "mobile"; + case "bot": + return "bot"; + case "unknown": + case undefined: + case null: + return "unknown"; + default: { + const _exhaustive: never = deviceType; + void _exhaustive; + return "unknown"; + } + } +} + +/** + * Prefer an explicit channel (ClientSourceHint / integration) when present; + * otherwise derive from session deviceType. Web is not a deviceType today — + * browser clients often report desktop; accept explicit "web" when hinted. + */ +export function resolveSourceChannel(input: { + readonly deviceType?: AuthClientMetadataDeviceType | null | undefined; + readonly channelHint?: SourceChannel | null | undefined; +}): SourceChannel { + if (input.channelHint !== undefined && input.channelHint !== null) { + return input.channelHint; + } + return sourceChannelFromDeviceType(input.deviceType); +} + +export function buildSourceRefFromClaim(input: { + readonly personId: string; + readonly username: string; + readonly channel: SourceChannel; + readonly location?: SourceRefLike["location"]; + readonly actor?: SourceRefLike["actor"]; +}): SourceRefLike { + return { + channel: input.channel, + personId: input.personId, + username: input.username, + ...(input.location !== undefined ? { location: input.location } : {}), + ...(input.actor !== undefined ? { actor: input.actor } : {}), + }; +} + +export type ParticipantSummaryLike = { + readonly personId: string; + readonly username: string; + readonly name?: string | undefined; + readonly firstChannel?: SourceChannel | undefined; + readonly channels?: ReadonlyArray | undefined; + readonly firstParticipatedAt: string; +}; + +/** + * Merge a user message SourceRef into ordered participant summaries. + * Origin person stays first when already present; new people append by + * first-participation time (caller passes chronological events). + */ +export function mergeParticipantSummaries(input: { + readonly existing: ReadonlyArray; + readonly source: { + readonly personId?: string | undefined; + readonly username?: string | undefined; + readonly channel: SourceChannel; + readonly name?: string | undefined; + }; + readonly participatedAt: string; + readonly originPersonId?: string | null | undefined; +}): ReadonlyArray { + const personId = input.source.personId; + if (personId === undefined || personId === null || personId.length === 0) { + return input.existing; + } + const username = input.source.username; + if (username === undefined || username === null || username.length === 0) { + return input.existing; + } + + const existingIndex = input.existing.findIndex((entry) => entry.personId === personId); + if (existingIndex !== -1) { + const existingEntry = input.existing[existingIndex]!; + const channels = + existingEntry.channels ?? + (existingEntry.firstChannel === undefined ? [] : [existingEntry.firstChannel]); + if (channels.includes(input.source.channel)) { + return input.existing; + } + return input.existing.map((entry, index) => + index === existingIndex + ? { + ...entry, + channels: [...channels, input.source.channel], + } + : entry, + ); + } + + const nextEntry: ParticipantSummaryLike = { + personId, + username, + ...(input.source.name !== undefined ? { name: input.source.name } : {}), + firstChannel: input.source.channel, + channels: [input.source.channel], + firstParticipatedAt: input.participatedAt, + }; + + const originId = input.originPersonId ?? null; + if (originId !== null && personId === originId) { + return [nextEntry, ...input.existing]; + } + + // Keep origin lead if present, then append by first-seen order. + if (originId !== null) { + const origin = input.existing.find((entry) => entry.personId === originId); + const rest = input.existing.filter((entry) => entry.personId !== originId); + if (origin !== undefined) { + return [origin, ...rest, nextEntry]; + } + } + + return [...input.existing, nextEntry]; +} + +/** First user-message SourceRef becomes origin when none set yet. */ +export function nextOriginSource(input: { + readonly current: SourceRefLike | null | undefined; + readonly messageSource: SourceRefLike | undefined; + readonly role: string; +}): SourceRefLike | null | undefined { + if (input.role !== "user") return input.current; + if (input.current !== undefined && input.current !== null) return input.current; + return input.messageSource ?? input.current ?? null; +} diff --git a/packages/shared/src/threadAttributeSearch.test.ts b/packages/shared/src/threadAttributeSearch.test.ts new file mode 100644 index 00000000000..9ba392600e3 --- /dev/null +++ b/packages/shared/src/threadAttributeSearch.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + buildThreadAttributeSearchTerms, + threadAttributeSearchMatches, + threadMatchesAttributeQuery, +} from "./threadAttributeSearch.ts"; + +const sample = { + title: "Fix gate SA-123 for multi-user claims", + branch: "pr/4521-identity-search", + originSource: { + channel: "discord" as const, + personId: "patroza", + username: "patroza", + location: { + issueKey: "SA-123", + number: 4521, + kind: "pr" as const, + }, + }, + participantSummaries: [ + { + personId: "patroza", + username: "patroza", + name: "Patrick Roza", + firstChannel: "discord" as const, + }, + { + personId: "julius", + username: "julius", + firstChannel: "desktop" as const, + }, + ], + extraTerms: ["t3-code"], +}; + +describe("buildThreadAttributeSearchTerms", () => { + it("includes identity handles and channels", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(terms).toEqual( + expect.arrayContaining([ + "patroza", + "@patroza", + "patroza@discord", + "@discord", + "discord", + "julius", + "@julius", + "julius@desktop", + "@desktop", + "desktop", + "patrick roza", + ]), + ); + }); + + it("includes PR and Jira tokens", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(terms).toEqual( + expect.arrayContaining(["#4521", "4521", "pr/4521", "pr-4521", "sa-123"]), + ); + }); + + it("includes title and branch", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(terms).toEqual( + expect.arrayContaining(["fix gate sa-123 for multi-user claims", "pr/4521-identity-search"]), + ); + }); +}); + +describe("threadMatchesAttributeQuery", () => { + it.each([ + ["@patroza"], + ["patroza@discord"], + ["@desktop"], + ["#4521"], + ["4521"], + ["SA-123"], + ["sa-123"], + ["julius"], + ["multi-user"], + ])("matches %s", (query) => { + expect(threadMatchesAttributeQuery(sample, query)).toBe(true); + }); + + it("rejects unrelated queries", () => { + expect(threadMatchesAttributeQuery(sample, "@theo")).toBe(false); + expect(threadMatchesAttributeQuery(sample, "#9999")).toBe(false); + expect(threadMatchesAttributeQuery(sample, "ZZ-1")).toBe(false); + }); + + it("empty query matches all", () => { + expect(threadMatchesAttributeQuery(sample, " ")).toBe(true); + }); +}); + +describe("threadAttributeSearchMatches", () => { + it("matches partial username prefixes", () => { + const terms = buildThreadAttributeSearchTerms(sample); + expect(threadAttributeSearchMatches(terms, "@patr")).toBe(true); + }); +}); diff --git a/packages/shared/src/threadAttributeSearch.ts b/packages/shared/src/threadAttributeSearch.ts new file mode 100644 index 00000000000..481bb74db82 --- /dev/null +++ b/packages/shared/src/threadAttributeSearch.ts @@ -0,0 +1,208 @@ +/** + * Search terms and match helpers for thread attributes beyond title/branch: + * identity handles (`@user`, `user@channel`, `@channel`), PR numbers (`#123`), + * and Jira keys (`SA-123`). + * + * Pure string helpers for web/mobile command palette and list filters. + * See docs/architecture/source-and-identity.md + */ + +export type ThreadAttributeSourceLike = { + readonly channel?: string | null | undefined; + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly location?: + | { + readonly number?: number | null | undefined; + readonly issueKey?: string | null | undefined; + readonly kind?: string | null | undefined; + } + | null + | undefined; +}; + +export type ThreadAttributeParticipantLike = { + readonly personId?: string | null | undefined; + readonly username?: string | null | undefined; + readonly name?: string | null | undefined; + readonly firstChannel?: string | null | undefined; +}; + +export type ThreadAttributeSearchInput = { + readonly title?: string | null | undefined; + readonly branch?: string | null | undefined; + readonly originSource?: ThreadAttributeSourceLike | null | undefined; + readonly participantSummaries?: ReadonlyArray | null | undefined; + /** Additional free-form terms (project title, etc.). */ + readonly extraTerms?: ReadonlyArray | null | undefined; +}; + +/** Jira-style issue keys: PROJ-123, SA-49, … */ +const JIRA_KEY_PATTERN = /\b([A-Za-z][A-Za-z0-9]+-\d+)\b/g; +/** Explicit PR markers in free text / branch names. */ +const PR_HASH_PATTERN = /#(\d+)\b/g; +const PR_SLUG_PATTERN = /\b(?:pr|pull)[-_/]?(\d+)\b/gi; + +function addTerm(into: Set, raw: string | null | undefined): void { + if (raw === null || raw === undefined) return; + const trimmed = raw.trim().toLowerCase(); + if (trimmed.length === 0) return; + into.add(trimmed); +} + +function addPersonTerms( + into: Set, + person: { + readonly username?: string | null | undefined; + readonly personId?: string | null | undefined; + readonly name?: string | null | undefined; + readonly channel?: string | null | undefined; + }, +): void { + const username = person.username?.trim().toLowerCase() ?? ""; + const personId = person.personId?.trim().toLowerCase() ?? ""; + const channel = person.channel?.trim().toLowerCase() ?? ""; + const name = person.name?.trim().toLowerCase() ?? ""; + + if (username.length > 0) { + addTerm(into, username); + addTerm(into, `@${username}`); + if (channel.length > 0) { + addTerm(into, `${username}@${channel}`); + } + } + if (personId.length > 0 && personId !== username) { + addTerm(into, personId); + addTerm(into, `@${personId}`); + if (channel.length > 0) { + addTerm(into, `${personId}@${channel}`); + } + } + if (name.length > 0) { + addTerm(into, name); + } +} + +function addChannelTerms(into: Set, channel: string | null | undefined): void { + const normalized = channel?.trim().toLowerCase() ?? ""; + if (normalized.length === 0) return; + addTerm(into, normalized); + addTerm(into, `@${normalized}`); +} + +function addPrNumber(into: Set, value: number | string): void { + const digits = String(value).replace(/\D/g, ""); + if (digits.length === 0) return; + addTerm(into, digits); + addTerm(into, `#${digits}`); + addTerm(into, `pr-${digits}`); + addTerm(into, `pr/${digits}`); +} + +function extractFromText(into: Set, text: string | null | undefined): void { + if (text === null || text === undefined || text.trim().length === 0) return; + const source = text; + + for (const match of source.matchAll(JIRA_KEY_PATTERN)) { + const key = match[1]; + if (key !== undefined) addTerm(into, key); + } + for (const match of source.matchAll(PR_HASH_PATTERN)) { + const n = match[1]; + if (n !== undefined) addPrNumber(into, n); + } + for (const match of source.matchAll(PR_SLUG_PATTERN)) { + const n = match[1]; + if (n !== undefined) addPrNumber(into, n); + } +} + +/** + * Build a deduped, lowercased bag of search terms for a thread. + * Suitable for command-palette `searchTerms` and list filters. + */ +export function buildThreadAttributeSearchTerms( + input: ThreadAttributeSearchInput, +): ReadonlyArray { + const terms = new Set(); + + addTerm(terms, input.title); + addTerm(terms, input.branch); + if (input.branch !== null && input.branch !== undefined && input.branch.trim().length > 0) { + addTerm(terms, `#${input.branch.trim()}`); + } + + extractFromText(terms, input.title); + extractFromText(terms, input.branch); + + const origin = input.originSource ?? null; + if (origin !== null) { + addChannelTerms(terms, origin.channel); + addPersonTerms(terms, { + username: origin.username, + personId: origin.personId, + channel: origin.channel, + }); + if (origin.location?.number !== undefined && origin.location.number !== null) { + addPrNumber(terms, origin.location.number); + } + if (origin.location?.issueKey) { + addTerm(terms, origin.location.issueKey); + } + } + + for (const participant of input.participantSummaries ?? []) { + addPersonTerms(terms, { + username: participant.username, + personId: participant.personId, + name: participant.name, + channel: participant.firstChannel, + }); + addChannelTerms(terms, participant.firstChannel); + } + + for (const extra of input.extraTerms ?? []) { + addTerm(terms, extra); + extractFromText(terms, extra); + } + + return [...terms]; +} + +/** + * Whether any search term matches the query (substring, case-insensitive). + * Query is normalized the same way as terms (trim + lower). + */ +export function threadAttributeSearchMatches(terms: ReadonlyArray, query: string): boolean { + const normalizedQuery = query.trim().toLowerCase().replace(/\s+/g, " "); + if (normalizedQuery.length === 0) return true; + if (terms.length === 0) return false; + + // Direct term substring (covers @user, user@channel, #123, sa-123, title words). + for (const term of terms) { + if (term.includes(normalizedQuery) || normalizedQuery.includes(term)) { + // Prefer: query is a prefix/substring of a term (user typed partial handle). + if (term.includes(normalizedQuery)) return true; + } + } + + // Joined haystack for multi-word title queries. + const haystack = terms.join(" "); + if (haystack.includes(normalizedQuery)) return true; + + // `#42` vs bare `42` already both in terms when PR-linked. + // `@desktop` is stored as both `desktop` and `@desktop`. + return false; +} + +/** + * Convenience: build terms and match in one call. + */ +export function threadMatchesAttributeQuery( + input: ThreadAttributeSearchInput, + query: string, +): boolean { + const normalizedQuery = query.trim(); + if (normalizedQuery.length === 0) return true; + return threadAttributeSearchMatches(buildThreadAttributeSearchTerms(input), normalizedQuery); +} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a6998716f8d..49a3bd44351 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,13 +24,13 @@ allowBuilds: catalog: dfx: 1.0.15 - "@clerk/backend": 3.14.0 - "@clerk/clerk-js": 6.25.12 - "@clerk/electron": 0.0.24 + "@clerk/backend": 3.13.0 + "@clerk/clerk-js": 6.25.7 + "@clerk/electron": 0.0.18 "@clerk/electron-passkeys": 0.0.3 - "@clerk/expo": 4.1.2 - "@clerk/react": 6.12.9 - "@clerk/shared": 4.25.9 + "@clerk/expo": 4.0.2 + "@clerk/react": 6.12.7 + "@clerk/shared": 4.25.7 "@effect/atom-react": 4.0.0-beta.102 "@effect/openapi-generator": 4.0.0-beta.102 "@effect/platform-bun": 4.0.0-beta.102 @@ -53,12 +53,12 @@ catalog: yaml: ^2.9.0 minimumReleaseAgeExclude: - - "@clerk/backend@3.14.0" - - "@clerk/clerk-js@6.25.12" - - "@clerk/electron@0.0.24" - - "@clerk/expo@4.1.2" - - "@clerk/react@6.12.9" - - "@clerk/shared@4.25.9" + - "@clerk/backend@3.13.0" + - "@clerk/clerk-js@6.25.7" + - "@clerk/electron@0.0.18" + - "@clerk/expo@4.0.2" + - "@clerk/react@6.12.7" + - "@clerk/shared@4.25.7" - "@distilled.cloud/aws@0.30.2" - "@distilled.cloud/axiom@0.30.2" - "@distilled.cloud/cloudflare@0.30.2" diff --git a/scripts/mobile-showcase-environment.ts b/scripts/mobile-showcase-environment.ts index f7854675351..9c04c7e9dd1 100644 --- a/scripts/mobile-showcase-environment.ts +++ b/scripts/mobile-showcase-environment.ts @@ -1,4 +1,4 @@ -// @effect-diagnostics nodeBuiltinImport:off globalTimers:off globalDate:off - This host-side fixture creates an isolated local T3 environment. +// @effect-diagnostics nodeBuiltinImport:off globalDate:off - This host-side fixture creates an isolated local T3 environment. import * as NodeChildProcess from "node:child_process"; import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; @@ -387,48 +387,6 @@ function insertThread( .run(input.id, isWorking ? "running" : "ready", isWorking ? turnId : null, updatedAt); } -const SEEDED_PROJECTION_TABLES = [ - "projection_pending_approvals", - "projection_thread_proposed_plans", - "projection_thread_activities", - "projection_thread_messages", - "projection_thread_sessions", - "projection_turns", - "projection_threads", - "projection_projects", - "projection_state", -] as const; - -function hasSeedableSchema(dbPath: string): boolean { - let database: NodeSqlite.DatabaseSync; - try { - database = new NodeSqlite.DatabaseSync(dbPath, { readOnly: true }); - } catch { - return false; - } - try { - const row = database - .prepare( - `SELECT COUNT(*) AS count FROM sqlite_master WHERE type = 'table' AND name IN (${SEEDED_PROJECTION_TABLES.map(() => "?").join(", ")})`, - ) - .get(...SEEDED_PROJECTION_TABLES) as { count: number }; - return row.count === SEEDED_PROJECTION_TABLES.length; - } catch { - return false; - } finally { - database.close(); - } -} - -async function waitForSeedableSchema(dbPath: string, timeoutMs = 60_000): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (hasSeedableSchema(dbPath)) return; - await new Promise((resolve) => setTimeout(resolve, 250)); - } - throw new Error(`The environment server did not migrate ${dbPath} within ${timeoutMs}ms.`); -} - function seedDatabase( dbPath: string, workspaceRoots: ReadonlyMap, @@ -443,7 +401,17 @@ function seedDatabase( const database = new NodeSqlite.DatabaseSync(dbPath, { timeout: 30_000 }); try { database.exec("BEGIN IMMEDIATE"); - for (const table of SEEDED_PROJECTION_TABLES) { + for (const table of [ + "projection_pending_approvals", + "projection_thread_proposed_plans", + "projection_thread_activities", + "projection_thread_messages", + "projection_thread_sessions", + "projection_turns", + "projection_threads", + "projection_projects", + "projection_state", + ]) { database.exec(`DELETE FROM ${table}`); } const insertProject = database.prepare( @@ -626,9 +594,6 @@ export async function seedShowcaseEnvironment(input: { }); }), ); - // The environment server begins listening before it finishes migrating the - // database, so wait for the schema before deleting from and reseeding it. - await waitForSeedableSchema(dbPath); seedDatabase(dbPath, workspaceRoots, projects, threads, now); const terminalDirectory = NodePath.join(input.baseDir, "userdata", "logs", "terminals"); diff --git a/scripts/mobile-showcase.config.ts b/scripts/mobile-showcase.config.ts index 4643c06ccfd..2fd743f4f62 100644 --- a/scripts/mobile-showcase.config.ts +++ b/scripts/mobile-showcase.config.ts @@ -25,8 +25,6 @@ export interface ShowcaseIosDevice { readonly simulatorDeviceType?: string; /** Appearance used when the CLI does not pass --appearance. */ readonly appearance: ShowcaseAppearance; - /** Orientation applied by the capture harness. Defaults to portrait. */ - readonly orientation?: "portrait" | "landscape"; readonly scenes: ReadonlyArray; readonly storeAsset: ShowcaseStoreAssetSpec; } @@ -124,13 +122,12 @@ const config: ShowcaseConfig = { simulator: "iPad Pro 13-inch (M5)", simulatorDeviceType: "com.apple.CoreSimulator.SimDeviceType.iPad-Pro-13-inch-M5-16GB", appearance: "dark", - orientation: "landscape", scenes: ["thread", "terminal", "review", "threads", "environments"], storeAsset: { store: "apple", directory: "apple/ipad-13", - width: 2752, - height: 2064, + width: 2064, + height: 2752, minimumUploadCount: 1, maximumUploadCount: 10, }, diff --git a/scripts/mobile-showcase.test.ts b/scripts/mobile-showcase.test.ts index 16fb3e230bb..242c9ebdb67 100644 --- a/scripts/mobile-showcase.test.ts +++ b/scripts/mobile-showcase.test.ts @@ -216,18 +216,17 @@ it("configures every default device with an exact upload-ready store target", () assert.deepStrictEqual( showcaseConfig.devices.map((device) => [ device.id, - device.platform === "ios" ? (device.orientation ?? "portrait") : null, device.storeAsset.directory, device.storeAsset.width, device.storeAsset.height, ]), [ - ["iphone-6.9", "portrait", "apple/iphone-6.9", 1320, 2868], - ["iphone-6.5", "portrait", "apple/iphone-6.5", 1284, 2778], - ["ipad-13", "landscape", "apple/ipad-13", 2752, 2064], - ["pixel", null, "google-play/phone", 1080, 1920], - ["android-tablet-7", null, "google-play/tablet-7", 1080, 1920], - ["android-tablet-10", null, "google-play/tablet-10", 1440, 2560], + ["iphone-6.9", "apple/iphone-6.9", 1320, 2868], + ["iphone-6.5", "apple/iphone-6.5", 1284, 2778], + ["ipad-13", "apple/ipad-13", 2064, 2752], + ["pixel", "google-play/phone", 1080, 1920], + ["android-tablet-7", "google-play/tablet-7", 1080, 1920], + ["android-tablet-10", "google-play/tablet-10", 1440, 2560], ], ); }); @@ -245,19 +244,22 @@ it("selects a reachable LAN IPv4 address", () => { }); it("maps capture scenes to the real application routes", () => { - assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code://"); - assert.equal(showcaseSceneUrl("environments", "environment-1"), "t3code://settings/environments"); + assert.equal(showcaseSceneUrl("threads", "environment-1"), "t3code-dev://"); + assert.equal( + showcaseSceneUrl("environments", "environment-1"), + "t3code-dev://settings/environments", + ); assert.equal( showcaseSceneUrl("thread", "environment-1"), - "t3code://threads/environment-1/remote-command-center", + "t3code-dev://threads/environment-1/remote-command-center", ); assert.equal( showcaseSceneUrl("terminal", "environment-1"), - "t3code://threads/environment-1/remote-command-center/terminal?terminalId=term-1", + "t3code-dev://threads/environment-1/remote-command-center/terminal?terminalId=term-1", ); assert.equal( showcaseSceneUrl("review", "environment-1"), - "t3code://threads/environment-1/remote-command-center/review", + "t3code-dev://threads/environment-1/remote-command-center/review", ); }); diff --git a/scripts/mobile-showcase.ts b/scripts/mobile-showcase.ts index ebc22f338be..85304aecd86 100644 --- a/scripts/mobile-showcase.ts +++ b/scripts/mobile-showcase.ts @@ -31,14 +31,14 @@ import { const REPO_ROOT = NodePath.resolve(NodePath.dirname(NodeURL.fileURLToPath(import.meta.url)), ".."); const MOBILE_ROOT = NodePath.join(REPO_ROOT, "apps/mobile"); -const ANDROID_PACKAGE = "com.t3tools.t3code"; -const APP_SCHEME = "t3code"; +const ANDROID_PACKAGE = "com.t3tools.t3code.dev"; +const APP_SCHEME = "t3code-dev"; const IOS_READY_FILENAME = "T3ShowcaseReadyScene"; const SERVER_HOST = "0.0.0.0"; const IOS_SIMULATOR_ARCH = NodeProcess.arch === "arm64" ? "arm64" : "x86_64"; const IOS_APP_PATH = NodePath.join( MOBILE_ROOT, - ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3Code.app", + ".showcase/ios-derived-data/Build/Products/Debug-iphonesimulator/T3CodeDev.app", ); const ANDROID_APK_PATH = NodePath.join( MOBILE_ROOT, @@ -58,7 +58,7 @@ const ANDROID_SDK_ROOT = resolveAndroidSdkRoot(NodeProcess.env); const MOBILE_BUILD_ENV = { ...NodeProcess.env, ANDROID_HOME: ANDROID_SDK_ROOT, - APP_VARIANT: "production", + APP_VARIANT: "development", EXPO_NO_GIT_STATUS: "1", JAVA_HOME: NodeProcess.env.JAVA_HOME ?? @@ -87,11 +87,9 @@ export interface ShowcaseCapture { } interface IosCaptureCleanup { - readonly name: string; readonly udid: string; readonly startedByRunner: boolean; readonly createdByRunner: boolean; - readonly restorePortrait: boolean; } interface AndroidCaptureCleanup { @@ -502,23 +500,6 @@ async function waitForPort(port: number, label = "Process", timeoutMs = 60_000): throw new Error(`${label} did not begin listening on port ${port} within ${timeoutMs}ms.`); } -async function waitForFileContent( - filePath: string, - label: string, - timeoutMs = 60_000, -): Promise { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - const content = await NodeFSP.readFile(filePath, "utf8").then( - (value) => value.trim(), - () => "", - ); - if (content) return content; - await delay(250); - } - throw new Error(`${label} was not written to ${filePath} within ${timeoutMs}ms.`); -} - async function reserveAvailablePort(): Promise { return await new Promise((resolve, reject) => { const server = NodeNet.createServer(); @@ -683,9 +664,9 @@ async function buildIos(): Promise { "xcodebuild", [ "-workspace", - NodePath.join(MOBILE_ROOT, "ios/T3Code.xcworkspace"), + NodePath.join(MOBILE_ROOT, "ios/T3CodeDev.xcworkspace"), "-scheme", - "T3Code", + "T3CodeDev", "-configuration", "Debug", "-sdk", @@ -794,49 +775,6 @@ async function normalizeIosSimulator(appearance: ShowcaseAppearance, udid: strin ]); } -async function setIosSimulatorOrientation( - orientation: NonNullable, - simulator: Pick, -): Promise { - await runCommand("open", ["-a", "Simulator", "--args", "-CurrentDeviceUDID", simulator.udid]); - const menuItem = orientation === "landscape" ? "Landscape Right" : "Portrait"; - await runCommand("osascript", [ - "-e", - "on run argv", - "-e", - "set simulatorName to item 1 of argv", - "-e", - 'tell application "Simulator" to activate', - "-e", - 'tell application "System Events" to tell process "Simulator"', - "-e", - "set simulatorWindows to {}", - "-e", - "repeat 40 times", - "-e", - 'set simulatorWindows to menu items of menu "Window" of menu bar item "Window" of menu bar 1 whose name starts with simulatorName', - "-e", - "if (count of simulatorWindows) is greater than 0 then exit repeat", - "-e", - "delay 0.25", - "-e", - "end repeat", - "-e", - 'if (count of simulatorWindows) is not 1 then error "Expected exactly one Simulator window for " & simulatorName', - "-e", - "click item 1 of simulatorWindows", - "-e", - `click menu item "${menuItem}" of menu "Orientation" of menu item "Orientation" of menu "Device" of menu bar item "Device" of menu bar 1`, - "-e", - "end tell", - "-e", - "delay 1", - "-e", - "end run", - simulator.name, - ]); -} - async function iosAppContainer(udid: string): Promise { return ( await commandOutput("xcrun", ["simctl", "get_app_container", udid, ANDROID_PACKAGE, "data"]) @@ -873,13 +811,7 @@ async function captureIos( ): Promise { const { simulator, createdByRunner } = await ensureIosSimulator(capture.device); const startedByRunner = simulator.state !== "Booted"; - registerCleanup({ - name: simulator.name, - udid: simulator.udid, - startedByRunner, - createdByRunner, - restorePortrait: capture.device.orientation === "landscape", - }); + registerCleanup({ udid: simulator.udid, startedByRunner, createdByRunner }); if (!startedByRunner) { // Clear transient SpringBoard state (permission prompts, stale URL-open // confirmations, keyboards) without erasing the developer's simulator. @@ -938,9 +870,6 @@ async function captureIos( "--showcaseScene", firstScene, ]); - if (capture.device.orientation === "landscape") { - await setIosSimulatorOrientation("landscape", simulator); - } }; await NodeFSP.rm(readyPath, { force: true }); await NodeFSP.writeFile(scenePath, firstScene); @@ -971,9 +900,6 @@ async function captureIos( `${scene}.png`, ); await runCommand("xcrun", ["simctl", "io", simulator.udid, "screenshot", destination]); - if (capture.device.orientation === "landscape") { - await runCommand("sips", ["--rotate", "90", destination]); - } await finalizeCapture(destination, capture.device); } } @@ -1299,12 +1225,12 @@ async function main(): Promise { showcaseServers.push(server); await waitForPort(port, `${environment.label} server`); await seedShowcaseEnvironment({ baseDir, projectIds: environment.projectIds }); - // The server begins listening before the ServerEnvironment layer - // persists the environment id, so poll rather than read once. - const environmentId = await waitForFileContent( - NodePath.join(baseDir, "userdata", "environment-id"), - `${environment.label} environment id`, - ); + const environmentId = ( + await NodeFSP.readFile(NodePath.join(baseDir, "userdata", "environment-id"), "utf8") + ).trim(); + if (!environmentId) { + throw new Error(`${environment.label} did not persist an environment id.`); + } showcaseEnvironments.push({ baseDir, environmentId, label: environment.label, port }); } @@ -1389,9 +1315,6 @@ async function main(): Promise { } } for (const cleanup of iosCleanups) { - if (cleanup.restorePortrait) { - await setIosSimulatorOrientation("portrait", cleanup).catch(() => undefined); - } if (cleanup.startedByRunner || cleanup.createdByRunner) { await runCommand("xcrun", ["simctl", "shutdown", cleanup.udid]).catch(() => undefined); } From 180a4be5eb018411f573be5733fb4e42fdb39b41 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:23:23 +0200 Subject: [PATCH 2/8] fix(identity): keep upstream Clerk catalog after reapply The path-filtered reapply pulled stale pnpm-workspace.yaml clerk pins (3.13.x) while the lockfile still reflected fork/changes (3.14.x), which breaks frozen install on the permanent overlay PR. --- pnpm-workspace.yaml | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 49a3bd44351..a6998716f8d 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,13 +24,13 @@ allowBuilds: catalog: dfx: 1.0.15 - "@clerk/backend": 3.13.0 - "@clerk/clerk-js": 6.25.7 - "@clerk/electron": 0.0.18 + "@clerk/backend": 3.14.0 + "@clerk/clerk-js": 6.25.12 + "@clerk/electron": 0.0.24 "@clerk/electron-passkeys": 0.0.3 - "@clerk/expo": 4.0.2 - "@clerk/react": 6.12.7 - "@clerk/shared": 4.25.7 + "@clerk/expo": 4.1.2 + "@clerk/react": 6.12.9 + "@clerk/shared": 4.25.9 "@effect/atom-react": 4.0.0-beta.102 "@effect/openapi-generator": 4.0.0-beta.102 "@effect/platform-bun": 4.0.0-beta.102 @@ -53,12 +53,12 @@ catalog: yaml: ^2.9.0 minimumReleaseAgeExclude: - - "@clerk/backend@3.13.0" - - "@clerk/clerk-js@6.25.7" - - "@clerk/electron@0.0.18" - - "@clerk/expo@4.0.2" - - "@clerk/react@6.12.7" - - "@clerk/shared@4.25.7" + - "@clerk/backend@3.14.0" + - "@clerk/clerk-js@6.25.12" + - "@clerk/electron@0.0.24" + - "@clerk/expo@4.1.2" + - "@clerk/react@6.12.9" + - "@clerk/shared@4.25.9" - "@distilled.cloud/aws@0.30.2" - "@distilled.cloud/axiom@0.30.2" - "@distilled.cloud/cloudflare@0.30.2" From 235e447f9b489b292f6c76d65c9c1345b463d791 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:31:48 +0200 Subject: [PATCH 3/8] fix(identity): restore shared product files dropped by reapply The path-filtered identity reapply overwrote fork/changes product with a stale overlay tree, dropping content search contracts/UI, file picker query helpers, desktop update helpers, ForwardCompatibleArray keybindings, and related tests. Restore those shared files from fork/changes so recent product work is not lost under the identity layer. --- .../components/desktopUpdate.logic.test.ts | 18 ++ .../web/src/components/desktopUpdate.logic.ts | 18 ++ .../src/components/files/FileBrowserPanel.tsx | 72 +++++++ .../src/components/files/FilePreviewPanel.tsx | 188 +++++++++++++----- .../files/projectFilesQueryState.ts | 27 +++ apps/web/src/state/projects.ts | 13 ++ apps/web/src/state/queries.ts | 97 ++++++++- packages/contracts/src/baseSchemas.ts | 25 +++ packages/contracts/src/keybindings.test.ts | 77 +++++++ packages/contracts/src/keybindings.ts | 13 +- packages/contracts/src/project.test.ts | 42 ++++ packages/contracts/src/project.ts | 82 +++++++- 12 files changed, 609 insertions(+), 63 deletions(-) diff --git a/apps/web/src/components/desktopUpdate.logic.test.ts b/apps/web/src/components/desktopUpdate.logic.test.ts index b07ae99c058..8d24b34a433 100644 --- a/apps/web/src/components/desktopUpdate.logic.test.ts +++ b/apps/web/src/components/desktopUpdate.logic.test.ts @@ -7,6 +7,7 @@ import { getDesktopUpdateActionError, getDesktopUpdateButtonTooltip, getDesktopUpdateInstallConfirmationMessage, + getDesktopUpdateReleaseUrl, isDesktopUpdateButtonDisabled, resolveDesktopUpdateButtonAction, shouldShowArm64IntelBuildWarning, @@ -158,6 +159,23 @@ describe("getDesktopUpdateActionError", () => { }); describe("desktop update UI helpers", () => { + it("builds the stable release URL for a downloaded version", () => { + expect(getDesktopUpdateReleaseUrl("0.0.30")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30", + ); + }); + + it("builds the nightly release URL without dropping its version suffix", () => { + expect(getDesktopUpdateReleaseUrl("0.0.30-nightly.20260728.931")).toBe( + "https://github.com/pingdotgg/t3code/releases/tag/v0.0.30-nightly.20260728.931", + ); + }); + + it("omits the release URL when the updater does not report a version", () => { + expect(getDesktopUpdateReleaseUrl(null)).toBeNull(); + expect(getDesktopUpdateReleaseUrl(" ")).toBeNull(); + }); + it("toasts only for actionable updater errors", () => { expect( shouldToastDesktopUpdateActionResult({ diff --git a/apps/web/src/components/desktopUpdate.logic.ts b/apps/web/src/components/desktopUpdate.logic.ts index 11c34777a41..dc09d7ca877 100644 --- a/apps/web/src/components/desktopUpdate.logic.ts +++ b/apps/web/src/components/desktopUpdate.logic.ts @@ -3,6 +3,24 @@ import { isWindowsPlatform } from "../lib/utils"; export type DesktopUpdateButtonAction = "download" | "install" | "none"; +const DESKTOP_RELEASE_TAG_URL = "https://github.com/pingdotgg/t3code/releases/tag"; + +/** + * The main process fills `downloadedVersion` from the updater's `update-downloaded` + * event, which is dispatched on its own fiber. A download RPC can therefore resolve + * before that write lands, so fall back to the version the download was started for. + */ +export function getDesktopUpdateDownloadedVersion(state: DesktopUpdateState): string | null { + return state.downloadedVersion ?? state.availableVersion; +} + +/** Release notes for an exact downloaded build; nightly suffixes are part of the tag. */ +export function getDesktopUpdateReleaseUrl(version: string | null): string | null { + const normalizedVersion = version?.trim(); + if (!normalizedVersion) return null; + return `${DESKTOP_RELEASE_TAG_URL}/v${encodeURIComponent(normalizedVersion)}`; +} + export function resolveDesktopUpdateButtonAction( state: DesktopUpdateState, ): DesktopUpdateButtonAction { diff --git a/apps/web/src/components/files/FileBrowserPanel.tsx b/apps/web/src/components/files/FileBrowserPanel.tsx index 307d4413751..ff658693a70 100644 --- a/apps/web/src/components/files/FileBrowserPanel.tsx +++ b/apps/web/src/components/files/FileBrowserPanel.tsx @@ -26,6 +26,10 @@ interface FileBrowserPanelProps { environmentId: EnvironmentId; cwd: string; projectName: string; + /** File currently open in the preview pane; revealed and selected in the tree. */ + selectedPath: string | null; + /** Bumped when the same path should be revealed again (e.g. re-opened from search). */ + selectedPathRevealId: number; onOpenFile: (relativePath: string) => void; } @@ -98,6 +102,8 @@ export default function FileBrowserPanel({ environmentId, cwd, projectName, + selectedPath, + selectedPathRevealId, onOpenFile, }: FileBrowserPanelProps) { const { resolvedTheme } = useTheme(); @@ -111,6 +117,9 @@ export default function FileBrowserPanel({ const entryKindsRef = useRef>(entryKinds); const treePaths = useMemo(() => entries.map(treePath), [entries]); const previousTreePathsRef = useRef([]); + const syncingSelectionRef = useRef(false); + const treeSelectionPathRef = useRef(null); + const handledRevealRef = useRef<{ path: string; revealId: number } | null>(null); // The tree renders rows in shadow DOM and its anchor rect is unreliable, so // capture the right-click position ourselves; contextmenu is a composed @@ -216,7 +225,12 @@ export default function FileBrowserPanel({ initialExpansion: 1, icons: T3_PIERRE_ICONS, onSelectionChange: (selectedPaths) => { + // The drag controller's selection cache must track every change, + // including reveal-driven ones, or drags act on a stale selection. dragMention.handleSelectionChange(selectedPaths); + // Selection changes driven by the reveal sync below are echoes of an + // already-open file, not a request to open it again. + if (syncingSelectionRef.current) return; // Starting a drag selects the dragged row; that selection is a side // effect of the gesture, not a request to open the file. if (dragMention.isDragInProgress()) { @@ -224,6 +238,7 @@ export default function FileBrowserPanel({ } const selectedPath = selectedPaths.at(-1)?.replace(/\/$/, ""); if (selectedPath && entryKindsRef.current.get(selectedPath) === "file") { + treeSelectionPathRef.current = selectedPath; onOpenFile(selectedPath); } }, @@ -247,6 +262,63 @@ export default function FileBrowserPanel({ model.resetPaths(treePaths); }, [entryKinds, model, treePaths]); + useEffect(() => { + if (!selectedPath) { + handledRevealRef.current = null; + return; + } + const revealRequest = { path: selectedPath, revealId: selectedPathRevealId }; + const handledReveal = handledRevealRef.current; + // Entry refreshes rebuild treePaths while the same preview stays open. + // Replaying a handled reveal would close an active tree search and steal focus. + if ( + handledReveal?.path === revealRequest.path && + handledReveal.revealId === revealRequest.revealId + ) { + return; + } + if (entryKinds.get(selectedPath) !== "file") return; + const selectedItem = model.getItem(selectedPath); + if (!selectedItem) return; + + // A selection that originated inside the tree (clicking a row, possibly + // in an active tree search) is already visible; re-revealing it would + // close the search and clobber the user's context. Only sync external + // opens (file picker, content search, chat links). + const selectedInTree = model + .getSelectedPaths() + .some((path) => path.replace(/\/$/, "") === selectedPath); + if (selectedInTree && treeSelectionPathRef.current === selectedPath) { + treeSelectionPathRef.current = null; + handledRevealRef.current = revealRequest; + return; + } + treeSelectionPathRef.current = null; + handledRevealRef.current = revealRequest; + + syncingSelectionRef.current = true; + model.closeSearch(); + for (const path of model.getSelectedPaths()) { + model.getItem(path)?.deselect(); + } + + // Directory rows are registered with a trailing slash (see treePath), so + // ancestor lookups must use the same form to expand them. + const segments = selectedPath.split("/"); + let ancestorPath = ""; + for (const segment of segments.slice(0, -1)) { + ancestorPath = ancestorPath ? `${ancestorPath}/${segment}` : segment; + const item = model.getItem(`${ancestorPath}/`) ?? model.getItem(ancestorPath); + if (item && "expand" in item) item.expand(); + } + + selectedItem.select(); + model.scrollToPath(selectedPath, { focus: true, offset: "center" }); + queueMicrotask(() => { + syncingSelectionRef.current = false; + }); + }, [entryKinds, model, selectedPath, selectedPathRevealId, treePaths]); + // Tag tree drags with the composer mention payload. The row is read from // the composed event path (the tree's shadow root is open), so this does // not depend on running after the tree's own dragstart handler; the drag diff --git a/apps/web/src/components/files/FilePreviewPanel.tsx b/apps/web/src/components/files/FilePreviewPanel.tsx index 24e63a6d8ea..a736cf96cd3 100644 --- a/apps/web/src/components/files/FilePreviewPanel.tsx +++ b/apps/web/src/components/files/FilePreviewPanel.tsx @@ -51,6 +51,7 @@ import { remapFileCommentAnnotations, } from "./fileCommentAnnotations"; import { installFileEditorDismissal } from "./fileEditorDismissal"; +import { resolveCenteredFileLineScrollTop } from "./fileLineReveal"; import { LocalCommentAnnotation } from "./LocalCommentAnnotation"; import { projectFileCacheKey, projectFileEditorCacheKey } from "./fileContentRevision"; import { fileBreadcrumbs } from "./filePath"; @@ -182,25 +183,53 @@ function updateFileLinkReveal(fileContainer: HTMLElement, line: number | null): ?.setAttribute(FILE_LINK_REVEAL_ATTRIBUTE, ""); } +/** + * Frames to keep retrying while the file contents or line metrics are not + * available yet (fresh mounts hydrate asynchronously). + */ +const REVEAL_MAX_ATTEMPTS = 30; +/** + * After scrolling to the target, hold it for a short window so late + * programmatic scroll resets (editable-editor focus and state restoration) + * cannot silently snap the file back to the top. Real user input cancels the + * guard immediately. + */ +const REVEAL_GUARD_FRAMES = 20; +const REVEAL_GUARD_TOLERANCE_PX = 2; + +interface FileRevealState { + frameId: number | null; + cancelGuard: (() => void) | null; + handledRequestId: number | null; + latestRequestId: number | null; +} + function useFileLineReveal( relativePath: string | null, revealLine: number | null, revealRequestId: number, ): FilePostRender { - const [handledRequestIdsByPath] = useState(() => new Map()); - const [latestRequestIdsByPath] = useState(() => new Map()); - const [pendingFramesByPath] = useState(() => new Map()); + const [revealStatesByPath] = useState(() => new Map()); return useCallback( (fileContainer, instance, phase) => { if (relativePath === null) return; + const existingState = revealStatesByPath.get(relativePath); + const state: FileRevealState = existingState ?? { + frameId: null, + cancelGuard: null, + handledRequestId: null, + latestRequestId: null, + }; + if (!existingState) revealStatesByPath.set(relativePath, state); + const cancelPendingReveal = () => { - const frameId = pendingFramesByPath.get(relativePath); - if (frameId !== undefined) { - cancelAnimationFrame(frameId); - pendingFramesByPath.delete(relativePath); + if (state.frameId !== null) { + cancelAnimationFrame(state.frameId); + state.frameId = null; } + state.cancelGuard?.(); }; if (phase === "unmount") { @@ -208,18 +237,20 @@ function useFileLineReveal( return; } + const contents = instance.file?.contents; const targetLine = - revealLine === null ? null : clampFileLine(instance.file?.contents ?? "", revealLine); + revealLine === null || contents === undefined ? null : clampFileLine(contents, revealLine); updateFileLinkReveal(fileContainer, targetLine); if (!(instance instanceof VirtualizedFile)) return; - if (latestRequestIdsByPath.get(relativePath) !== revealRequestId) { + if (state.latestRequestId !== revealRequestId) { cancelPendingReveal(); - latestRequestIdsByPath.set(relativePath, revealRequestId); + state.latestRequestId = revealRequestId; + state.handledRequestId = null; } - if (targetLine === null) { + if (revealLine === null) { fileContainer.style.minHeight = ""; return; } @@ -230,54 +261,113 @@ function useFileLineReveal( Math.max(instance.height, scrollContainer.clientHeight), )}px`; - if ( - handledRequestIdsByPath.get(relativePath) === revealRequestId || - pendingFramesByPath.has(relativePath) - ) { + if (state.handledRequestId === revealRequestId || state.frameId !== null) { return; } - const reveal = () => { - pendingFramesByPath.delete(relativePath); - if ( - latestRequestIdsByPath.get(relativePath) !== revealRequestId || - !fileContainer.isConnected - ) { - return; - } - - const linePosition = instance.getLinePosition(targetLine); - if (!linePosition) return; + const resolveScrollTarget = (line: number): number | null => { + const linePosition = instance.getLinePosition(line); + if (!linePosition) return null; + const scrollContainerRect = scrollContainer.getBoundingClientRect(); const fileTop = scrollContainer.scrollTop + fileContainer.getBoundingClientRect().top - - scrollContainer.getBoundingClientRect().top; - const centeredTop = Math.max( - 0, - fileTop + - linePosition.top - - Math.max(0, (scrollContainer.clientHeight - linePosition.height) / 2), - ); - const maxScrollTop = Math.max( - 0, - scrollContainer.scrollHeight - scrollContainer.clientHeight, - ); + scrollContainerRect.top; + const root = fileContainer.shadowRoot ?? fileContainer; + const renderedLineElement = root.querySelector(`[data-line="${line}"]`); + const renderedLineRect = renderedLineElement?.getBoundingClientRect(); - scrollContainer.scrollTop = Math.min(centeredTop, maxScrollTop); - handledRequestIdsByPath.set(relativePath, revealRequestId); + return resolveCenteredFileLineScrollTop({ + scrollTop: scrollContainer.scrollTop, + scrollHeight: scrollContainer.scrollHeight, + viewportTop: scrollContainerRect.top, + viewportHeight: scrollContainer.clientHeight, + fileTop, + estimatedLine: linePosition, + ...(renderedLineRect && renderedLineRect.height > 0 + ? { + renderedLine: { + top: renderedLineRect.top, + height: renderedLineRect.height, + }, + } + : {}), + }); }; - pendingFramesByPath.set(relativePath, requestAnimationFrame(reveal)); + const guardScrollTarget = (line: number) => { + let framesLeft = REVEAL_GUARD_FRAMES; + let guardFrameId: number | null = null; + const cancelGuard = () => { + if (guardFrameId !== null) { + cancelAnimationFrame(guardFrameId); + guardFrameId = null; + } + scrollContainer.removeEventListener("wheel", cancelGuard); + scrollContainer.removeEventListener("touchstart", cancelGuard); + scrollContainer.removeEventListener("pointerdown", cancelGuard, true); + window.removeEventListener("keydown", cancelGuard, true); + if (state.cancelGuard === cancelGuard) state.cancelGuard = null; + }; + scrollContainer.addEventListener("wheel", cancelGuard, { passive: true }); + scrollContainer.addEventListener("touchstart", cancelGuard, { passive: true }); + // Pierre stops gutter pointer events from bubbling. Listen in capture + // so starting a comment cancels the reveal guard before the row expands. + scrollContainer.addEventListener("pointerdown", cancelGuard, { + passive: true, + capture: true, + }); + window.addEventListener("keydown", cancelGuard, true); + const holdTarget = () => { + guardFrameId = null; + framesLeft -= 1; + if (framesLeft <= 0 || !scrollContainer.isConnected) { + cancelGuard(); + return; + } + const targetTop = resolveScrollTarget(line); + if ( + targetTop !== null && + Math.abs(scrollContainer.scrollTop - targetTop) > REVEAL_GUARD_TOLERANCE_PX + ) { + scrollContainer.scrollTop = targetTop; + } + guardFrameId = requestAnimationFrame(holdTarget); + }; + guardFrameId = requestAnimationFrame(holdTarget); + state.cancelGuard = cancelGuard; + }; + + const scheduleReveal = (attempt: number) => { + state.frameId = requestAnimationFrame(() => { + state.frameId = null; + if (state.latestRequestId !== revealRequestId || !fileContainer.isConnected) { + return; + } + + // Contents and line metrics can lag the first post-render on fresh + // mounts; clamping against missing contents would scroll to line 1 + // and wrongly mark the request handled. + const currentContents = instance.file?.contents; + const line = + currentContents === undefined ? null : clampFileLine(currentContents, revealLine); + const targetTop = line === null ? null : resolveScrollTarget(line); + if (line === null || targetTop === null) { + if (attempt < REVEAL_MAX_ATTEMPTS) scheduleReveal(attempt + 1); + return; + } + updateFileLinkReveal(fileContainer, line); + + scrollContainer.scrollTop = targetTop; + state.handledRequestId = revealRequestId; + guardScrollTarget(line); + }); + }; + + scheduleReveal(0); }, - [ - handledRequestIdsByPath, - latestRequestIdsByPath, - pendingFramesByPath, - relativePath, - revealLine, - revealRequestId, - ], + [revealStatesByPath, relativePath, revealLine, revealRequestId], ); } @@ -963,6 +1053,8 @@ export default function FilePreviewPanel({ environmentId={environmentId} cwd={cwd} projectName={projectName} + selectedPath={relativePath} + selectedPathRevealId={revealRequestId} onOpenFile={onOpenFile} /> diff --git a/apps/web/src/components/files/projectFilesQueryState.ts b/apps/web/src/components/files/projectFilesQueryState.ts index 0d3fb8dd941..d165c1d1a7a 100644 --- a/apps/web/src/components/files/projectFilesQueryState.ts +++ b/apps/web/src/components/files/projectFilesQueryState.ts @@ -11,6 +11,7 @@ import { useCallback } from "react"; import { appAtomRegistry } from "~/rpc/atomRegistry"; import { projectEnvironment } from "~/state/projects"; +import { useProjectPathSearch } from "~/state/queries"; import { executeAtomQuery } from "@t3tools/client-runtime/state/runtime"; const EMPTY_PROJECT_FILE_PATH = ""; @@ -136,6 +137,32 @@ export function useProjectEntriesQuery( }; } +/** + * Backing query for the project file picker: a debounced, bounded, file-only + * server search. An empty query is a valid request — the index answers it + * with frecency-ordered files, so the picker's initial view is recent files + * without transferring the full workspace listing. `matchedQuery` is the + * query the returned entries were computed for, so the caller can highlight + * against results instead of half-typed input. + */ +export function useProjectFilePickerQuery( + environmentId: EnvironmentId, + cwd: string, + query: string, + limit: number, +) { + const search = useProjectPathSearch({ environmentId, cwd, query, kind: "file" }, limit, { + allowEmptyQuery: true, + }); + + return { + entries: search.isPending ? [] : search.entries, + error: search.error, + isPending: search.isPending, + matchedQuery: search.searchedQuery, + }; +} + export function useProjectFileQuery( environmentId: EnvironmentId, cwd: string, diff --git a/apps/web/src/state/projects.ts b/apps/web/src/state/projects.ts index 7a879988328..d4e1098a364 100644 --- a/apps/web/src/state/projects.ts +++ b/apps/web/src/state/projects.ts @@ -1,11 +1,24 @@ import { createEnvironmentProjectAtoms } from "@t3tools/client-runtime/state/projects"; import { createProjectEnvironmentAtoms } from "@t3tools/client-runtime/state/projects"; +import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime"; +import { WS_METHODS } from "@t3tools/contracts"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; import { environmentSnapshotAtom } from "./shell"; export const projectEnvironment = createProjectEnvironmentAtoms(connectionAtomRuntime); +/** + * Web-only: project content search backs the ⇧⌘F dialog, which has no mobile + * surface, so the atom family lives here instead of the shared client-runtime + * project atoms consumed by the mobile app. + */ +export const projectContentSearch = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:projects:search-contents", + tag: WS_METHODS.projectsSearchContents, + staleTimeMs: 5_000, + idleTtlMs: 60_000, +}); export const environmentProjects = createEnvironmentProjectAtoms({ catalogValueAtom: environmentCatalog.catalogValueAtom, snapshotAtom: environmentSnapshotAtom, diff --git a/apps/web/src/state/queries.ts b/apps/web/src/state/queries.ts index a9564c2fd64..2a095b8f584 100644 --- a/apps/web/src/state/queries.ts +++ b/apps/web/src/state/queries.ts @@ -12,6 +12,8 @@ import { type VcsRefTarget } from "@t3tools/client-runtime/state/vcs"; import type { EnvironmentId, OrchestrationThread, + ProjectContentMatch, + ProjectEntryKind, ThreadId, VcsListRefsResult, VcsRef, @@ -24,16 +26,19 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { appAtomRegistry } from "../rpc/atomRegistry"; import { orchestrationEnvironment } from "./orchestration"; import { isPaginatedBranchesNextPagePending } from "./paginatedBranches"; -import { projectEnvironment } from "./projects"; +import { projectContentSearch, projectEnvironment } from "./projects"; import { useEnvironmentQuery } from "./query"; import { useEnvironmentThread } from "./threads"; import { vcsEnvironment } from "./vcs"; -const COMPOSER_PATH_SEARCH_DEBOUNCE_MS = 120; +const PROJECT_PATH_SEARCH_DEBOUNCE_MS = 120; const COMPOSER_PATH_SEARCH_LIMIT = 80; +const PROJECT_CONTENT_SEARCH_DEBOUNCE_MS = 120; +const PROJECT_CONTENT_SEARCH_LIMIT = 500; const THREAD_SEARCH_DEBOUNCE_MS = 200; const VCS_REF_LIST_LIMIT = 100; const EMPTY_REFS: ReadonlyArray = []; +const EMPTY_CONTENT_MATCHES: ReadonlyArray = []; const INITIAL_BRANCH_CURSORS = [undefined] as const; const EMPTY_THREAD_SEARCH_MATCHES: ReadonlyArray = Object.freeze([]); const EMPTY_THREAD_SEARCH_ATOM = Atom.make({ @@ -229,26 +234,50 @@ export function usePaginatedBranches(target: VcsRefTarget) { }; } -export function useComposerPathSearch(target: ComposerPathSearchTarget) { +type ProjectPathSearchTarget = ComposerPathSearchTarget & { + readonly kind?: ProjectEntryKind | undefined; +}; + +export function areProjectPathSearchTargetsEqual( + left: ProjectPathSearchTarget, + right: ProjectPathSearchTarget, +): boolean { + return ( + left.environmentId === right.environmentId && + left.cwd === right.cwd && + left.query === right.query && + left.kind === right.kind + ); +} + +export function useProjectPathSearch( + target: ProjectPathSearchTarget, + limit: number, + options?: { readonly allowEmptyQuery?: boolean }, +) { + const allowEmptyQuery = options?.allowEmptyQuery === true; const normalizedTarget = useMemo( () => ({ environmentId: target.environmentId, cwd: target.cwd, - query: target.query?.trim() ?? "", + query: target.query == null ? null : target.query.trim(), + kind: target.kind, }), - [target.cwd, target.environmentId, target.query], + [target.cwd, target.environmentId, target.kind, target.query], ); - const debouncedTarget = useDebouncedValue(normalizedTarget, COMPOSER_PATH_SEARCH_DEBOUNCE_MS); + const debouncedTarget = useDebouncedValue(normalizedTarget, PROJECT_PATH_SEARCH_DEBOUNCE_MS); const result = useEnvironmentQuery( debouncedTarget.environmentId !== null && debouncedTarget.cwd !== null && - debouncedTarget.query.length > 0 + debouncedTarget.query !== null && + (allowEmptyQuery || debouncedTarget.query.length > 0) ? projectEnvironment.searchEntries({ environmentId: debouncedTarget.environmentId, input: { cwd: debouncedTarget.cwd, query: debouncedTarget.query, - limit: COMPOSER_PATH_SEARCH_LIMIT, + limit, + ...(debouncedTarget.kind ? { kind: debouncedTarget.kind } : {}), }, }) : null, @@ -257,11 +286,61 @@ export function useComposerPathSearch(target: ComposerPathSearchTarget) { return { entries: result.data?.entries ?? [], error: result.error, - isPending: normalizedTarget.query !== debouncedTarget.query || result.isPending, + isPending: + !areProjectPathSearchTargetsEqual(normalizedTarget, debouncedTarget) || result.isPending, + searchedQuery: debouncedTarget.query ?? "", refresh: result.refresh, }; } +export function useComposerPathSearch(target: ComposerPathSearchTarget) { + return useProjectPathSearch(target, COMPOSER_PATH_SEARCH_LIMIT); +} + +interface ProjectContentSearchTarget { + readonly environmentId: EnvironmentId | null; + readonly cwd: string | null; + readonly query: string; + readonly caseSensitive: boolean; + readonly wholeWord: boolean; + readonly useRegex: boolean; +} + +export function useProjectContentSearch(target: ProjectContentSearchTarget) { + // Whitespace is significant in content queries; trimming is only used to + // decide whether the input is blank. + const query = target.query; + const hasQuery = query.trim().length > 0; + const debouncedQuery = useDebouncedValue(query, PROJECT_CONTENT_SEARCH_DEBOUNCE_MS); + const result = useEnvironmentQuery( + target.environmentId !== null && + target.cwd !== null && + hasQuery && + debouncedQuery.trim().length > 0 + ? projectContentSearch({ + environmentId: target.environmentId, + input: { + cwd: target.cwd, + query: debouncedQuery, + limit: PROJECT_CONTENT_SEARCH_LIMIT, + caseSensitive: target.caseSensitive, + wholeWord: target.wholeWord, + useRegex: target.useRegex, + }, + }) + : null, + ); + + return { + matches: result.data?.matches ?? EMPTY_CONTENT_MATCHES, + error: result.error, + isPending: hasQuery && (query !== debouncedQuery || result.isPending), + hasQuery, + truncated: result.data?.truncated ?? false, + invalidRegex: target.useRegex && result.data?.regexFallbackError !== undefined, + }; +} + export function useCheckpointDiff( target: CheckpointDiffTarget, options?: { readonly enabled?: boolean }, diff --git a/packages/contracts/src/baseSchemas.ts b/packages/contracts/src/baseSchemas.ts index a8fa565cef4..9a63f22c9ef 100644 --- a/packages/contracts/src/baseSchemas.ts +++ b/packages/contracts/src/baseSchemas.ts @@ -1,4 +1,5 @@ import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as SchemaTransformation from "effect/SchemaTransformation"; @@ -20,6 +21,30 @@ export const PortSchema = Schema.Int.check(Schema.isBetween({ minimum: 1, maximu export const IsoDateTime = Schema.String; export type IsoDateTime = typeof IsoDateTime.Type; +/** + * Wire codec for server→client arrays whose element unions grow over time + * (new literal members, new struct variants). Decoding drops elements the + * current build cannot decode instead of failing the whole payload — a client + * has to keep decoding configs sent by servers newer than itself, and + * rejecting the payload would take down the connection over data the client + * couldn't act on anyway. Encoding is the plain array encoding. + */ +export const ForwardCompatibleArray = (element: Element) => { + const decodeElement = Schema.decodeUnknownOption(element as never); + return Schema.Array(Schema.Unknown).pipe( + Schema.decodeTo( + Schema.Array(element), + SchemaTransformation.transform, ReadonlyArray>({ + decode: (values) => + values.filter((value) => Option.isSome(decodeElement(value))) as ReadonlyArray< + Element["Encoded"] + >, + encode: (values) => values, + }), + ), + ); +}; + /** * Construct a branded identifier. Enforces non-empty trimmed strings */ diff --git a/packages/contracts/src/keybindings.test.ts b/packages/contracts/src/keybindings.test.ts index 33ecd38039f..ec8c839be95 100644 --- a/packages/contracts/src/keybindings.test.ts +++ b/packages/contracts/src/keybindings.test.ts @@ -20,6 +20,7 @@ const decode = ( >; const decodeResolvedRule = Schema.decodeUnknownEffect(ResolvedKeybindingRule as never); +const encodeResolvedKeybindings = Schema.encodeEffect(ResolvedKeybindingsConfig); it.effect("parses keybinding rules", () => Effect.gen(function* () { @@ -59,6 +60,18 @@ it.effect("parses keybinding rules", () => }); assert.strictEqual(parsedCommandPalette.command, "commandPalette.toggle"); + const parsedFilePicker = yield* decode(KeybindingRule, { + key: "mod+p", + command: "filePicker.toggle", + }); + assert.strictEqual(parsedFilePicker.command, "filePicker.toggle"); + + const parsedProjectSearch = yield* decode(KeybindingRule, { + key: "mod+shift+f", + command: "projectSearch.toggle", + }); + assert.strictEqual(parsedProjectSearch.command, "projectSearch.toggle"); + const parsedLocal = yield* decode(KeybindingRule, { key: "mod+shift+n", command: "chat.newLocal", @@ -173,6 +186,70 @@ it.effect("parses resolved keybindings arrays", () => }), ); +const shortcut = { + key: "p", + metaKey: false, + ctrlKey: false, + shiftKey: false, + altKey: false, + modKey: true, +}; + +it.effect("drops resolved rules with commands this build does not know", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + { command: "terminal.toggle", shortcut }, + { command: "someFuture.toggle", shortcut }, + { command: "filePicker.toggle", shortcut }, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["terminal.toggle", "filePicker.toggle"], + ); + }), +); + +it.effect("drops resolved rules with unknown when-node types", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + { + command: "terminal.toggle", + shortcut, + whenAst: { type: "xor", left: 1, right: 2 }, + }, + { command: "terminal.split", shortcut }, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["terminal.split"], + ); + }), +); + +it.effect("drops malformed resolved rule entries", () => + Effect.gen(function* () { + const parsed = yield* decode(ResolvedKeybindingsConfig, [ + "garbage", + { command: "terminal.toggle", shortcut }, + null, + ]); + assert.deepEqual( + parsed.map((rule) => rule.command), + ["terminal.toggle"], + ); + }), +); + +it.effect("encodes resolved keybindings to the plain wire shape", () => + Effect.gen(function* () { + const rules = [{ command: "terminal.toggle" as const, shortcut }]; + const encoded = yield* encodeResolvedKeybindings(rules); + assert.deepEqual(encoded, rules); + const roundTripped = yield* decode(ResolvedKeybindingsConfig, encoded); + assert.deepEqual(roundTripped, rules); + }), +); + it.effect("drops unknown fields in resolved keybinding rules", () => decodeResolvedRule({ command: "terminal.toggle", diff --git a/packages/contracts/src/keybindings.ts b/packages/contracts/src/keybindings.ts index f000648d236..eba8f8ef170 100644 --- a/packages/contracts/src/keybindings.ts +++ b/packages/contracts/src/keybindings.ts @@ -1,5 +1,5 @@ import * as Schema from "effect/Schema"; -import { TrimmedString } from "./baseSchemas.ts"; +import { ForwardCompatibleArray, TrimmedString } from "./baseSchemas.ts"; export const MAX_KEYBINDING_VALUE_LENGTH = 64; export const MAX_KEYBINDING_WHEN_LENGTH = 256; @@ -63,6 +63,8 @@ const STATIC_KEYBINDING_COMMANDS = [ "preview.zoomOut", "preview.resetZoom", "commandPalette.toggle", + "filePicker.toggle", + "projectSearch.toggle", "composer.stash", "board.open", "chat.new", @@ -154,7 +156,14 @@ export const ResolvedKeybindingRule = Schema.Struct({ }).annotate({ parseOptions: { onExcessProperty: "ignore" } }); export type ResolvedKeybindingRule = typeof ResolvedKeybindingRule.Type; -export const ResolvedKeybindingsConfig = Schema.Array(ResolvedKeybindingRule).check( +/** + * The command set grows over time, so a client may receive rules it cannot + * represent (a command or `when` node added after that client shipped). + * Decoding drops those rules instead of failing the whole payload — + * rejecting the config would take down the connection over a shortcut the + * client couldn't dispatch anyway. + */ +export const ResolvedKeybindingsConfig = ForwardCompatibleArray(ResolvedKeybindingRule).check( Schema.isMaxLength(MAX_KEYBINDINGS_COUNT), ); export type ResolvedKeybindingsConfig = typeof ResolvedKeybindingsConfig.Type; diff --git a/packages/contracts/src/project.test.ts b/packages/contracts/src/project.test.ts index ea9d5a90e7c..8e6771cba88 100644 --- a/packages/contracts/src/project.test.ts +++ b/packages/contracts/src/project.test.ts @@ -3,10 +3,40 @@ import { describe, expect, it } from "vite-plus/test"; import { ProjectReadFileError, + ProjectSearchContentsError, + ProjectSearchContentsInput, ProjectSearchEntriesError, + ProjectSearchEntriesInput, ProjectWriteFileError, } from "./project.ts"; +const decodeSearchEntriesInput = Schema.decodeUnknownSync(ProjectSearchEntriesInput); +const decodeSearchContentsInput = Schema.decodeUnknownSync(ProjectSearchContentsInput); + +describe("project search inputs", () => { + it("allows an empty entries query for bounded frecency browsing", () => { + const decoded = decodeSearchEntriesInput({ + cwd: "/workspace", + query: " ", + limit: 10, + kind: "file", + }); + expect(decoded.query).toBe(""); + }); + + it("preserves whitespace in content search queries", () => { + const decoded = decodeSearchContentsInput({ + cwd: "/workspace", + query: " foo ", + limit: 10, + caseSensitive: false, + wholeWord: false, + useRegex: false, + }); + expect(decoded.query).toBe(" foo "); + }); +}); + describe("project RPC errors", () => { it("derives stable messages from structured request context while retaining causes", () => { const cause = new Error("sensitive platform detail"); @@ -39,6 +69,18 @@ describe("project RPC errors", () => { expect(readError.message).toBe("Failed to read workspace file 'src/index.ts' in '/workspace'."); expect(readError.message).not.toContain(cause.message); expect(readError.cause).toBe(cause); + + const contentSearchError = new ProjectSearchContentsError({ + cwd: "/workspace", + queryLength: "authorization: Bearer secret-token".length, + limit: 100, + failure: "search_index_search_failed", + cause, + }); + expect(contentSearchError.message).toBe("Failed to search workspace contents in '/workspace'."); + expect(contentSearchError.message).not.toContain(cause.message); + expect(contentSearchError).not.toHaveProperty("query"); + expect(contentSearchError.cause).toBe(cause); }); it("decodes legacy message-only errors during rolling upgrades", () => { diff --git a/packages/contracts/src/project.ts b/packages/contracts/src/project.ts index d59b9770ad3..a1b11df73b2 100644 --- a/packages/contracts/src/project.ts +++ b/packages/contracts/src/project.ts @@ -1,19 +1,29 @@ import * as Schema from "effect/Schema"; -import { NonNegativeInt, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; +import { + NonNegativeInt, + PositiveInt, + TrimmedNonEmptyString, + TrimmedString, +} from "./baseSchemas.ts"; const PROJECT_SEARCH_ENTRIES_MAX_LIMIT = 200; +const PROJECT_SEARCH_CONTENTS_MAX_LIMIT = 500; const PROJECT_WRITE_FILE_PATH_MAX_LENGTH = 512; const PROJECT_READ_FILE_PATH_MAX_LENGTH = 512; +export const ProjectEntryKind = Schema.Literals(["file", "directory"]); +export type ProjectEntryKind = typeof ProjectEntryKind.Type; + export const ProjectSearchEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, - query: TrimmedNonEmptyString.check(Schema.isMaxLength(256)), + // An empty query is a bounded browse: the index returns frecency-ordered + // entries, which the file picker uses for its initial results. + query: TrimmedString.check(Schema.isMaxLength(256)), limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_ENTRIES_MAX_LIMIT)), + kind: Schema.optional(ProjectEntryKind), }); export type ProjectSearchEntriesInput = typeof ProjectSearchEntriesInput.Type; -const ProjectEntryKind = Schema.Literals(["file", "directory"]); - export const ProjectEntry = Schema.Struct({ path: TrimmedNonEmptyString, kind: ProjectEntryKind, @@ -26,6 +36,39 @@ export const ProjectSearchEntriesResult = Schema.Struct({ }); export type ProjectSearchEntriesResult = typeof ProjectSearchEntriesResult.Type; +export const ProjectSearchContentsInput = Schema.Struct({ + cwd: TrimmedNonEmptyString, + // Whitespace is significant in content queries (" foo", regex trailing + // spaces), so the query is deliberately not trimmed on the wire. + query: Schema.String.check(Schema.isNonEmpty(), Schema.isMaxLength(256)), + limit: PositiveInt.check(Schema.isLessThanOrEqualTo(PROJECT_SEARCH_CONTENTS_MAX_LIMIT)), + caseSensitive: Schema.Boolean, + wholeWord: Schema.Boolean, + useRegex: Schema.Boolean, +}); +export type ProjectSearchContentsInput = typeof ProjectSearchContentsInput.Type; + +export const ProjectContentMatchRange = Schema.Struct({ + start: NonNegativeInt, + end: NonNegativeInt, +}); +export type ProjectContentMatchRange = typeof ProjectContentMatchRange.Type; + +export const ProjectContentMatch = Schema.Struct({ + path: TrimmedNonEmptyString, + lineNumber: PositiveInt, + lineContent: Schema.String, + matchRanges: Schema.Array(ProjectContentMatchRange), +}); +export type ProjectContentMatch = typeof ProjectContentMatch.Type; + +export const ProjectSearchContentsResult = Schema.Struct({ + matches: Schema.Array(ProjectContentMatch), + truncated: Schema.Boolean, + regexFallbackError: Schema.optional(Schema.String), +}); +export type ProjectSearchContentsResult = typeof ProjectSearchContentsResult.Type; + export const ProjectListEntriesInput = Schema.Struct({ cwd: TrimmedNonEmptyString, }); @@ -94,6 +137,37 @@ export class ProjectSearchEntriesError extends Schema.TaggedErrorClass()( + "ProjectSearchContentsError", + { + cwd: Schema.optional(TrimmedNonEmptyString), + queryLength: Schema.optional(NonNegativeInt), + limit: Schema.optional(PositiveInt), + failure: Schema.optional(ProjectEntriesFailure), + normalizedCwd: Schema.optional(TrimmedNonEmptyString), + timeout: Schema.optional(TrimmedNonEmptyString), + detail: Schema.optional(TrimmedNonEmptyString), + message: TrimmedNonEmptyString, + cause: Schema.optional(Schema.Defect()), + }, +) { + // @effect-diagnostics-next-line overriddenSchemaConstructor:off + constructor( + props: ProjectEntriesFailureContext & { + readonly cwd: string; + readonly queryLength: number; + readonly limit: number; + }, + ) { + super({ + ...props, + message: + decodedProjectErrorMessage(props) ?? + `Failed to search workspace contents in '${props.cwd}'.`, + } as any); + } +} + export class ProjectListEntriesError extends Schema.TaggedErrorClass()( "ProjectListEntriesError", { From 412941523afe0364b123501e2700247a3c433bd3 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:33:44 +0200 Subject: [PATCH 4/8] fix(identity): drop obsolete requestSingleInstanceLock test mock ElectronApp no longer exposes requestSingleInstanceLock (Clerk bridge owns the single-instance lock). Remove it from DesktopUpdates test stubs so desktop typecheck is green on the identity overlay. --- apps/desktop/src/updates/DesktopUpdates.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 533a6cba862..666f32556bf 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -130,7 +130,6 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { setName: () => Effect.void, setAboutPanelOptions: () => Effect.void, setAppUserModelId: () => Effect.void, - requestSingleInstanceLock: Effect.succeed(true), isDefaultProtocolClient: () => Effect.succeed(false), setAsDefaultProtocolClient: () => Effect.succeed(false), setDesktopName: () => Effect.void, From a458588a16bb5e574110187e2e4a22afc767d8dd Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:35:41 +0200 Subject: [PATCH 5/8] fix(identity): restore project content search under identity layer The identity reapply dropped projects.searchContents contracts, RPC wiring, workspace search implementation, and related auth scopes while keeping the web UI that depends on them. Re-add the shared product surface without removing identity RPCs so typecheck and search stay green. --- apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/workspace/WorkspaceEntries.test.ts | 344 +++++++++++++++++- apps/server/src/workspace/WorkspaceEntries.ts | 105 ++++-- .../workspace/WorkspaceSearchIndex.test.ts | 143 +++++++- .../src/workspace/WorkspaceSearchIndex.ts | 315 ++++++++++++++-- apps/server/src/ws.ts | 18 + packages/contracts/src/rpc.ts | 11 + 7 files changed, 858 insertions(+), 79 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 276ed32098c..90cc4976c62 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -61,6 +61,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.sourceControlPublishRepository]: AuthOrchestrationOperateScope, [WS_METHODS.projectsListEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsReadFile]: AuthOrchestrationReadScope, + [WS_METHODS.projectsSearchContents]: AuthOrchestrationReadScope, [WS_METHODS.projectsSearchEntries]: AuthOrchestrationReadScope, [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/workspace/WorkspaceEntries.test.ts b/apps/server/src/workspace/WorkspaceEntries.test.ts index a08350ed959..d47aaaec826 100644 --- a/apps/server/src/workspace/WorkspaceEntries.test.ts +++ b/apps/server/src/workspace/WorkspaceEntries.test.ts @@ -72,7 +72,12 @@ const git = (cwd: string, args: ReadonlyArray, env?: NodeJS.ProcessEnv) return result.stdout.trim(); }); -const searchWorkspaceEntries = (input: { cwd: string; query: string; limit: number }) => +const searchWorkspaceEntries = (input: { + cwd: string; + query: string; + limit: number; + kind?: "file" | "directory"; +}) => Effect.gen(function* () { const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; return yield* workspaceEntries.search(input); @@ -200,6 +205,62 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { }), ); + it.effect("applies the file filter before limiting search results", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-file-limit-" }); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, "src/internal.ts"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "src", + limit: 1, + kind: "file", + }); + + expect(result.entries).toEqual([{ path: "src/index.ts", kind: "file" }]); + expect(result.truncated).toBe(true); + }), + ); + + it.effect("answers an empty file-filtered query with a bounded file listing", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-empty-query-" }); + yield* writeTextFile(cwd, "src/index.ts"); + yield* writeTextFile(cwd, "README.md"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "", + limit: 10, + kind: "file", + }); + + const paths = result.entries.map((entry) => entry.path); + expect(paths).toHaveLength(2); + expect(paths).toContain("src/index.ts"); + expect(paths).toContain("README.md"); + expect(result.entries.every((entry) => entry.kind === "file")).toBe(true); + }), + ); + + it.effect("returns only directories for the directory filter", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-directory-filter-" }); + yield* writeTextFile(cwd, "src/index.ts"); + + const result = yield* searchWorkspaceEntries({ + cwd, + query: "src", + limit: 10, + kind: "directory", + }); + + expect(result.entries).toEqual([{ path: "src", kind: "directory" }]); + expect(result.truncated).toBe(false); + }), + ); + it.effect("excludes gitignored paths for git repositories", () => Effect.gen(function* () { const cwd = yield* makeTempDir({ prefix: "t3code-workspace-gitignore-", git: true }); @@ -292,6 +353,287 @@ it.layer(TestLayer, { excludeTestServices: true })("WorkspaceEntries", (it) => { ); }); + describe("searchContents", () => { + it.effect("returns content matches with file paths, line numbers, and ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-search-" }); + yield* writeTextFile( + cwd, + "src/shapes.ts", + "export const square = 4;\nexport const Square = 16;\nexport const squareSize = 8;\n", + ); + yield* writeTextFile(cwd, "src/other.ts", "const circle = true;\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "Square", + limit: 100, + caseSensitive: false, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches.map((match) => [match.path, match.lineNumber])).toEqual([ + ["src/shapes.ts", 1], + ["src/shapes.ts", 2], + ]); + expect(result.matches[0]?.matchRanges).toEqual([{ start: 13, end: 19 }]); + expect(result.truncated).toBe(false); + }), + ); + + it.effect("honors case sensitivity and gitignore rules", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-ignore-", git: true }); + yield* writeTextFile(cwd, ".gitignore", "ignored.txt\n"); + yield* writeTextFile(cwd, "src/keep.ts", "square\nSquare\n"); + yield* writeTextFile(cwd, "ignored.txt", "Square\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "Square", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ path: "src/keep.ts", lineNumber: 2 }); + }), + ); + + it.effect("filters whole-word matches by word boundaries without widening ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-whole-word-" }); + yield* writeTextFile(cwd, "src/words.ts", "note notes denote\nfootnote note\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "note", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + // "notes", "denote", and "footnote" are word-adjacent and excluded; + // ranges cover exactly the query, never boundary characters. + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [{ start: 0, end: 4 }], + }), + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 2, + matchRanges: [{ start: 9, end: 13 }], + }), + ]); + }), + ); + + it.effect("finds later whole-word matches in a file after rejected raw matches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-late-whole-word-" }); + yield* writeTextFile(cwd, "src/words.ts", `${"afoo\n".repeat(10)}foo\n`); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo", + limit: 1, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 11, + matchRanges: [{ start: 0, end: 3 }], + }), + ]); + }), + ); + + it.effect("treats astral-plane letters as whole word characters", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-astral-word-" }); + yield* writeTextFile(cwd, "src/words.ts", "𐐀foo foo foo𐐀\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result.matches).toEqual([ + expect.objectContaining({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [{ start: 6, end: 9 }], + }), + ]); + }), + ); + + it.effect("matches punctuation-edged whole-word queries including adjacent occurrences", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-punctuation-" }); + yield* writeTextFile(cwd, "src/words.ts", "-foo- -foo- -foo-\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "-foo-", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + // Consuming-boundary regex would swallow the separating spaces and + // drop the middle occurrence; boundary post-filtering keeps all three. + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [ + { start: 0, end: 5 }, + { start: 6, end: 11 }, + { start: 12, end: 17 }, + ], + }); + }), + ); + + it.effect("matches punctuation-edged regex queries as whole words", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-punctuation-" }); + yield* writeTextFile(cwd, "src/words.ts", "foo- foo-\nafoo-b\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo-", + limit: 100, + caseSensitive: true, + wholeWord: true, + useRegex: true, + }); + + // wholeWord + useRegex must not silently drop non-word-edged patterns + // like "foo-", and "afoo-" is excluded because 'a'/'f' are both word + // characters at the match's left edge. + expect(result.matches).toHaveLength(1); + expect(result.matches[0]).toMatchObject({ + path: "src/words.ts", + lineNumber: 1, + matchRanges: [ + { start: 0, end: 4 }, + { start: 5, end: 9 }, + ], + }); + }), + ); + + it.effect("caps matches per file so one dense file cannot fill the page", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-per-file-cap-" }); + yield* writeTextFile(cwd, "src/dense.ts", "needle\n".repeat(300)); + yield* writeTextFile(cwd, "src/other.ts", "needle\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "needle", + limit: 500, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + const byPath = new Map(); + for (const match of result.matches) { + byPath.set(match.path, (byPath.get(match.path) ?? 0) + 1); + } + expect(byPath.get("src/dense.ts")).toBe(100); + expect(byPath.get("src/other.ts")).toBe(1); + }), + ); + + it.effect("preserves regex escapes during case-insensitive searches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-regex-" }); + yield* writeTextFile(cwd, "src/shapes.ts", "Square\nsquare\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "\\SQUARE", + limit: 100, + caseSensitive: false, + wholeWord: false, + useRegex: true, + }); + + expect(result.matches.map((match) => match.lineNumber)).toEqual([1, 2]); + }), + ); + + it.effect("preserves invalid regex errors during case-insensitive searches", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-invalid-regex-" }); + yield* writeTextFile(cwd, "src/shapes.ts", "foobar\n"); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "foo)bar(", + limit: 100, + caseSensitive: false, + wholeWord: false, + useRegex: true, + }); + + expect(result.regexFallbackError).toBeDefined(); + expect(result.matches).toEqual([]); + }), + ); + + it.effect("maps multi-byte lines to string-indexed ranges", () => + Effect.gen(function* () { + const cwd = yield* makeTempDir({ prefix: "t3code-workspace-content-multibyte-" }); + yield* writeTextFile(cwd, "src/notes.ts", 'const label = "héllo wörld";\n'); + + const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; + const result = yield* workspaceEntries.searchContents({ + cwd, + query: "wörld", + limit: 100, + caseSensitive: true, + wholeWord: false, + useRegex: false, + }); + + expect(result.matches).toHaveLength(1); + const match = result.matches[0]!; + const range = match.matchRanges[0]!; + expect(match.lineContent.slice(range.start, range.end)).toBe("wörld"); + }), + ); + }); + describe("browse", () => { it.effect("returns matching directories and excludes files", () => Effect.gen(function* () { diff --git a/apps/server/src/workspace/WorkspaceEntries.ts b/apps/server/src/workspace/WorkspaceEntries.ts index 7501cbe0eab..bb2113dac37 100644 --- a/apps/server/src/workspace/WorkspaceEntries.ts +++ b/apps/server/src/workspace/WorkspaceEntries.ts @@ -14,11 +14,14 @@ import type { FilesystemBrowseResult, ProjectListEntriesInput, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesInput, ProjectSearchEntriesResult, } from "@t3tools/contracts"; import { HostProcessPlatform } from "@t3tools/shared/hostProcess"; import { isExplicitRelativePath, isWindowsAbsolutePath } from "@t3tools/shared/path"; +import { normalizeSearchQuery } from "@t3tools/shared/searchRanking"; import * as WorkspacePaths from "./WorkspacePaths.ts"; import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; @@ -93,6 +96,9 @@ export class WorkspaceEntries extends Context.Service< readonly search: ( input: ProjectSearchEntriesInput, ) => Effect.Effect; + readonly searchContents: ( + input: ProjectSearchContentsInput, + ) => Effect.Effect; readonly refresh: (cwd: string) => Effect.Effect; } >()("t3/workspace/WorkspaceEntries") {} @@ -148,33 +154,37 @@ export const make = Effect.gen(function* () { const normalizedCwd = yield* normalizeWorkspaceRoot(cwd).pipe( Effect.orElseSucceed(() => cwd), ); - if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, normalizedCwd))) { - return; - } - const recoverRefreshFailure = ( - cause: - | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed - | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut - | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, - ) => - Effect.gen(function* () { - yield* Effect.logWarning("Failed to refresh workspace search index", { - cwd, - cause, + for (const variant of WorkspaceSearchIndex.WORKSPACE_SEARCH_INDEX_VARIANTS) { + const indexKey = WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, variant); + if (!(yield* RcMap.has(workspaceSearchIndexes.rcMap, indexKey))) { + continue; + } + const recoverRefreshFailure = ( + cause: + | WorkspaceSearchIndex.WorkspaceSearchIndexCreateFailed + | WorkspaceSearchIndex.WorkspaceSearchIndexScanTimedOut + | WorkspaceSearchIndex.WorkspaceSearchIndexRefreshFailed, + ) => + Effect.gen(function* () { + yield* Effect.logWarning("Failed to refresh workspace search index", { + cwd, + variant, + cause, + }); + yield* workspaceSearchIndexes.invalidate(indexKey); }); - yield* workspaceSearchIndexes.invalidate(normalizedCwd); - }); - yield* Effect.gen(function* () { - const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - yield* searchIndex.refresh(); - }).pipe( - Effect.provide(workspaceSearchIndexes.get(normalizedCwd)), - Effect.catchTags({ - WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, - WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, - WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, - }), - ); + yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + yield* searchIndex.refresh(); + }).pipe( + Effect.provide(workspaceSearchIndexes.get(indexKey)), + Effect.catchTags({ + WorkspaceSearchIndexCreateFailed: recoverRefreshFailure, + WorkspaceSearchIndexScanTimedOut: recoverRefreshFailure, + WorkspaceSearchIndexRefreshFailed: recoverRefreshFailure, + }), + ); + } }, ); @@ -230,28 +240,55 @@ export const make = Effect.gen(function* () { const search: WorkspaceEntries["Service"]["search"] = Effect.fn("WorkspaceEntries.search")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); - const normalizedQuery = input.query - .trim() - .toLowerCase() - .replace(/^[@./]+/, ""); + const normalizedQuery = normalizeSearchQuery(input.query, { + trimLeadingPattern: /^[@./]+/, + }); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; - return yield* searchIndex.search(normalizedQuery, input.limit); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + return yield* searchIndex.search(normalizedQuery, input.limit, input.kind); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), + ), + ), + ); }, ); + const searchContents: WorkspaceEntries["Service"]["searchContents"] = Effect.fn( + "WorkspaceEntries.searchContents", + )(function* (input) { + const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); + return yield* Effect.gen(function* () { + const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; + return yield* searchIndex.searchContents(input); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "content"), + ), + ), + ); + }); + const list: WorkspaceEntries["Service"]["list"] = Effect.fn("WorkspaceEntries.list")( function* (input) { const normalizedCwd = yield* normalizeWorkspaceRoot(input.cwd); return yield* Effect.gen(function* () { const searchIndex = yield* WorkspaceSearchIndex.WorkspaceSearchIndex; return yield* searchIndex.list(); - }).pipe(Effect.provide(workspaceSearchIndexes.get(normalizedCwd))); + }).pipe( + Effect.provide( + workspaceSearchIndexes.get( + WorkspaceSearchIndex.workspaceSearchIndexKey(normalizedCwd, "paths"), + ), + ), + ); }, ); - return WorkspaceEntries.of({ browse, list, refresh, search }); + return WorkspaceEntries.of({ browse, list, refresh, search, searchContents }); }); export const layer = Layer.effect(WorkspaceEntries, make).pipe( diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts index 9b7ed4e2453..15572837030 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.test.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.test.ts @@ -1,4 +1,4 @@ -import { FileFinder } from "@ff-labs/fff-node"; +import { FileFinder, type GrepCursor, type GrepOptions, type GrepResult } from "@ff-labs/fff-node"; import { afterEach, expect, it } from "@effect/vitest"; import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; @@ -51,6 +51,41 @@ it.effect("keeps returned FileFinder creation diagnostics out of the cause chain }), ); +it.effect("waits for the full content index warmup before returning", () => + Effect.gen(function* () { + const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); + const finder = { + destroy: vi.fn(), + waitForIndexReady, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); + + expect(waitForIndexReady).toHaveBeenCalledWith(15_000); + }), +); + +it.effect("preserves a full-index warmup timeout as a structured error", () => + Effect.gen(function* () { + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const error = yield* Effect.flip( + Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), + ); + + expect(error).toMatchObject({ + _tag: "WorkspaceSearchIndexScanTimedOut", + cwd: "/workspace/project", + timeout: "15 seconds", + }); + }), +); + it.effect("preserves FileFinder destroy failures as structured defects", () => Effect.gen(function* () { const cause = new Error("native destroy failed"); @@ -58,7 +93,7 @@ it.effect("preserves FileFinder destroy failures as structured defects", () => destroy: vi.fn(() => { throw cause; }), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), } as unknown as FileFinder; vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); @@ -85,12 +120,16 @@ it.effect("preserves search and refresh failures with operation context", () => Effect.gen(function* () { const searchCause = new Error("native search failed"); const refreshCause = new Error("native scan failed"); + const contentSearchCause = new Error("native grep failed"); const finder = { destroy: vi.fn(), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => { throw searchCause; }), + grep: vi.fn(() => { + throw contentSearchCause; + }), scanFiles: vi.fn(() => { throw refreshCause; }), @@ -100,6 +139,15 @@ it.effect("preserves search and refresh failures with operation context", () => const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); const query = "authorization: Bearer secret-token"; const searchError = yield* Effect.flip(searchIndex.search(query, 3)); + const contentSearchError = yield* Effect.flip( + searchIndex.searchContents({ + query, + limit: 3, + caseSensitive: false, + wholeWord: false, + useRegex: false, + }), + ); const refreshError = yield* Effect.flip(searchIndex.refresh()); expect(searchError).toMatchObject({ @@ -112,6 +160,16 @@ it.effect("preserves search and refresh failures with operation context", () => }); expect(searchError).not.toHaveProperty("query"); expect(searchError.message).not.toMatch(/Bearer|secret-token/); + expect(contentSearchError).toMatchObject({ + _tag: "WorkspaceSearchIndexSearchFailed", + cwd: "/workspace/project", + queryLength: query.length, + pageSize: 3, + reason: "FileFinder.grep threw unexpectedly.", + cause: contentSearchCause, + }); + expect(contentSearchError).not.toHaveProperty("query"); + expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); expect(refreshError).toMatchObject({ _tag: "WorkspaceSearchIndexRefreshFailed", cwd: "/workspace/project", @@ -127,7 +185,7 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => Effect.gen(function* () { const finder = { destroy: vi.fn(), - isScanning: vi.fn(() => false), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), } as unknown as FileFinder; @@ -157,3 +215,80 @@ it.effect("keeps returned search diagnostics out of the cause chain", () => }), ), ); + +it.effect("continues whole-word searches after a filtered grep page", () => + Effect.scoped( + Effect.gen(function* () { + const nextCursor = { + __brand: "GrepCursor", + _offset: 1, + } as GrepCursor; + const grepResult = ( + lineContent: string, + matchRanges: Array<[number, number]>, + cursor: GrepCursor | null, + ): GrepResult => ({ + items: [ + { + relativePath: "src/words.ts", + fileName: "words.ts", + gitStatus: "unmodified", + size: lineContent.length, + modified: 0, + isBinary: false, + totalFrecencyScore: 0, + accessFrecencyScore: 0, + modificationFrecencyScore: 0, + lineNumber: 1, + col: 0, + byteOffset: 0, + lineContent, + matchRanges, + }, + ], + totalMatched: 1, + totalFilesSearched: 1, + totalFiles: 1, + filteredFileCount: 1, + nextCursor: cursor, + }); + const grep = vi.fn((_query: string, options?: GrepOptions) => + options?.cursor + ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } + : { + ok: true as const, + value: grepResult("needleSuffix", [[0, 6]], nextCursor), + }, + ); + const finder = { + destroy: vi.fn(), + waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), + grep, + } as unknown as FileFinder; + vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); + + const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); + const result = yield* searchIndex.searchContents({ + query: "needle", + limit: 1, + caseSensitive: true, + wholeWord: true, + useRegex: false, + }); + + expect(result).toEqual({ + matches: [ + { + path: "src/words.ts", + lineNumber: 1, + lineContent: "needle", + matchRanges: [{ start: 0, end: 6 }], + }, + ], + truncated: false, + }); + expect(grep).toHaveBeenCalledTimes(2); + expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor); + }), + ), +); diff --git a/apps/server/src/workspace/WorkspaceSearchIndex.ts b/apps/server/src/workspace/WorkspaceSearchIndex.ts index db4d46851e7..8bf36b7a80a 100644 --- a/apps/server/src/workspace/WorkspaceSearchIndex.ts +++ b/apps/server/src/workspace/WorkspaceSearchIndex.ts @@ -1,22 +1,36 @@ -import { FileFinder, type MixedItem, type MixedSearchResult } from "@ff-labs/fff-node"; +import { + type DirItem, + type DirSearchResult, + type FileItem, + FileFinder, + type GrepCursor, + type MixedItem, + type MixedSearchResult, + type Result, + type SearchResult, +} from "@ff-labs/fff-node"; import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as LayerMap from "effect/LayerMap"; -import * as Schedule from "effect/Schedule"; import * as Schema from "effect/Schema"; import type { ProjectEntry, + ProjectEntryKind, ProjectListEntriesResult, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesResult, } from "@t3tools/contracts"; const WORKSPACE_INDEX_MAX_ENTRIES = 25_000; const WORKSPACE_INDEX_PAGE_SIZE = WORKSPACE_INDEX_MAX_ENTRIES + 2; const WORKSPACE_INDEX_SCAN_TIMEOUT = "15 seconds"; +const WORKSPACE_INDEX_SCAN_TIMEOUT_MS = 15_000; const WORKSPACE_INDEX_IDLE_TTL = "15 minutes"; -const WORKSPACE_INDEX_SCAN_POLL_INTERVAL = "50 millis"; +const CONTENT_SEARCH_TIME_BUDGET_MS = 250; +const CONTENT_SEARCH_MAX_MATCHES_PER_FILE = 100; export class WorkspaceSearchIndexCreateFailed extends Schema.TaggedErrorClass()( "WorkspaceSearchIndexCreateFailed", @@ -96,7 +110,11 @@ export class WorkspaceSearchIndex extends Context.Service< readonly search: ( query: string, limit: number, + kind?: ProjectEntryKind, ) => Effect.Effect; + readonly searchContents: ( + input: Omit, + ) => Effect.Effect; readonly refresh: () => Effect.Effect< void, WorkspaceSearchIndexRefreshFailed | WorkspaceSearchIndexScanTimedOut @@ -129,6 +147,43 @@ function toProjectEntry(item: MixedItem): ProjectEntry | null { }; } +function toFileEntry(item: FileItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + return normalizedPath ? { path: normalizedPath, kind: "file" } : null; +} + +function toDirectoryEntry(item: DirItem): ProjectEntry | null { + const normalizedPath = trimDirectorySeparator(toPosixPath(item.relativePath)); + return normalizedPath ? { path: normalizedPath, kind: "directory" } : null; +} + +function mapFileSearchResult(result: SearchResult, limit: number): ProjectSearchEntriesResult { + return { + entries: result.items + .flatMap((item) => { + const entry = toFileEntry(item); + return entry ? [entry] : []; + }) + .slice(0, limit), + truncated: result.totalMatched > limit, + }; +} + +function mapDirectorySearchResult( + result: DirSearchResult, + limit: number, +): ProjectSearchEntriesResult { + const entries = result.items.flatMap((item) => { + const entry = toDirectoryEntry(item); + return entry ? [entry] : []; + }); + const rootDirectoryCount = result.items.some((item) => item.relativePath.length === 0) ? 1 : 0; + return { + entries: entries.slice(0, limit), + truncated: result.totalMatched - rootDirectoryCount > limit, + }; +} + function mapMixedSearchResult( result: MixedSearchResult, limit: number, @@ -155,6 +210,74 @@ function mapMixedSearchResult( }; } +const WORD_CHARACTER = /[\p{Letter}\p{Mark}\p{Number}_]/u; + +function codePointAt(line: string, index: number): string | undefined { + const codePoint = line.codePointAt(index); + return codePoint === undefined ? undefined : String.fromCodePoint(codePoint); +} + +function codePointBefore(line: string, index: number): string | undefined { + if (index <= 0) return undefined; + const previousCodeUnit = line.charCodeAt(index - 1); + const previousIndex = + previousCodeUnit >= 0xdc00 && previousCodeUnit <= 0xdfff ? index - 2 : index - 1; + return codePointAt(line, previousIndex); +} + +function buildContentSearchQuery(input: Omit): { + readonly searchQuery: string; + readonly regexMode: boolean; +} { + if (input.caseSensitive) { + return { searchQuery: input.query, regexMode: input.useRegex }; + } + // Plain mode relies on smart case: an all-lowercase needle matches + // case-insensitively. Regex mode needs an explicit inline flag instead. + return input.useRegex + ? { searchQuery: `(?i)${input.query}`, regexMode: true } + : { searchQuery: input.query.toLowerCase(), regexMode: false }; +} + +function mapContentMatchRanges( + line: string, + byteRanges: ReadonlyArray, +): Array<{ readonly start: number; readonly end: number }> { + const lineBytes = Buffer.from(line); + const toStringIndex = (byteOffset: number) => lineBytes.subarray(0, byteOffset).toString().length; + return byteRanges.map(([startByte, endByte]) => ({ + start: toStringIndex(startByte), + end: toStringIndex(endByte), + })); +} + +/** + * Whole-word filtering happens after the grep rather than by wrapping the + * pattern in boundary regex: consuming boundaries such as `(?:^|\W)` swallow + * the separator between adjacent matches and widen the reported ranges, and + * `\b` cannot match punctuation-edged queries at all. Matching VS Code, a + * match edge is a word boundary when it touches the line edge, the + * neighbouring character is not a word character, or the match's own edge + * character is not a word character. + */ +function isWholeWordRange( + line: string, + range: { readonly start: number; readonly end: number }, +): boolean { + if (range.end <= range.start) return false; + const isWord = (character: string | undefined) => + character !== undefined && WORD_CHARACTER.test(character); + const leftIsBoundary = + range.start === 0 || + !isWord(codePointBefore(line, range.start)) || + !isWord(codePointAt(line, range.start)); + const rightIsBoundary = + range.end >= line.length || + !isWord(codePointAt(line, range.end)) || + !isWord(codePointBefore(line, range.end)); + return leftIsBoundary && rightIsBoundary; +} + function withDirectoryAncestors(entries: ReadonlyArray): ProjectEntry[] { const entryByPath = new Map(entries.map((entry) => [entry.path, entry])); for (const entry of entries) { @@ -169,13 +292,19 @@ function withDirectoryAncestors(entries: ReadonlyArray): ProjectEn return [...entryByPath.values()]; } -const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (cwd: string) { +const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* ( + cwd: string, + variant: WorkspaceSearchIndexVariant, +) { const result = yield* Effect.try({ try: () => FileFinder.create({ basePath: cwd, disableMmapCache: true, - disableContentIndexing: true, + // Content indexing costs scan CPU and memory, so only the on-demand + // content-search index pays for it; path-only consumers (file tree, + // composer path search, file picker) keep the lightweight index. + disableContentIndexing: variant !== "content", aiMode: false, enableFsRootScanning: true, enableHomeDirScanning: true, @@ -194,53 +323,65 @@ const createFinder = Effect.fn("WorkspaceSearchIndex.createFinder")(function* (c }); }); -const waitForScan = (cwd: string, finder: FileFinder, onFailure: (cause: unknown) => E) => - Effect.try({ - try: () => finder.isScanning(), - catch: onFailure, - }).pipe( - Effect.repeat({ - while: (scanning) => scanning, - schedule: Schedule.spaced(WORKSPACE_INDEX_SCAN_POLL_INTERVAL), - }), - Effect.timeoutOrElse({ - duration: WORKSPACE_INDEX_SCAN_TIMEOUT, - orElse: () => - new WorkspaceSearchIndexScanTimedOut({ cwd, timeout: WORKSPACE_INDEX_SCAN_TIMEOUT }), - }), - Effect.withSpan("WorkspaceSearchIndex.waitForScan"), - ); +const waitForIndexReady = Effect.fn("WorkspaceSearchIndex.waitForIndexReady")(function* ( + cwd: string, + finder: FileFinder, + onFailure: (input: { readonly reason: string; readonly cause?: unknown }) => E, +): Effect.fn.Return { + const result = yield* Effect.tryPromise({ + try: () => finder.waitForIndexReady(WORKSPACE_INDEX_SCAN_TIMEOUT_MS), + catch: (cause) => + onFailure({ + reason: "FileFinder.waitForIndexReady rejected unexpectedly.", + cause, + }), + }); + if (!result.ok) { + return yield* Effect.fail(onFailure({ reason: result.error })); + } + if (!result.value) { + return yield* new WorkspaceSearchIndexScanTimedOut({ + cwd, + timeout: WORKSPACE_INDEX_SCAN_TIMEOUT, + }); + } +}); -export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: string) { - const finder = yield* Effect.acquireRelease(createFinder(cwd), (finder) => +export const make = Effect.fn("WorkspaceSearchIndex.make")(function* ( + cwd: string, + variant: WorkspaceSearchIndexVariant = "paths", +) { + const finder = yield* Effect.acquireRelease(createFinder(cwd, variant), (finder) => Effect.try({ try: () => finder.destroy(), catch: (cause) => new WorkspaceSearchIndexDestroyFailed({ cwd, cause }), }).pipe(Effect.orDie), ); - yield* waitForScan( + yield* waitForIndexReady( cwd, finder, - (cause) => + ({ reason, cause }) => new WorkspaceSearchIndexCreateFailed({ cwd, - reason: "FileFinder.isScanning threw while creating the index.", + reason, cause, }), ); - const runMixedSearch = Effect.fn("WorkspaceSearchIndex.runMixedSearch")(function* ( + const runSearch = Effect.fn("WorkspaceSearchIndex.runSearch")(function*
( query: string, pageSize: number, - ) { + operation: "directorySearch" | "fileSearch" | "grep" | "mixedSearch", + execute: () => Result, + ): Effect.fn.Return { const result = yield* Effect.try({ - try: () => finder.mixedSearch(query, { pageSize }), + try: execute, catch: (cause) => new WorkspaceSearchIndexSearchFailed({ cwd, queryLength: query.length, pageSize, - reason: "FileFinder.mixedSearch threw unexpectedly.", + reason: `FileFinder.${operation} threw unexpectedly.`, cause, }), }); @@ -273,13 +414,13 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin reason: result.error, }); } - yield* waitForScan( + yield* waitForIndexReady( cwd, finder, - (cause) => + ({ reason, cause }) => new WorkspaceSearchIndexRefreshFailed({ cwd, - reason: "FileFinder.isScanning threw while refreshing the index.", + reason, cause, }), ); @@ -287,7 +428,9 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const list: WorkspaceSearchIndex["Service"]["list"] = Effect.fn("WorkspaceSearchIndex.list")( function* () { - const result = yield* runMixedSearch("", WORKSPACE_INDEX_PAGE_SIZE); + const result = yield* runSearch("", WORKSPACE_INDEX_PAGE_SIZE, "mixedSearch", () => + finder.mixedSearch("", { pageSize: WORKSPACE_INDEX_PAGE_SIZE }), + ); const mapped = mapMixedSearchResult(result, WORKSPACE_INDEX_MAX_ENTRIES); const sortedEntries = withDirectoryAncestors(mapped.entries).toSorted((left, right) => left.path.localeCompare(right.path), @@ -302,20 +445,112 @@ export const make = Effect.fn("WorkspaceSearchIndex.make")(function* (cwd: strin const search: WorkspaceSearchIndex["Service"]["search"] = Effect.fn( "WorkspaceSearchIndex.search", - )(function* (query, limit) { - const result = yield* runMixedSearch(query, Math.max(1, limit + 1)); + )(function* (query, limit, kind) { + const pageSize = Math.max(1, limit + 1); + if (kind === "file") { + const result = yield* runSearch(query, pageSize, "fileSearch", () => + finder.fileSearch(query, { pageSize }), + ); + return mapFileSearchResult(result, limit); + } + if (kind === "directory") { + const result = yield* runSearch(query, pageSize, "directorySearch", () => + finder.directorySearch(query, { pageSize }), + ); + return mapDirectorySearchResult(result, limit); + } + const result = yield* runSearch(query, pageSize, "mixedSearch", () => + finder.mixedSearch(query, { pageSize }), + ); return mapMixedSearchResult(result, limit); }); - return WorkspaceSearchIndex.of({ list, refresh, search }); + const searchContents: WorkspaceSearchIndex["Service"]["searchContents"] = Effect.fn( + "WorkspaceSearchIndex.searchContents", + )(function* (input) { + const { searchQuery, regexMode } = buildContentSearchQuery(input); + const deadline = performance.now() + CONTENT_SEARCH_TIME_BUDGET_MS; + // Grep cursors advance by file, so whole-word post-filtering needs enough + // raw candidates from the current file before moving to the next one. + const rawPageSize = input.wholeWord + ? Math.max(input.limit, CONTENT_SEARCH_MAX_MATCHES_PER_FILE) + : input.limit; + const matches: Array = []; + let nextCursor: GrepCursor | null = null; + let regexFallbackError: string | undefined; + + do { + const remainingTimeBudgetMs = Math.max(1, Math.ceil(deadline - performance.now())); + const result = yield* runSearch(input.query, input.limit, "grep", () => + finder.grep(searchQuery, { + mode: regexMode ? "regex" : "plain", + smartCase: !input.caseSensitive && !regexMode, + // A single dense file must not consume the whole result page. + maxMatchesPerFile: Math.min(CONTENT_SEARCH_MAX_MATCHES_PER_FILE, rawPageSize), + pageSize: rawPageSize, + cursor: nextCursor, + timeBudgetMs: remainingTimeBudgetMs, + }), + ); + + for (const match of result.items) { + const matchRanges = mapContentMatchRanges(match.lineContent, match.matchRanges).filter( + (range) => !input.wholeWord || isWholeWordRange(match.lineContent, range), + ); + if (matchRanges.length === 0) continue; + matches.push({ + path: toPosixPath(match.relativePath), + lineNumber: match.lineNumber, + lineContent: match.lineContent, + matchRanges, + }); + } + nextCursor = result.nextCursor; + regexFallbackError ??= result.regexFallbackError; + } while (matches.length < input.limit && nextCursor !== null && performance.now() < deadline); + + return { + matches: matches.slice(0, input.limit), + truncated: matches.length > input.limit || nextCursor !== null, + ...(regexFallbackError !== undefined ? { regexFallbackError } : {}), + }; + }); + + return WorkspaceSearchIndex.of({ list, refresh, search, searchContents }); }); +export const WORKSPACE_SEARCH_INDEX_VARIANTS = ["paths", "content"] as const; +export type WorkspaceSearchIndexVariant = (typeof WORKSPACE_SEARCH_INDEX_VARIANTS)[number]; + +/** + * Composite LayerMap key so the lightweight path index and the on-demand + * content-search index of the same workspace are separate resources with + * independent lifecycles. "\n" cannot appear in a filesystem path. + */ +export const workspaceSearchIndexKey = (cwd: string, variant: WorkspaceSearchIndexVariant) => + `${variant}\n${cwd}`; + +function parseWorkspaceSearchIndexKey(key: string): { + readonly cwd: string; + readonly variant: WorkspaceSearchIndexVariant; +} { + const separatorIndex = key.indexOf("\n"); + return { + variant: key.slice(0, separatorIndex) as WorkspaceSearchIndexVariant, + cwd: key.slice(separatorIndex + 1), + }; +} + /** * A layer factory is required because every index is scoped to a concrete - * workspace root. WorkspaceSearchIndexMap owns memoization and idle cleanup; - * using a default cwd here would mix resources from different workspaces. + * workspace root and variant. WorkspaceSearchIndexMap owns memoization and + * idle cleanup; using a default cwd here would mix resources from different + * workspaces. */ -export const layer = (cwd: string) => Layer.effect(WorkspaceSearchIndex, make(cwd)); +export const layer = (key: string) => { + const { cwd, variant } = parseWorkspaceSearchIndexKey(key); + return Layer.effect(WorkspaceSearchIndex, make(cwd, variant)); +}; export class WorkspaceSearchIndexMap extends LayerMap.Service()( "t3/workspace/WorkspaceSearchIndexMap", diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 3fff5a95bf1..6a0ef5ab590 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -42,6 +42,7 @@ import { type ProjectFileOperation, ProjectListEntriesError, ProjectReadFileError, + ProjectSearchContentsError, ProjectSearchEntriesError, ProjectWriteFileError, RelayClientInstallFailedError, @@ -1974,6 +1975,23 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.projectsSearchContents]: (input) => + observeRpcEffect( + WS_METHODS.projectsSearchContents, + workspaceEntries.searchContents(input).pipe( + Effect.mapError( + (cause) => + new ProjectSearchContentsError({ + cwd: input.cwd, + queryLength: input.query.length, + limit: input.limit, + ...projectEntriesFailureContext(cause), + cause, + }), + ), + ), + { "rpc.aggregate": "workspace" }, + ), [WS_METHODS.projectsListEntries]: (input) => observeRpcEffect( WS_METHODS.projectsListEntries, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 917d320f856..3b2f894c038 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -94,6 +94,9 @@ import { ProjectReadFileError, ProjectReadFileInput, ProjectReadFileResult, + ProjectSearchContentsError, + ProjectSearchContentsInput, + ProjectSearchContentsResult, ProjectSearchEntriesError, ProjectSearchEntriesInput, ProjectSearchEntriesResult, @@ -187,6 +190,7 @@ export const WS_METHODS = { projectsRemove: "projects.remove", projectsListEntries: "projects.listEntries", projectsReadFile: "projects.readFile", + projectsSearchContents: "projects.searchContents", projectsSearchEntries: "projects.searchEntries", projectsWriteFile: "projects.writeFile", @@ -505,6 +509,12 @@ export const WsProjectsSearchEntriesRpc = Rpc.make(WS_METHODS.projectsSearchEntr error: Schema.Union([ProjectSearchEntriesError, EnvironmentAuthorizationError]), }); +export const WsProjectsSearchContentsRpc = Rpc.make(WS_METHODS.projectsSearchContents, { + payload: ProjectSearchContentsInput, + success: ProjectSearchContentsResult, + error: Schema.Union([ProjectSearchContentsError, EnvironmentAuthorizationError]), +}); + export const WsProjectsListEntriesRpc = Rpc.make(WS_METHODS.projectsListEntries, { payload: ProjectListEntriesInput, success: ProjectListEntriesResult, @@ -916,6 +926,7 @@ export const WsRpcGroup = RpcGroup.make( WsSourceControlPublishRepositoryRpc, WsProjectsListEntriesRpc, WsProjectsReadFileRpc, + WsProjectsSearchContentsRpc, WsProjectsSearchEntriesRpc, WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, From daa9d3ea62bda33c1a0e18e00a5e0800914b64de Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:36:23 +0200 Subject: [PATCH 6/8] fix(identity): allow ReactNode command palette descriptions Project file picker (from fork/changes) renders fuzzy-highlighted path descriptions as elements; the identity reapply left description typed as string only. Widen it to ReactNode so web typecheck matches the UI. --- apps/web/src/components/CommandPalette.logic.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/CommandPalette.logic.ts b/apps/web/src/components/CommandPalette.logic.ts index b563b5a8890..1638e6f4857 100644 --- a/apps/web/src/components/CommandPalette.logic.ts +++ b/apps/web/src/components/CommandPalette.logic.ts @@ -27,7 +27,7 @@ export interface CommandPaletteItem { readonly value: string; readonly searchTerms: ReadonlyArray; readonly title: ReactNode; - readonly description?: string; + readonly description?: ReactNode; readonly threadContentMatch?: CommandPaletteThreadContentMatch; readonly timestamp?: string; readonly icon: ReactNode; From 36e4057ea0ad61b3951a9a91c6d7fb990b3c44a7 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:36:58 +0200 Subject: [PATCH 7/8] fix(identity): restore os-jank hydratePosixHome from fork/changes Identity reapply dropped hydratePosixHome while leaving its unit test, breaking server typecheck. --- apps/server/src/os-jank.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/apps/server/src/os-jank.ts b/apps/server/src/os-jank.ts index bc72758bc71..18ddbc66c0c 100644 --- a/apps/server/src/os-jank.ts +++ b/apps/server/src/os-jank.ts @@ -36,6 +36,18 @@ function hydratePosixPath(env: NodeJS.ProcessEnv, platform: NodeJS.Platform): vo } } +export function hydratePosixHome( + env: NodeJS.ProcessEnv, + resolveHomeDir = () => NodeOS.userInfo().homedir, +): void { + if ((env.HOME?.trim() ?? "").length > 0) return; + + const homeDir = resolveHomeDir(); + if (homeDir.length > 0) { + env.HOME = homeDir; + } +} + export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< void, never, @@ -63,6 +75,13 @@ export const fixPath = Effect.fn("fixPath")(function* (): Effect.fn.Return< if (platform !== "darwin" && platform !== "linux") return; + yield* Effect.sync(() => hydratePosixHome(env)).pipe( + Effect.catchDefect((defect) => + Effect.sync(() => { + logPathHydrationWarning("Failed to hydrate HOME from the user account.", defect); + }), + ), + ); yield* Effect.sync(() => hydratePosixPath(env, platform)).pipe( Effect.catchDefect((defect) => Effect.sync(() => { From 488af133fd27be2044121baf874b0cb3e9a895a9 Mon Sep 17 00:00:00 2001 From: T3 Code PR Stack <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:40:52 +0200 Subject: [PATCH 8/8] fix(identity): restore ProjectFavicon cache behavior from fork/changes Identity reapply left a stale ProjectFavicon/test combo that fails in CI with useContext outside providers. Restore the fork/changes implementation and tests so Fork CI Test is green on the overlay. --- apps/web/src/components/ProjectFavicon.tsx | 61 +++++++++++++++------- packages/shared/src/projectFavicon.test.ts | 26 ++++++++- packages/shared/src/projectFavicon.ts | 17 ++++++ 3 files changed, 83 insertions(+), 21 deletions(-) diff --git a/apps/web/src/components/ProjectFavicon.tsx b/apps/web/src/components/ProjectFavicon.tsx index bcc49339cc6..a3a84d197f9 100644 --- a/apps/web/src/components/ProjectFavicon.tsx +++ b/apps/web/src/components/ProjectFavicon.tsx @@ -1,12 +1,15 @@ import type { EnvironmentId } from "@t3tools/contracts"; -import { isProjectFaviconFallbackUrl } from "@t3tools/shared/projectFavicon"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, +} from "@t3tools/shared/projectFavicon"; import { FolderIcon } from "lucide-react"; import type { ComponentType } from "react"; import { useState } from "react"; import { useAssetUrl } from "../assets/assetUrls"; import { cn } from "~/lib/utils"; -const loadedProjectFaviconSrcs = new Set(); +const loadedProjectFaviconSrcs = new Map(); export function ProjectFavicon(input: { environmentId: EnvironmentId; @@ -24,9 +27,12 @@ export function ProjectFavicon(input: { return ; } + const cacheKey = getProjectFaviconCacheKey(input.environmentId, input.cwd, src); + return ( ; }) { - const [status, setStatus] = useState<"loading" | "loaded" | "error">(() => - loadedProjectFaviconSrcs.has(src) ? "loaded" : "loading", + const [displayedSrc, setDisplayedSrc] = useState( + () => loadedProjectFaviconSrcs.get(cacheKey) ?? null, ); + const isLoading = displayedSrc !== src; + const handleLoadError = (failedSrc: string) => { + if (loadedProjectFaviconSrcs.get(cacheKey) === failedSrc) { + loadedProjectFaviconSrcs.delete(cacheKey); + } + setDisplayedSrc((currentSrc) => (currentSrc === failedSrc ? null : currentSrc)); + }; return ( <> - {status !== "loaded" ? ( + {displayedSrc === null ? ( ) : null} - { - loadedProjectFaviconSrcs.add(src); - setStatus("loaded"); - }} - onError={() => setStatus("error")} - /> + {displayedSrc ? ( + handleLoadError(displayedSrc)} + /> + ) : null} + {isLoading ? ( + { + loadedProjectFaviconSrcs.set(cacheKey, src); + setDisplayedSrc(src); + }} + onError={() => handleLoadError(src)} + /> + ) : null} ); } diff --git a/packages/shared/src/projectFavicon.test.ts b/packages/shared/src/projectFavicon.test.ts index 0011b2fc7c9..1df17cc7fe5 100644 --- a/packages/shared/src/projectFavicon.test.ts +++ b/packages/shared/src/projectFavicon.test.ts @@ -1,8 +1,32 @@ import { describe, expect, it } from "vite-plus/test"; -import { isProjectFaviconFallbackUrl, PROJECT_FAVICON_FALLBACK_MARKER } from "./projectFavicon.ts"; +import { + getProjectFaviconCacheKey, + isProjectFaviconFallbackUrl, + PROJECT_FAVICON_FALLBACK_MARKER, +} from "./projectFavicon.ts"; describe("project favicon", () => { + it("uses the project and versioned filename as the cache identity", () => { + const firstUrl = "https://environment.example/api/assets/first-signed-token/v1-20-favicon.svg"; + const refreshedUrl = + "https://environment.example/api/assets/refreshed-signed-token/v1-20-favicon.svg"; + + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).toBe( + getProjectFaviconCacheKey("environment-1", "/workspace", refreshedUrl), + ); + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( + getProjectFaviconCacheKey( + "environment-1", + "/workspace", + "https://environment.example/api/assets/refreshed-signed-token/v2-20-favicon.svg", + ), + ); + expect(getProjectFaviconCacheKey("environment-1", "/workspace", firstUrl)).not.toBe( + getProjectFaviconCacheKey("environment-2", "/workspace", firstUrl), + ); + }); + it("identifies fallback asset URLs by their dedicated filename", () => { expect( isProjectFaviconFallbackUrl( diff --git a/packages/shared/src/projectFavicon.ts b/packages/shared/src/projectFavicon.ts index 2e46429b6c1..eebc1a8a1b6 100644 --- a/packages/shared/src/projectFavicon.ts +++ b/packages/shared/src/projectFavicon.ts @@ -1,5 +1,22 @@ export const PROJECT_FAVICON_FALLBACK_MARKER = "project-favicon-missing"; +export function getProjectFaviconCacheKey( + environmentId: string, + workspaceRoot: string, + url: string, +) { + let revision = url; + + try { + const pathname = new URL(url, "https://t3.invalid").pathname; + revision = pathname.slice(pathname.lastIndexOf("/") + 1); + } catch { + // Keep the full value as a safe fallback for malformed URLs. + } + + return JSON.stringify([environmentId, workspaceRoot, revision]); +} + export function isProjectFaviconFallbackUrl(url: string | null | undefined): boolean { if (!url) return false;