diff --git a/CHANGELOG.md b/CHANGELOG.md index e7031bc..bbd850c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,14 @@ All notable changes to Termic, newest first. This file is the human-authored source of truth: the in-app Update card and the /changelog page on termic.dev are generated from it. See the `release` skill for how entries are added. +## [Unreleased] + +Closed tabs and archived tasks are one click away instead of buried in History. + +### Features +- Surface closed sessions in the "+" menu: the tab strip's "+" lists recently closed secondary agent tabs, letting you resume the exact same session instead of losing it for good; the sidebar's project "+" does the same for recently archived tasks. (#77) +- Closing a non-shell terminal or agent tab can now skip the confirmation dialog, via the dialog's "Don't ask again" checkbox or the new "Confirm before closing an agent tab" toggle in Settings > General, once the new Resume section has you covered. A toast then points back to it. + ## [0.20.1] - 2026-07-10 Workspaces are now Tasks, and you can create one in two clicks from the sidebar. diff --git a/src/components/settings/GeneralSection.tsx b/src/components/settings/GeneralSection.tsx index ad023dd..3f14e54 100644 --- a/src/components/settings/GeneralSection.tsx +++ b/src/components/settings/GeneralSection.tsx @@ -49,6 +49,8 @@ export function GeneralSection() { const setSettledHighlight = usePrefs(s => s.setSettledHighlight); const workingIndicator = usePrefs(s => s.workingIndicator); const setWorkingIndicator = usePrefs(s => s.setWorkingIndicator); + const confirmBeforeCloseAgentTab = usePrefs(s => s.confirmBeforeCloseAgentTab); + const setConfirmBeforeCloseAgentTab = usePrefs(s => s.setConfirmBeforeCloseAgentTab); const loadRemoteImages = usePrefs(s => s.loadRemoteImages); const setLoadRemoteImages = usePrefs(s => s.setLoadRemoteImages); const globalDefaultSandbox = usePrefs(s => s.globalDefaultSandbox); @@ -281,6 +283,15 @@ export function GeneralSection() { /> +
+ +
+
` at the call site; this component renders only // the items (so the caller can also customize positioning). -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useApp } from "@/store/app"; import { useUI } from "@/store/ui"; import { visibleCliIds } from "@/lib/agents"; import { createQuickTask, readNewTaskMode, writeNewTaskMode, type NewTaskMode } from "@/lib/quickTask"; -import { CliIcon, CLI_BRAND_COLOR } from "@/icons/cli"; -import { DropdownItem, DropdownSeparator } from "@/components/ui/Dropdown"; +import { taskRestore } from "@/lib/ipc"; +import { CliIcon, CLI_BRAND_COLOR, resolveIconId } from "@/icons/cli"; +import { DropdownItem, DropdownLabel, DropdownSeparator } from "@/components/ui/Dropdown"; import { GitBranch, GitBranchPlus, Link2, TerminalSquare, SquareChevronRight, Settings2 } from "lucide-react"; import { cn } from "@/lib/utils"; +/** Coarse relative label for an archived-task timestamp. Unlike the tab + * strip's Resume entries (always seconds/minutes old), a task can sit + * archived for a long time, so this scales from minutes up through a + * short date instead of capping at hours. */ +function relativeArchivedTime(iso: string): string { + const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.floor(hours / 24); + if (days < 7) return `${days}d ago`; + return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(new Date(iso)); +} + /** Small section header: uppercase label + one-line explanation. Used for * the non-git "RUN IN FOLDER" case, where there's no worktree/main choice * to make. Not a dropdown menu item — pure visual, doesn't trap focus. */ @@ -42,6 +58,21 @@ export function ProjectActionsMenuItems({ projectId, onPick }: { const detectedClis = useApp(s => s.detectedClis); const openNewTask = useUI(s => s.openNewTask); const openCustomCommand = useUI(s => s.openCustomCommand); + const setActiveTask = useApp(s => s.setActiveTask); + const loadAll = useApp(s => s.loadAll); + const setView = useApp(s => s.setView); + const tasks = useApp(s => s.tasks); + // Recently archived tasks for THIS project, most-recent first — same + // sort HistoryView uses, just capped and scoped to one project so the + // launcher menu can offer a one-click shortcut back into one instead of + // making the user leave to the full History page. + const archivedTasks = useMemo( + () => tasks + .filter(t => t.project_id === projectId && t.archived) + .sort((a, b) => (b.archived_at ?? b.created).localeCompare(a.archived_at ?? a.created)) + .slice(0, 5), + [tasks, projectId], + ); const project = useApp(s => s.projects.find(p => p.id === projectId)); const isMulti = (project?.type ?? "single") === "multi"; // Non-git projects (issue #4) have no branches / worktrees — the only way @@ -194,6 +225,41 @@ export function ProjectActionsMenuItems({ projectId, onPick }: {
)} + {/* Recently archived tasks for this project — a shortcut to + HistoryView's restore (same task_restore IPC + setActiveTask) + without leaving the sidebar. "More…" hands off to the full page + for anything past the first few. */} + {archivedTasks.length > 0 && ( + <> + + Resume + {archivedTasks.map(t => { + const iconId = resolveIconId(t.cli, agents); + return ( + { + try { + const restored = await taskRestore(t.id); + await loadAll(); + setActiveTask(restored.id); + } catch (err) { + console.error("task_restore failed:", err); + } + }}> + + + +
+
{t.name}
+
{relativeArchivedTime(t.archived_at ?? t.created)}
+
+
+ ); + })} + setView("history")}> + More… + + + )} ); } diff --git a/src/components/task/TabBar.tsx b/src/components/task/TabBar.tsx index dd9ac67..f971398 100644 --- a/src/components/task/TabBar.tsx +++ b/src/components/task/TabBar.tsx @@ -2,7 +2,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import type { Task, Tab, TerminalTab, Agent } from "@/lib/types"; -import { useApp, useTaskTabs, useActiveTabId } from "@/store/app"; +import { useApp, useTaskTabs, useActiveTabId, type ClosedTabEntry } from "@/store/app"; import { getAllLeaves } from "@/lib/splitTree"; import { useTabStripDrag } from "./useTabStripDrag"; import { Button } from "@/components/ui/Button"; @@ -22,6 +22,51 @@ import { fileIconUrl } from "@/lib/explorer/iconResolver"; const CLIS = ["claude", "codex", "agy", "grok", "opencode"] as const; +// Stable reference for the "no closed tabs yet" case. `s.closedTabs[task.id] +// ?? []` would mint a NEW array on every selector call, which Zustand's +// default Object.is comparison treats as "changed" — re-rendering TabBar on +// every unrelated store write (PTY output ticks etc) and, worse, feeding a +// runaway render loop. A shared empty array keeps the reference stable. +const NO_CLOSED_TABS: ClosedTabEntry[] = []; + +/** Coarse "Nm ago" label for a closed-tab timestamp. Closed tabs are + * always recent (session-only list), so minute/hour granularity is + * enough — no need for History's day/week/month buckets. */ +function relativeTime(iso: string): string { + const mins = Math.floor((Date.now() - new Date(iso).getTime()) / 60_000); + if (mins < 1) return "just now"; + if (mins < 60) return `${mins}m ago`; + const hours = Math.floor(mins / 60); + if (hours < 24) return `${hours}h ago`; + return `${Math.floor(hours / 24)}d ago`; +} + +/** "Resume" section rows — recently closed secondary agent tabs (see + * `ClosedTabEntry`). Icon uses the same resolveIconId/CLI_BRAND_COLOR + * pairing as TabPill so a resumed tab's row matches the tab it becomes. */ +function ResumeMenuItems({ entries, agents, onResume }: { + entries: ClosedTabEntry[]; agents: Agent[]; onResume: (entryId: string) => void; +}) { + return ( + <> + {entries.map(entry => { + const iconId = resolveIconId(entry.cli, agents); + return ( + onResume(entry.id)}> + + + +
+
{entry.title}
+
{relativeTime(entry.closedAt)}
+
+
+ ); + })} + + ); +} + /** Registry entries rendered as dropdown rows — shared by the main strip's * and the right strip's + menus (both their "New terminal" custom entries * and their "New agent" lists) so the two menus can't drift apart. */ @@ -72,6 +117,9 @@ export function TabBar({ task }: { task: Task }) { [registry], ); const openBroadcast = useUI(s => s.openBroadcast); + const closedTabs = useApp(s => s.closedTabs[task.id] ?? NO_CLOSED_TABS); + const resumeClosedTab = useApp(s => s.resumeClosedTab); + const setView = useApp(s => s.setView); const [open, setOpen] = useState(false); const suppressDropdownReturn = useRef(false); const [renaming, setRenaming] = useState<{ id: string; value: string } | null>(null); @@ -133,6 +181,15 @@ export function TabBar({ task }: { task: Task }) { addAndFocusTab({ id: crypto.randomUUID(), type: "terminal", title: displayName, cli }); } + // Reopen a closed tab with its original session id (see resumeClosedTab + // in the store) — same dropdown-close/focus dance as addAndFocusTab, + // just driven by the store action instead of a locally-built tab. + function resumeAndFocus(entryId: string) { + suppressDropdownReturn.current = true; + resumeClosedTab(task.id, entryId); + setOpen(false); + } + /** Plain login-shell tab. Always uncaged: only agents run inside the * task's seatbelt (see ShellTerminalItem / TerminalPane spawn). */ function spawnShellTab() { @@ -205,6 +262,16 @@ export function TabBar({ task }: { task: Task }) { New agent visibleClis.has(a.id))} onSpawn={spawnTab} /> + {closedTabs.length > 0 && ( + <> + + Resume + + { setOpen(false); setView("history"); }}> + More… + + + )} diff --git a/src/lib/closeTab.ts b/src/lib/closeTab.ts index 39b3ba1..dce5eac 100644 --- a/src/lib/closeTab.ts +++ b/src/lib/closeTab.ts @@ -8,6 +8,7 @@ import { useApp } from "@/store/app"; import { useUI } from "@/store/ui"; +import { usePrefs } from "@/store/prefs"; import type { Tab } from "@/lib/types"; import { agentDisplayName, isTerminalCli } from "@/lib/agents"; @@ -41,6 +42,12 @@ async function confirmTabClose(tab: Tab | undefined, paneTab: boolean): Promise< // terminal-like tabs have no session to lose either. Close silently // instead of a fake "Stops the running process" confirm. if (termLike && !tab.ptyId) return true; + // Fast path: the user opted out via the dialog's "Don't ask again" + // checkbox below. The "+" menu's Resume section (backed by closedTabs) + // makes undoing a close one click away, so a blocking modal on every + // close is no longer the only safety net. requestCloseTab / + // requestClosePaneTab toast a Resume shortcut once the close lands. + if (!usePrefs.getState().confirmBeforeCloseAgentTab) return true; const label = tab.cli === "custom" ? (tab.title || "this command") : agentDisplayName(tab.cli, useApp.getState().agents); @@ -54,18 +61,65 @@ async function confirmTabClose(tab: Tab | undefined, paneTab: boolean): Promise< : "Ends this agent's session. It won't be restored when the task reopens.", confirmLabel: "Close tab", destructive: !isMain && !termLike, + checkbox: { label: "Don't ask again", defaultValue: false }, }); - return ok === true; + // Only persist the opt-out when the user actually confirmed the close — + // ticking the box then backing out (Escape / Cancel / click-outside) + // still resolves with ok.checked=true (ConfirmDialog reports whatever + // the checkbox state was at dismissal), so gating on confirmed too + // stops a cancelled close from silently disabling future confirmations. + if (ok.confirmed && ok.checked) usePrefs.getState().setConfirmBeforeCloseAgentTab(false); + return ok.confirmed; } return true; } +/** After a fast-path close (confirm skipped, see above), tell the user + * where the tab went. Secondary tabs snapshot into `closedTabs` on close + * (see app.ts's closeTab) so the toast's action can reopen the exact one + * that was just closed; the main tab already auto-resumes on its own, so + * it gets an explanatory toast with no action. No-op for shells (closed + * silently, nothing to report) and edit tabs (own confirm path, unrelated + * to this pref). */ +function toastClosedTab(taskId: string, tab: Tab, paneTab: boolean) { + if (tab.type !== "terminal" || tab.cli === "shell") return; + const label = tab.cli === "custom" + ? (tab.title || "this command") + : agentDisplayName(tab.cli, useApp.getState().agents); + // Pane tabs are never snapshotted into closedTabs (see app.ts's closeTab) — + // there's nothing to point the user back to, so just confirm the close. + if (paneTab) { + useUI.getState().pushToast(`Closed "${label}".`, "info"); + return; + } + const resumable = !tab.is_default; + if (!resumable) { + useUI.getState().pushToast(`Closed "${label}". It resumes automatically when you reopen this task.`, "info"); + return; + } + // Bind THIS close's entry now (toastClosedTab runs synchronously right + // after closeTab, so closedTabs[taskId][0] is guaranteed to be it) rather + // than re-deriving "the latest entry" at click time — otherwise a second + // close (or a menu Resume) within the toast's ttl would make this button + // reopen the wrong tab. resumeClosedTab no-ops if the id is already gone. + const entryId = useApp.getState().closedTabs[taskId]?.[0]?.id; + useUI.getState().pushToast(`Closed "${label}". Resume it from the + menu.`, "info", { + ttlMs: 6000, + action: { + label: "Resume", + onClick: () => { if (entryId) useApp.getState().resumeClosedTab(taskId, entryId); }, + }, + }); +} + /** Close a main-pane tab, asking first when closing is destructive. * Resolves once the tab is closed or the user backs out. */ export async function requestCloseTab(taskId: string, tabId: string) { const tab = useApp.getState().tabs[taskId]?.find(t => t.id === tabId); if (!(await confirmTabClose(tab, false))) return; + const fastClose = tab?.type === "terminal" && tab.cli !== "shell" && !usePrefs.getState().confirmBeforeCloseAgentTab; useApp.getState().closeTab(taskId, tabId); + if (fastClose && tab) toastClosedTab(taskId, tab, false); } /** Close a split-pane tab with the same confirm gate as the main path. @@ -75,6 +129,8 @@ export async function requestCloseTab(taskId: string, tabId: string) { export async function requestClosePaneTab(taskId: string, paneId: string, tabId: string): Promise { const tab = useApp.getState().tabs[taskId]?.find(t => t.id === tabId); if (!(await confirmTabClose(tab, true))) return false; + const fastClose = tab?.type === "terminal" && tab.cli !== "shell" && !usePrefs.getState().confirmBeforeCloseAgentTab; useApp.getState().closePaneTab(taskId, paneId, tabId); + if (fastClose && tab) toastClosedTab(taskId, tab, true); return true; } diff --git a/src/store/app.ts b/src/store/app.ts index d3baa20..d88aa9b 100644 --- a/src/store/app.ts +++ b/src/store/app.ts @@ -12,6 +12,24 @@ import * as ipc from "@/lib/ipc"; import { focusTerminalTab, focusMainTab, focusPaneTab } from "@/lib/tabFocus"; import { agentDisplayName } from "@/lib/agents"; +/** A secondary agent tab closed via the "X", snapshotted just before it's + * dropped from `persisted_tabs` (see `syncDurableTabs`'s forget rule). + * Powers the "+" menu's Resume section — in-memory only, cleared on app + * restart. Main/default tabs are excluded: they already auto-resume via + * `persisted_tabs` when the task wakes, so listing them here would + * be redundant. */ +export interface ClosedTabEntry { + id: string; + cli: string; + title: string; + customTitle?: boolean; + command?: string | null; + sessionId?: string | null; + closedAt: string; +} + +const MAX_CLOSED_TABS = 6; + interface View { /** Underlying page — dashboard / history / empty. NOT "settings": Settings * is a separate overlay flag (`settingsOpen`), so closing it returns to @@ -40,6 +58,9 @@ export interface AppState { tabs: Record; /** task id → active tab id */ activeTab: Record; + /** task id → recently closed secondary agent tabs, most-recent + * first, capped at MAX_CLOSED_TABS. See `ClosedTabEntry`. */ + closedTabs: Record; view: View; compactSidebar: boolean; rightPanelHidden: boolean; @@ -226,6 +247,11 @@ export interface AppState { * No-op if the order is unchanged. */ reorderTab: (taskId: string, tabId: string, toIndex: number) => void; closeTab: (taskId: string, tabId: string) => void; + /** Reopen a `closedTabs` entry as a fresh tab, forcing its original + * `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) => void; setActiveTabId: (taskId: string, tabId: string) => void; persistTab: (taskId: string, tabId: string) => void; openPreviewTab: (taskId: string, data: { type: "edit" | "diff"; path: string; title: string; revealAt?: { line: number; col?: number }; revealHeading?: string }) => void; @@ -349,6 +375,7 @@ export const useApp = create((set, get) => ({ activeTaskId: null, tabs: {}, activeTab: {}, + closedTabs: {}, fsRevision: {}, view: { page: "dashboard" }, compactSidebar: initialCompact, @@ -1464,6 +1491,24 @@ export const useApp = create((set, get) => ({ const closing = list[idx]; // Best-effort PTY kill; ignore failures (already-dead PTYs etc.). if (closing.type === "terminal" && closing.ptyId) ipc.ptyKill(closing.ptyId).catch(() => {}); + // Snapshot secondary agent tabs into closedTabs before syncDurableTabs + // forgets them for good (see the merge rule below) — this is the only + // point their session_id survives, so the "+" menu's Resume section can + // still reopen them. Shells (no session) and the main tab (already + // auto-resumes via persisted_tabs) are excluded. + const closingTerm = closing.type === "terminal" ? closing as TerminalTab : null; + const closedEntry: ClosedTabEntry | null = + closingTerm && closingTerm.cli !== "shell" && !closingTerm.is_default && !closingTerm.paneId + ? { + id: crypto.randomUUID(), + cli: closingTerm.cli, + title: closingTerm.customTitle ? closingTerm.title : (closingTerm.liveTitle || closingTerm.title), + customTitle: closingTerm.customTitle, + command: closingTerm.command ?? null, + sessionId: closingTerm.sessionId ?? null, + closedAt: new Date().toISOString(), + } + : null; const next = list.filter(t => t.id !== tabId); const wasActive = s.activeTab[taskId] === tabId; // Active-tab replacement considers only main tabs (no paneId). @@ -1490,6 +1535,12 @@ export const useApp = create((set, get) => ({ splitTree: _st ? splitTreeRest : s.splitTree, activePaneId: _ap ? activePaneRest : s.activePaneId, paneHistory: _ph ? paneHistoryRest : s.paneHistory, + ...(closedEntry ? { + closedTabs: { + ...s.closedTabs, + [taskId]: [closedEntry, ...(s.closedTabs[taskId] ?? [])].slice(0, MAX_CLOSED_TABS), + }, + } : {}), }; if (isLast) { // Evict from mountedTasks → TaskView unmounts → xterm @@ -1523,6 +1574,24 @@ export const useApp = create((set, get) => ({ } }, + resumeClosedTab: (taskId, entryId) => { + const entry = (get().closedTabs[taskId] ?? []).find(e => e.id === entryId); + if (!entry) return; + set(s => ({ + closedTabs: { ...s.closedTabs, [taskId]: (s.closedTabs[taskId] ?? []).filter(e => e.id !== entryId) }, + })); + const tab: TerminalTab = { + id: crypto.randomUUID(), + type: "terminal", + title: entry.title, + customTitle: entry.customTitle, + cli: entry.cli, + command: entry.command ?? undefined, + sessionId: entry.sessionId ?? undefined, + }; + get().addTab(taskId, tab); + }, + 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: diff --git a/src/store/prefs.ts b/src/store/prefs.ts index 8a6cf07..1ffe192 100644 --- a/src/store/prefs.ts +++ b/src/store/prefs.ts @@ -33,6 +33,7 @@ const LS_LIGATURES = "codeLigatures"; const LS_THEME = "themeMode"; const LS_DESKTOPNOTIF = "desktopNotifications"; const LS_SETTLED_HIGHLIGHT = "settledHighlight"; +const LS_CONFIRM_CLOSE_AGENT_TAB = "confirmBeforeCloseAgentTab"; const LS_WORKING_INDICATOR = "workingIndicator"; const LS_DEFAULT_SANDBOX = "globalDefaultSandbox"; const LS_SANDBOX_BYPASS = "sandboxBypassPermissions"; @@ -351,6 +352,13 @@ interface PrefsState { * in-app "done" signal. Some users find it distracting and want * the sidebar to stay calm regardless. */ settledHighlight: boolean; + /** Whether closing a non-shell terminal/agent tab asks for confirmation + * first. ON by default. Once the "+" menu's Resume section makes + * undoing a close one click away, users who've learned that can turn + * this off via the dialog's "Don't ask again" checkbox — closing then + * happens immediately, with a toast pointing back at Resume. Dirty + * edit-tab closes are never gated by this; that confirm always fires. */ + confirmBeforeCloseAgentTab: boolean; /** Show a spinner on an agent's tab (and sidebar icon) WHILE it's * working. OFF by default — experimental. The "working" workState is * always tracked internally to drive work-done detection; this pref only @@ -497,6 +505,7 @@ interface PrefsState { setCompletionSound: (v: boolean) => void; setCompletionSoundId: (id: CompletionSoundId) => void; setSettledHighlight: (v: boolean) => void; + setConfirmBeforeCloseAgentTab: (v: boolean) => void; setWorkingIndicator: (v: boolean) => void; setLoadRemoteImages: (v: boolean) => void; setGlobalDefaultSandbox: (v: boolean) => void; @@ -599,6 +608,7 @@ const initialCompletionSoundId = readCompletionSoundId(); // users who toggled it OFF keep their setting (lsGetBool returns the // stored value when present). const initialSettledHighlight = lsGetBool(LS_SETTLED_HIGHLIGHT, true); +const initialConfirmCloseAgentTab = lsGetBool(LS_CONFIRM_CLOSE_AGENT_TAB, true); // OFF by default — experimental re-introduction of the work-in-progress // spinner. Opt in via Settings → General. const initialWorkingIndicator = lsGetBool(LS_WORKING_INDICATOR, false); @@ -635,6 +645,7 @@ export const usePrefs = create(set => ({ completionSound: initialCompletionSound, completionSoundId: initialCompletionSoundId, settledHighlight: initialSettledHighlight, + confirmBeforeCloseAgentTab: initialConfirmCloseAgentTab, workingIndicator: initialWorkingIndicator, loadRemoteImages: initialLoadRemoteImages, globalDefaultSandbox: initialDefaultSandbox, @@ -780,6 +791,10 @@ export const usePrefs = create(set => ({ try { localStorage.setItem(LS_SETTLED_HIGHLIGHT, v ? "1" : "0"); } catch {} set({ settledHighlight: v }); }, + setConfirmBeforeCloseAgentTab: (v) => { + try { localStorage.setItem(LS_CONFIRM_CLOSE_AGENT_TAB, v ? "1" : "0"); } catch {} + set({ confirmBeforeCloseAgentTab: v }); + }, setWorkingIndicator: (v) => { try { localStorage.setItem(LS_WORKING_INDICATOR, v ? "1" : "0"); } catch {} set({ workingIndicator: v });