Skip to content
Closed
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
14 changes: 14 additions & 0 deletions apps/web/src/components/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1583,6 +1583,7 @@ export default function ChatView(props: ChatViewProps) {
};
}, [routeKind, routeThreadRef, routeThreadState]);
const markThreadVisited = useUiStateStore((store) => store.markThreadVisited);
const toggleThreadRead = useUiStateStore((store) => store.toggleThreadRead);
const settings = useEnvironmentSettings(environmentId);
const setStickyComposerModelSelection = useComposerDraftStore(
(store) => store.setStickyModelSelection,
Expand Down Expand Up @@ -6749,6 +6750,17 @@ export default function ChatView(props: ChatViewProps) {
return;
}

if (command === "thread.toggleUnread") {
event.preventDefault();
event.stopPropagation();
if (event.repeat || !activeThreadKey) return;
// Opening a thread stamps lastVisitedAt at completedAt. This handler
// only runs after that visit, so unread sticks until the user leaves
// or a later completion retriggers the visit effect.
toggleThreadRead(activeThreadKey, activeLatestTurn?.completedAt);
return;
}

if (command === "terminal.toggle") {
event.preventDefault();
event.stopPropagation();
Expand Down Expand Up @@ -6913,6 +6925,7 @@ export default function ChatView(props: ChatViewProps) {
activeThreadRef,
activeThreadPinned,
activeThreadSettled,
activeLatestTurn?.completedAt,
canInterruptRunningThread,
activeThreadKey,
terminalUiState.terminalOpen,
Expand All @@ -6938,6 +6951,7 @@ export default function ChatView(props: ChatViewProps) {
confirmAndUnpinThread,
copyActiveThreadReference,
getShortcutContext,
toggleThreadRead,
toggleRightPanel,
toggleRightPanelMaximized,
toggleTerminalVisibility,
Expand Down
44 changes: 42 additions & 2 deletions apps/web/src/components/CommandPalette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@
import { threadPullRequestLinkMode } from "@t3tools/client-runtime/thread-pull-request-compatibility";
import { visibleThreadPullRequests } from "@t3tools/shared/threadPullRequests";

import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment";
import {
scopeProjectRef,
scopedThreadKey,
scopeThreadRef,
} from "@t3tools/client-runtime/environment";
import {
canCreateProjectInEnvironment,
getCloneDestinationBrowsePath,
Expand Down Expand Up @@ -48,6 +52,7 @@ import {
FolderIcon,
FolderPlusIcon,
LinkIcon,
MailOpenIcon,
MessageSquareIcon,
MonitorIcon,
MoonIcon,
Expand Down Expand Up @@ -187,7 +192,7 @@ import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
import { ComposerHandleContext, useComposerHandleContext } from "../composerHandleContext";
import type { ChatComposerHandle } from "./chat/ChatComposer";
import { getProjectOrderKey, selectProjectGroupingSettings } from "../logicalProject";
import { legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
import { isThreadUnread, legacyProjectCwdPreferenceKey, useUiStateStore } from "../uiStateStore";
import {
buildSidebarProjectPickerEntries,
buildSidebarProjectSnapshots,
Expand Down Expand Up @@ -765,6 +770,14 @@ function OpenCommandPaletteDialog(props: {
}
}, [activeThreadReferenceCopyTarget]);
const projectOrder = useUiStateStore((store) => store.projectOrder);
const toggleThreadRead = useUiStateStore((store) => store.toggleThreadRead);
const activeThreadLastVisitedAt = useUiStateStore((store) =>
activeThread
? store.threadLastVisitedAtById[
scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id))
]
: undefined,
);
const threads = useThreadShells();
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const {
Expand Down Expand Up @@ -1790,6 +1803,22 @@ function OpenCommandPaletteDialog(props: {
});
}

if (activeThread !== null) {
const threadKey = scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id));
const unread = isThreadUnread(activeThreadLastVisitedAt, activeThread.latestTurn?.completedAt);
actionItems.push({
kind: "action",
value: "action:toggle-thread-read",
searchTerms: ["unread", "read", "mark unread", "mark as read", "toggle"],
title: unread ? "Mark as read" : "Mark unread",
icon: <MailOpenIcon className={ITEM_ICON_CLASS} />,
shortcutCommand: "thread.toggleUnread",
run: async () => {
toggleThreadRead(threadKey, activeThread.latestTurn?.completedAt);
},
});
}

if (
activeThread !== null &&
threadPullRequestLinkMode(activeThreadServerConfig?.environment.capabilities) !== "unsupported"
Expand Down Expand Up @@ -2688,6 +2717,17 @@ function OpenCommandPaletteDialog(props: {
void copyActiveThreadReference();
return;
}
if (command === "thread.toggleUnread") {
event.preventDefault();
event.stopPropagation();
if (activeThread === null) return;
setOpen(false);
toggleThreadRead(
scopedThreadKey(scopeThreadRef(activeThread.environmentId, activeThread.id)),
activeThread.latestTurn?.completedAt,
);
return;
}

if (addProjectCloneFlow?.step === "repository" && event.key === "Enter") {
event.preventDefault();
Expand Down
10 changes: 2 additions & 8 deletions apps/web/src/components/Sidebar.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import type { SidebarThreadSummary, Thread } from "../types";
import { cn } from "../lib/utils";
import { isLatestTurnSettled } from "../session-logic";
import { isThreadUnread } from "../uiStateStore";

export function shouldNavigateAfterThreadPark(input: {
readonly threadKey: string;
Expand Down Expand Up @@ -633,14 +634,7 @@ export function useThreadJumpHintVisibility(): {
}

export function hasUnseenCompletion(thread: ThreadStatusInput): boolean {
if (!thread.latestTurn?.completedAt) return false;
const completedAt = Date.parse(thread.latestTurn.completedAt);
if (Number.isNaN(completedAt)) return false;
if (!thread.lastVisitedAt) return false;

const lastVisitedAt = Date.parse(thread.lastVisitedAt);
if (Number.isNaN(lastVisitedAt)) return true;
return completedAt > lastVisitedAt;
return isThreadUnread(thread.lastVisitedAt, thread.latestTurn?.completedAt);
}

export function shouldClearThreadSelectionOnMouseDown(target: HTMLElement | null): boolean {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ describe("KeybindingsSettings.logic", () => {
"modelPicker.previousProvider",
"modelPicker.nextProvider",
"thread.copyReference",
"thread.toggleUnread",
"pullRequest.copyNumber",
]) {
expect(rows.find((row) => row.command === command)).toMatchObject({
Expand Down Expand Up @@ -204,6 +205,7 @@ describe("KeybindingsSettings.logic", () => {
expect(commandLabel("commandPalette.toggle")).toBe("Command Palette: Toggle");
expect(commandLabel("themeEditor.toggle")).toBe("Theme Editor: Toggle");
expect(commandLabel("script.setup-db.run")).toBe("Run Script: Setup Db");
expect(commandLabel("thread.toggleUnread")).toBe("Thread: Toggle Read/Unread");
});

it("builds known when variable options from defaults without frontend labels", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export function buildKeybindingCommandOptions(

export function commandLabel(command: KeybindingCommand): string {
if (command === "thread.copyReference") return "Pull Request: Copy Link or Thread ID";
if (command === "thread.toggleUnread") return "Thread: Toggle Read/Unread";
const raw = String(command);
if (raw.startsWith("script.") && raw.endsWith(".run")) {
return `Run Script: ${titleCaseCommandSegment(raw.slice("script.".length, -".run".length))}`;
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/components/settings/settingsSearch.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,10 @@ describe("searchSettings", () => {
id: "keybinding-thread.copyReference",
to: "/settings/keybindings",
});
expect(searchSettings("toggle read")[0]).toMatchObject({
id: "keybinding-thread.toggleUnread",
to: "/settings/keybindings",
});
});

it("ranks keybinding commands after other settings", () => {
Expand Down
27 changes: 27 additions & 0 deletions apps/web/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,11 @@ const DEFAULT_BINDINGS = compile([
command: "thread.settle",
whenAst: whenNot(whenIdentifier("terminalFocus")),
},
{
shortcut: modShortcut("u", { shiftKey: true }),
command: "thread.toggleUnread",
whenAst: whenNot(whenIdentifier("terminalFocus")),
},
{ shortcut: modShortcut("1"), command: "thread.jump.1" },
{ shortcut: modShortcut("2"), command: "thread.jump.2" },
{ shortcut: modShortcut("3"), command: "thread.jump.3" },
Expand Down Expand Up @@ -226,6 +231,27 @@ describe("settle thread shortcut", () => {
});
});

describe("toggle unread shortcut", () => {
it("resolves outside the terminal", () => {
assert.equal(
resolveShortcutCommand(event({ key: "u", metaKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
platform: "MacIntel",
context: { terminalFocus: false },
}),
"thread.toggleUnread",
);
});

it("does not intercept the terminal", () => {
assert.isNull(
resolveShortcutCommand(event({ key: "u", ctrlKey: true, shiftKey: true }), DEFAULT_BINDINGS, {
platform: "Win32",
context: { terminalFocus: true },
}),
);
});
});

describe("copy thread reference shortcut", () => {
it("resolves Cmd+Shift+C on macOS and Ctrl+Shift+C elsewhere", () => {
assert.equal(
Expand Down Expand Up @@ -1230,6 +1256,7 @@ describe("composer and pull request shortcuts", () => {
it.each([
["s", "thread.settle"],
["p", "thread.pin"],
["u", "thread.toggleUnread"],
])(`preserves the existing %s shortcut on ${platform}`, (key, command) => {
assert.strictEqual(
resolveShortcutCommand(
Expand Down
18 changes: 18 additions & 0 deletions apps/web/src/uiStateStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@ import { ProjectId, ThreadId } from "@t3tools/contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";

import {
isThreadUnread,
legacyProjectCwdPreferenceKey,
markThreadUnread,
markThreadVisited,
parsePersistedState,
toggleThreadRead,
PERSISTED_STATE_KEY,
type PersistedUiState,
persistState,
Expand Down Expand Up @@ -56,6 +58,22 @@ describe("uiStateStore pure functions", () => {
expect(markThreadUnread(next, threadId, null)).toBe(next);
});

it("toggles read and unread from the completion timestamp", () => {
const threadId = ThreadId.make("thread-1");
const completedAt = "2026-02-25T12:30:00.000Z";
const read = markThreadVisited(makeUiState(), threadId, completedAt);

expect(isThreadUnread(read.threadLastVisitedAtById[threadId], completedAt)).toBe(false);

const unread = toggleThreadRead(read, threadId, completedAt);
expect(unread.threadLastVisitedAtById[threadId]).toBe("2026-02-25T12:29:59.999Z");
expect(isThreadUnread(unread.threadLastVisitedAtById[threadId], completedAt)).toBe(true);

const toggledRead = toggleThreadRead(unread, threadId, completedAt);
expect(toggledRead.threadLastVisitedAtById[threadId]).toBe(completedAt);
expect(toggleThreadRead(read, threadId, null)).toBe(read);
});

it("resolves project expansion from logical, physical, and legacy preference keys", () => {
const physicalKey = "environment:/repo/project";
const legacyKey = legacyProjectCwdPreferenceKey("/repo/project");
Expand Down
31 changes: 31 additions & 0 deletions apps/web/src/uiStateStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,34 @@ export function markThreadUnread(
};
}

/** Same rule as the sidebar unread badge: never-visited counts as read. */
export function isThreadUnread(
lastVisitedAt: string | undefined,
latestTurnCompletedAt: string | null | undefined,
): boolean {
if (!latestTurnCompletedAt) return false;
const completedAt = Date.parse(latestTurnCompletedAt);
if (Number.isNaN(completedAt)) return false;
if (!lastVisitedAt) return false;
const lastVisitedAtMs = Date.parse(lastVisitedAt);
if (Number.isNaN(lastVisitedAtMs)) return true;
return completedAt > lastVisitedAtMs;
}

export function toggleThreadRead(
state: UiState,
threadId: string,
latestTurnCompletedAt: string | null | undefined,
): UiState {
if (
latestTurnCompletedAt &&
isThreadUnread(state.threadLastVisitedAtById[threadId], latestTurnCompletedAt)
) {
return markThreadVisited(state, threadId, latestTurnCompletedAt);
}
return markThreadUnread(state, threadId, latestTurnCompletedAt);
}

export function setThreadChangedFilesExpanded(
state: UiState,
threadId: string,
Expand Down Expand Up @@ -426,6 +454,7 @@ export function reorderProjects(
interface UiStateStore extends UiState {
markThreadVisited: (threadId: string, visitedAt: string) => void;
markThreadUnread: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
toggleThreadRead: (threadId: string, latestTurnCompletedAt: string | null | undefined) => void;
setThreadChangedFilesExpanded: (threadId: string, turnId: string, expanded: boolean) => void;
setDefaultAdvertisedEndpointKey: (key: string | null) => void;
setSidebarProjectScopeKey: (projectKey: string | null) => void;
Expand All @@ -444,6 +473,8 @@ export const useUiStateStore = create<UiStateStore>((set) => ({
set((state) => markThreadVisited(state, threadId, visitedAt)),
markThreadUnread: (threadId, latestTurnCompletedAt) =>
set((state) => markThreadUnread(state, threadId, latestTurnCompletedAt)),
toggleThreadRead: (threadId, latestTurnCompletedAt) =>
set((state) => toggleThreadRead(state, threadId, latestTurnCompletedAt)),
setThreadChangedFilesExpanded: (threadId, turnId, expanded) =>
set((state) => setThreadChangedFilesExpanded(state, threadId, turnId, expanded)),
setDefaultAdvertisedEndpointKey: (key) =>
Expand Down
7 changes: 7 additions & 0 deletions packages/contracts/src/keybindings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,13 @@ it.effect("parses keybinding rules", () =>
});
assert.strictEqual(parsedThreadCopyReference.command, "thread.copyReference");

const parsedThreadToggleUnread = yield* decode(KeybindingRule, {
key: "mod+shift+u",
command: "thread.toggleUnread",
when: "!terminalFocus",
});
assert.strictEqual(parsedThreadToggleUnread.command, "thread.toggleUnread");

const parsedPullRequestCopyNumber = yield* decode(KeybindingRule, {
key: "mod+shift+k",
command: "pullRequest.copyNumber",
Expand Down
1 change: 1 addition & 0 deletions packages/contracts/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ const THREAD_KEYBINDING_COMMANDS = [
"thread.copyReference",
"thread.settle",
"thread.pin",
"thread.toggleUnread",
...THREAD_JUMP_KEYBINDING_COMMANDS,
] as const;
export type ThreadKeybindingCommand = (typeof THREAD_KEYBINDING_COMMANDS)[number];
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/keybindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const DEFAULT_KEYBINDINGS: ReadonlyArray<KeybindingRule> = [
{ key: "mod+shift+c", command: "thread.copyReference", when: "!terminalFocus" },
{ key: "mod+shift+s", command: "thread.settle", when: "!terminalFocus" },
{ key: "mod+shift+p", command: "thread.pin", when: "!terminalFocus" },
{ key: "mod+shift+u", command: "thread.toggleUnread", when: "!terminalFocus" },
...THREAD_JUMP_KEYBINDING_COMMANDS.map((command, index) => ({
key: `mod+${index + 1}`,
command,
Expand Down
Loading