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"
) {
Comment on lines +64 to +68

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Medium hooks/useMarkFirstSeenCompletedThreadsUnread.ts:64

resolveFirstSeenCompletedThreads marks a completed thread as seen and pushes it to newlyUnreadThreads even when latestTurn.completedAt is null. Since markThreadUnread ignores null-timestamp threads, the thread is recorded as seen but never actually marked unread. When the real completedAt arrives in a later snapshot, the thread key is already in the previous-seen set, so it is never retried — the thread stays read forever. Consider deferring the push to newlyUnreadThreads until completedAt is non-null, while still tracking the key as seen.

    if (
      previousThreadKeys !== undefined &&
      !previousThreadKeys.has(threadKey) &&
      thread.latestTurn?.state === "completed" &&
+     thread.latestTurn.completedAt != null
    ) {
🤖 Copy this AI Prompt to have your agent fix this:
In file @apps/web/src/hooks/useMarkFirstSeenCompletedThreadsUnread.ts around lines 64-68:

`resolveFirstSeenCompletedThreads` marks a completed thread as seen and pushes it to `newlyUnreadThreads` even when `latestTurn.completedAt` is `null`. Since `markThreadUnread` ignores null-timestamp threads, the thread is recorded as seen but never actually marked unread. When the real `completedAt` arrives in a later snapshot, the thread key is already in the previous-seen set, so it is never retried — the thread stays read forever. Consider deferring the push to `newlyUnreadThreads` until `completedAt` is non-null, while still tracking the key as seen.

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 @@ -27,6 +27,7 @@ import {
toastManager,
} from "../components/ui/toast";
import { resolveAndPersistPreferredEditor } from "../editorPreferences";
import { useMarkFirstSeenCompletedThreadsUnread } from "../hooks/useMarkFirstSeenCompletedThreadsUnread";
import { useClientSettings } from "../hooks/useSettings";
import {
deriveLogicalProjectKeyFromSettings,
Expand Down Expand Up @@ -277,6 +278,8 @@ function AuthenticatedTracingBootstrap() {
}

function EventRouter() {
useMarkFirstSeenCompletedThreadsUnread();

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