diff --git a/docs/data-model.md b/docs/data-model.md index fb269402..9753ba46 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 c6db300c..08d2eadc 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,25 @@ 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. 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, 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 d795d5c8..e01947f0 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,138 @@ 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("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("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-tauri/src/cli_server.rs b/src-tauri/src/cli_server.rs index 13374b99..78cc6872 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 d980e149..e2a988c6 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 c4897bef..6f045ad8 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 68dcdffb..b097dbce 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 4efdc994..347f347c 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 @@ -3812,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(); @@ -3895,13 +3905,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 +3922,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 +3950,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 +3994,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 +4016,14 @@ function TaskRow({ w, compact, dragging = false, dragTy = 0, onDragPointerDown, /> + { setTabMenuId(null); markTabUnread(w.id, tab.id); }} + > + + {t("tabMenuMarkUnread")} + { 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/index.css b/src/index.css index 7457a621..1d2aa53b 100644 --- a/src/index.css +++ b/src/index.css @@ -93,6 +93,9 @@ only a hard-coded fallback (var(--color-info, #4aa3ff)) sprinkled at the point of use; defined here so themes can override it. */ --color-info: #4aa3ff; + /* "Marked unread" orange: the dot a tab the user marked unread from the + sidebar menu carries until it is shown again. */ + --color-marked: #f0883e; /* Behind the HTML preview's iframe (HtmlPane). A document that sets no background expects the browser's white page, and black default text on the app's dark --color-bg is unreadable. Deliberately the same in diff --git a/src/lib/taskGroups.test.ts b/src/lib/taskGroups.test.ts index 2597bb46..02e0001a 100644 --- a/src/lib/taskGroups.test.ts +++ b/src/lib/taskGroups.test.ts @@ -181,3 +181,13 @@ describe("groupBadgeKinds", () => { 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 acb45c67..d4a9de79 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 1971219c..98a62109 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 5cf7f9a1..d60db963 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 1b17e1fc..e8dacab4 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 0108e17b..de549e26 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 3ac6593c..f6e00baf 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 4b27ce7b..1c152c0a 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 b5350dca..276447eb 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 be0f7961..721f3c62 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", () => ({ @@ -877,6 +878,172 @@ 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().setActiveTabId("ws1", "t0"); + 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("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("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; + 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 +1193,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 968d9437..10dd34e8 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 } : {}), @@ -607,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` @@ -615,10 +647,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 +1132,12 @@ export const useApp = create((set, get) => ({ if (unreadClearsOnSight(t.unread)) { nt = { ...nt, unread: null }; } + // 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) { @@ -1113,6 +1153,7 @@ export const useApp = create((set, get) => ({ }); return { tabs: { ...s.tabs, [id]: next } }; }); + if (prev !== id && tabs.some(t => t.id === activeId && t.type === "terminal" && t.markedUnread)) get().syncDurableTabs(id); } }, @@ -1728,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 @@ -1752,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); @@ -1812,6 +1855,7 @@ export const useApp = create((set, get) => ({ }; }); if (moved) { + clearShownMark(get, set, taskId); focusMainTab(tabId); get().syncDurableTabs(taskId); get().saveSplitLayout(taskId); @@ -1878,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). @@ -1969,6 +2013,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 +2059,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 +2261,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 } } : {}), @@ -2387,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 @@ -2428,61 +2476,80 @@ 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) => { + // 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().activeTaskId === taskId && 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: + // - 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 (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. + 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