openclaw/apps/web/lib/tab-state.ts
kumarabhirup 89289bb31d
feat(chat): multi-session chat tabs with stop controls UI
Tab-based multi-chat system supporting concurrent parent and subagent sessions, per-session stop controls, layout/scroll fixes, and attachment display improvements.
2026-03-15 00:31:55 -07:00

204 lines
6.2 KiB
TypeScript

/**
* Tab state management for the workspace.
*
* Tabs are stored in localStorage keyed per workspace.
* The URL reflects only the active tab's content (backward compatible).
*/
export type TabType = "home" | "file" | "chat" | "app" | "object" | "cron";
export const HOME_TAB_ID = "__home__";
export const HOME_TAB: Tab = {
id: HOME_TAB_ID,
type: "home",
title: "Home",
pinned: true,
};
export type Tab = {
id: string;
type: TabType;
title: string;
icon?: string;
path?: string;
sessionId?: string;
sessionKey?: string;
parentSessionId?: string;
pinned?: boolean;
};
export type TabState = {
tabs: Tab[];
activeTabId: string | null;
};
const STORAGE_PREFIX = "dench:tabs";
function storageKey(workspaceId?: string | null): string {
return `${STORAGE_PREFIX}:${workspaceId || "default"}`;
}
export function generateTabId(): string {
return Math.random().toString(36).slice(2, 10);
}
function ensureHomeTab(state: TabState): TabState {
const hasHome = state.tabs.some((t) => t.id === HOME_TAB_ID);
if (hasHome) {
// Make sure home is always first
const home = state.tabs.find((t) => t.id === HOME_TAB_ID)!;
const rest = state.tabs.filter((t) => t.id !== HOME_TAB_ID);
return { ...state, tabs: [home, ...rest] };
}
return {
tabs: [HOME_TAB, ...state.tabs],
activeTabId: state.activeTabId || HOME_TAB_ID,
};
}
export function loadTabs(workspaceId?: string | null): TabState {
if (typeof window === "undefined") return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
try {
const raw = localStorage.getItem(storageKey(workspaceId));
if (!raw) return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
const parsed = JSON.parse(raw) as TabState;
if (!Array.isArray(parsed.tabs)) return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
return ensureHomeTab(parsed);
} catch {
return { tabs: [HOME_TAB], activeTabId: HOME_TAB_ID };
}
}
export function saveTabs(state: TabState, workspaceId?: string | null): void {
if (typeof window === "undefined") return;
try {
const serializable: TabState = {
tabs: state.tabs.map(({ id, type, title, icon, path, sessionId, sessionKey, parentSessionId, pinned }) => ({
id, type, title, icon, path, sessionId, sessionKey, parentSessionId, pinned,
})),
activeTabId: state.activeTabId,
};
localStorage.setItem(storageKey(workspaceId), JSON.stringify(serializable));
} catch {
// localStorage full or unavailable
}
}
export function findTabByPath(tabs: Tab[], path: string): Tab | undefined {
return tabs.find((t) => t.path === path);
}
export function findTabBySessionId(tabs: Tab[], sessionId: string): Tab | undefined {
return tabs.find((t) => t.type === "chat" && t.sessionId === sessionId);
}
export function findTabBySessionKey(tabs: Tab[], sessionKey: string): Tab | undefined {
return tabs.find((t) => t.type === "chat" && t.sessionKey === sessionKey);
}
export function openTab(state: TabState, tab: Tab): TabState {
const existing = tab.path
? findTabByPath(state.tabs, tab.path)
: tab.sessionKey
? findTabBySessionKey(state.tabs, tab.sessionKey)
: tab.sessionId
? findTabBySessionId(state.tabs, tab.sessionId)
: undefined;
if (existing) {
return { ...state, activeTabId: existing.id };
}
return {
tabs: [...state.tabs, tab],
activeTabId: tab.id,
};
}
export function closeTab(state: TabState, tabId: string): TabState {
if (tabId === HOME_TAB_ID) return state;
const idx = state.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return state;
if (state.tabs[idx].pinned) return state;
const newTabs = state.tabs.filter((t) => t.id !== tabId);
let newActiveId = state.activeTabId;
if (state.activeTabId === tabId) {
if (newTabs.length === 0) {
newActiveId = null;
} else if (idx < newTabs.length) {
newActiveId = newTabs[idx].id;
} else {
newActiveId = newTabs[newTabs.length - 1].id;
}
}
return { tabs: newTabs, activeTabId: newActiveId };
}
export function closeOtherTabs(state: TabState, tabId: string): TabState {
const keep = state.tabs.filter((t) => t.id === tabId || t.pinned);
return { tabs: keep, activeTabId: tabId };
}
export function closeTabsToRight(state: TabState, tabId: string): TabState {
const idx = state.tabs.findIndex((t) => t.id === tabId);
if (idx === -1) return state;
const keep = state.tabs.filter((t, i) => i <= idx || t.pinned);
const activeStillExists = keep.some((t) => t.id === state.activeTabId);
return { tabs: keep, activeTabId: activeStillExists ? state.activeTabId : tabId };
}
export function closeAllTabs(state: TabState): TabState {
const pinned = state.tabs.filter((t) => t.pinned);
const activeStillExists = pinned.some((t) => t.id === state.activeTabId);
return { tabs: pinned, activeTabId: activeStillExists ? state.activeTabId : HOME_TAB_ID };
}
export function activateTab(state: TabState, tabId: string): TabState {
if (!state.tabs.some((t) => t.id === tabId)) return state;
return { ...state, activeTabId: tabId };
}
export function reorderTabs(state: TabState, fromIndex: number, toIndex: number): TabState {
if (fromIndex === toIndex) return state;
// Don't allow moving the home tab or moving anything before it
if (state.tabs[fromIndex]?.id === HOME_TAB_ID) return state;
const effectiveTo = Math.max(1, toIndex); // keep index 0 reserved for home
const tabs = [...state.tabs];
const [moved] = tabs.splice(fromIndex, 1);
tabs.splice(effectiveTo, 0, moved);
return { ...state, tabs };
}
export function togglePinTab(state: TabState, tabId: string): TabState {
return {
...state,
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, pinned: !t.pinned } : t,
),
};
}
export function updateTabTitle(state: TabState, tabId: string, title: string): TabState {
return {
...state,
tabs: state.tabs.map((t) =>
t.id === tabId ? { ...t, title } : t,
),
};
}
export function inferTabType(path: string): TabType {
if (path.includes(".dench.app")) return "app";
if (path.startsWith("~cron")) return "cron";
return "file";
}
export function inferTabTitle(path: string, name?: string): string {
if (name) return name;
return path.split("/").pop() || path;
}