Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import { EnvironmentId, ThreadId } from "@t3tools/contracts";
import { describe, expect, it } from "vite-plus/test";

import { resolveFirstSeenCompletedThreads } from "./useMarkFirstSeenCompletedThreadsUnread";

const localEnvironmentId = EnvironmentId.make("environment-local");
const remoteEnvironmentId = EnvironmentId.make("environment-remote");

function thread(
id: string,
state: "completed" | "running" = "completed",
environmentId = localEnvironmentId,
) {
return {
environmentId,
id: ThreadId.make(id),
latestTurn: {
state,
completedAt: "2026-06-18T09:00:00.000Z",
},
} as const;
}

describe("resolveFirstSeenCompletedThreads", () => {
it("seeds initial snapshot history without marking it unread", () => {
const result = resolveFirstSeenCompletedThreads({
threads: [thread("historical")],
environmentSnapshotIds: [localEnvironmentId],
previouslySeenThreadKeysByEnvironment: new Map(),
});

expect(result.newlyUnreadThreads).toEqual([]);
expect(result.nextSeenThreadKeysByEnvironment.get(localEnvironmentId)).toEqual(
new Set([scopedThreadKey(scopeThreadRef(localEnvironmentId, ThreadId.make("historical")))]),
);
});

it("marks a completed thread that first appears after bootstrap unread", () => {
const historicalKey = scopedThreadKey(
scopeThreadRef(localEnvironmentId, ThreadId.make("historical")),
);
const completedKey = scopedThreadKey(
scopeThreadRef(localEnvironmentId, ThreadId.make("completed")),
);
const result = resolveFirstSeenCompletedThreads({
threads: [thread("historical"), thread("completed")],
environmentSnapshotIds: [localEnvironmentId],
previouslySeenThreadKeysByEnvironment: new Map([
[localEnvironmentId, new Set([historicalKey])],
]),
});

expect(result.newlyUnreadThreads).toEqual([
{
threadKey: completedKey,
completedAt: "2026-06-18T09:00:00.000Z",
},
]);
});

it("does not mark a new unfinished thread or a thread outside a snapshot environment", () => {
const result = resolveFirstSeenCompletedThreads({
threads: [thread("running", "running"), thread("remote", "completed", remoteEnvironmentId)],
environmentSnapshotIds: [localEnvironmentId],
previouslySeenThreadKeysByEnvironment: new Map([[localEnvironmentId, new Set()]]),
});

expect(result.newlyUnreadThreads).toEqual([]);
expect(result.nextSeenThreadKeysByEnvironment.has(remoteEnvironmentId)).toBe(false);
});
});
97 changes: 97 additions & 0 deletions apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { useAtomValue } from "@effect/atom-react";
import { scopedThreadKey, scopeThreadRef } from "@t3tools/client-runtime/environment";
import type { EnvironmentId, ThreadId } from "@t3tools/contracts";
import * as Option from "effect/Option";
import { Atom } from "effect/unstable/reactivity";
import { useEffect, useRef } from "react";

import { environmentCatalog } from "../connection/catalog";
import { useThreadShells } from "../state/entities";
import { environmentShell } from "../state/shell";
import { useUiStateStore } from "../uiStateStore";

const environmentSnapshotIdsAtom = Atom.make((get): ReadonlyArray<EnvironmentId> => {
const environmentIds: EnvironmentId[] = [];
for (const environmentId of get(environmentCatalog.catalogValueAtom).entries.keys()) {
if (Option.isSome(get(environmentShell.stateValueAtom(environmentId)).snapshot)) {
environmentIds.push(environmentId);
}
}
return environmentIds;
}).pipe(Atom.withLabel("completed-thread-unread:snapshot-environments"));

interface FirstSeenThreadInput {
readonly environmentId: EnvironmentId;
readonly id: ThreadId;
readonly latestTurn: {
readonly state: string;
readonly completedAt: string | null;
} | null;
}

export function resolveFirstSeenCompletedThreads(input: {
readonly threads: ReadonlyArray<FirstSeenThreadInput>;
readonly environmentSnapshotIds: ReadonlyArray<EnvironmentId>;
readonly previouslySeenThreadKeysByEnvironment: ReadonlyMap<EnvironmentId, ReadonlySet<string>>;
}): {
readonly nextSeenThreadKeysByEnvironment: Map<EnvironmentId, Set<string>>;
readonly newlyUnreadThreads: ReadonlyArray<{
readonly threadKey: string;
readonly completedAt: string | null;
}>;
} {
const snapshotEnvironmentIds = new Set(input.environmentSnapshotIds);
const nextSeenThreadKeysByEnvironment = new Map<EnvironmentId, Set<string>>();
const newlyUnreadThreads: Array<{
readonly threadKey: string;
readonly completedAt: string | null;
}> = [];
for (const environmentId of snapshotEnvironmentIds) {
nextSeenThreadKeysByEnvironment.set(environmentId, new Set());
}

for (const thread of input.threads) {
if (!snapshotEnvironmentIds.has(thread.environmentId)) {
continue;
}

const threadKey = scopedThreadKey(scopeThreadRef(thread.environmentId, thread.id));
nextSeenThreadKeysByEnvironment.get(thread.environmentId)?.add(threadKey);

const previousThreadKeys = input.previouslySeenThreadKeysByEnvironment.get(
thread.environmentId,
);
if (
previousThreadKeys !== undefined &&
!previousThreadKeys.has(threadKey) &&
thread.latestTurn?.state === "completed"
) {
newlyUnreadThreads.push({
threadKey,
completedAt: thread.latestTurn.completedAt,
});
}
}

return { nextSeenThreadKeysByEnvironment, newlyUnreadThreads };
}

export function useMarkFirstSeenCompletedThreadsUnread(): void {
const threads = useThreadShells();
const environmentSnapshotIds = useAtomValue(environmentSnapshotIdsAtom);
const seenThreadKeysByEnvironmentRef = useRef<Map<EnvironmentId, Set<string>>>(new Map());

useEffect(() => {
const { nextSeenThreadKeysByEnvironment, newlyUnreadThreads } =
resolveFirstSeenCompletedThreads({
threads,
environmentSnapshotIds,
previouslySeenThreadKeysByEnvironment: seenThreadKeysByEnvironmentRef.current,
});
for (const thread of newlyUnreadThreads) {
useUiStateStore.getState().markThreadUnread(thread.threadKey, thread.completedAt);
}

seenThreadKeysByEnvironmentRef.current = nextSeenThreadKeysByEnvironment;
}, [environmentSnapshotIds, threads]);
}
3 changes: 3 additions & 0 deletions apps/web/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
} from "../components/ui/toast";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { applyAppearanceFontVariables } from "~/appearanceFonts";
import { useMarkFirstSeenCompletedThreadsUnread } from "../hooks/useMarkFirstSeenCompletedThreadsUnread";
import { useClientSettings } from "../hooks/useSettings";
import {
deriveLogicalProjectKeyFromSettings,
Expand Down Expand Up @@ -317,6 +318,8 @@ function AuthenticatedTracingBootstrap() {
}

function EventRouter() {
useMarkFirstSeenCompletedThreadsUnread();

const navigate = useNavigate();
const pathname = useLocation({ select: (loc) => loc.pathname });
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
Expand Down
Loading