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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/data-model.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ Wildcard scans skip `.git` and do not descend through directory symlinks.
- **Scratchpad** (`scratch/<task_id>/index.json` + `scratch/<task_id>/<pad_id>.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 `<profile_dir>/scratch/projects/<project_id>/`, **profile** at `<profile_dir>/scratch/profile/`, and **global** at `<root_profile_dir>/scratch/global/`, the one copy every profile sees. `scratch_dir` in `lib.rs` routes all four from one target id (`<task_id>`, `project_<id>`, `profile_<slug>`, `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/<sync_id>/scratch/`, project under `profiles/<sync_id>/scratch/projects/<project_sync_id>/`.
- **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`.
Expand Down
25 changes: 24 additions & 1 deletion docs/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
149 changes: 145 additions & 4 deletions e2e/specs/tabs-layout.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,13 +406,22 @@ describe("sidebar tab row menu", () => {
return [...menu.querySelectorAll("[role='menuitem']")].map((e) => e.textContent?.trim() ?? "");
}) as Promise<string[]>;

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<boolean>, { 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<string | null>;

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");
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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) =>
Expand Down
12 changes: 6 additions & 6 deletions src-tauri/src/cli_server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand All @@ -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(),
Expand All @@ -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(),
Expand Down
Loading
Loading