From 61f30321f2f4d670537a14b53d411eeaa6b91c9e Mon Sep 17 00:00:00 2001 From: Jonas Utke Date: Thu, 15 Oct 2026 11:54:26 +0200 Subject: [PATCH 1/3] feat(sidebar): New above and Mark as unread on a tab row's menu New above is New below's submenu with addTab(..., { beforeTabId }), so the pick lands in the clicked tab's slot; resumeClosedTab takes the same option and an unpinned tab still never lands inside the pinned block. Mark as unread sets TerminalTab.markedUnread. The row, the folded task row and every rollup that reads taskWorkBadge draw an orange dot (--color-marked), ranked under attention and done and over working. It is not `unread`, so it never reaches the OS notification path. Showing that tab clears it (setActiveTabId, or setActiveTask when it is the task's active tab); opening the task on another tab leaves it. It rides task_set_tabs as `marked_unread`, skipped when false, so it survives a relaunch and older task files are unchanged. Co-Authored-By: Claude Opus 5.5 --- docs/data-model.md | 1 + docs/ui.md | 16 ++- e2e/specs/tabs-layout.e2e.ts | 101 +++++++++++++++++- src-tauri/src/cli_server.rs | 12 +-- src-tauri/src/lib.rs | 20 ++++ src/components/TaskWorkBadge.tsx | 17 +++ src/components/sidebar/RollupMarks.tsx | 2 +- src/components/sidebar/Sidebar.tsx | 81 +++++++++++--- src/index.css | 3 + src/lib/taskGroups.test.ts | 10 ++ src/lib/taskGroups.ts | 4 +- src/lib/taskWorkState.test.ts | 22 +++- src/lib/taskWorkState.ts | 9 +- src/lib/types.ts | 6 ++ src/locales/en/chrome.ts | 1 + src/locales/en/sidebar.ts | 2 + src/locales/zh-CN/chrome.ts | 1 + src/locales/zh-CN/sidebar.ts | 2 + src/store/app.test.ts | 117 ++++++++++++++++++++ src/store/app.ts | 142 +++++++++++++++---------- 20 files changed, 479 insertions(+), 90 deletions(-) diff --git a/docs/data-model.md b/docs/data-model.md index fb2694028..9753ba466 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -46,6 +46,7 @@ Wildcard scans skip `.git` and do not descend through directory symlinks. - **Scratchpad** (`scratch//index.json` + `scratch//.txt`, GH #244) — an untitled buffer that survives a relaunch, scoped to ONE task. Stored here rather than in the worktree so it never appears in `git status`, in the agent's review diff, or in a commit. The index record (`id`, `title`, `syntax`, `order`, `created_at`, `updated_at`) exists because a pad has no filename to re-derive a title or syntax from, and one index read beats stat-ing N files on launch. Pads are NOT part of `persisted_tabs`, which is agent-tabs-only by construction; they restore from this index when their task is first entered. - **Shared scratchpads** (GH #385) — three more scopes beside the task's, each a plain DIRECTORY TREE of files the user names (no index, no untitled buffers): **project** at `/scratch/projects//`, **profile** at `/scratch/profile/`, and **global** at `/scratch/global/`, the one copy every profile sees. `scratch_dir` in `lib.rs` routes all four from one target id (``, `project_`, `profile_`, `global`). They outlive the task that wrote them, which is the point: a finding one agent leaves in the project pad is there for the next task. Every path a command takes is checked against the scope's root (`reject_escaping_segments`: no `..`, no rooted or drive-relative segment, on every platform), and a symlink is not listed in the tree, nor followed by the sync export. Config sync mirrors the three shared scopes (never the task pads): global under `scratch/` at the repo root, profile under `profiles//scratch/`, project under `profiles//scratch/projects//`. - **Tab properties** (`PersistedTab.props` on a task's `persisted_tabs`, `TerminalTab.props` live, GH #358): `{ key, value, since }`, in the order they were set; `since` (epoch ms) is when the key was FIRST set on the tab, so an update keeps the key where it is. Set by agents through `termic prop` / MCP `task_prop` on their own tab (`$TERMIC_TAB_ID`, injected into every agent PTY on the host and, through the env overlay, in Docker). The webview store is the ONLY writer (`setTabProp`, reached by the `tab_props` RPC): the list rides every `task_set_tabs`, so a Rust-side write would be overwritten by the next sync. The task row shows the collected view (`lib/tabProps.ts collectTaskProps`): one entry per key, keys by earliest first-set, each key's distinct values joined with ", " in tab order. Closing a tab drops its properties; shell tabs are not durable, so theirs do not survive a relaunch. Limits live in `termic-proto` (`prop_key_problem`, `prop_value_problem`, 8 keys per tab). Empty is not serialized. +- **Marked unread** (`PersistedTab.marked_unread`, `TerminalTab.markedUnread` live): set by the sidebar tab row's "Mark as unread", cleared when that tab is next shown (docs/ui.md, "The sidebar's tab rows"). Rides every `task_set_tabs` like `pinned`. False is not serialized, so older task files are unchanged. - **Scheduled queue messages** (`PersistedTab.scheduled` on a task's `persisted_tabs`, GH #300) — `{ id, text, not_before, created }` (epoch ms). A queue message with a "send after" date, one-shot. It lives on the TAB record because it belongs to that chat's session: an archived task never mounts, so it never sends; a restore brings it back; closing a secondary tab (which drops the record) deletes it, and the close asks first. Only scheduled items persist; ordinary queue items stay runtime-only. Written solely by `task_set_tab_scheduled`; `task_set_tabs` carries it forward by tab id like `session_id` (`merge_persisted_tabs`), so a rename racing a schedule write cannot drop it. Empty is not serialized, so older task files are unchanged. On restore `ensureDefaultTab` hydrates it into `tab.queue` as items with `notBefore`. **Archiving a task leaves its pads alone.** Archiving is recoverable (the task stays in History, the branch stays in git) and notes about the work are exactly what someone wants back when they restore it. The only thing that deletes pads is `delete_task_file`, the hard delete behind History's "Empty archive" and project removal — pinned by `only_the_hard_delete_purges_scratchpads` in `lib.rs`. diff --git a/docs/ui.md b/docs/ui.md index c6db300cc..e5e9fbadc 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -446,9 +446,13 @@ listener has to be attached first, and rAF is frozen on an occluded window (see ## The sidebar's tab rows Each expanded task lists its main-pane terminal tabs as child rows. Click -activates, double-click renames, and right-click opens a menu with two +activates, double-click renames, and right-click opens a menu with four entries: +- **New above** is New below with `addTab(..., { beforeTabId })`, so the + pick lands in the clicked tab's own slot. The pinned-block rule below + holds the same way: an unpinned tab above a pinned one lands after the + block. - **New below** is the "+" menu (`NewTabMenuItems`) minus the scratchpads, which get no row, so "below" would mean nothing for them. The pick goes through `addTab(..., { afterTabId })` (`resumeClosedTab` takes the same @@ -458,6 +462,16 @@ entries: ends after the LAST pinned main-strip tab (a split-pane tab keeps its array slot and can sit between two pinned ones). The sidebar, the tab strip and `persisted_tabs` all read that one list, so the slot holds in all three and across a relaunch. +- **Mark as unread** sets `TerminalTab.markedUnread` (`markTabUnread`). + The row draws an orange dot (`TaskWorkBadge reason="marked"`, + `--color-marked`), and so does the folded task row and every rollup + that reads `taskWorkBadge`: it ranks under attention and done and over + working, and no pref hides it, since the user set it by hand. It is + deliberately not `unread`, which is agent news and feeds the OS + notification. Showing that tab clears it: `setActiveTabId` on it, or + `setActiveTask` when it is the task's active tab. Opening the task on + another tab leaves it. It is persisted (`marked_unread`), so it + survives a relaunch, and only agent tabs persist at all. - **Close** is `requestCloseTab`, so a running agent still asks first. It is a Radix `DropdownMenu` opened on `contextmenu`, the task row's diff --git a/e2e/specs/tabs-layout.e2e.ts b/e2e/specs/tabs-layout.e2e.ts index d795d5c81..2dd8e15d7 100644 --- a/e2e/specs/tabs-layout.e2e.ts +++ b/e2e/specs/tabs-layout.e2e.ts @@ -406,13 +406,22 @@ describe("sidebar tab row menu", () => { return [...menu.querySelectorAll("[role='menuitem']")].map((e) => e.textContent?.trim() ?? ""); }) as Promise; - async function openNewBelow(tabId: string) { - await clickMenuItemUntilReady("New below", () => browser.execute(() => + async function openNewSub(label: "New above" | "New below", tabId: string) { + await clickMenuItemUntilReady(label, () => browser.execute(() => [...document.querySelectorAll("[role='menuitem']")].some((e) => e.textContent?.trim() === "Terminal"), ) as Promise, { reopen: () => openTabRowMenu(tabId) }); } + const openNewBelow = (tabId: string) => openNewSub("New below", tabId); + const openNewAbove = (tabId: string) => openNewSub("New above", tabId); - it("opens on right-click with New below and Close, and Escape changes nothing", async () => { + /** The mark a tab row (or, folded, the task row) draws, or null. */ + const rowMark = (sel: string) => + browser.execute((q) => { + const b = document.querySelector(`${q} [data-testid="work-badge"]`) as HTMLElement | null; + return b?.dataset.workState ?? null; + }, sel) as Promise; + + it("opens on right-click with New above, New below, Mark as unread and Close, and Escape changes nothing", async () => { await waitForAppShell(); await requireTermicApi(); taskId = await openTask("e2e-tabrow-menu"); @@ -437,7 +446,7 @@ describe("sidebar tab row menu", () => { [...document.querySelectorAll('[data-testid="sidebar-tab-menu"] [role="menuitem"]')] .map((e) => e.textContent?.trim() ?? ""), ); - expect(items).toEqual(["New below", "Close"]); + expect(items).toEqual(["New above", "New below", "Mark as unread", "Close"]); await snap("sidebar-tab-row-menu.png"); await browser.execute(() => { @@ -511,6 +520,90 @@ describe("sidebar tab row menu", () => { }, { timeout: 10_000, timeoutMsg: "persisted_tabs never took the inserted order" }); }); + it("New above > Terminal lands right above the clicked row, in the sidebar and the strip", async () => { + const before = await tabIds(taskId); + const anchor = before[1]; + await openTabRowMenu(anchor); + await openNewAbove(anchor); + await clickMenuItemUntilReady("Terminal", async () => (await tabIds(taskId)).length === before.length + 1, + { reopen: async () => { await openTabRowMenu(anchor); await openNewAbove(anchor); } }); + + const after = await tabIds(taskId); + const added = after.find((id) => !before.includes(id))!; + expect(after).toEqual([before[0], added, ...before.slice(1)]); + await browser.waitUntil(async () => JSON.stringify(await sidebarRowIds(taskId)) === JSON.stringify(after), + { timeout: 5_000, timeoutMsg: `sidebar rows never matched ${after.join(",")}` }); + await browser.waitUntil(async () => JSON.stringify(await stripPillIds(taskId)) === JSON.stringify(after), + { timeout: 5_000, timeoutMsg: `tab strip never matched ${after.join(",")}` }); + expect(await browser.execute((id) => window.__termic!.useApp.getState().activeTab[id], taskId)).toBe(added); + }); + + it("New above the first tab puts the new one first", async () => { + const before = await tabIds(taskId); + await openTabRowMenu(before[0]); + await openNewAbove(before[0]); + await clickMenuItemUntilReady("Terminal", async () => (await tabIds(taskId)).length === before.length + 1, + { reopen: async () => { await openTabRowMenu(before[0]); await openNewAbove(before[0]); } }); + const after = await tabIds(taskId); + const added = after.find((id) => !before.includes(id))!; + expect(after).toEqual([added, ...before]); + }); + + it("Mark as unread draws an orange dot on the row and the folded task until the tab is shown", async () => { + const agent = await browser.execute((id) => + (window.__termic!.useApp.getState().tabs[id] ?? []).find((t: any) => t.cli === "fakeagent")!.id, taskId) as string; + // Not the tab on screen: marking it must not need a visit to stick. + expect(await browser.execute((id) => window.__termic!.useApp.getState().activeTab[id], taskId)).not.toBe(agent); + await openTabRowMenu(agent); + await clickMenuItem("Mark as unread"); + await waitGone('[data-testid="sidebar-tab-menu"]', 5_000); + + const rowSel = `[data-sidebar-tab-id="${agent}"]`; + await browser.waitUntil(async () => (await rowMark(rowSel)) === "marked", + { timeout: 5_000, timeoutMsg: "the marked row never drew its dot" }); + // Measured, not eyeballed: the dot is the --color-marked token. + const colors = await browser.execute((q) => { + const dot = document.querySelector(`${q} [data-work-state="marked"] span`) as HTMLElement; + const probe = document.createElement("span"); + probe.style.color = "var(--color-marked)"; + document.body.appendChild(probe); + const want = getComputedStyle(probe).color; + probe.remove(); + return { dot: getComputedStyle(dot).backgroundColor, want }; + }, rowSel); + expect(colors.dot).toBe(colors.want); + await snap("sidebar-tab-row-marked-unread.png"); + + // It is on disk, so a relaunch keeps it. + await browser.waitUntil(async () => { + const p = await browser.execute(async (id, t) => { + const tasks = await window.__termic!.ipc.tasksList(); + return (tasks.find((x: any) => x.id === id)?.persisted_tabs ?? []).find((x: any) => x.id === t)?.marked_unread ?? false; + }, taskId, agent); + return p === true; + }, { timeout: 10_000, timeoutMsg: "marked_unread never reached the task file" }); + + // Folded, the task row carries it. + await browser.execute((id) => window.__termic!.useApp.getState().setTaskCollapsed(id, true), taskId); + await browser.waitUntil(async () => (await rowMark(`[data-sidebar-task-row="${taskId}"]`)) === "marked", + { timeout: 5_000, timeoutMsg: "the folded task row never showed the marked dot" }); + await snap("sidebar-task-row-marked-unread.png"); + await browser.execute((id) => window.__termic!.useApp.getState().setTaskCollapsed(id, false), taskId); + await waitVisible(rowSel, 5_000); + + // Showing the tab is reading it. + await clickWhenVisible(rowSel); + await browser.waitUntil(async () => (await rowMark(rowSel)) === null, + { timeout: 5_000, timeoutMsg: "the dot stayed after the tab was shown" }); + await browser.waitUntil(async () => { + const p = await browser.execute(async (id, t) => { + const tasks = await window.__termic!.ipc.tasksList(); + return (tasks.find((x: any) => x.id === id)?.persisted_tabs ?? []).find((x: any) => x.id === t)?.marked_unread ?? false; + }, taskId, agent); + return p === false; + }, { timeout: 10_000, timeoutMsg: "the cleared mark never left the task file" }); + }); + it("Close removes the clicked tab and only that one", async () => { const before = await tabIds(taskId); const shell = await browser.execute((id) => diff --git a/src-tauri/src/cli_server.rs b/src-tauri/src/cli_server.rs index 13374b990..78cc6872a 100644 --- a/src-tauri/src/cli_server.rs +++ b/src-tauri/src/cli_server.rs @@ -9576,7 +9576,7 @@ mod tests { .lock() .unwrap() .insert("close_tab".into(), Ok(serde_json::json!({ "killedPty": true }))); - let persisted = |id: &str, cli: &str, run: Option<&str>| crate::PersistedTab { props: Default::default(), + let persisted = |id: &str, cli: &str, run: Option<&str>| crate::PersistedTab { props: Default::default(), marked_unread: false, agent_args: Vec::new(), id: id.into(), cli: cli.into(), @@ -9615,7 +9615,7 @@ mod tests { .lock() .unwrap() .insert("close_tab".into(), Ok(serde_json::json!({}))); - let persisted = |id: &str, is_default: bool| crate::PersistedTab { props: Default::default(), + let persisted = |id: &str, is_default: bool| crate::PersistedTab { props: Default::default(), marked_unread: false, agent_args: Vec::new(), id: id.into(), cli: "claude".into(), @@ -9696,7 +9696,7 @@ mod tests { // the snapshot was fresh by AGE while missing the new tab entirely. let host = StubHost::default(); let mut t = w3(&host); - t.persisted_tabs.push(crate::PersistedTab { props: Default::default(), + t.persisted_tabs.push(crate::PersistedTab { props: Default::default(), marked_unread: false, agent_args: Vec::new(), id: "brand-new".into(), cli: "shell".into(), @@ -9759,7 +9759,7 @@ mod tests { let host = StubHost::default(); let mut t = w3(&host); t.persisted_tabs = vec![ - crate::PersistedTab { props: Default::default(), + crate::PersistedTab { props: Default::default(), marked_unread: false, agent_args: Vec::new(), id: "tab-a".into(), cli: "claude".into(), @@ -9773,7 +9773,7 @@ mod tests { pinned: false, scheduled: Vec::new(), }, - crate::PersistedTab { props: Default::default(), + crate::PersistedTab { props: Default::default(), marked_unread: false, agent_args: Vec::new(), id: "tab-x".into(), cli: "custom".into(), @@ -9787,7 +9787,7 @@ mod tests { pinned: false, scheduled: Vec::new(), }, - crate::PersistedTab { props: Default::default(), + crate::PersistedTab { props: Default::default(), marked_unread: false, agent_args: Vec::new(), id: "tab-sh".into(), cli: "shell".into(), diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d980e149a..e2a988c64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -776,6 +776,10 @@ pub struct PersistedTab { /// pinned tab comes back pinned and leftmost. #[serde(default)] pub pinned: bool, + /// Marked unread from the sidebar tab menu; cleared when the tab is + /// next shown. Skipped when false so existing task files do not change. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub marked_unread: bool, /// Queue messages with a "send after" date (GH #300). Owned solely by /// `task_set_tab_scheduled`; `task_set_tabs` PRESERVES it by tab id, for /// the same reason it preserves `session_id`: a rename racing a schedule @@ -840,6 +844,8 @@ pub struct PersistedTabInput { #[serde(default)] pub pinned: bool, #[serde(default)] + pub marked_unread: bool, + #[serde(default)] pub props: Vec, } @@ -8754,6 +8760,7 @@ fn merge_persisted_tabs( pane_leaf_id: if keep_pane { t.pane_leaf_id } else { None }, run_member: if keep_pane { t.run_member } else { None }, pinned: t.pinned, + marked_unread: t.marked_unread, props: t.props, } }) @@ -8787,6 +8794,7 @@ fn task_set_tabs(id: String, tabs: Vec) -> Result<(), String> && a.pane_leaf_id == b.pane_leaf_id && a.run_member == b.run_member && a.pinned == b.pinned + && a.marked_unread == b.marked_unread && a.scheduled == b.scheduled && a.props == b.props }); @@ -30043,6 +30051,18 @@ mod tests { assert!(back.pinned); } + #[test] + fn marked_unread_survives_a_round_trip_and_is_absent_when_false() { + let t = PersistedTab { id: "t1".into(), cli: "claude".into(), marked_unread: true, ..Default::default() }; + let back: PersistedTab = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap(); + assert!(back.marked_unread); + // An unmarked tab writes the record exactly as before the field. + let plain = PersistedTab { id: "t1".into(), cli: "claude".into(), ..Default::default() }; + assert!(!serde_json::to_string(&plain).unwrap().contains("marked_unread")); + let old: PersistedTab = serde_json::from_str(r#"{"id":"t1","cli":"claude"}"#).unwrap(); + assert!(!old.marked_unread); + } + // Scheduled queue messages (GH #300) ride on the tab record. #[test] fn a_task_file_without_scheduled_loads_with_none() { diff --git a/src/components/TaskWorkBadge.tsx b/src/components/TaskWorkBadge.tsx index c4897befb..6f045ad82 100644 --- a/src/components/TaskWorkBadge.tsx +++ b/src/components/TaskWorkBadge.tsx @@ -109,6 +109,23 @@ export function TaskWorkBadge( ); } + // Marked unread by the user: the done bullet's shape, in orange. + if (reason === "marked") { + return ( + + + + ); + } // done — solid blue bullet, iTerm2-style, in --color-info (defined in // @theme; themes can override). h-3.5 visually matches the bell + spinner. return ( diff --git a/src/components/sidebar/RollupMarks.tsx b/src/components/sidebar/RollupMarks.tsx index 68dcdffbd..b097dbce0 100644 --- a/src/components/sidebar/RollupMarks.tsx +++ b/src/components/sidebar/RollupMarks.tsx @@ -26,7 +26,7 @@ export function WorkMarkList({ kinds, badgeTestId }: { kinds: string; badgeTestI ) : ( - + ))} ); diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 4efdc9941..0cc73b7a5 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -12,7 +12,7 @@ import { usePrefs, scheduledNavVisible, taskLocationIconShown, newTaskButtonShow import { Button } from "@/components/ui/Button"; import { Tip } from "@/components/ui/Tooltip"; import { Spinner } from "@/components/ui/Spinner"; -import { LayoutGrid, History, Columns3, CalendarClock, FolderPlus, Settings, Plus, MoreHorizontal, Archive, Layers, Moon, Cog, MoreVertical, GitBranch, GitBranchPlus, FolderGit2, ChevronRight, ChevronDown, Bug, Mail, Zap, X, Pencil, Copy, ChevronsDownUp, ChevronsUpDown, Check, AudioWaveform, Radio, SquareChevronRight, CircleStop, Trash2, Folder, FolderMinus, FolderOpen, Megaphone, Keyboard, Activity, Waypoints, Square, Play, GitPullRequest, SlidersHorizontal } from "lucide-react"; +import { LayoutGrid, History, Columns3, CalendarClock, FolderPlus, Settings, Plus, MoreHorizontal, Archive, Layers, Moon, Cog, MoreVertical, GitBranch, GitBranchPlus, FolderGit2, ChevronRight, ChevronDown, Bug, Mail, Zap, X, Pencil, Copy, ChevronsDownUp, ChevronsUpDown, Check, AudioWaveform, Radio, SquareChevronRight, CircleStop, Trash2, Folder, FolderMinus, FolderOpen, Megaphone, Keyboard, Activity, Waypoints, Square, Play, GitPullRequest, SlidersHorizontal, CircleDot } from "lucide-react"; import { DropdownRoot, DropdownTrigger, DropdownMenu, DropdownItem, DropdownSeparator, DropdownLabel, DropdownSub, DropdownSubTrigger, DropdownSubContent } from "@/components/ui/Dropdown"; import { ContextMenuRoot, ContextMenuTrigger, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuLabel, ContextMenuSub, ContextMenuSubTrigger, ContextMenuSubContent } from "@/components/ui/ContextMenu"; import { ProjectActionsMenuItems } from "./ProjectActionsMenuItems"; @@ -60,7 +60,7 @@ import { useTaskQuery } from "@/hooks/useTaskQuery"; import { setTreeFold, treeFoldKey, type TreeFold } from "@/lib/treeFold"; import { SpawnedFromMark, SpawnLinksOverlay } from "./SpawnLinks"; import { crossProjectStrays, flattenSegments, groupColorCss as taskGroupColorCss, groupLabel, layoutTaskList, liveGroups, nextGroupColor } from "@/lib/taskGroups"; -import { taskNeedsAttention, taskWorkDone, taskWorking, taskDelegated } from "@/lib/taskWorkState"; +import { taskNeedsAttention, taskWorkDone, taskWorking, taskDelegated, taskMarkedUnread } from "@/lib/taskWorkState"; import { delegatedTitle } from "@/lib/delegatedWork"; import { FILE_MANAGER } from "@/lib/openExternal"; import { kbd } from "@/lib/platform"; @@ -2929,6 +2929,7 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, const ensureDefaultTab = useApp(s => s.ensureDefaultTab); const addTab = useApp(s => s.addTab); const resumeClosedTab = useApp(s => s.resumeClosedTab); + const markTabUnread = useApp(s => s.markTabUnread); const setView = useApp(s => s.setView); const renameTab = useApp(s => s.renameTab); const clearTabCustomTitle = useApp(s => s.clearTabCustomTitle); @@ -3055,12 +3056,15 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, // Working aggregate is independent of settledHighlight (it's its own // opt-in pref) but yields to attention/done — a finished or blocked agent // is more actionable than one still chugging. - const hasWorking = !hasAttention && !hasDone && taskWorking(tabs, workPrefs); + // A tab the user marked unread by hand: under done, over working (the + // precedence in taskWorkBadge). + const hasMarked = !hasAttention && !hasDone && taskMarkedUnread(tabs); + const hasWorking = !hasAttention && !hasDone && !hasMarked && taskWorking(tabs, workPrefs); // Lowest rung: something the agent started is still running, and nothing // else wanted the slot. `rowDelegated` is the report itself, for the // tooltip; the first tab that has one speaks for the row, which matches how // every other aggregate here works. - const hasDelegated = !hasAttention && !hasDone && !hasWorking + const hasDelegated = !hasAttention && !hasDone && !hasMarked && !hasWorking && taskDelegated(tabs, workPrefs); const rowDelegated = (tabs.find( t => t.type === "terminal" && !!(t as TerminalTab).delegatedWork, @@ -3120,13 +3124,13 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, setMenuOpen(false); } - // The tab row's "New below": the same activate-first dance as - // spawnIntoTask, but the new tab lands right under the row that was + // The tab row's "New above" / "New below": the same activate-first dance + // as spawnIntoTask, but the new tab lands right next to the row that was // right-clicked instead of at the end of the list. - function spawnBelowTab(afterTabId: string, tab: Tab) { + function spawnNextToTab(anchorId: string, where: "above" | "below", tab: Tab) { setActive(w.id); ensureDefaultTab(w.id, w.cli || "claude"); - addTab(w.id, tab, { afterTabId }); + addTab(w.id, tab, where === "above" ? { beforeTabId: anchorId } : { afterTabId: anchorId }); setTabMenuId(null); } @@ -3158,10 +3162,10 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, for the full bell/check badge, so color carries meaning: warm = needs you, blue = work done. The ring lifts it off the icon regardless of the tile's background. */} - {(hasAttention || hasDone) ? ( + {(hasAttention || hasDone || hasMarked) ? ( ) : hasWorking ? ( // No room for a full spinner on the rail; a faint pulsing dot @@ -3771,6 +3775,7 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, > {collapsed && (hasAttention ? : hasDone ? + : hasMarked ? : hasWorking ? : hasDelegated ? : null)} @@ -3784,10 +3789,12 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, const isTabHot = isTabActive; const showBell = attentionIndicator && tab.unread?.reason === "attention"; const showDone = settledHighlight && !showBell && tab.workState === "done"; - const showWorking = workingIndicator && !showBell && !showDone && tab.workState === "working"; + // Marked unread by hand: under done, over working (taskWorkBadge). + const showMarked = !showBell && !showDone && !!tab.markedUnread; + const showWorking = workingIndicator && !showBell && !showDone && !showMarked && tab.workState === "working"; // An idle tab that still has something running. Lowest priority: it // only ever draws in the slot nothing else wanted. - const showDelegated = workingIndicator && !showBell && !showDone && !showWorking + const showDelegated = workingIndicator && !showBell && !showDone && !showMarked && !showWorking && !!tab.delegatedWork; const rawTitle = tab.customTitle ? tab.title : (tab.liveTitle || tab.title); const title = tab.customTitle @@ -3895,13 +3902,14 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, appears when hovering the badge itself, not the whole row — row hover keeps the badge visible. */} - {(showBell || showDone || showWorking || showDelegated) && ( + {(showBell || showDone || showMarked || showWorking || showDelegated) && ( // Pinned to its own layer, same reason as the task row's // badge above: a transition-only layer gets snapped into // existence and the mark jumps a pixel on hover. {showBell ? : showDone ? + : showMarked ? : showWorking ? : } @@ -3911,7 +3919,7 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, onClick={(e) => { e.stopPropagation(); requestCloseTab(w.id, tab.id); }} className={cn( "absolute inset-0 flex items-center justify-center rounded p-0.5 text-[var(--color-fg-faint)] hover:bg-[var(--color-bg-3)] hover:text-[var(--color-fg)]", - (showBell || showDone || showWorking || showDelegated) + (showBell || showDone || showMarked || showWorking || showDelegated) // Badge visible: X only on badge-slot hover ? "opacity-0 group-hover/badge:opacity-100 pointer-events-none group-hover/badge:pointer-events-auto" // No badge: X on row hover (original behaviour) @@ -3939,6 +3947,39 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, so without this a pick would reach the row's onClick and re-activate the anchor tab over the new one. */}
e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()}> + + + + + {t("tabMenuNewAbove")} + + + + + spawnNextToTab(tab.id, "above", { + id: crypto.randomUUID(), + type: "terminal", + title: agentDisplayName(cli, agents), + cli, + })} + onSpawnShell={() => spawnNextToTab(tab.id, "above", { + id: crypto.randomUUID(), + type: "terminal", + title: t("terminalTabTitle"), + cli: "shell", + })} + onResume={(entryId) => { + setActive(w.id); + ensureDefaultTab(w.id, w.cli || "claude"); + resumeClosedTab(w.id, entryId, { beforeTabId: tab.id }); + setTabMenuId(null); + }} + onMore={() => { setTabMenuId(null); setView("history", { projectId: w.project_id }); }} + /> + + @@ -3950,13 +3991,13 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, spawnBelowTab(tab.id, { + onSpawnCli={(cli) => spawnNextToTab(tab.id, "below", { id: crypto.randomUUID(), type: "terminal", title: agentDisplayName(cli, agents), cli, })} - onSpawnShell={() => spawnBelowTab(tab.id, { + onSpawnShell={() => spawnNextToTab(tab.id, "below", { id: crypto.randomUUID(), type: "terminal", title: t("terminalTabTitle"), @@ -3972,6 +4013,14 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, /> + { setTabMenuId(null); markTabUnread(w.id, tab.id); }} + > + + {t("tabMenuMarkUnread")} + { expect(groupBadgeKinds([idle, idle], ALL, true)).toEqual([]); }); }); + +describe("groupBadgeKinds with a hand-marked tab", () => { + it("rolls the marked dot up between done and working", () => { + const ON = { settledHighlight: true, workingIndicator: true, attentionIndicator: true }; + const marked = [{ id: "m", type: "terminal", markedUnread: true }] as never; + const done = [{ id: "d", type: "terminal", workState: "done" }] as never; + const working = [{ id: "w", type: "terminal", workState: "working" }] as never; + expect(groupBadgeKinds([working, marked, done], ON, true)).toEqual(["done", "marked", "working"]); + }); +}); diff --git a/src/lib/taskGroups.ts b/src/lib/taskGroups.ts index acb45c67d..d4a9de79c 100644 --- a/src/lib/taskGroups.ts +++ b/src/lib/taskGroups.ts @@ -150,8 +150,8 @@ export const flattenSegments = (segs: TaskListSegment[]): Task[] => segs.flatMap(s => (s.kind === "task" ? [s.task] : s.tasks)); /** One mark a collapsed group's caption can carry, in the order drawn. */ -export type GroupBadgeKind = "attention" | "done" | "partial" | "working" | "delegated"; -const GROUP_BADGE_ORDER: GroupBadgeKind[] = ["attention", "done", "partial", "working", "delegated"]; +export type GroupBadgeKind = "attention" | "done" | "partial" | "marked" | "working" | "delegated"; +const GROUP_BADGE_ORDER: GroupBadgeKind[] = ["attention", "done", "partial", "marked", "working", "delegated"]; /** What a COLLAPSED group shows in place of its rows: one of each mark any * member's row would draw, never just the most urgent, so "two finished and diff --git a/src/lib/taskWorkState.test.ts b/src/lib/taskWorkState.test.ts index 1971219c8..98a621091 100644 --- a/src/lib/taskWorkState.test.ts +++ b/src/lib/taskWorkState.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; import { - taskNeedsAttention, taskWorkDone, taskWorking, taskWorkBadge, taskDelegated, unreadClearsOnSight, + taskNeedsAttention, taskWorkDone, taskWorking, taskWorkBadge, taskDelegated, taskMarkedUnread, unreadClearsOnSight, } from "@/lib/taskWorkState"; import type { Tab } from "@/lib/types"; import type { DelegatedWork } from "@/lib/delegatedWork"; @@ -166,3 +166,23 @@ describe("unreadClearsOnSight", () => { expect(unreadClearsOnSight(undefined)).toBe(false); }); }); + +describe("a tab marked unread by hand", () => { + const OFF = { settledHighlight: false, workingIndicator: false, attentionIndicator: false }; + + it("draws the marked dot, whatever the work-state prefs say", () => { + expect(taskMarkedUnread([term({ markedUnread: true } as Partial)])).toBe(true); + expect(taskWorkBadge([term({ markedUnread: true } as Partial)], OFF)).toBe("marked"); + }); + + it("ranks under attention and done, over working", () => { + const m = term({ id: "m", markedUnread: true } as Partial); + expect(taskWorkBadge([m, term({ unread: { reason: "attention" } })], ON)).toBe("attention"); + expect(taskWorkBadge([m, term({ workState: "done" })], ON)).toBe("done"); + expect(taskWorkBadge([m, term({ workState: "working" })], ON)).toBe("marked"); + }); + + it("ignores non-terminal tabs", () => { + expect(taskMarkedUnread([edit({ markedUnread: true } as Partial)])).toBe(false); + }); +}); diff --git a/src/lib/taskWorkState.ts b/src/lib/taskWorkState.ts index 5cf7f9a15..d60db963e 100644 --- a/src/lib/taskWorkState.ts +++ b/src/lib/taskWorkState.ts @@ -18,7 +18,7 @@ import type { Tab } from "./types"; * `cliAgentState.ts` ranks working ABOVE attention for the CLI wire; that * divergence predates this helper and is deliberately not resolved here, * because `TaskSummary.work_state` is a published contract. */ -export type WorkBadgeReason = "attention" | "done" | "working" | "delegated"; +export type WorkBadgeReason = "attention" | "done" | "working" | "delegated" | "marked"; export interface WorkStatePrefs { /** `settledHighlight` — gates the DONE bullet. It used to gate attention @@ -98,6 +98,12 @@ export function taskDelegatedWork(tabs: Tab[], p: WorkStatePrefs) { return (t && t.type === "terminal" ? t.delegatedWork : null) ?? null; } +/** The user marked a tab unread from its sidebar menu (`markedUnread`). Not + * behind a pref: the user asked for this mark by hand. Ranks under done and + * over working, so a spinner cannot hide a reminder the user set. */ +export const taskMarkedUnread = (tabs: Tab[]): boolean => + tabs.some(t => t.type === "terminal" && !!t.markedUnread); + /** The whole precedence in one call: what this row should draw, or null. * * Callers that need the individual flags (the sidebar logs all three to the @@ -106,6 +112,7 @@ export function taskDelegatedWork(tabs: Tab[], p: WorkStatePrefs) { export function taskWorkBadge(tabs: Tab[], p: WorkStatePrefs): WorkBadgeReason | null { if (taskNeedsAttention(tabs, p)) return "attention"; if (taskWorkDone(tabs, p)) return "done"; + if (taskMarkedUnread(tabs)) return "marked"; if (taskWorking(tabs, p)) return "working"; // LAST, and it is the only rung that is not a work state: a tab with nothing // spinning and nothing to announce, that still has something running. It diff --git a/src/lib/types.ts b/src/lib/types.ts index 1b17e1fcc..e8dacab41 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -464,6 +464,8 @@ export interface PersistedTab { run_member?: string | null; /** Pinned state, so a pinned tab comes back pinned and leftmost. */ pinned?: boolean; + /** Marked unread from the sidebar menu, so the mark survives a relaunch. */ + marked_unread?: boolean; /** Scheduled queue messages (GH #300). Absent when there are none (Rust * skips an empty list). Owned by `taskSetTabScheduled`; `taskSetTabs` * never writes it. */ @@ -1507,6 +1509,10 @@ export interface BaseTab { * `OSC 1337;RequestAttention=yes|fireworks`. Cleared when the tab * becomes active. Renders as a small dot independent of unread. */ needsAttention?: boolean; + /** The user marked this tab unread from its sidebar menu. Draws an orange + * dot on the tab row and the task row until the tab is next shown. + * Not `unread`: that is agent news and drives the OS notification. */ + markedUnread?: boolean; /** Triggered when the tab requires user attention (BEL, idle, exit, * agent-emitted "done" or explicit "attention" — agent is blocked * waiting for the user to approve/answer). */ diff --git a/src/locales/en/chrome.ts b/src/locales/en/chrome.ts index 0108e17bb..de549e26d 100644 --- a/src/locales/en/chrome.ts +++ b/src/locales/en/chrome.ts @@ -125,6 +125,7 @@ export default { attention: "Agent needs your input", done: "Agent finished a turn", doneAria: "Work done", + marked: "Marked unread", doneDelegated: "Agent finished a turn, {{held}}", doneDelegatedRunning: "Agent finished a turn, {{held}} still running", delegated: "Background work still running", diff --git a/src/locales/en/sidebar.ts b/src/locales/en/sidebar.ts index 3ac6593c3..f6e00baf8 100644 --- a/src/locales/en/sidebar.ts +++ b/src/locales/en/sidebar.ts @@ -122,7 +122,9 @@ export default { stopTask: "Stop task", archiveTask: "Archive task", closeTab: "Close tab", + tabMenuNewAbove: "New above", tabMenuNewBelow: "New below", + tabMenuMarkUnread: "Mark as unread", tabMenuClose: "Close", terminalTabTitle: "Terminal", taskNamePlaceholder: "Task name", diff --git a/src/locales/zh-CN/chrome.ts b/src/locales/zh-CN/chrome.ts index 4b27ce7b4..1c152c0a0 100644 --- a/src/locales/zh-CN/chrome.ts +++ b/src/locales/zh-CN/chrome.ts @@ -125,6 +125,7 @@ export default { attention: "智能体需要你的输入", done: "智能体完成了一轮", doneAria: "工作完成", + marked: "标记为未读", doneDelegated: "智能体完成了一轮,{{held}}", doneDelegatedRunning: "智能体完成了一轮,{{held}} 仍在运行", delegated: "有后台工作仍在运行", diff --git a/src/locales/zh-CN/sidebar.ts b/src/locales/zh-CN/sidebar.ts index b5350dca5..276447ebe 100644 --- a/src/locales/zh-CN/sidebar.ts +++ b/src/locales/zh-CN/sidebar.ts @@ -122,7 +122,9 @@ export default { stopTask: "停止任务", archiveTask: "归档任务", closeTab: "关闭标签页", + tabMenuNewAbove: "在上方新建", tabMenuNewBelow: "在下方新建", + tabMenuMarkUnread: "标记为未读", tabMenuClose: "关闭", terminalTabTitle: "终端", taskNamePlaceholder: "任务名称", diff --git a/src/store/app.test.ts b/src/store/app.test.ts index be0f79617..986cd7bf8 100644 --- a/src/store/app.test.ts +++ b/src/store/app.test.ts @@ -877,6 +877,111 @@ describe("addTab afterTabId", () => { }); }); +// ── addTab beforeTabId (sidebar tab row "New above") ───────────────── + +describe("addTab beforeTabId", () => { + const ids = getTabIds; + function seed(taskId: string, n: number) { + for (let i = 0; i < n; i++) addTab(taskId, makeTermTab({ id: `t${i}` })); + } + + it("inserts directly before the anchor", () => { + seed("ws1", 3); + useApp.getState().addTab("ws1", makeTermTab({ id: "new" }), { beforeTabId: "t1" }); + expect(ids("ws1")).toEqual(["t0", "new", "t1", "t2"]); + }); + + it("can put the new tab first", () => { + seed("ws1", 2); + useApp.getState().addTab("ws1", makeTermTab({ id: "new" }), { beforeTabId: "t0" }); + expect(ids("ws1")).toEqual(["new", "t0", "t1"]); + }); + + it("appends when the anchor is gone", () => { + seed("ws1", 2); + useApp.getState().addTab("ws1", makeTermTab({ id: "new" }), { beforeTabId: "nope" }); + expect(ids("ws1")).toEqual(["t0", "t1", "new"]); + }); + + it("keeps an unpinned tab out of the pinned block, above a pinned tab too", () => { + addTab("ws1", makeTermTab({ id: "p0", pinned: true })); + addTab("ws1", makeTermTab({ id: "p1", pinned: true })); + addTab("ws1", makeTermTab({ id: "t0" })); + useApp.getState().addTab("ws1", makeTermTab({ id: "new" }), { beforeTabId: "p1" }); + expect(ids("ws1")).toEqual(["p0", "p1", "new", "t0"]); + }); + + it("resumeClosedTab honours it too", () => { + seed("ws1", 3); + useApp.setState(s => ({ + closedTabs: { ...s.closedTabs, ws1: [{ id: "e1", tabId: "back", title: "Claude", cli: "claude", closedAt: new Date().toISOString() } as never] }, + })); + useApp.getState().resumeClosedTab("ws1", "e1", { beforeTabId: "t1" }); + expect(ids("ws1")).toEqual(["t0", "back", "t1", "t2"]); + }); +}); + +// ── markTabUnread (sidebar tab row "Mark as unread") ───────────────── + +describe("markTabUnread", () => { + const marked = (id: string) => + !!(useApp.getState().tabs["ws1"].find(t => t.id === id) as TerminalTab).markedUnread; + const lastPersisted = () => { + const calls = vi.mocked(ipc.taskSetTabs).mock.calls; + return calls[calls.length - 1]?.[1] as PersistedTab[]; + }; + + beforeEach(() => { + useApp.setState({ tasks: [makeTask({ id: "ws1" })] } as never); + for (const id of ["t0", "t1"]) addTab("ws1", makeTermTab({ id, cli: "claude" })); + }); + + it("marks the tab and writes the mark to disk", () => { + useApp.getState().markTabUnread("ws1", "t1"); + expect(marked("t1")).toBe(true); + expect(marked("t0")).toBe(false); + expect(lastPersisted().map(p => !!p.marked_unread)).toEqual([false, true]); + }); + + it("clears when that tab is shown, on disk too", () => { + useApp.getState().markTabUnread("ws1", "t1"); + useApp.getState().setActiveTabId("ws1", "t1"); + expect(marked("t1")).toBe(false); + expect(lastPersisted().some(p => p.marked_unread)).toBe(false); + }); + + it("stays when another tab of the task is shown", () => { + useApp.getState().markTabUnread("ws1", "t1"); + useApp.getState().setActiveTabId("ws1", "t0"); + expect(marked("t1")).toBe(true); + }); + + it("opening the task clears only the tab it lands on", () => { + useApp.getState().markTabUnread("ws1", "t0"); + useApp.getState().markTabUnread("ws1", "t1"); + useApp.setState(s => ({ activeTab: { ...s.activeTab, ws1: "t0" }, activeTaskId: null })); + useApp.getState().setActiveTask("ws1"); + expect(marked("t0")).toBe(false); + expect(marked("t1")).toBe(true); + expect(lastPersisted().map(p => !!p.marked_unread)).toEqual([false, true]); + }); + + it("is not agent news: no unread, so no notification", () => { + useApp.getState().markTabUnread("ws1", "t1"); + const t = useApp.getState().tabs["ws1"].find(x => x.id === "t1") as TerminalTab; + expect(t.unread ?? null).toBeNull(); + }); + + it("an already marked tab is not written again", () => { + useApp.getState().markTabUnread("ws1", "t1"); + vi.mocked(ipc.taskSetTabs).mockClear(); + const before = useApp.getState().tabs; + useApp.getState().markTabUnread("ws1", "t1"); + expect(useApp.getState().tabs).toBe(before); + expect(ipc.taskSetTabs).not.toHaveBeenCalled(); + }); +}); + // ── pin / unpin (issue #183) ────────────────────────────────────────── describe("pinTab / unpinTab", () => { @@ -1026,6 +1131,18 @@ describe("ensureDefaultTab — seed / restore / migrate", () => { expect(tabs.map(t => !!t.pinned)).toEqual([true, false]); }); + it("restores a tab marked unread", () => { + const persisted: PersistedTab[] = [ + { id: "t1", cli: "claude", is_default: true }, + { id: "t2", cli: "codex", marked_unread: true }, + ]; + useApp.setState({ tasks: [makeTask({ persisted_tabs: persisted })] }); + useApp.getState().ensureDefaultTab("ws1", "claude"); + + const tabs = useApp.getState().tabs["ws1"]; + expect(tabs.map(t => !!(t as TerminalTab).markedUnread)).toEqual([false, true]); + }); + it("re-derives the title for tabs the user never renamed", () => { const persisted: PersistedTab[] = [ { id: "t1", cli: "gemini", is_default: true, custom_title: false, title: "stale" }, diff --git a/src/store/app.ts b/src/store/app.ts index 968d94374..308297391 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -378,8 +378,12 @@ export interface AppState { /** `opts.focus` (default true) — pass false to add the tab without * activating or stealing keyboard focus (e.g. a background setup run). * `opts.afterTabId` inserts right after that tab instead of appending - * (the sidebar tab row's "New below"); an unknown id appends. */ - addTab: (taskId: string, tab: Tab, opts?: { focus?: boolean; afterTabId?: string }) => void; + * (the sidebar tab row's "New below"); an unknown id appends. + * `opts.beforeTabId` inserts right before that tab instead ("New above"). */ + addTab: (taskId: string, tab: Tab, opts?: { focus?: boolean; afterTabId?: string; beforeTabId?: string }) => void; + /** Mark a tab unread from the sidebar menu (`markedUnread`). Persisted; + * cleared the next time the tab is shown. */ + markTabUnread: (taskId: string, tabId: string) => void; /** Add a terminal tab to whichever pane is active: the focused split pane * (wired into its tab strip) or the main pane. Used by the Run pop-out * (GH #54) so the run terminal lands where the user is looking. @@ -399,7 +403,7 @@ export interface AppState { * `sessionId` so the agent resumes via `--resume ` (see * `decideResume` in TerminalPane) instead of starting a new * conversation. Removes the entry from `closedTabs` once reopened. */ - resumeClosedTab: (taskId: string, entryId: string, opts?: { afterTabId?: string }) => void; + resumeClosedTab: (taskId: string, entryId: string, opts?: { afterTabId?: string; beforeTabId?: string }) => void; setActiveTabId: (taskId: string, tabId: string) => void; /** Show a place WITHOUT treating it as a visit — see the implementation. */ previewPlace: (taskId: string, tabId: string) => void; @@ -589,6 +593,7 @@ function durablePersistedTabs(tabs: Tab[] | undefined): PersistedTab[] { // in its pane on relaunch (the run script re-fires, like custom tabs). run_member: t.runTab ? t.runTab.member : null, pinned: !!t.pinned, + ...(t.markedUnread ? { marked_unread: true } : {}), // The store is the ONLY writer of tab properties (GH #358), so the // full list rides every sync and the disk copy simply follows it. ...(t.props?.length ? { props: t.props } : {}), @@ -615,10 +620,12 @@ function withPriorScheduled(next: PersistedTab[], prior: PersistedTab[]): Persis * is not one: a split-pane tab keeps its array slot and can sit between two * pinned tabs ([A(P), X(pane), C(P)]), and stopping there would put the new * tab between A and C in the strip. The `sidebarTabMoveIndex` rule. */ -function insertTab(list: Tab[], tab: Tab, afterTabId?: string): Tab[] { - const anchor = afterTabId ? list.findIndex(t => t.id === afterTabId) : -1; +function insertTab(list: Tab[], tab: Tab, afterTabId?: string, beforeTabId?: string): Tab[] { + const anchorId = beforeTabId ?? afterTabId; + const anchor = anchorId ? list.findIndex(t => t.id === anchorId) : -1; if (anchor < 0) return [...list, tab]; - let at = anchor + 1; + // "Above" a tab is its own slot; the pin rule below applies the same way. + let at = beforeTabId ? anchor : anchor + 1; if (!tab.pinned) { let pinEnd = 0; list.forEach((t, i) => { if (t.pinned && !(t as TerminalTab).paneId) pinEnd = i + 1; }); @@ -1098,6 +1105,8 @@ export const useApp = create((set, get) => ({ if (unreadClearsOnSight(t.unread)) { nt = { ...nt, unread: null }; } + // Only the tab on screen counts as seen for a manual mark. + if (t.id === activeId && t.markedUnread) nt = { ...nt, markedUnread: false }; const clearable = t.workState === "done" || (t.workState === "working" && visitMayClearWorking(s.agentHooksInstalled, t.cli)); if (t.id === activeId && clearable) { @@ -1113,6 +1122,7 @@ export const useApp = create((set, get) => ({ }); return { tabs: { ...s.tabs, [id]: next } }; }); + if (tabs.some(t => t.id === activeId && t.type === "terminal" && t.markedUnread)) get().syncDurableTabs(id); } }, @@ -1969,6 +1979,7 @@ export const useApp = create((set, get) => ({ ...(pt.agent_args?.length ? { agentArgs: pt.agent_args } : {}), ...(unattendedRestore && pt.is_default ? { unattended: true } : {}), ...(pt.pinned ? { pinned: true } : {}), + ...(pt.marked_unread ? { markedUnread: true } : {}), ...(pt.scheduled?.length ? { queue: hydrateScheduled(pt.scheduled) } : {}), ...(pt.props?.length ? { props: pt.props } : {}), // idle: restored run tabs keep their spot but never auto-fire the @@ -2014,6 +2025,7 @@ export const useApp = create((set, get) => ({ customTitle: !!pt.custom_title, paneId: pt.pane_leaf_id!, ...(pt.pinned ? { pinned: true } : {}), + ...(pt.marked_unread ? { markedUnread: true } : {}), ...(pt.command ? { command: pt.command } : {}), ...(pt.session_id ? { sessionId: pt.session_id } : {}), ...(pt.agent_args?.length ? { agentArgs: pt.agent_args } : {}), @@ -2215,7 +2227,7 @@ export const useApp = create((set, get) => ({ addTab: (taskId, tab, opts) => { const focus = opts?.focus ?? true; set(s => { - const next = insertTab(s.tabs[taskId] || [], tab, opts?.afterTabId); + const next = insertTab(s.tabs[taskId] || [], tab, opts?.afterTabId, opts?.beforeTabId); return { tabs: { ...s.tabs, [taskId]: next }, ...(focus ? { activeTab: { ...s.activeTab, [taskId]: tab.id } } : {}), @@ -2428,61 +2440,75 @@ export const useApp = create((set, get) => ({ sessionId: entry.sessionId ?? undefined, agentArgs: entry.agentArgs, }; - get().addTab(taskId, tab, { afterTabId: opts?.afterTabId }); + get().addTab(taskId, tab, { afterTabId: opts?.afterTabId, beforeTabId: opts?.beforeTabId }); }, - setActiveTabId: (taskId, tabId) => set(s => { - // Looking at a tab = "I've seen this / I'm dealing with it now." - // Clear EVERY status flag on focus: - // - unread (bell/attention/exit) - // - workState (done OR working) → idle - // Clearing "working" on focus is the manual escape hatch for - // stuck spinners, and it applies ONLY to agents whose state is read - // from their terminal (see visitMayClearWorking). For an agent that - // reports its own state there is nothing to escape from, and the - // clear was destroying live state that nothing put back. - // Historically it was unconditional because agents like Claude Code - // stream continuously (thinking dots, elapsed counter), defeating every automatic - // demoter — focusing the tab is now the user's "I see it, kill - // the spinner" gesture. The next sender signal / submit will - // re-arm working if work is actually still happening. - const list = s.tabs[taskId] || []; - const now = Date.now(); - const next = list.map(t => { - if (t.id !== tabId) return t; - // Terminal tabs carry workState — clear via a typed TerminalTab - // patch. Edit/diff tabs only have `unread` to clear. - if (t.type === "terminal") { - const patch: Partial = {}; - // Not an attention bell: the agent is still waiting on an answer. - if (unreadClearsOnSight(t.unread)) patch.unread = null; - // "Some of the delegated work came back" is news, and looking at the - // tab is reading it: drop the partial mark and keep the plain - // delegated ring, which still says the rest is running. - if (t.delegatedWork?.partial) patch.delegatedWork = { ...t.delegatedWork, partial: false }; - if (t.workState === "done" - || (t.workState === "working" && visitMayClearWorking(s.agentHooksInstalled, t.cli))) { - patch.workState = "idle"; - patch.workProgress = null; - patch.workProgressKind = null; - patch.workClearedAt = now; + setActiveTabId: (taskId, tabId) => { + const marked = (get().tabs[taskId] || []).some(t => t.id === tabId && t.type === "terminal" && t.markedUnread); + set(s => { + // Looking at a tab = "I've seen this / I'm dealing with it now." + // Clear EVERY status flag on focus: + // - unread (bell/attention/exit) + // - workState (done OR working) → idle + // Clearing "working" on focus is the manual escape hatch for + // stuck spinners, and it applies ONLY to agents whose state is read + // from their terminal (see visitMayClearWorking). For an agent that + // reports its own state there is nothing to escape from, and the + // clear was destroying live state that nothing put back. + // Historically it was unconditional because agents like Claude Code + // stream continuously (thinking dots, elapsed counter), defeating every automatic + // demoter — focusing the tab is now the user's "I see it, kill + // the spinner" gesture. The next sender signal / submit will + // re-arm working if work is actually still happening. + const list = s.tabs[taskId] || []; + const now = Date.now(); + const next = list.map(t => { + if (t.id !== tabId) return t; + // Terminal tabs carry workState — clear via a typed TerminalTab + // patch. Edit/diff tabs only have `unread` to clear. + if (t.type === "terminal") { + const patch: Partial = {}; + // Not an attention bell: the agent is still waiting on an answer. + if (unreadClearsOnSight(t.unread)) patch.unread = null; + // A tab the user marked unread is read once it is shown again. + if (t.markedUnread) patch.markedUnread = false; + // "Some of the delegated work came back" is news, and looking at the + // tab is reading it: drop the partial mark and keep the plain + // delegated ring, which still says the rest is running. + if (t.delegatedWork?.partial) patch.delegatedWork = { ...t.delegatedWork, partial: false }; + if (t.workState === "done" + || (t.workState === "working" && visitMayClearWorking(s.agentHooksInstalled, t.cli))) { + patch.workState = "idle"; + patch.workProgress = null; + patch.workProgressKind = null; + patch.workClearedAt = now; + } + return Object.keys(patch).length ? { ...t, ...patch } : t; } - return Object.keys(patch).length ? { ...t, ...patch } : t; - } - // Every non-terminal tab type has only `unread` to clear. Spreading a - // `Partial` over the union widens `type` back to the union - // (TS distributes the spread, not the narrowing), so cast the result - // rather than the patch. - // A pad's own "changed since you looked" mark clears the same way. - if (t.type === "scratch" && t.unseen) return { ...t, unread: null, unseen: false } as Tab; - if (t.unread) return { ...t, unread: null } as Tab; - return t; + // Every non-terminal tab type has only `unread` to clear. Spreading a + // `Partial` over the union widens `type` back to the union + // (TS distributes the spread, not the narrowing), so cast the result + // rather than the patch. + // A pad's own "changed since you looked" mark clears the same way. + if (t.type === "scratch" && t.unseen) return { ...t, unread: null, unseen: false } as Tab; + if (t.unread) return { ...t, unread: null } as Tab; + return t; + }); + return { + activeTab: { ...s.activeTab, [taskId]: tabId }, + tabs: { ...s.tabs, [taskId]: next }, + }; }); - return { - activeTab: { ...s.activeTab, [taskId]: tabId }, - tabs: { ...s.tabs, [taskId]: next }, - }; - }), + // The mark is on disk, so seeing the tab has to clear it there too. + if (marked) get().syncDurableTabs(taskId); + }, + + markTabUnread: (taskId, tabId) => { + const tab = (get().tabs[taskId] || []).find(t => t.id === tabId); + if (!tab || tab.type !== "terminal" || tab.markedUnread) return; + get().patchTab(taskId, tabId, { markedUnread: true }); + get().syncDurableTabs(taskId); + }, /** * Show a place, without any of the bookkeeping that means "the user is now From 5557572cf3758ea27b580dc838be3d386c505994 Mon Sep 17 00:00:00 2001 From: Jonas Utke Date: Thu, 15 Oct 2026 13:58:58 +0200 Subject: [PATCH 2/3] fix(sidebar): a tab marked unread while open stays marked until it is revisited Clicking another tab row re-activates the task first, and setActiveTask read the marked tab still on screen as seen, so the dot went the moment the user left. The mark now clears only when its tab comes on screen: setActiveTask on a task that was not active, setActiveTabId on a tab that was not already showing. Co-Authored-By: Claude Opus 5.5 --- docs/ui.md | 9 ++++++--- e2e/specs/tabs-layout.e2e.ts | 24 ++++++++++++++++++++++++ src/store/app.test.ts | 19 +++++++++++++++++++ src/store/app.ts | 19 ++++++++++++++----- 4 files changed, 63 insertions(+), 8 deletions(-) diff --git a/docs/ui.md b/docs/ui.md index e5e9fbadc..7c16d426c 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -468,9 +468,12 @@ entries: that reads `taskWorkBadge`: it ranks under attention and done and over working, and no pref hides it, since the user set it by hand. It is deliberately not `unread`, which is agent news and feeds the OS - notification. Showing that tab clears it: `setActiveTabId` on it, or - `setActiveTask` when it is the task's active tab. Opening the task on - another tab leaves it. It is persisted (`marked_unread`), so it + notification. The tab coming on screen clears it: `setActiveTabId` on + a tab that was not already showing, or `setActiveTask` on a task that + was not active, for its active tab. Picking the tab already on screen + again is not a visit (every sidebar row click re-activates its task + first), so marking the open tab lasts until you leave it and come back. + Opening the task on another tab leaves it. It is persisted (`marked_unread`), so it survives a relaunch, and only agent tabs persist at all. - **Close** is `requestCloseTab`, so a running agent still asks first. diff --git a/e2e/specs/tabs-layout.e2e.ts b/e2e/specs/tabs-layout.e2e.ts index 2dd8e15d7..d0c211818 100644 --- a/e2e/specs/tabs-layout.e2e.ts +++ b/e2e/specs/tabs-layout.e2e.ts @@ -604,6 +604,30 @@ describe("sidebar tab row menu", () => { }, { timeout: 10_000, timeoutMsg: "the cleared mark never left the task file" }); }); + it("Mark as unread on the open tab lasts until it is left and shown again", async () => { + const open = await browser.execute((id) => window.__termic!.useApp.getState().activeTab[id], taskId) as string; + const other = (await tabIds(taskId)).find((id) => id !== open)!; + const openSel = `[data-sidebar-tab-id="${open}"]`; + await openTabRowMenu(open); + await clickMenuItem("Mark as unread"); + await waitGone('[data-testid="sidebar-tab-menu"]', 5_000); + await browser.waitUntil(async () => (await rowMark(openSel)) === "marked", + { timeout: 5_000, timeoutMsg: "the open tab never drew its marked dot" }); + + // Leaving through another row: that click re-activates the task first, + // which must not read the marked tab as seen on the way out. + await clickWhenVisible(`[data-sidebar-tab-id="${other}"]`); + await browser.waitUntil(async () => + (await browser.execute((id) => window.__termic!.useApp.getState().activeTab[id], taskId)) === other, + { timeout: 5_000, timeoutMsg: "the other tab never became active" }); + expect(await rowMark(openSel)).toBe("marked"); + + // Coming back is the visit that reads it. + await clickWhenVisible(openSel); + await browser.waitUntil(async () => (await rowMark(openSel)) === null, + { timeout: 5_000, timeoutMsg: "the dot stayed after the tab was shown again" }); + }); + it("Close removes the clicked tab and only that one", async () => { const before = await tabIds(taskId); const shell = await browser.execute((id) => diff --git a/src/store/app.test.ts b/src/store/app.test.ts index 986cd7bf8..5f19c8d04 100644 --- a/src/store/app.test.ts +++ b/src/store/app.test.ts @@ -944,6 +944,7 @@ describe("markTabUnread", () => { }); it("clears when that tab is shown, on disk too", () => { + useApp.getState().setActiveTabId("ws1", "t0"); useApp.getState().markTabUnread("ws1", "t1"); useApp.getState().setActiveTabId("ws1", "t1"); expect(marked("t1")).toBe(false); @@ -966,6 +967,24 @@ describe("markTabUnread", () => { expect(lastPersisted().map(p => !!p.marked_unread)).toEqual([false, true]); }); + it("marking the tab on screen lasts until it is left and shown again", () => { + // The sidebar row click: setActiveTask on the task already on screen, + // then setActiveTabId on the row. Neither may read the marked tab as seen. + useApp.setState(s => ({ activeTab: { ...s.activeTab, ws1: "t0" }, activeTaskId: "ws1" })); + useApp.getState().markTabUnread("ws1", "t0"); + useApp.getState().setActiveTask("ws1"); + useApp.getState().setActiveTabId("ws1", "t1"); + expect(marked("t0")).toBe(true); + // Picking the tab that is already showing is not a visit either. + useApp.getState().markTabUnread("ws1", "t1"); + useApp.getState().setActiveTabId("ws1", "t1"); + expect(marked("t1")).toBe(true); + // Coming back is. + useApp.getState().setActiveTask("ws1"); + useApp.getState().setActiveTabId("ws1", "t0"); + expect(marked("t0")).toBe(false); + }); + it("is not agent news: no unread, so no notification", () => { useApp.getState().markTabUnread("ws1", "t1"); const t = useApp.getState().tabs["ws1"].find(x => x.id === "t1") as TerminalTab; diff --git a/src/store/app.ts b/src/store/app.ts index 308297391..80a17932f 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -1105,8 +1105,12 @@ export const useApp = create((set, get) => ({ if (unreadClearsOnSight(t.unread)) { nt = { ...nt, unread: null }; } - // Only the tab on screen counts as seen for a manual mark. - if (t.id === activeId && t.markedUnread) nt = { ...nt, markedUnread: false }; + // A manual mark clears only when its tab COMES on screen: the task + // was not active before. Re-activating the task already on + // screen (every sidebar tab-row click does, before it picks the + // tab) is not a visit, or marking the open tab and clicking away + // would clear it on the way out. + if (t.id === activeId && t.markedUnread && prev !== id) nt = { ...nt, markedUnread: false }; const clearable = t.workState === "done" || (t.workState === "working" && visitMayClearWorking(s.agentHooksInstalled, t.cli)); if (t.id === activeId && clearable) { @@ -1122,7 +1126,7 @@ export const useApp = create((set, get) => ({ }); return { tabs: { ...s.tabs, [id]: next } }; }); - if (tabs.some(t => t.id === activeId && t.type === "terminal" && t.markedUnread)) get().syncDurableTabs(id); + if (prev !== id && tabs.some(t => t.id === activeId && t.type === "terminal" && t.markedUnread)) get().syncDurableTabs(id); } }, @@ -2444,7 +2448,12 @@ export const useApp = create((set, get) => ({ }, setActiveTabId: (taskId, tabId) => { - const marked = (get().tabs[taskId] || []).some(t => t.id === tabId && t.type === "terminal" && t.markedUnread); + // A manual mark clears when the tab COMES on screen, not when the tab + // that is already showing is picked again: marking the open tab must + // last until the user leaves it and comes back. + const alreadyShown = get().activeTab[taskId] === tabId; + const marked = !alreadyShown + && (get().tabs[taskId] || []).some(t => t.id === tabId && t.type === "terminal" && t.markedUnread); set(s => { // Looking at a tab = "I've seen this / I'm dealing with it now." // Clear EVERY status flag on focus: @@ -2471,7 +2480,7 @@ export const useApp = create((set, get) => ({ // Not an attention bell: the agent is still waiting on an answer. if (unreadClearsOnSight(t.unread)) patch.unread = null; // A tab the user marked unread is read once it is shown again. - if (t.markedUnread) patch.markedUnread = false; + if (marked) patch.markedUnread = false; // "Some of the delegated work came back" is news, and looking at the // tab is reading it: drop the partial mark and keep the plain // delegated ring, which still says the rest is running. From e8b009591a3a974beb3c3bd0c1a01c7791a57aa0 Mon Sep 17 00:00:00 2001 From: Jonas Utke Date: Thu, 15 Oct 2026 14:08:30 +0200 Subject: [PATCH 3/3] fix(sidebar): clear a manual unread mark only where its tab comes on screen Review follow-ups for Mark as unread: - A tab row click picked the task before the tab, so setActiveTask read the task's previous tab as seen and cleared its mark although it never showed. Rows (tree and status section) now pick the tab first, and a tab counts as already showing only when its task is the one on screen. - A marked tab moved into a split pane has no sidebar row, so nothing could clear it; moving it there clears it. - A close or a move that hands the main pane to a marked tab writes activeTab directly, and a click on it is no visit; clearShownMark clears it there when the task is on screen. Co-Authored-By: Claude Opus 5.5 --- docs/ui.md | 10 ++++-- e2e/specs/tabs-layout.e2e.ts | 24 +++++++++++++ src/components/sidebar/Sidebar.tsx | 5 ++- src/components/sidebar/StatusSection.tsx | 5 +-- src/store/app.test.ts | 43 ++++++++++++++++++++++++ src/store/app.ts | 38 +++++++++++++++++++-- 6 files changed, 117 insertions(+), 8 deletions(-) diff --git a/docs/ui.md b/docs/ui.md index 7c16d426c..08d2eadc7 100644 --- a/docs/ui.md +++ b/docs/ui.md @@ -473,8 +473,14 @@ entries: was not active, for its active tab. Picking the tab already on screen again is not a visit (every sidebar row click re-activates its task first), so marking the open tab lasts until you leave it and come back. - Opening the task on another tab leaves it. It is persisted (`marked_unread`), so it - survives a relaunch, and only agent tabs persist at all. + Opening the task on another tab leaves it, which is why a tab row click + picks its tab BEFORE activating the task (`setActiveTask` reads the + task's current active tab as seen). A close or a move that hands the + main pane to a marked tab clears it (`clearShownMark`), and a tab moved + into a split pane loses it, since a pane tab has no sidebar row to clear + it from. It is persisted (`marked_unread`), so it survives a relaunch, + and only agent tabs persist at all. The one exception is the open tab of + the task on screen: the relaunch shows it again, which reads it. - **Close** is `requestCloseTab`, so a running agent still asks first. It is a Radix `DropdownMenu` opened on `contextmenu`, the task row's diff --git a/e2e/specs/tabs-layout.e2e.ts b/e2e/specs/tabs-layout.e2e.ts index d0c211818..e01947f0b 100644 --- a/e2e/specs/tabs-layout.e2e.ts +++ b/e2e/specs/tabs-layout.e2e.ts @@ -628,6 +628,30 @@ describe("sidebar tab row menu", () => { { timeout: 5_000, timeoutMsg: "the dot stayed after the tab was shown again" }); }); + it("opening the task through another tab row leaves the mark on the tab it last showed", async () => { + const last = await browser.execute((id) => window.__termic!.useApp.getState().activeTab[id], taskId) as string; + const other = (await tabIds(taskId)).find((id) => id !== last)!; + const lastSel = `[data-sidebar-tab-id="${last}"]`; + await openTabRowMenu(last); + await clickMenuItem("Mark as unread"); + await waitGone('[data-testid="sidebar-tab-menu"]', 5_000); + await browser.waitUntil(async () => (await rowMark(lastSel)) === "marked", + { timeout: 5_000, timeoutMsg: "the tab never drew its marked dot" }); + // Setup: the task is no longer the one on screen. + await browser.execute(() => window.__termic!.useApp.getState().setActiveTask(null)); + + await clickWhenVisible(`[data-sidebar-tab-id="${other}"]`); + await browser.waitUntil(async () => (await browser.execute( + (id) => { const s = window.__termic!.useApp.getState(); return s.activeTaskId === id ? s.activeTab[id] : null; }, + taskId)) === other, { timeout: 5_000, timeoutMsg: "the clicked tab never came up" }); + // The tab the task last showed never came on screen, so it is unread still. + expect(await rowMark(lastSel)).toBe("marked"); + + await clickWhenVisible(lastSel); + await browser.waitUntil(async () => (await rowMark(lastSel)) === null, + { timeout: 5_000, timeoutMsg: "the dot stayed after the tab was shown" }); + }); + it("Close removes the clicked tab and only that one", async () => { const before = await tabIds(taskId); const shell = await browser.execute((id) => diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index 0cc73b7a5..347f347c8 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -3819,8 +3819,11 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, onClick={() => { // The click that trails a drop is not a selection. if (tabDrag.clickSuppressed.current) { tabDrag.clickSuppressed.current = false; return; } - setActive(w.id); + // Tab first: activating the task reads its CURRENT active tab + // as seen, which must be this row's tab, not the one the task + // last showed (a manual unread mark on that one would go). setActiveTabId(w.id, tab.id); + setActive(w.id); }} onDoubleClick={(e) => { e.stopPropagation(); diff --git a/src/components/sidebar/StatusSection.tsx b/src/components/sidebar/StatusSection.tsx index 1cbdfa50c..661447de9 100644 --- a/src/components/sidebar/StatusSection.tsx +++ b/src/components/sidebar/StatusSection.tsx @@ -511,10 +511,11 @@ function StatusTaskTabs({ taskId, agents, workPrefs }: { data-active={hot || undefined} role="button" tabIndex={0} - onClick={() => { setActive(taskId); setActiveTabId(taskId, tab.id); }} + // Tab first, as in the tree's tab rows (see there). + onClick={() => { setActiveTabId(taskId, tab.id); setActive(taskId); }} onKeyDown={ev => { if (ev.key === "Enter" || ev.key === " ") { - ev.preventDefault(); setActive(taskId); setActiveTabId(taskId, tab.id); + ev.preventDefault(); setActiveTabId(taskId, tab.id); setActive(taskId); } }} className={cn( diff --git a/src/store/app.test.ts b/src/store/app.test.ts index 5f19c8d04..721f3c621 100644 --- a/src/store/app.test.ts +++ b/src/store/app.test.ts @@ -11,6 +11,7 @@ vi.mock("@/lib/ipc", () => ({ detectClis: vi.fn().mockResolvedValue([]), taskSetTabs: vi.fn().mockResolvedValue(undefined), taskSetTabSessionId: vi.fn().mockResolvedValue(undefined), + taskSetSplitLayout: vi.fn().mockResolvedValue(undefined), })); vi.mock("@/lib/tabFocus", () => ({ @@ -985,6 +986,48 @@ describe("markTabUnread", () => { expect(marked("t0")).toBe(false); }); + it("opening an inactive task on another tab leaves its last tab's mark", () => { + // The row click order: the row's tab first, then the task. + useApp.setState(s => ({ activeTab: { ...s.activeTab, ws1: "t0" }, activeTaskId: null })); + useApp.getState().markTabUnread("ws1", "t0"); + useApp.getState().setActiveTabId("ws1", "t1"); + useApp.getState().setActiveTask("ws1"); + expect(marked("t0")).toBe(true); + }); + + it("picking the remembered tab of an inactive task is a visit", () => { + useApp.setState(s => ({ activeTab: { ...s.activeTab, ws1: "t0" }, activeTaskId: null })); + useApp.getState().markTabUnread("ws1", "t0"); + useApp.getState().setActiveTabId("ws1", "t0"); + expect(marked("t0")).toBe(false); + }); + + it("a marked tab moved into a split pane loses the mark", () => { + addTab("ws1", makeTermTab({ id: "t2", cli: "claude" })); + useApp.getState().markTabUnread("ws1", "t2"); + useApp.getState().moveTabToSplit("ws1", "t2", null, "right"); + expect(marked("t2")).toBe(false); + expect(lastPersisted().some(p => p.marked_unread)).toBe(false); + }); + + it("a marked tab that takes over the main pane on a close is read", () => { + useApp.setState({ activeTaskId: "ws1" } as never); + useApp.getState().setActiveTabId("ws1", "t1"); + useApp.getState().markTabUnread("ws1", "t0"); + useApp.getState().closeTab("ws1", "t1"); + expect(useApp.getState().activeTab["ws1"]).toBe("t0"); + expect(marked("t0")).toBe(false); + expect(lastPersisted().some(p => p.marked_unread)).toBe(false); + }); + + it("a close on a task not on screen leaves the revealed tab's mark", () => { + useApp.setState({ activeTaskId: null } as never); + useApp.setState(s => ({ activeTab: { ...s.activeTab, ws1: "t1" } })); + useApp.getState().markTabUnread("ws1", "t0"); + useApp.getState().closeTab("ws1", "t1"); + expect(marked("t0")).toBe(true); + }); + it("is not agent news: no unread, so no notification", () => { useApp.getState().markTabUnread("ws1", "t1"); const t = useApp.getState().tabs["ws1"].find(x => x.id === "t1") as TerminalTab; diff --git a/src/store/app.ts b/src/store/app.ts index 80a17932f..10dd34e80 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -612,6 +612,33 @@ function withPriorScheduled(next: PersistedTab[], prior: PersistedTab[]): Persis return next.map(t => byId.has(t.id) ? { ...t, scheduled: byId.get(t.id) } : t); } +/** A tab moved into a split pane has no sidebar row, so nothing could ever + * clear a manual unread mark there, and dragging it in is looking at it. */ +function intoPane(t: Tab, paneId: string): Tab { + return { ...t, paneId, ...((t as TerminalTab).markedUnread ? { markedUnread: false } : {}) } as Tab; +} + +/** Clear the manual unread mark on the tab a close or a move just put on + * screen in the main pane. Those paths write `activeTab` directly rather + * than through setActiveTabId, and a click on the revealed tab is no + * visit (it is already showing), so without this the dot would stick. + * Only when the task is the one on screen; persists the clear. */ +function clearShownMark( + get: () => AppState, + set: (fn: (s: AppState) => Partial) => void, + taskId: string, +) { + const s = get(); + if (s.activeTaskId !== taskId) return; + const id = s.activeTab[taskId]; + const tab = (s.tabs[taskId] ?? []).find(t => t.id === id); + if (!tab || tab.type !== "terminal" || !tab.markedUnread) return; + set(st => ({ + tabs: { ...st.tabs, [taskId]: (st.tabs[taskId] ?? []).map(t => t.id === id ? { ...t, markedUnread: false } as Tab : t) }, + })); + get().syncDurableTabs(taskId); +} + /** `list` with `tab` added: right after `afterTabId` when that tab is in * the list, else at the end. An unpinned tab never lands inside the pinned * block at the front of the main strip (the invariant `reseatAtPinBoundary` @@ -1742,7 +1769,7 @@ export const useApp = create((set, get) => ({ } const updatedTabs = (s.tabs[taskId] ?? []).map(t => - t.id !== tabId ? t : { ...t, paneId: toPaneId }, + t.id !== tabId ? t : intoPane(t, toPaneId), ); // Moving the ACTIVE main tab out: hand the main pane to its neighbor @@ -1766,6 +1793,8 @@ export const useApp = create((set, get) => ({ } : {}), }; }); + // The main pane's next tab just came on screen. + clearShownMark(get, set, taskId); // The moved tab becomes the target pane's visible tab — focus follows the // drop so the user can type immediately (terminal or editor). focusPaneTab(tabId); @@ -1826,6 +1855,7 @@ export const useApp = create((set, get) => ({ }; }); if (moved) { + clearShownMark(get, set, taskId); focusMainTab(tabId); get().syncDurableTabs(taskId); get().saveSplitLayout(taskId); @@ -1892,7 +1922,7 @@ export const useApp = create((set, get) => ({ moved = true; const updatedTabs = (s.tabs[taskId] ?? []).map(t => - t.id !== tabId ? t : { ...t, paneId: newLeafId }, + t.id !== tabId ? t : intoPane(t, newLeafId), ); // Moving the ACTIVE main tab out: hand main to its neighbor (same rule // as closeTab / moveTabToPane). @@ -2403,6 +2433,8 @@ export const useApp = create((set, get) => ({ // it), while a closed MAIN tab stays durable and auto-resumes when the // task wakes — see the merge rule in syncDurableTabs. No-op if // nothing changed, and skipped entirely when nothing was closed. + // The tab that takes over the main pane just came on screen. + if (closed) clearShownMark(get, set, taskId); if (closed) get().syncDurableTabs(taskId); if (focusId) { // Sync activePaneId to the main pane so the store agrees with where @@ -2451,7 +2483,7 @@ export const useApp = create((set, get) => ({ // A manual mark clears when the tab COMES on screen, not when the tab // that is already showing is picked again: marking the open tab must // last until the user leaves it and comes back. - const alreadyShown = get().activeTab[taskId] === tabId; + const alreadyShown = get().activeTaskId === taskId && get().activeTab[taskId] === tabId; const marked = !alreadyShown && (get().tabs[taskId] || []).some(t => t.id === tabId && t.type === "terminal" && t.markedUnread); set(s => {