Tab-based multi-chat system supporting concurrent parent and subagent sessions, per-session stop controls, layout/scroll fixes, and attachment display improvements.
204 lines
6.2 KiB
TypeScript
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;
|
|
}
|