char-fork/llama.cpp

git clone https://git.t4t.associates/char-fork/llama.cpp

Aleksander Grygierui: Chat Conversation Tabbed navigation (#27263)8144f3192

char/aliceai-t5-moe
5.3 KiB154 linesraw
1/**
2 * tabsStore - Reactive State Store for Browser-Style Conversation Tabs
3 *
4 * Tracks which conversations and the new-chat screen are open as tabs in
5 * the chat layout, in order. Real conversation tabs are `#/chat/<id>`
6 * routes; the new-chat tab is the bare `#/` route, represented here by the
7 * `NEW_CHAT_TAB_ID` sentinel (see {@link NEW_CHAT_TAB_ID}).
8 *
9 * **Architecture & Relationships:**
10 * - **conversationsStore**: owns conversation data; calls `removeTabs()` /
11 *   `close()` when conversations are deleted. This store never imports it,
12 *   so there is no circular dependency - tab names are resolved by the
13 *   ChatTabs component from conversationsStore.
14 * - Tab order persists to localStorage and is pruned against the loaded
15 *   conversation list on init. The new-chat tab is kept across reloads.
16 */
17
18import { browser } from '$app/environment';
19import { goto } from '$app/navigation';
20import { CONVERSATION_TABS_LOCALSTORAGE_KEY, NEW_CHAT_TAB_ID, ROUTES } from '$lib/constants';
21import { RouterService } from '$lib/services/router.service';
22import { untrack } from 'svelte';
23
24class TabsStore {
25	/** Ordered tab ids: conversation ids and the `NEW_CHAT_TAB_ID` sentinel */
26	openTabs = $state<string[]>([]);
27
28	/** False until init() has read the persisted tabs; save() is a no-op before that */
29	private initialized = false;
30
31	/** Navigate to a tab (the new-chat sentinel maps to the bare `#/` route) */
32	async activate(id: string): Promise<void> {
33		await goto(id === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(id));
34	}
35
36	/** Remove all tabs (e.g. after deleting all conversations) */
37	clear(): void {
38		this.openTabs = [];
39		this.save();
40	}
41
42	/**
43	 * Close a tab. When it belongs to the active route, navigate to the left
44	 * neighbor (or the right one when the closed tab was leftmost), falling
45	 * back to the new-chat screen when no tabs remain.
46	 * @param id - Tab id to close
47	 * @param activeTabId - Tab id of the current route, if any
48	 */
49	async close(id: string, activeTabId: string | null): Promise<void> {
50		const idx = this.openTabs.indexOf(id);
51
52		if (idx === -1) {
53			// tab not tracked (e.g. Conversation tabs are off); still fall back to
54			// the new-chat screen when closing the active conversation
55			if (id === activeTabId) {
56				await goto(ROUTES.START);
57			}
58
59			return;
60		}
61
62		this.openTabs = this.openTabs.filter((tabId) => tabId !== id);
63		this.save();
64
65		if (id !== activeTabId) return;
66
67		const target = (idx > 0 ? this.openTabs[idx - 1] : this.openTabs[0]) ?? null;
68
69		if (target) {
70			await goto(target === NEW_CHAT_TAB_ID ? ROUTES.START : RouterService.chat(target));
71		} else {
72			await goto(ROUTES.START);
73		}
74	}
75
76	/**
77	 * Load persisted tabs, dropping conversation ids that no longer exist.
78	 * Called once from initStores() after conversations are loaded.
79	 * Merges with (rather than replaces) current openTabs: the chat layout
80	 * syncs the route's tab before this async init completes, and replacing
81	 * here would drop it.
82	 * @param validIds - Ids of conversations present in the database
83	 */
84	init(validIds: string[]): void {
85		if (!browser) return;
86
87		// the new-chat sentinel is a pseudo-tab, not a conversation, but it is
88		// still kept so a reload on `#/` does not drop the tab the user is on
89		const isLive = (id: string) => validIds.includes(id) || id === NEW_CHAT_TAB_ID;
90		const persisted = this.load().filter(isLive);
91		// tabs already in openTabs come from the live route, so they stay as they
92		// are: `validIds` is a snapshot and a conversation created while the list
93		// was loading is not in it
94		const extras = this.openTabs.filter((id) => !persisted.includes(id));
95
96		this.openTabs = [...persisted, ...extras];
97		this.initialized = true;
98		this.save();
99	}
100
101	/**
102	 * Remove tabs without navigating. Used when conversations are deleted
103	 * while some other conversation stays open.
104	 * @param ids - Tab ids to drop
105	 */
106	removeTabs(ids: string[]): void {
107		const removed = new Set(ids);
108		const next = this.openTabs.filter((id) => !removed.has(id));
109
110		if (next.length !== this.openTabs.length) {
111			this.openTabs = next;
112			this.save();
113		}
114	}
115
116	/**
117	 * Sync the tab strip with the route. Called from the chat layout on every
118	 * navigation, so any way of reaching a conversation or new-chat tab opens
119	 * a tab for it.
120	 * @param id - The conversation (or temporary new-chat) id of the route
121	 */
122	syncWithRoute(id: string): void {
123		// untrack: callers invoke this from an effect keyed on the route, and
124		// reading openTabs here would subscribe that effect to openTabs too -
125		// closing the active tab would then re-run the effect and re-add the tab
126		untrack(() => {
127			if (!this.openTabs.includes(id)) {
128				this.openTabs = [...this.openTabs, id];
129				this.save();
130			}
131		});
132	}
133
134	private load(): string[] {
135		try {
136			const raw = localStorage.getItem(CONVERSATION_TABS_LOCALSTORAGE_KEY);
137			const parsed: unknown = raw ? JSON.parse(raw) : [];
138
139			return Array.isArray(parsed) ? parsed.filter((id) => typeof id === 'string') : [];
140		} catch {
141			return [];
142		}
143	}
144
145	private save(): void {
146		// never write before init has read the persisted tabs, or an early
147		// route sync (layout effect runs before async init) would clobber them
148		if (!browser || !this.initialized) return;
149
150		localStorage.setItem(CONVERSATION_TABS_LOCALSTORAGE_KEY, JSON.stringify(this.openTabs));
151	}
152}
153
154export const tabsStore = new TabsStore();