summaryrefslogtreecommitdiffhomepage
path: root/packages/frontend/src/lib/tabs.svelte.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 14:43:16 +0900
committerAdam Malczewski <[email protected]>2026-06-02 14:43:16 +0900
commit3ebcd49c404ed287a97af159ac8adfa63d572849 (patch)
treef8245ed28530a8e96046221eb1d7eca47d508dc8 /packages/frontend/src/lib/tabs.svelte.ts
parent7c527b4d8a72159954405e720d5bf776802dc0ff (diff)
downloaddispatch-3ebcd49c404ed287a97af159ac8adfa63d572849.tar.gz
dispatch-3ebcd49c404ed287a97af159ac8adfa63d572849.zip
feat(tabs): drag-reorder + double-click rename + per-tab chat draft
- TabBar: HTML5 drag-and-drop to reorder user tabs (subagent tabs untouched); double-click a tab title to rename (Enter/blur confirm, Escape cancel). - Store: add reorderTabs/renameTab/setDraft; per-tab in-memory `draft` and `manualTitle` fields. Manual rename suppresses first-message auto-title. - ChatInput: bind to the active tab's draft so switching tabs saves/restores unsent text instead of clobbering it. - Backend: updateTabPositions() + PATCH /tabs/reorder persist tab order to the existing `position` column; tabs without a stored position fall to the end then get explicit positions on first reorder. - Tests: store reorder/rename/auto-title-guard/draft coverage; core updateTabPositions coverage (FakeDatabase extended with transaction support).
Diffstat (limited to 'packages/frontend/src/lib/tabs.svelte.ts')
-rw-r--r--packages/frontend/src/lib/tabs.svelte.ts81
1 files changed, 80 insertions, 1 deletions
diff --git a/packages/frontend/src/lib/tabs.svelte.ts b/packages/frontend/src/lib/tabs.svelte.ts
index ec718bd..1c94593 100644
--- a/packages/frontend/src/lib/tabs.svelte.ts
+++ b/packages/frontend/src/lib/tabs.svelte.ts
@@ -177,6 +177,19 @@ export interface Tab {
/** Total chunk count for this tab on the backend (drives "more to load?"). */
totalChunks: number;
/**
+ * Unsent chat-input text for THIS tab (in-memory only — never persisted).
+ * Saved/restored on tab switch so a draft is never lost or clobbered by
+ * switching tabs. Cleared on send.
+ */
+ draft: string;
+ /**
+ * True once the user has manually renamed this tab (double-click rename).
+ * Suppresses the first-message auto-title so a chosen name is never
+ * clobbered. In-memory only — a renamed tab is no longer "New Tab" on
+ * reload, so the auto-title guard already won't fire for it.
+ */
+ manualTitle: boolean;
+ /**
* Cumulative prompt-cache token telemetry for this tab since the page
* loaded (in-memory only — resets on reload). Undefined until the first
* `usage` event arrives. Drives the "Cache Rate" sidebar view.
@@ -298,6 +311,8 @@ export function createTabStore() {
workingDirectory: null,
queuedMessages: [],
chunkLimit: appSettings.chunkLimit,
+ draft: "",
+ manualTitle: false,
oldestLoadedSeq: null,
totalChunks: 0,
};
@@ -373,6 +388,8 @@ export function createTabStore() {
workingDirectory: null,
queuedMessages: [],
chunkLimit: appSettings.chunkLimit,
+ draft: "",
+ manualTitle: false,
oldestLoadedSeq: win.oldestSeq,
totalChunks: win.total,
};
@@ -426,6 +443,61 @@ export function createTabStore() {
}
/**
+ * Rename a tab. Records `manualTitle` so the first-message auto-title never
+ * clobbers the user's chosen name, and persists the new title to the DB
+ * (fire-and-forget — the optimistic local update is the source of truth for
+ * the open session).
+ */
+ function renameTab(id: string, title: string): void {
+ const trimmed = title.trim();
+ if (!trimmed) return;
+ updateTab(id, { title: trimmed, manualTitle: true });
+ fetch(`${config.apiBase}/tabs/${id}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ title: trimmed }),
+ }).catch(() => {});
+ }
+
+ /**
+ * Reorder the top-row USER tabs to match `orderedUserTabIds`. Subagent tabs
+ * (those with a `parentTabId`) keep their relative order untouched — they
+ * live in a separate row and aren't draggable. The new left-to-right user
+ * order is persisted via `PATCH /tabs/reorder`, which rewrites each open
+ * tab's `position` (fire-and-forget, matching the title-persist style).
+ */
+ function reorderTabs(orderedUserTabIds: string[]): void {
+ const byId = new Map(tabs.map((t) => [t.id, t]));
+ const ordered = orderedUserTabIds
+ .map((id) => byId.get(id))
+ .filter((t): t is Tab => t !== undefined && t.parentTabId === null);
+ // Bail if the requested order doesn't cover exactly the current user tabs
+ // (stale drag against a since-changed tab set) — never drop tabs.
+ const currentUserCount = tabs.filter((t) => t.parentTabId === null).length;
+ if (ordered.length !== currentUserCount) return;
+ const subagentTabs = tabs.filter((t) => t.parentTabId !== null);
+ tabs = [...ordered, ...subagentTabs];
+ // Persist the full open-tab order (user tabs first, then subagents) so the
+ // backend `position` column matches what the user sees on reload.
+ const persistOrder = [...ordered, ...subagentTabs].map((t) => t.id);
+ fetch(`${config.apiBase}/tabs/reorder`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ ids: persistOrder }),
+ }).catch(() => {});
+ }
+
+ /**
+ * Persist the unsent chat-input text for a tab (in-memory only). Saved on
+ * every keystroke so switching tabs preserves the draft and restoring the
+ * target tab shows its own text. No-op if the tab is gone.
+ */
+ function setDraft(id: string, text: string): void {
+ if (!getTabById(id)) return;
+ updateTab(id, { draft: text });
+ }
+
+ /**
* Record whether a tab's chat view is scrolled up (viewing older history).
* Used to suppress automatic eviction while the user is reading old
* messages — we don't want to delete what they're currently looking at.
@@ -854,6 +926,8 @@ export function createTabStore() {
workingDirectory: null,
queuedMessages: [],
chunkLimit: appSettings.chunkLimit,
+ draft: "",
+ manualTitle: false,
oldestLoadedSeq: win.oldestSeq,
totalChunks: win.total,
cacheStats: row.usageStats ?? undefined,
@@ -1203,6 +1277,8 @@ export function createTabStore() {
workingDirectory: newTabEvent.workingDirectory ?? null,
queuedMessages: [],
chunkLimit: appSettings.chunkLimit,
+ draft: "",
+ manualTitle: false,
oldestLoadedSeq: null,
totalChunks: 0,
};
@@ -1589,7 +1665,7 @@ export function createTabStore() {
updateTab(tab.id, { live: [...tab.live, userMsg] });
// Generate a title from the first user message of an empty tab.
const isFirstMessage = tab.chunks.length === 0 && tab.live.length === 0;
- if (isFirstMessage || tab.title === "New Tab") {
+ if (!tab.manualTitle && (isFirstMessage || tab.title === "New Tab")) {
const titleText = text.length > 50 ? `${text.slice(0, 47)}...` : text;
updateTab(tab.id, { title: titleText });
fetch(`${config.apiBase}/tabs/${tab.id}`, {
@@ -2033,6 +2109,9 @@ export function createTabStore() {
createNewTab,
switchTab,
closeTab,
+ renameTab,
+ reorderTabs,
+ setDraft,
sendMessage,
cancelQueuedMessage,
stopGeneration,