summaryrefslogtreecommitdiffhomepage
path: root/src/features/tabs/tabs.ts
blob: 3360d3fb30eda160afae32a5cc7dfa4faf082540 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
export interface Tab {
	readonly conversationId: string;
	readonly model: string;
	readonly title: string;
}

export interface TabsState {
	readonly tabs: readonly Tab[];
	readonly activeConversationId: string | null;
}

const DEFAULT_TITLE = "New chat";
const DEFAULT_MAX_TITLE_LENGTH = 40;

export function initialState(persisted?: TabsState): TabsState {
	if (persisted !== undefined) return persisted;
	return { tabs: [], activeConversationId: null };
}

export function newDraft(state: TabsState): TabsState {
	return { ...state, activeConversationId: null };
}

export function createTab(state: TabsState, tab: Tab): TabsState {
	const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
	const tabs = exists ? state.tabs : [...state.tabs, tab];
	return { tabs, activeConversationId: tab.conversationId };
}

/**
 * Add a tab WITHOUT switching the active conversation — used by the
 * `conversation.open` WS broadcast (CLI `--open`): the tab appears in the
 * strip but the user stays on their current tab. No-op if already open.
 */
export function openTab(state: TabsState, tab: Tab): TabsState {
	const exists = state.tabs.some((t) => t.conversationId === tab.conversationId);
	if (exists) return state;
	return { tabs: [...state.tabs, tab], activeConversationId: state.activeConversationId };
}

export function selectTab(state: TabsState, conversationId: string): TabsState {
	return { ...state, activeConversationId: conversationId };
}

export function closeTab(state: TabsState, conversationId: string): TabsState {
	const idx = state.tabs.findIndex((t) => t.conversationId === conversationId);
	if (idx === -1) return state;

	const tabs = state.tabs.filter((t) => t.conversationId !== conversationId);

	if (state.activeConversationId !== conversationId) {
		return { tabs, activeConversationId: state.activeConversationId };
	}

	if (tabs.length === 0) {
		return { tabs, activeConversationId: null };
	}

	// prefer previous tab, else next
	const neighborIdx = idx > 0 ? idx - 1 : 0;
	const neighbor = tabs[neighborIdx];
	return { tabs, activeConversationId: neighbor?.conversationId ?? null };
}

export function setModel(state: TabsState, conversationId: string, model: string): TabsState {
	const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, model } : t));
	return { tabs, activeConversationId: state.activeConversationId };
}

export function setTitle(state: TabsState, conversationId: string, title: string): TabsState {
	const tabs = state.tabs.map((t) => (t.conversationId === conversationId ? { ...t, title } : t));
	return { tabs, activeConversationId: state.activeConversationId };
}

export function activeTab(state: TabsState): Tab | null {
	if (state.activeConversationId === null) return null;
	return state.tabs.find((t) => t.conversationId === state.activeConversationId) ?? null;
}

export interface ScrollMetrics {
	readonly scrollLeft: number;
	readonly clientWidth: number;
	readonly scrollWidth: number;
}

const STUCK_EPSILON = 1;

/**
 * True when a right-pinned sticky element is floating over scrolled content — the
 * strip overflows horizontally AND is not scrolled fully to the right. When it is
 * at rest (no overflow, or scrolled to the end so it sits at its natural position)
 * this returns false. Pure: layout measurements in, boolean out.
 */
export function isStuckToEnd(m: ScrollMetrics): boolean {
	const overflows = m.scrollWidth > m.clientWidth + STUCK_EPSILON;
	const notAtEnd = m.scrollLeft + m.clientWidth < m.scrollWidth - STUCK_EPSILON;
	return overflows && notAtEnd;
}

export function deriveTitle(message: string, max: number = DEFAULT_MAX_TITLE_LENGTH): string {
	const trimmed = message.trim().replace(/\s+/g, " ");
	if (trimmed.length === 0) return DEFAULT_TITLE;
	if (trimmed.length <= max) return trimmed;
	return `${trimmed.slice(0, max)}\u2026`;
}

/** Minimum length of a tab handle (git-style short id). */
export const MIN_HANDLE_LENGTH = 4;

/**
 * The short "handle" shown on a tab: the shortest prefix of `conversationId`
 * (at least `MIN_HANDLE_LENGTH` chars) that is unique among all open tabs — a
 * git-style short id. Grows by a char only when another open tab shares the
 * prefix, and shrinks back when that sibling closes. Pure: the id + every open
 * id in, the handle string out. (`allIds` may include `conversationId` itself.)
 */
export function shortHandle(conversationId: string, allIds: readonly string[]): string {
	const others = allIds.filter((id) => id !== conversationId);
	for (let len = MIN_HANDLE_LENGTH; len < conversationId.length; len++) {
		const candidate = conversationId.slice(0, len);
		if (!others.some((id) => id.startsWith(candidate))) return candidate;
	}
	return conversationId;
}