-
+
{fromVersion} → {state.targetVersion}
@@ -50,7 +63,6 @@ export function ServerUpdateProgress({
const complete = index < currentIndex;
const current = index === currentIndex;
const failed = current && state.status === "failed";
- const running = current && state.status === "running";
return (
-
- {complete ? (
-
- ) : (
-
- )}
- {running ? step.activeLabel : step.label}
+
+ {complete ? : null}
+
+ {step.label}
{index < UPDATE_STEPS.length - 1 ? (
@@ -100,11 +108,15 @@ export function ServerUpdateProgress({
);
})}
- {state.status === "failed" ? (
-
- {state.message}
-
- ) : null}
+
+ {updateStatusCopy(state, serverLabel)}
+
);
}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index 5dce9af0cb3..bf211bc22cf 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -33,6 +33,11 @@ import {
ThreadStatusLabel,
ThreadWorktreeIndicator,
} from "./ThreadStatusIndicators";
+import { ParticipantStack, SourceChannelGlyph } from "./identity/ParticipantStack";
+import {
+ isIdentityClaimRequiredMessage,
+ requestIdentityClaimGate,
+} from "./identity/IdentityClaimGate";
import { hasComposerDraftMessage, useComposerDraftStore } from "../composerDraftStore";
import { ProjectFavicon, ProjectFaviconFallback } from "./ProjectFavicon";
import { useAtomValue } from "@effect/atom-react";
@@ -158,7 +163,6 @@ import {
shouldShowArm64IntelBuildWarning,
shouldToastDesktopUpdateActionResult,
} from "./desktopUpdate.logic";
-import { showDesktopUpdateDownloadedToast } from "./desktopUpdate.toast";
import { Alert, AlertAction, AlertDescription, AlertTitle } from "./ui/alert";
import { Button } from "./ui/button";
import {
@@ -814,21 +818,34 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr
onDoubleClick={handleRenameInputClick}
/>
) : (
-
+ {!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;