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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
11 changes: 11 additions & 0 deletions src/components/settings/GeneralSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -281,6 +283,15 @@ export function GeneralSection() {
/>
</div>

<div className="border-t border-[var(--color-border-soft)] pt-6">
<Toggle
label="Confirm before closing an agent tab"
hint="Ask before closing a non-shell terminal or agent tab. Turning this off (or unchecking it once from the close dialog) closes tabs immediately; a toast then points back to the '+' menu's Resume section to bring one back."
value={confirmBeforeCloseAgentTab}
onChange={setConfirmBeforeCloseAgentTab}
/>
</div>

<div className="border-t border-[var(--color-border-soft)] pt-6">
<Toggle
label="Copy on select"
Expand Down
72 changes: 69 additions & 3 deletions src/components/sidebar/ProjectActionsMenuItems.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,32 @@
// Wrap in a `<DropdownMenu>` 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. */
Expand All @@ -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
Expand Down Expand Up @@ -194,6 +225,41 @@ export function ProjectActionsMenuItems({ projectId, onPick }: {
</div>
</DropdownItem>
)}
{/* 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 && (
<>
<DropdownSeparator />
<DropdownLabel>Resume</DropdownLabel>
{archivedTasks.map(t => {
const iconId = resolveIconId(t.cli, agents);
return (
<DropdownItem key={t.id} onSelect={async () => {
try {
const restored = await taskRestore(t.id);
await loadAll();
setActiveTask(restored.id);
} catch (err) {
console.error("task_restore failed:", err);
}
}}>
<span className={cn("shrink-0", CLI_BRAND_COLOR[iconId] || "text-[var(--color-fg-dim)]")}>
<CliIcon cli={iconId} className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate">{t.name}</div>
<div className="text-[11px] text-[var(--color-fg-faint)]">{relativeArchivedTime(t.archived_at ?? t.created)}</div>
</div>
</DropdownItem>
);
})}
<DropdownItem onSelect={() => setView("history")}>
More…
</DropdownItem>
</>
)}
</>
);
}
69 changes: 68 additions & 1 deletion src/components/task/TabBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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 (
<DropdownItem key={entry.id} onSelect={() => onResume(entry.id)}>
<span className={cn("shrink-0", CLI_BRAND_COLOR[iconId] || "text-[var(--color-fg-dim)]")}>
<CliIcon cli={iconId} className="h-4 w-4" />
</span>
<div className="min-w-0 flex-1">
<div className="truncate">{entry.title}</div>
<div className="text-[11px] text-[var(--color-fg-faint)]">{relativeTime(entry.closedAt)}</div>
</div>
</DropdownItem>
);
})}
</>
);
}

/** 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. */
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -205,6 +262,16 @@ export function TabBar({ task }: { task: Task }) {
<DropdownSeparator />
<DropdownLabel>New agent</DropdownLabel>
<CliMenuItems entries={registry.filter(a => visibleClis.has(a.id))} onSpawn={spawnTab} />
{closedTabs.length > 0 && (
<>
<DropdownSeparator />
<DropdownLabel>Resume</DropdownLabel>
<ResumeMenuItems entries={closedTabs} agents={registry} onResume={resumeAndFocus} />
<DropdownItem onSelect={() => { setOpen(false); setView("history"); }}>
More…
</DropdownItem>
</>
)}
</DropdownMenu>
</DropdownRoot>
</div>
Expand Down
58 changes: 57 additions & 1 deletion src/lib/closeTab.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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);
Expand All @@ -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.
Expand All @@ -75,6 +129,8 @@ export async function requestCloseTab(taskId: string, tabId: string) {
export async function requestClosePaneTab(taskId: string, paneId: string, tabId: string): Promise<boolean> {
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;
}
Loading
Loading