pi-tau-web-server 1.0.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +303 -0
- package/bin/auth.js +96 -0
- package/bin/config.js +99 -0
- package/bin/model-utils.js +134 -0
- package/bin/server-main.js +1189 -0
- package/bin/sessions.js +492 -0
- package/bin/tau.js +8 -0
- package/bin/tree.js +248 -0
- package/bin/types.js +2 -0
- package/package.json +74 -0
- package/public/app-main.js +2025 -0
- package/public/app-types.js +1 -0
- package/public/app.js +1 -0
- package/public/command-palette.js +42 -0
- package/public/dialogs.js +199 -0
- package/public/file-browser.js +196 -0
- package/public/icons/apple-touch-icon.png +0 -0
- package/public/icons/favicon-16.png +0 -0
- package/public/icons/favicon-32.png +0 -0
- package/public/icons/tau-192.png +0 -0
- package/public/icons/tau-512.png +0 -0
- package/public/icons/tau-logo.png +0 -0
- package/public/icons/tau-logo.svg +13 -0
- package/public/icons/tau-maskable-512.png +0 -0
- package/public/icons/tau-new.png +0 -0
- package/public/index.html +264 -0
- package/public/launcher-panel.js +54 -0
- package/public/launcher.js +84 -0
- package/public/manifest.json +28 -0
- package/public/markdown.js +336 -0
- package/public/message-renderer.js +268 -0
- package/public/model-picker.js +478 -0
- package/public/session-sidebar.js +460 -0
- package/public/session-stats-card.js +123 -0
- package/public/state.js +81 -0
- package/public/style.css +3864 -0
- package/public/sw.js +66 -0
- package/public/themes.js +72 -0
- package/public/tool-card.js +317 -0
- package/public/tree-view.js +474 -0
- package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
- package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
- package/public/vendor/katex/katex.min.css +1 -0
- package/public/vendor/katex/katex.min.js +1 -0
- package/public/voice-input.js +74 -0
- package/public/websocket-client.js +156 -0
- package/scripts/copy-katex.mjs +32 -0
- package/src/pi-extension/tau-tree.ts +71 -0
- package/src/public/app-main.ts +2190 -0
- package/src/public/app-types.ts +96 -0
- package/src/public/app.ts +1 -0
- package/src/public/command-palette.ts +53 -0
- package/src/public/dialogs.ts +251 -0
- package/src/public/file-browser.ts +224 -0
- package/src/public/launcher-panel.ts +68 -0
- package/src/public/launcher.ts +101 -0
- package/src/public/legacy-dom.d.ts +29 -0
- package/src/public/markdown.ts +372 -0
- package/src/public/message-renderer.ts +311 -0
- package/src/public/model-picker.ts +500 -0
- package/src/public/session-sidebar.ts +522 -0
- package/src/public/session-stats-card.ts +176 -0
- package/src/public/state.ts +96 -0
- package/src/public/sw.ts +79 -0
- package/src/public/themes.ts +73 -0
- package/src/public/tool-card.ts +375 -0
- package/src/public/tree-view.ts +527 -0
- package/src/public/voice-input.ts +98 -0
- package/src/public/websocket-client.ts +165 -0
- package/src/server/auth.ts +88 -0
- package/src/server/config.ts +88 -0
- package/src/server/model-utils.ts +122 -0
- package/src/server/server-main.ts +1004 -0
- package/src/server/sessions.ts +481 -0
- package/src/server/tau.ts +9 -0
- package/src/server/tree.ts +288 -0
- package/src/server/types.ts +68 -0
- package/tsconfig.json +3 -0
- package/tsconfig.public.json +15 -0
- package/tsconfig.server.json +16 -0
|
@@ -0,0 +1,2190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Main App - Ties everything together
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { WebSocketClient } from './websocket-client.js';
|
|
6
|
+
import { StateManager } from './state.js';
|
|
7
|
+
import { MessageRenderer } from './message-renderer.js';
|
|
8
|
+
import { ToolCardRenderer, type ToolExecution, type ToolResult } from './tool-card.js';
|
|
9
|
+
import { DialogHandler, type DialogRequest } from './dialogs.js';
|
|
10
|
+
import { SessionSidebar, type SidebarProject, type SidebarSession } from './session-sidebar.js';
|
|
11
|
+
import { themes, applyTheme, getCurrentTheme } from './themes.js';
|
|
12
|
+
import { FileBrowser, getFileIcon } from './file-browser.js';
|
|
13
|
+
import { setupLauncherPanel } from './launcher-panel.js';
|
|
14
|
+
import { setupModelPicker } from './model-picker.js';
|
|
15
|
+
import { setupTreeView } from './tree-view.js';
|
|
16
|
+
import { setupVoiceInput } from './voice-input.js';
|
|
17
|
+
import { setupCommandPalette } from './command-palette.js';
|
|
18
|
+
import { setupSessionStatsCard, type SessionStats } from './session-stats-card.js';
|
|
19
|
+
|
|
20
|
+
import type { AppEvent, AppMessage, ExtensionUIRequest, LiveInstance, LiveSession, MessageContentBlock, ModelRecord, PendingFilePath, PendingImage, QueuedCommand, RpcCommand, UsageRecord } from './app-types.js';
|
|
21
|
+
|
|
22
|
+
type SessionHistoryEntry = { type?: string; message?: AppMessage };
|
|
23
|
+
|
|
24
|
+
type LiveSessionSnapshotData = {
|
|
25
|
+
sessionId?: string;
|
|
26
|
+
sessionFile?: string | null;
|
|
27
|
+
session?: { sessionFile?: string | null };
|
|
28
|
+
isStreaming?: boolean;
|
|
29
|
+
model?: ModelRecord | null;
|
|
30
|
+
thinkingLevel?: string;
|
|
31
|
+
entries?: SessionHistoryEntry[];
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
type RpcEventDetail = { sessionId?: string; event?: AppEvent };
|
|
35
|
+
|
|
36
|
+
// Initialize components
|
|
37
|
+
const wsUrl = (location.protocol === 'https:' ? 'wss:' : 'ws:') + '//' + location.host + '/ws';
|
|
38
|
+
const wsClient = new WebSocketClient(wsUrl);
|
|
39
|
+
const state = new StateManager();
|
|
40
|
+
// All element lookups below query the app's static index.html shell, which is
|
|
41
|
+
// present before this module runs (the script is a deferred module at the end
|
|
42
|
+
// of <body>). A missing element means the page is structurally broken, so we
|
|
43
|
+
// assert non-null at the query site rather than guarding every usage.
|
|
44
|
+
const messageRenderer = new MessageRenderer(document.getElementById('messages')!);
|
|
45
|
+
const toolCardRenderer = new ToolCardRenderer(document.getElementById('messages')!);
|
|
46
|
+
const dialogHandler = new DialogHandler(document.getElementById('dialog-container')!, wsClient, () => activeLiveSessionId);
|
|
47
|
+
|
|
48
|
+
// Session sidebar
|
|
49
|
+
const sidebar = new SessionSidebar(
|
|
50
|
+
document.getElementById('session-list')!,
|
|
51
|
+
handleSessionSelect
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
// UI elements
|
|
55
|
+
const messageInput = document.getElementById('message-input')!;
|
|
56
|
+
const chatForm = document.getElementById('chat-form')!;
|
|
57
|
+
const sendBtn = document.getElementById('send-btn')!;
|
|
58
|
+
const abortBtn = document.getElementById('abort-btn')!;
|
|
59
|
+
const statusIndicator = document.getElementById('status-indicator')!;
|
|
60
|
+
const statusText = document.getElementById('status-text')!;
|
|
61
|
+
// Tracks the pending timer that restores statusText after a transient
|
|
62
|
+
// status message (rpcCommand success/error). Any new status message must
|
|
63
|
+
// clear this so a stale restore cannot overwrite a later, longer-lived
|
|
64
|
+
// message (e.g. the red-dot error flash).
|
|
65
|
+
let statusRestoreTimer: ReturnType<typeof setTimeout> | null = null;
|
|
66
|
+
// Tracks the pending timer that restores the status indicator (dot) and
|
|
67
|
+
// text after a red-dot error flash. Kept SEPARATE from statusRestoreTimer
|
|
68
|
+
// so a normal setStatusMessage call cannot cancel the only callback that
|
|
69
|
+
// would clear the `error` class — otherwise an unrelated status update
|
|
70
|
+
// during the 3s flash would strand the dot red with no timer to reset it.
|
|
71
|
+
let statusFlashTimer: ReturnType<typeof setTimeout> | null = null;
|
|
72
|
+
// Restore the status indicator dot to its real connection/streaming state.
|
|
73
|
+
// Only touches the indicator class (not statusText), so callers can set the
|
|
74
|
+
// accompanying text themselves.
|
|
75
|
+
function restoreStatusIndicator() {
|
|
76
|
+
const open = wsClient.ws?.readyState === WebSocket.OPEN;
|
|
77
|
+
statusIndicator.className = `status-indicator ${
|
|
78
|
+
open && state.isStreaming ? 'streaming' : (open ? 'connected' : 'disconnected')
|
|
79
|
+
}`;
|
|
80
|
+
}
|
|
81
|
+
// Set a transient statusText message and schedule its restore. Cancels any
|
|
82
|
+
// previously scheduled restore so overlapping messages cannot race. A new
|
|
83
|
+
// status message also supersedes any active error flash: it cancels the
|
|
84
|
+
// flash's restore and returns the dot to its real state so the `error`
|
|
85
|
+
// class cannot linger with no timer to clear it.
|
|
86
|
+
function setStatusMessage(text: string, restoreText: string | null = null, restoreMs = 3000) {
|
|
87
|
+
if (statusFlashTimer !== null) {
|
|
88
|
+
clearTimeout(statusFlashTimer);
|
|
89
|
+
statusFlashTimer = null;
|
|
90
|
+
restoreStatusIndicator();
|
|
91
|
+
}
|
|
92
|
+
clearTimeout(statusRestoreTimer ?? undefined);
|
|
93
|
+
statusText.textContent = text;
|
|
94
|
+
if (restoreText !== null) {
|
|
95
|
+
statusRestoreTimer = setTimeout(() => {
|
|
96
|
+
statusRestoreTimer = null;
|
|
97
|
+
statusText.textContent = restoreText;
|
|
98
|
+
}, restoreMs);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
// Turn the status indicator red and show an error message; after `ms`,
|
|
102
|
+
// restore the indicator to the real connection state and reset the text.
|
|
103
|
+
function flashStatusError(msg: string, ms = 3000) {
|
|
104
|
+
// Reset the class atomically so no stale connected/disconnected/streaming
|
|
105
|
+
// class lingers alongside `error` (matches updateConnectionStatus' style).
|
|
106
|
+
statusIndicator.className = 'status-indicator error';
|
|
107
|
+
// Cancel any pending status-text restore (e.g. rpcCommand's 'Done' ->
|
|
108
|
+
// 'Connected' timer from an earlier successful step in the same flow) so it
|
|
109
|
+
// cannot overwrite this error message while the red dot persists.
|
|
110
|
+
clearTimeout(statusRestoreTimer ?? undefined);
|
|
111
|
+
// Cancel any prior flash restore so overlapping flashes (a second
|
|
112
|
+
// model-save failure within 3s) cannot leak a stray restore that
|
|
113
|
+
// would reset the dot before this flash's own restore fires.
|
|
114
|
+
clearTimeout(statusFlashTimer ?? undefined);
|
|
115
|
+
statusText.textContent = msg;
|
|
116
|
+
// Schedule the dot+text restore on the dedicated statusFlashTimer so a
|
|
117
|
+
// later setStatusMessage (e.g. the user clicking the thinking-level cycle
|
|
118
|
+
// button right after a thinking-level failure) cannot cancel the only
|
|
119
|
+
// callback that clears the `error` class. If a new status message does
|
|
120
|
+
// arrive, setStatusMessage itself retires the flash via restoreStatusIndicator.
|
|
121
|
+
statusFlashTimer = setTimeout(() => {
|
|
122
|
+
statusFlashTimer = null;
|
|
123
|
+
restoreStatusIndicator();
|
|
124
|
+
const open = wsClient.ws?.readyState === WebSocket.OPEN;
|
|
125
|
+
// Preserve an in-progress stream: restore the streaming text too.
|
|
126
|
+
statusText.textContent = (open && state.isStreaming) ? 'Working...'
|
|
127
|
+
: (open ? 'Connected' : 'Disconnected');
|
|
128
|
+
}, ms);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const sidebarEl = document.getElementById('sidebar')!;
|
|
132
|
+
const sidebarToggle = document.getElementById('sidebar-toggle')!;
|
|
133
|
+
const sidebarOverlay = document.getElementById('sidebar-overlay')!;
|
|
134
|
+
|
|
135
|
+
const refreshSessionsBtn = document.getElementById('refresh-sessions-btn')!;
|
|
136
|
+
const sessionSearchInput = document.getElementById('session-search-input')!;
|
|
137
|
+
const typingIndicator = document.getElementById('typing-indicator')!;
|
|
138
|
+
|
|
139
|
+
const contextPillEl = document.getElementById('context-pill')!;
|
|
140
|
+
const scrollBottomBtn = document.getElementById('scroll-bottom-btn')!;
|
|
141
|
+
const scrollBottomBadge = document.getElementById('scroll-bottom-badge')!;
|
|
142
|
+
const messagesContainer = document.getElementById('messages')!;
|
|
143
|
+
const launcherPanel = setupLauncherPanel({
|
|
144
|
+
launcherEl: document.getElementById('launcher')!,
|
|
145
|
+
messagesContainer,
|
|
146
|
+
async createSession(projectPath) {
|
|
147
|
+
try {
|
|
148
|
+
const res = await fetch('/api/live-sessions', {
|
|
149
|
+
method: 'POST',
|
|
150
|
+
headers: { 'Content-Type': 'application/json' },
|
|
151
|
+
body: JSON.stringify({ cwd: projectPath, model: '' }),
|
|
152
|
+
});
|
|
153
|
+
const data = await res.json();
|
|
154
|
+
if (data.session) {
|
|
155
|
+
upsertLiveSession(data.session);
|
|
156
|
+
await selectLiveSession(data.session.id);
|
|
157
|
+
}
|
|
158
|
+
} catch (e) {
|
|
159
|
+
console.error('[Launcher] Failed to create Tau tab:', e);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
// State tracking
|
|
165
|
+
let currentStreamingElement: HTMLElement | null = null;
|
|
166
|
+
let currentStreamingText = '';
|
|
167
|
+
let sessionTotalCost = 0;
|
|
168
|
+
let lastInputTokens = 0;
|
|
169
|
+
let contextWindowSize = 0; // fetched from model info
|
|
170
|
+
let originalTitle = document.title;
|
|
171
|
+
let hasFocus = true;
|
|
172
|
+
let unreadCount = 0;
|
|
173
|
+
let isScrolledUp = false;
|
|
174
|
+
let hasNewWhileScrolled = false;
|
|
175
|
+
let lastSentMessage: string | null = null; // Track to avoid duplicate rendering from backend echo events
|
|
176
|
+
let lastUsage: UsageRecord | null = null; // Full usage object for context visualiser
|
|
177
|
+
let activeLiveSessionFile: string | null = null; // The active live session file path
|
|
178
|
+
let viewingActiveSession = false; // Whether we're viewing a live backend Tau tab or historical read-only session
|
|
179
|
+
let hasReceivedInitialServerState = false;
|
|
180
|
+
let liveInstances: LiveInstance[] = []; // Sidebar live indicators derived from backend live sessions
|
|
181
|
+
let liveSessions: LiveSession[] = [];
|
|
182
|
+
let activeLiveSessionId = localStorage.getItem('tau-active-live-session-id') || null;
|
|
183
|
+
let hasRestoredInitialLiveSession = false;
|
|
184
|
+
let pendingExtensionUIRequests: ExtensionUIRequest[] = []; // background session UI requests waiting for that Tau tab to be selected
|
|
185
|
+
dialogHandler.onIdle = () => processQueuedExtensionUIRequest();
|
|
186
|
+
|
|
187
|
+
// File browser
|
|
188
|
+
const fileSidebar = document.getElementById('file-sidebar')!;
|
|
189
|
+
const fileSidebarToggle = document.getElementById('file-sidebar-toggle')!;
|
|
190
|
+
const fileSidebarClose = document.getElementById('file-sidebar-close')!;
|
|
191
|
+
const fileSidebarUp = document.getElementById('file-sidebar-up')!;
|
|
192
|
+
const fileList = document.getElementById('file-list')!;
|
|
193
|
+
const fileSidebarPath = document.getElementById('file-sidebar-path')!;
|
|
194
|
+
const fileBrowser = new FileBrowser(fileList, fileSidebarPath, messageInput, (filePath) => {
|
|
195
|
+
const name = filePath.split(/[/\\]/).pop() || filePath;
|
|
196
|
+
const ext = name.split('.').pop()?.toLowerCase() || '';
|
|
197
|
+
pendingFilePaths.push({ path: filePath, name, ext, sessionId: activeLiveSessionId });
|
|
198
|
+
renderAttachmentPreviews();
|
|
199
|
+
}, () => (viewingActiveSession && liveSessions.some(s => s.id === activeLiveSessionId) ? activeLiveSessionId : null));
|
|
200
|
+
|
|
201
|
+
fileSidebarToggle.addEventListener('click', () => {
|
|
202
|
+
const isCollapsed = fileSidebar.classList.toggle('collapsed');
|
|
203
|
+
if (!isCollapsed && !fileBrowser.currentPath) {
|
|
204
|
+
fileBrowser.load(); // Load session cwd
|
|
205
|
+
}
|
|
206
|
+
localStorage.setItem('tau-file-sidebar', isCollapsed ? 'closed' : 'open');
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
fileSidebarClose.addEventListener('click', () => {
|
|
210
|
+
fileSidebar.classList.add('collapsed');
|
|
211
|
+
localStorage.setItem('tau-file-sidebar', 'closed');
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
fileSidebarUp.addEventListener('click', () => {
|
|
215
|
+
const parent = fileBrowser.getParentPath();
|
|
216
|
+
if (parent) fileBrowser.load(parent);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
fetch('/api/health').then(r => r.json()).then(data => {
|
|
220
|
+
const names: Record<string, string> = { win32: 'Explorer', darwin: 'Finder', linux: 'file manager' };
|
|
221
|
+
const name = names[data.platform] || 'file manager';
|
|
222
|
+
document.getElementById('file-sidebar-finder')!.title = `Open in ${name}`;
|
|
223
|
+
}).catch(() => {});
|
|
224
|
+
|
|
225
|
+
document.getElementById('file-sidebar-finder')!.addEventListener('click', () => {
|
|
226
|
+
const sessionId = viewingActiveSession && activeLiveSessionId ? activeLiveSessionId : null;
|
|
227
|
+
if (fileBrowser.currentPath && sessionId) {
|
|
228
|
+
fetch('/api/open', {
|
|
229
|
+
method: 'POST',
|
|
230
|
+
headers: { 'Content-Type': 'application/json' },
|
|
231
|
+
body: JSON.stringify({ filePath: fileBrowser.currentPath, sessionId }),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
// Restore file sidebar state
|
|
237
|
+
if (localStorage.getItem('tau-file-sidebar') === 'open') {
|
|
238
|
+
fileSidebar.classList.remove('collapsed');
|
|
239
|
+
fileBrowser.load();
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
// ═══════════════════════════════════════
|
|
244
|
+
// Focus tracking for tab title notifications
|
|
245
|
+
// ═══════════════════════════════════════
|
|
246
|
+
|
|
247
|
+
window.addEventListener('focus', () => {
|
|
248
|
+
hasFocus = true;
|
|
249
|
+
unreadCount = 0;
|
|
250
|
+
document.title = originalTitle;
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
window.addEventListener('blur', () => {
|
|
258
|
+
hasFocus = false;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Reconnect WebSocket when returning to the app (iOS suspends WS connections)
|
|
262
|
+
document.addEventListener('visibilitychange', () => {
|
|
263
|
+
if (document.visibilityState === 'visible' && wsClient.ws?.readyState !== WebSocket.OPEN) {
|
|
264
|
+
console.log('[App] Returning to app, reconnecting...');
|
|
265
|
+
wsClient.forceReconnect();
|
|
266
|
+
}
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ═══════════════════════════════════════
|
|
270
|
+
// Scroll-to-bottom button + new message indicator
|
|
271
|
+
// ═══════════════════════════════════════
|
|
272
|
+
|
|
273
|
+
messagesContainer.addEventListener('scroll', () => {
|
|
274
|
+
const threshold = 150;
|
|
275
|
+
const atBottom = messagesContainer.scrollHeight - messagesContainer.scrollTop - messagesContainer.clientHeight < threshold;
|
|
276
|
+
isScrolledUp = !atBottom;
|
|
277
|
+
|
|
278
|
+
if (atBottom) {
|
|
279
|
+
scrollBottomBtn.classList.add('hidden');
|
|
280
|
+
scrollBottomBadge.classList.add('hidden');
|
|
281
|
+
hasNewWhileScrolled = false;
|
|
282
|
+
} else {
|
|
283
|
+
scrollBottomBtn.classList.remove('hidden');
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
scrollBottomBtn.addEventListener('click', () => {
|
|
288
|
+
messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
|
|
289
|
+
scrollBottomBtn.classList.add('hidden');
|
|
290
|
+
scrollBottomBadge.classList.add('hidden');
|
|
291
|
+
hasNewWhileScrolled = false;
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
function scrollToBottom() {
|
|
295
|
+
messagesContainer.scrollTo({ top: messagesContainer.scrollHeight, behavior: 'smooth' });
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function showNewMessageBadge() {
|
|
299
|
+
if (isScrolledUp) {
|
|
300
|
+
hasNewWhileScrolled = true;
|
|
301
|
+
scrollBottomBadge.classList.remove('hidden');
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ═══════════════════════════════════════
|
|
306
|
+
// WebSocket event handlers
|
|
307
|
+
// ═══════════════════════════════════════
|
|
308
|
+
|
|
309
|
+
wsClient.addEventListener('connected', () => {
|
|
310
|
+
updateConnectionStatus('connected');
|
|
311
|
+
// Fetch model context window size for token % display
|
|
312
|
+
setTimeout(fetchContextWindow, 1000);
|
|
313
|
+
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
wsClient.addEventListener('disconnected', () => {
|
|
317
|
+
updateConnectionStatus('disconnected');
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
wsClient.addEventListener('reconnectFailed', () => {
|
|
321
|
+
updateConnectionStatus('disconnected');
|
|
322
|
+
messageRenderer.renderError('Connection lost. Please refresh the page.');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
wsClient.addEventListener('rpcEvent', (e: Event) => {
|
|
326
|
+
const detail = (e as CustomEvent<RpcEventDetail>).detail || {};
|
|
327
|
+
const event = (detail.event || detail) as AppEvent;
|
|
328
|
+
const sessionId = detail.sessionId;
|
|
329
|
+
if (sessionId) {
|
|
330
|
+
const session = liveSessions.find(s => s.id === sessionId);
|
|
331
|
+
if (session) {
|
|
332
|
+
session.lastActiveAt = new Date().toISOString();
|
|
333
|
+
if (event.type === 'agent_start' || event.type === 'turn_start') session.isStreaming = true;
|
|
334
|
+
if (event.type === 'agent_end' || event.type === 'turn_end') session.isStreaming = false;
|
|
335
|
+
if (event.type === 'agent_start' || event.type === 'turn_start' || event.type === 'agent_end' || event.type === 'turn_end') {
|
|
336
|
+
// Keep an open tree modal honest: disable its rows while the turn
|
|
337
|
+
// runs, and refetch the tree (re-enabling the rows) when it ends.
|
|
338
|
+
treeViewController.notifyStreamingChanged(sessionId, !!session.isStreaming);
|
|
339
|
+
}
|
|
340
|
+
if (event.type === 'session_name' && event.name) session.sessionName = event.name;
|
|
341
|
+
if ((event.message as AppMessage)?.usage) session.contextUsage = { ...(session.contextUsage || {}), usage: (event.message as AppMessage).usage };
|
|
342
|
+
renderLiveTabs();
|
|
343
|
+
}
|
|
344
|
+
if (sessionId !== activeLiveSessionId || !viewingActiveSession) {
|
|
345
|
+
if (event.type === 'extension_ui_request') queueExtensionUIRequest(event, sessionId);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
handleRPCEvent(event, sessionId);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
wsClient.addEventListener('serverError', (e: Event) => {
|
|
353
|
+
messageRenderer.renderError((e as CustomEvent<{ message: string }>).detail.message);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
wsClient.addEventListener('stateUpdate', (e: Event) => {
|
|
357
|
+
const detail = (e as CustomEvent<{ liveSessions?: LiveSession[] }>).detail;
|
|
358
|
+
const wasViewingLive = viewingActiveSession;
|
|
359
|
+
const launcherVisible = launcherPanel.isVisible();
|
|
360
|
+
hasReceivedInitialServerState = true;
|
|
361
|
+
setLiveSessions(detail.liveSessions || []);
|
|
362
|
+
if (!hasRestoredInitialLiveSession || (wasViewingLive && !launcherVisible)) {
|
|
363
|
+
hasRestoredInitialLiveSession = true;
|
|
364
|
+
restoreActiveLiveSession();
|
|
365
|
+
} else {
|
|
366
|
+
if (activeLiveSessionId && !liveSessions.some(s => s.id === activeLiveSessionId)) {
|
|
367
|
+
activeLiveSessionId = null;
|
|
368
|
+
localStorage.removeItem('tau-active-live-session-id');
|
|
369
|
+
renderQueuedMessages();
|
|
370
|
+
renderLiveTabs();
|
|
371
|
+
}
|
|
372
|
+
updateLiveSessionInputState();
|
|
373
|
+
updateLiveSessionIndicators();
|
|
374
|
+
}
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
wsClient.addEventListener('liveSessionCreated', (e: Event) => {
|
|
378
|
+
upsertLiveSession((e as CustomEvent<LiveSession>).detail);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
wsClient.addEventListener('liveSessionUpdated', (e: Event) => {
|
|
382
|
+
const detail = (e as CustomEvent<LiveSession>).detail;
|
|
383
|
+
upsertLiveSession(detail);
|
|
384
|
+
if (detail?.id === activeLiveSessionId) applyActiveSessionMetadata(detail);
|
|
385
|
+
});
|
|
386
|
+
|
|
387
|
+
wsClient.addEventListener('liveSessionClosed', (e: Event) => {
|
|
388
|
+
handleLiveSessionClosed((e as CustomEvent<{ sessionId: string }>).detail.sessionId);
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
// Receive a full live-session state snapshot.
|
|
392
|
+
wsClient.addEventListener('liveSessionSnapshot', (e: Event) => {
|
|
393
|
+
applyLiveSessionSnapshot((e as CustomEvent<LiveSessionSnapshotData>).detail);
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
// ═══════════════════════════════════════
|
|
397
|
+
// Live-session tabs
|
|
398
|
+
// ═══════════════════════════════════════
|
|
399
|
+
|
|
400
|
+
const liveTabsList = document.getElementById('live-tabs-list');
|
|
401
|
+
const liveTabAddBtn = document.getElementById('live-tab-add');
|
|
402
|
+
const newLiveSessionOverlay = document.getElementById('new-live-session-overlay');
|
|
403
|
+
const newLiveSessionModal = document.getElementById('new-live-session-modal');
|
|
404
|
+
const newLiveSessionForm = document.getElementById('new-live-session-form');
|
|
405
|
+
const newLiveSessionCwd = document.getElementById('new-live-session-cwd')!;
|
|
406
|
+
const newLiveSessionModel = document.getElementById('new-live-session-model')!;
|
|
407
|
+
const newLiveSessionProjects = document.getElementById('new-live-session-projects');
|
|
408
|
+
const newLiveSessionSubmit = document.getElementById('new-live-session-submit')!;
|
|
409
|
+
|
|
410
|
+
function setLiveSessions(sessions: LiveSession[]) {
|
|
411
|
+
liveSessions = sessions || [];
|
|
412
|
+
liveInstances = liveSessions.map(s => ({ sessionFile: s.sessionFile, cwd: s.cwd, port: location.port }));
|
|
413
|
+
renderLiveTabs();
|
|
414
|
+
updateLiveSessionIndicators();
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function handleLiveSessionClosed(closedId: string) {
|
|
418
|
+
if (!closedId) return;
|
|
419
|
+
liveSessions = liveSessions.filter(s => s.id !== closedId);
|
|
420
|
+
messageQueue = messageQueue.filter(cmd => cmd.sessionId !== closedId);
|
|
421
|
+
pendingExtensionUIRequests = pendingExtensionUIRequests.filter(req => req.sessionId !== closedId);
|
|
422
|
+
if (dialogHandler.currentRequest?.sessionId === closedId) {
|
|
423
|
+
dialogHandler.clearCurrentDialog();
|
|
424
|
+
processQueuedExtensionUIRequest();
|
|
425
|
+
}
|
|
426
|
+
renderQueuedMessages();
|
|
427
|
+
if (activeLiveSessionId === closedId) {
|
|
428
|
+
const wasViewingActive = viewingActiveSession;
|
|
429
|
+
activeLiveSessionId = null;
|
|
430
|
+
localStorage.removeItem('tau-active-live-session-id');
|
|
431
|
+
activeLiveSessionFile = null;
|
|
432
|
+
currentStreamingElement = null;
|
|
433
|
+
currentStreamingThinking = '';
|
|
434
|
+
currentStreamingText = '';
|
|
435
|
+
state.reset();
|
|
436
|
+
showTypingIndicator(false);
|
|
437
|
+
if (wasViewingActive) {
|
|
438
|
+
messageRenderer.clear();
|
|
439
|
+
toolCardRenderer.clear();
|
|
440
|
+
const next = getMostRecentLiveSession();
|
|
441
|
+
if (next) selectLiveSession(next.id);
|
|
442
|
+
else {
|
|
443
|
+
viewingActiveSession = false;
|
|
444
|
+
messageRenderer.renderWelcome();
|
|
445
|
+
updateLiveSessionInputState();
|
|
446
|
+
updateUI();
|
|
447
|
+
}
|
|
448
|
+
} else {
|
|
449
|
+
updateLiveSessionInputState();
|
|
450
|
+
updateUI();
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
liveInstances = liveSessions.map(s => ({ sessionFile: s.sessionFile, cwd: s.cwd, port: location.port }));
|
|
454
|
+
renderLiveTabs();
|
|
455
|
+
updateLiveSessionIndicators();
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function upsertLiveSession(session: LiveSession) {
|
|
459
|
+
if (!session) return;
|
|
460
|
+
const idx = liveSessions.findIndex(s => s.id === session.id);
|
|
461
|
+
let shouldRenderTabs = false;
|
|
462
|
+
if (idx >= 0) {
|
|
463
|
+
const before = liveTabSignature(liveSessions[idx]);
|
|
464
|
+
liveSessions[idx] = { ...liveSessions[idx], ...session };
|
|
465
|
+
shouldRenderTabs = before !== liveTabSignature(liveSessions[idx]);
|
|
466
|
+
} else {
|
|
467
|
+
liveSessions.push(session);
|
|
468
|
+
shouldRenderTabs = true;
|
|
469
|
+
}
|
|
470
|
+
liveInstances = liveSessions.map(s => ({ sessionFile: s.sessionFile, cwd: s.cwd, port: location.port }));
|
|
471
|
+
if (shouldRenderTabs) renderLiveTabs();
|
|
472
|
+
updateLiveSessionIndicators();
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
function getMostRecentLiveSession() {
|
|
476
|
+
return [...liveSessions].sort((a, b) =>
|
|
477
|
+
new Date(b.lastActiveAt || b.createdAt || 0).getTime() - new Date(a.lastActiveAt || a.createdAt || 0).getTime()
|
|
478
|
+
)[0] || null;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function basename(p: string) {
|
|
482
|
+
return (p || '').split(/[/\\]/).filter(Boolean).pop() || p || 'session';
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function compactModelLabel(session: LiveSession) {
|
|
486
|
+
const raw = session.modelLabel || session.modelSpec || (session.model as ModelRecord)?.id || (session.model as ModelRecord)?.name || 'default';
|
|
487
|
+
return String(raw).replace(/^.*\//, '').replace(/^claude-/, '').replace(/-\d{8}$/, '');
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function liveTabSignature(session: LiveSession) {
|
|
491
|
+
return [
|
|
492
|
+
session.sessionName || basename(session.cwd || ''),
|
|
493
|
+
compactModelLabel(session),
|
|
494
|
+
session.cwd || '',
|
|
495
|
+
session.modelSpec || '',
|
|
496
|
+
session.isStreaming ? 'streaming' : 'idle',
|
|
497
|
+
hasPendingExtensionUIRequest(session.id) ? 'ui' : '',
|
|
498
|
+
].join('\u001f');
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function renderLiveTabs() {
|
|
502
|
+
if (!liveTabsList) return;
|
|
503
|
+
const existing = new Map<string, HTMLButtonElement>();
|
|
504
|
+
liveTabsList.querySelectorAll<HTMLButtonElement>('.live-tab').forEach((tab) => {
|
|
505
|
+
if (tab.dataset.sessionId) existing.set(tab.dataset.sessionId, tab);
|
|
506
|
+
});
|
|
507
|
+
const seen = new Set<string>();
|
|
508
|
+
liveSessions.forEach((session, index) => {
|
|
509
|
+
let tab = existing.get(session.id);
|
|
510
|
+
if (!tab) {
|
|
511
|
+
tab = document.createElement('button');
|
|
512
|
+
tab.type = 'button';
|
|
513
|
+
tab.className = 'live-tab';
|
|
514
|
+
tab.dataset.sessionId = session.id;
|
|
515
|
+
tab.addEventListener('click', () => selectLiveSession(session.id));
|
|
516
|
+
}
|
|
517
|
+
seen.add(session.id);
|
|
518
|
+
tab.classList.toggle('active', session.id === activeLiveSessionId);
|
|
519
|
+
tab.title = `${session.cwd || ''}${session.modelSpec ? ` • ${session.modelSpec}` : ''}`;
|
|
520
|
+
const signature = liveTabSignature(session);
|
|
521
|
+
if (tab.dataset.signature !== signature) {
|
|
522
|
+
tab.dataset.signature = signature;
|
|
523
|
+
tab.innerHTML = `
|
|
524
|
+
${session.isStreaming ? '<span class="live-tab-streaming-dot"></span>' : ''}
|
|
525
|
+
${hasPendingExtensionUIRequest(session.id) ? '<span class="live-tab-ui-dot" title="Waiting for response">?</span>' : ''}
|
|
526
|
+
<span class="live-tab-title">${escapeHtml(session.sessionName || basename(session.cwd || ''))}</span>
|
|
527
|
+
<span class="live-tab-model">${escapeHtml(compactModelLabel(session))}</span>
|
|
528
|
+
<span class="live-tab-close" title="Close Tau tab">×</span>
|
|
529
|
+
`;
|
|
530
|
+
tab.querySelector('.live-tab-close')?.addEventListener('click', (ev) => {
|
|
531
|
+
ev.stopPropagation();
|
|
532
|
+
closeLiveSession(session.id);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
const currentAtIndex = liveTabsList.children[index];
|
|
536
|
+
if (currentAtIndex !== tab) liveTabsList.insertBefore(tab, currentAtIndex || null);
|
|
537
|
+
});
|
|
538
|
+
for (const [id, tab] of existing) {
|
|
539
|
+
if (!seen.has(id)) tab.remove();
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function restoreActiveLiveSession() {
|
|
544
|
+
const saved = activeLiveSessionId && liveSessions.find(s => s.id === activeLiveSessionId);
|
|
545
|
+
const next = saved || getMostRecentLiveSession();
|
|
546
|
+
if (next) {
|
|
547
|
+
selectLiveSession(next.id);
|
|
548
|
+
} else {
|
|
549
|
+
activeLiveSessionId = null;
|
|
550
|
+
viewingActiveSession = false;
|
|
551
|
+
activeLiveSessionFile = null;
|
|
552
|
+
localStorage.removeItem('tau-active-live-session-id');
|
|
553
|
+
state.reset();
|
|
554
|
+
renderQueuedMessages();
|
|
555
|
+
renderLiveTabs();
|
|
556
|
+
updateLiveSessionInputState();
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
async function selectLiveSession(id: string) {
|
|
561
|
+
const session = liveSessions.find(s => s.id === id);
|
|
562
|
+
if (!session) return;
|
|
563
|
+
suspendCurrentDialogForTabSwitch(id);
|
|
564
|
+
launcherPanel.hide();
|
|
565
|
+
activeLiveSessionId = id;
|
|
566
|
+
localStorage.setItem('tau-active-live-session-id', id);
|
|
567
|
+
viewingActiveSession = true;
|
|
568
|
+
activeLiveSessionFile = session.sessionFile || null;
|
|
569
|
+
renderLiveTabs();
|
|
570
|
+
renderQueuedMessages();
|
|
571
|
+
applyActiveSessionMetadata(session);
|
|
572
|
+
currentStreamingElement = null;
|
|
573
|
+
currentStreamingThinking = '';
|
|
574
|
+
currentStreamingText = '';
|
|
575
|
+
state.reset();
|
|
576
|
+
state.setStreaming(!!session.isStreaming);
|
|
577
|
+
messageRenderer.clear();
|
|
578
|
+
toolCardRenderer.clear();
|
|
579
|
+
try {
|
|
580
|
+
const res = await fetch(`/api/live-sessions/${encodeURIComponent(id)}/snapshot`);
|
|
581
|
+
const data = await res.json();
|
|
582
|
+
if (!res.ok || data.error) {
|
|
583
|
+
handleLiveSessionClosed(id);
|
|
584
|
+
throw new Error(data.error || 'Live session not found');
|
|
585
|
+
}
|
|
586
|
+
applyLiveSessionSnapshot({ ...data, sessionId: id });
|
|
587
|
+
} catch (e) {
|
|
588
|
+
messageRenderer.renderError((e instanceof Error ? e.message : '') || 'Failed to load live session snapshot');
|
|
589
|
+
return;
|
|
590
|
+
}
|
|
591
|
+
if (!fileSidebar.classList.contains('collapsed')) fileBrowser.load();
|
|
592
|
+
updateLiveSessionInputState();
|
|
593
|
+
processQueuedExtensionUIRequest(id);
|
|
594
|
+
flushQueue();
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
function applyActiveSessionMetadata(session: LiveSession) {
|
|
598
|
+
if (!session) return;
|
|
599
|
+
// Server is canonical: session.model is always null or a full {provider,id}
|
|
600
|
+
// object, so assign directly. No modelLabel/modelSpec string fallbacks.
|
|
601
|
+
modelPickerController.setModelState(session.model || '', session.thinkingLevel || 'off');
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
async function closeLiveSession(id: string) {
|
|
605
|
+
const session = liveSessions.find(s => s.id === id);
|
|
606
|
+
if (!session) return;
|
|
607
|
+
const hasQueuedMessages = messageQueue.some(cmd => cmd.sessionId === id);
|
|
608
|
+
if (session.isStreaming || hasQueuedMessages) {
|
|
609
|
+
const reason = session.isStreaming && hasQueuedMessages
|
|
610
|
+
? 'This Tau tab is streaming and has queued unsent messages. Close it, terminate the Pi session, and discard the queue?'
|
|
611
|
+
: session.isStreaming
|
|
612
|
+
? 'This Tau tab is streaming. Close it and terminate the Pi session?'
|
|
613
|
+
: 'This Tau tab has queued unsent messages. Close it and discard them?';
|
|
614
|
+
if (!confirm(reason)) return;
|
|
615
|
+
}
|
|
616
|
+
try {
|
|
617
|
+
await fetch(`/api/live-sessions/${encodeURIComponent(id)}`, { method: 'DELETE' });
|
|
618
|
+
} catch (e) {
|
|
619
|
+
messageRenderer.renderError('Failed to close Tau tab');
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function loadProjectChips() {
|
|
624
|
+
if (!newLiveSessionProjects) return;
|
|
625
|
+
newLiveSessionProjects.innerHTML = '';
|
|
626
|
+
try {
|
|
627
|
+
const res = await fetch('/api/projects');
|
|
628
|
+
const data = await res.json();
|
|
629
|
+
for (const project of data.projects || []) {
|
|
630
|
+
const chip = document.createElement('button');
|
|
631
|
+
chip.type = 'button';
|
|
632
|
+
chip.className = 'project-chip';
|
|
633
|
+
chip.textContent = project.name;
|
|
634
|
+
chip.title = project.path;
|
|
635
|
+
chip.addEventListener('click', () => { newLiveSessionCwd.value = project.path; });
|
|
636
|
+
newLiveSessionProjects.appendChild(chip);
|
|
637
|
+
}
|
|
638
|
+
} catch {}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function openNewLiveSessionModal() {
|
|
642
|
+
newLiveSessionOverlay?.classList.remove('hidden');
|
|
643
|
+
newLiveSessionModal?.classList.remove('hidden');
|
|
644
|
+
newLiveSessionSubmit.disabled = false;
|
|
645
|
+
if (!newLiveSessionCwd.value) newLiveSessionCwd.value = liveSessions.find(s => s.id === activeLiveSessionId)?.cwd || '';
|
|
646
|
+
loadProjectChips();
|
|
647
|
+
requestAnimationFrame(() => newLiveSessionCwd?.focus());
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function closeNewLiveSessionModal() {
|
|
651
|
+
newLiveSessionOverlay?.classList.add('hidden');
|
|
652
|
+
newLiveSessionModal?.classList.add('hidden');
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
liveTabAddBtn?.addEventListener('click', openNewLiveSessionModal);
|
|
656
|
+
document.getElementById('new-live-session-close')?.addEventListener('click', closeNewLiveSessionModal);
|
|
657
|
+
document.getElementById('new-live-session-cancel')?.addEventListener('click', closeNewLiveSessionModal);
|
|
658
|
+
newLiveSessionOverlay?.addEventListener('click', closeNewLiveSessionModal);
|
|
659
|
+
newLiveSessionForm?.addEventListener('submit', async (e) => {
|
|
660
|
+
e.preventDefault();
|
|
661
|
+
const cwd = newLiveSessionCwd.value.trim();
|
|
662
|
+
if (!cwd) return;
|
|
663
|
+
newLiveSessionSubmit.disabled = true;
|
|
664
|
+
newLiveSessionSubmit.textContent = 'Starting…';
|
|
665
|
+
try {
|
|
666
|
+
const res = await fetch('/api/live-sessions', {
|
|
667
|
+
method: 'POST',
|
|
668
|
+
headers: { 'Content-Type': 'application/json' },
|
|
669
|
+
body: JSON.stringify({ cwd, model: newLiveSessionModel.value.trim() }),
|
|
670
|
+
});
|
|
671
|
+
const data = await res.json();
|
|
672
|
+
if (!res.ok || data.error) throw new Error(data.error || 'Failed to create Tau tab');
|
|
673
|
+
upsertLiveSession(data.session);
|
|
674
|
+
closeNewLiveSessionModal();
|
|
675
|
+
newLiveSessionModel.value = '';
|
|
676
|
+
await selectLiveSession(data.session.id);
|
|
677
|
+
} catch (err) {
|
|
678
|
+
messageRenderer.renderError((err instanceof Error ? err.message : '') || 'Failed to create Tau tab');
|
|
679
|
+
} finally {
|
|
680
|
+
newLiveSessionSubmit.disabled = false;
|
|
681
|
+
newLiveSessionSubmit.textContent = 'Create tab';
|
|
682
|
+
}
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
// ═══════════════════════════════════════
|
|
686
|
+
// RPC event handlers
|
|
687
|
+
// ═══════════════════════════════════════
|
|
688
|
+
|
|
689
|
+
function handleRPCEvent(event: AppEvent, sessionId: string | null = null) {
|
|
690
|
+
switch (event.type) {
|
|
691
|
+
case 'agent_start':
|
|
692
|
+
case 'turn_start':
|
|
693
|
+
handleAgentStart();
|
|
694
|
+
break;
|
|
695
|
+
case 'agent_end':
|
|
696
|
+
case 'turn_end':
|
|
697
|
+
handleAgentEnd();
|
|
698
|
+
break;
|
|
699
|
+
case 'message_start':
|
|
700
|
+
handleMessageStart(event.message as AppMessage);
|
|
701
|
+
break;
|
|
702
|
+
case 'message_update':
|
|
703
|
+
handleMessageUpdate(event);
|
|
704
|
+
break;
|
|
705
|
+
case 'message_end':
|
|
706
|
+
handleMessageEnd(event.message as AppMessage);
|
|
707
|
+
break;
|
|
708
|
+
case 'tool_execution_start':
|
|
709
|
+
handleToolExecutionStart(event);
|
|
710
|
+
break;
|
|
711
|
+
case 'tool_execution_update':
|
|
712
|
+
handleToolExecutionUpdate(event);
|
|
713
|
+
break;
|
|
714
|
+
case 'tool_execution_end':
|
|
715
|
+
handleToolExecutionEnd(event);
|
|
716
|
+
break;
|
|
717
|
+
case 'auto_compaction_start':
|
|
718
|
+
handleCompactionStart();
|
|
719
|
+
break;
|
|
720
|
+
case 'auto_compaction_end':
|
|
721
|
+
handleCompactionEnd(event);
|
|
722
|
+
break;
|
|
723
|
+
case 'extension_ui_request':
|
|
724
|
+
handleExtensionUIRequest(event, sessionId);
|
|
725
|
+
break;
|
|
726
|
+
case 'extension_error':
|
|
727
|
+
messageRenderer.renderError(`Extension error: ${event.error}`);
|
|
728
|
+
break;
|
|
729
|
+
case 'session_name':
|
|
730
|
+
// Auto-title: update sidebar with new session name
|
|
731
|
+
if (event.name) {
|
|
732
|
+
const activeItem = document.querySelector('.session-item.active .session-title');
|
|
733
|
+
if (activeItem) activeItem.textContent = event.name;
|
|
734
|
+
}
|
|
735
|
+
break;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
function handleCompactionStart() {
|
|
740
|
+
const el = document.createElement('div');
|
|
741
|
+
el.className = 'system-message compaction-message';
|
|
742
|
+
el.id = 'compaction-indicator';
|
|
743
|
+
el.innerHTML = '<span class="compaction-spinner">⟳</span> Compacting context…';
|
|
744
|
+
messagesContainer.appendChild(el);
|
|
745
|
+
scrollToBottom();
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
function handleCompactionEnd(event: AppEvent) {
|
|
749
|
+
const indicator = document.getElementById('compaction-indicator');
|
|
750
|
+
if (indicator) {
|
|
751
|
+
const summary = event.summary ? ` — ${event.summary}` : '';
|
|
752
|
+
indicator.innerHTML = `✓ Context compacted${summary}`;
|
|
753
|
+
indicator.classList.add('compaction-done');
|
|
754
|
+
}
|
|
755
|
+
// Reset token tracking — the stats refresh below and the next message
|
|
756
|
+
// bring in fresh post-compaction numbers.
|
|
757
|
+
lastInputTokens = 0;
|
|
758
|
+
updateContextPill();
|
|
759
|
+
hideCompactButton();
|
|
760
|
+
void sessionStatsCard.refresh();
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
function handleAgentStart() {
|
|
764
|
+
state.setStreaming(true);
|
|
765
|
+
showTypingIndicator(true);
|
|
766
|
+
updateUI();
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
function handleAgentEnd() {
|
|
770
|
+
const wasStreaming = state.isStreaming;
|
|
771
|
+
state.setStreaming(false);
|
|
772
|
+
showTypingIndicator(false);
|
|
773
|
+
currentStreamingElement = null;
|
|
774
|
+
currentStreamingText = '';
|
|
775
|
+
updateUI();
|
|
776
|
+
|
|
777
|
+
// A turn just finished — pull authoritative post-turn stats from pi so the
|
|
778
|
+
// context pill and stats card stop relying on incremental usage events.
|
|
779
|
+
// Guard with wasStreaming so paired turn_end/agent_end events do not
|
|
780
|
+
// double-fetch for the same completed turn.
|
|
781
|
+
if (wasStreaming) void sessionStatsCard.refresh();
|
|
782
|
+
|
|
783
|
+
// Notify via tab title if unfocused. Guard with wasStreaming so paired
|
|
784
|
+
// turn_end/agent_end events do not double-count the same completed turn.
|
|
785
|
+
if (wasStreaming && !hasFocus) {
|
|
786
|
+
unreadCount++;
|
|
787
|
+
document.title = `(${unreadCount}) ● ${originalTitle}`;
|
|
788
|
+
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
let currentStreamingThinking = '';
|
|
793
|
+
|
|
794
|
+
function handleMessageStart(message: AppMessage) {
|
|
795
|
+
if (message.role === 'assistant') {
|
|
796
|
+
currentStreamingText = '';
|
|
797
|
+
currentStreamingThinking = '';
|
|
798
|
+
currentStreamingElement = messageRenderer.renderAssistantMessage(
|
|
799
|
+
{ content: '' },
|
|
800
|
+
true
|
|
801
|
+
);
|
|
802
|
+
} else if (message.role === 'user') {
|
|
803
|
+
// User messages can echo back via backend events; only render if we did
|
|
804
|
+
// not just send this message ourselves.
|
|
805
|
+
if (!lastSentMessage || getMessageText(message) !== lastSentMessage) {
|
|
806
|
+
const content = getMessageText(message);
|
|
807
|
+
if (content) {
|
|
808
|
+
messageRenderer.renderUserMessage({ content });
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
lastSentMessage = null;
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function getMessageText(message: AppMessage) {
|
|
816
|
+
if (typeof message.content === 'string') return message.content;
|
|
817
|
+
if (Array.isArray(message.content)) {
|
|
818
|
+
return message.content.filter(b => b.type === 'text').map(b => b.text).join('\n');
|
|
819
|
+
}
|
|
820
|
+
return '';
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function getMessageThinking(message: AppMessage) {
|
|
824
|
+
if (!Array.isArray(message?.content)) return '';
|
|
825
|
+
return message.content
|
|
826
|
+
.filter(b => b.type === 'thinking')
|
|
827
|
+
.map(b => b.thinking || b.text || '')
|
|
828
|
+
.filter(Boolean)
|
|
829
|
+
.join('\n');
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
function handleMessageUpdate(event: AppEvent) {
|
|
833
|
+
const { assistantMessageEvent } = event;
|
|
834
|
+
if (!assistantMessageEvent) return;
|
|
835
|
+
|
|
836
|
+
if (assistantMessageEvent.type === 'thinking_delta') {
|
|
837
|
+
if (!currentStreamingElement) {
|
|
838
|
+
currentStreamingElement = messageRenderer.renderAssistantMessage({ content: '' }, true);
|
|
839
|
+
}
|
|
840
|
+
currentStreamingThinking += assistantMessageEvent.delta;
|
|
841
|
+
if (currentStreamingElement) {
|
|
842
|
+
messageRenderer.updateStreamingThinking(currentStreamingElement, currentStreamingThinking);
|
|
843
|
+
}
|
|
844
|
+
} else if (assistantMessageEvent.type === 'text_delta') {
|
|
845
|
+
if (!currentStreamingElement) {
|
|
846
|
+
currentStreamingElement = messageRenderer.renderAssistantMessage({ content: '' }, true);
|
|
847
|
+
}
|
|
848
|
+
currentStreamingText += assistantMessageEvent.delta;
|
|
849
|
+
if (currentStreamingElement) {
|
|
850
|
+
messageRenderer.updateStreamingMessage(
|
|
851
|
+
currentStreamingElement,
|
|
852
|
+
currentStreamingText
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function handleMessageEnd(message: AppMessage) {
|
|
859
|
+
if (!currentStreamingElement && message?.role === 'assistant') {
|
|
860
|
+
messageRenderer.renderAssistantMessage(message, false, true);
|
|
861
|
+
}
|
|
862
|
+
if (currentStreamingElement) {
|
|
863
|
+
// If this client attached mid-stream, local deltas may be incomplete. The
|
|
864
|
+
// message_end payload is authoritative, so refresh the streaming DOM from it
|
|
865
|
+
// before finalizing.
|
|
866
|
+
if (message?.role === 'assistant') {
|
|
867
|
+
const finalText = getMessageText(message);
|
|
868
|
+
const finalThinking = getMessageThinking(message);
|
|
869
|
+
if (finalText && finalText.length >= currentStreamingText.length) {
|
|
870
|
+
currentStreamingText = finalText;
|
|
871
|
+
messageRenderer.updateStreamingMessage(currentStreamingElement, currentStreamingText);
|
|
872
|
+
}
|
|
873
|
+
if (finalThinking && finalThinking.length >= currentStreamingThinking.length) {
|
|
874
|
+
currentStreamingThinking = finalThinking;
|
|
875
|
+
messageRenderer.updateStreamingThinking(currentStreamingElement, currentStreamingThinking);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
// Pass usage info for cost display
|
|
880
|
+
const usage = message?.usage || null;
|
|
881
|
+
// Pass thinking content so finalize can render the thinking block
|
|
882
|
+
messageRenderer.finalizeStreamingMessage(currentStreamingElement, usage, currentStreamingThinking);
|
|
883
|
+
currentStreamingElement = null;
|
|
884
|
+
currentStreamingThinking = '';
|
|
885
|
+
currentStreamingText = '';
|
|
886
|
+
|
|
887
|
+
// Track session cost and tokens
|
|
888
|
+
if (usage?.cost?.total) {
|
|
889
|
+
sessionTotalCost += usage.cost.total;
|
|
890
|
+
}
|
|
891
|
+
if (usage?.input) {
|
|
892
|
+
lastInputTokens = usage.input + (usage.cacheRead || 0);
|
|
893
|
+
lastUsage = usage;
|
|
894
|
+
}
|
|
895
|
+
updateContextPill();
|
|
896
|
+
showNewMessageBadge();
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function handleToolExecutionStart(event: AppEvent) {
|
|
901
|
+
const { toolCallId, toolName, args } = event;
|
|
902
|
+
if (!toolCallId) return;
|
|
903
|
+
|
|
904
|
+
state.addToolExecution(toolCallId, {
|
|
905
|
+
toolName,
|
|
906
|
+
args,
|
|
907
|
+
status: 'pending',
|
|
908
|
+
});
|
|
909
|
+
|
|
910
|
+
const exec = state.getToolExecution(toolCallId);
|
|
911
|
+
if (exec) toolCardRenderer.createToolCard(exec as ToolExecution);
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function handleToolExecutionUpdate(event: AppEvent) {
|
|
915
|
+
const { toolCallId, partialResult } = event;
|
|
916
|
+
if (!toolCallId) return;
|
|
917
|
+
const output = formatToolOutput(partialResult);
|
|
918
|
+
|
|
919
|
+
state.updateToolExecution(toolCallId, {
|
|
920
|
+
status: 'streaming',
|
|
921
|
+
output,
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
const exec = state.getToolExecution(toolCallId);
|
|
925
|
+
if (exec) toolCardRenderer.updateToolCard(exec as ToolExecution);
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
function handleToolExecutionEnd(event: AppEvent) {
|
|
929
|
+
const { toolCallId, result, isError } = event;
|
|
930
|
+
if (!toolCallId) return;
|
|
931
|
+
const output = formatToolOutput(result);
|
|
932
|
+
|
|
933
|
+
state.updateToolExecution(toolCallId, {
|
|
934
|
+
status: isError ? 'error' : 'complete',
|
|
935
|
+
output,
|
|
936
|
+
isError,
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
toolCardRenderer.finalizeToolCard(toolCallId, result as ToolResult, isError ?? false);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
function hasPendingExtensionUIRequest(sessionId: string) {
|
|
943
|
+
return pendingExtensionUIRequests.some(req => req.sessionId === sessionId);
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
function queueExtensionUIRequest(event: AppEvent, sessionId: string) {
|
|
947
|
+
if (!sessionId) {
|
|
948
|
+
handleExtensionUIRequest(event, sessionId);
|
|
949
|
+
return;
|
|
950
|
+
}
|
|
951
|
+
if (!pendingExtensionUIRequests.some(req => req.sessionId === sessionId && req.event?.id === event.id)) {
|
|
952
|
+
pendingExtensionUIRequests.push({ sessionId, event });
|
|
953
|
+
}
|
|
954
|
+
renderLiveTabs();
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
function processQueuedExtensionUIRequest(sessionId = activeLiveSessionId) {
|
|
958
|
+
if (!sessionId || !viewingActiveSession || sessionId !== activeLiveSessionId || dialogHandler.currentRequest) return;
|
|
959
|
+
const idx = pendingExtensionUIRequests.findIndex(req => req.sessionId === sessionId);
|
|
960
|
+
if (idx === -1) return;
|
|
961
|
+
const [{ event }] = pendingExtensionUIRequests.splice(idx, 1);
|
|
962
|
+
renderLiveTabs();
|
|
963
|
+
handleExtensionUIRequest(event, sessionId);
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
function suspendCurrentDialogForTabSwitch(nextSessionId: string) {
|
|
967
|
+
const current = dialogHandler.currentRequest;
|
|
968
|
+
if (!current?.sessionId || current.sessionId === nextSessionId) return;
|
|
969
|
+
const event = current.request;
|
|
970
|
+
if (event && !pendingExtensionUIRequests.some(req => req.sessionId === current.sessionId && req.event?.id === event.id)) {
|
|
971
|
+
pendingExtensionUIRequests.unshift({ sessionId: current.sessionId, event });
|
|
972
|
+
}
|
|
973
|
+
dialogHandler.clearCurrentDialog();
|
|
974
|
+
renderLiveTabs();
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
function handleExtensionUIRequest(event: AppEvent, sessionId: string | null = null) {
|
|
978
|
+
const request = (sessionId ? { ...event, sessionId } : event) as DialogRequest;
|
|
979
|
+
switch (event.method) {
|
|
980
|
+
case 'select':
|
|
981
|
+
dialogHandler.showSelect(request);
|
|
982
|
+
break;
|
|
983
|
+
case 'confirm':
|
|
984
|
+
dialogHandler.showConfirm(request);
|
|
985
|
+
break;
|
|
986
|
+
case 'input':
|
|
987
|
+
dialogHandler.showInput(request);
|
|
988
|
+
break;
|
|
989
|
+
case 'editor':
|
|
990
|
+
dialogHandler.showEditor(request);
|
|
991
|
+
break;
|
|
992
|
+
case 'notify':
|
|
993
|
+
dialogHandler.showNotification(request);
|
|
994
|
+
break;
|
|
995
|
+
default:
|
|
996
|
+
console.warn('[App] Unknown extension UI method:', event.method);
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
function formatToolOutput(result: unknown) {
|
|
1001
|
+
if (!result) return '';
|
|
1002
|
+
|
|
1003
|
+
const r = result as { content?: MessageContentBlock[] };
|
|
1004
|
+
if (r.content && Array.isArray(r.content)) {
|
|
1005
|
+
return r.content
|
|
1006
|
+
.map((block) => {
|
|
1007
|
+
if (block.type === 'text') return block.text;
|
|
1008
|
+
return JSON.stringify(block);
|
|
1009
|
+
})
|
|
1010
|
+
.join('\n');
|
|
1011
|
+
}
|
|
1012
|
+
|
|
1013
|
+
return JSON.stringify(result, null, 2);
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// ═══════════════════════════════════════
|
|
1017
|
+
// Input handling — textarea with auto-resize
|
|
1018
|
+
// ═══════════════════════════════════════
|
|
1019
|
+
|
|
1020
|
+
chatForm.addEventListener('submit', (e) => {
|
|
1021
|
+
e.preventDefault();
|
|
1022
|
+
sendMessage();
|
|
1023
|
+
});
|
|
1024
|
+
|
|
1025
|
+
messageInput.addEventListener('keydown', (e) => {
|
|
1026
|
+
// Enter sends, Shift+Enter inserts newline
|
|
1027
|
+
if (e.key === 'Enter' && !e.shiftKey) {
|
|
1028
|
+
e.preventDefault();
|
|
1029
|
+
sendMessage();
|
|
1030
|
+
}
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
// Auto-resize textarea
|
|
1034
|
+
messageInput.addEventListener('input', () => {
|
|
1035
|
+
messageInput.style.height = 'auto';
|
|
1036
|
+
messageInput.style.height = Math.min(messageInput.scrollHeight, 200) + 'px';
|
|
1037
|
+
});
|
|
1038
|
+
|
|
1039
|
+
// ═══════════════════════════════════════
|
|
1040
|
+
// Attachments (images + file browser paths)
|
|
1041
|
+
// ═══════════════════════════════════════
|
|
1042
|
+
|
|
1043
|
+
const attachBtn = document.getElementById('attach-btn')!;
|
|
1044
|
+
const imageInput = document.getElementById('image-input')!;
|
|
1045
|
+
const imagePreviews = document.getElementById('image-previews')!;
|
|
1046
|
+
|
|
1047
|
+
let pendingImages: PendingImage[] = []; // { data: base64, mimeType }
|
|
1048
|
+
let pendingFilePaths: PendingFilePath[] = []; // { path, name, ext } — from file browser (populated by callback above)
|
|
1049
|
+
|
|
1050
|
+
const MAX_IMAGE_DIM = 2048;
|
|
1051
|
+
const VALID_MIME_TYPES = ['image/png', 'image/jpeg', 'image/gif', 'image/webp'];
|
|
1052
|
+
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'ico']);
|
|
1053
|
+
|
|
1054
|
+
function getFileChipIcon(name: string) {
|
|
1055
|
+
return getFileIcon(name || 'file', false);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function processImageFile(file: File): Promise<PendingImage> {
|
|
1059
|
+
return new Promise((resolve, reject) => {
|
|
1060
|
+
const mimeType = VALID_MIME_TYPES.includes(file.type) ? file.type : 'image/png';
|
|
1061
|
+
|
|
1062
|
+
const reader = new FileReader();
|
|
1063
|
+
reader.onerror = () => reject(new Error('Failed to read file'));
|
|
1064
|
+
reader.onload = () => {
|
|
1065
|
+
const img = new Image();
|
|
1066
|
+
img.onload = () => {
|
|
1067
|
+
let { width, height } = img;
|
|
1068
|
+
if (width > MAX_IMAGE_DIM || height > MAX_IMAGE_DIM) {
|
|
1069
|
+
const scale = MAX_IMAGE_DIM / Math.max(width, height);
|
|
1070
|
+
width = Math.round(width * scale);
|
|
1071
|
+
height = Math.round(height * scale);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
const canvas = document.createElement('canvas');
|
|
1075
|
+
canvas.width = width;
|
|
1076
|
+
canvas.height = height;
|
|
1077
|
+
canvas.getContext('2d')?.drawImage(img, 0, 0, width, height);
|
|
1078
|
+
|
|
1079
|
+
const outputMime = (mimeType === 'image/jpeg') ? 'image/jpeg' : 'image/png';
|
|
1080
|
+
const quality = (outputMime === 'image/jpeg') ? 0.85 : undefined;
|
|
1081
|
+
const dataUrl = canvas.toDataURL(outputMime, quality);
|
|
1082
|
+
const base64 = dataUrl.split(',')[1];
|
|
1083
|
+
if (!base64) { reject(new Error('Failed to encode image')); return; }
|
|
1084
|
+
resolve({ data: base64, mimeType: outputMime });
|
|
1085
|
+
};
|
|
1086
|
+
img.onerror = () => reject(new Error('Failed to decode image'));
|
|
1087
|
+
img.src = String(reader.result || '');
|
|
1088
|
+
};
|
|
1089
|
+
reader.readAsDataURL(file);
|
|
1090
|
+
});
|
|
1091
|
+
}
|
|
1092
|
+
|
|
1093
|
+
async function addAttachments(files: FileList | File[]) {
|
|
1094
|
+
const list = Array.from(files); // snapshot before any await
|
|
1095
|
+
for (const file of list) {
|
|
1096
|
+
if (!file.type.startsWith('image/')) continue;
|
|
1097
|
+
try {
|
|
1098
|
+
pendingImages.push(await processImageFile(file));
|
|
1099
|
+
} catch (e) {
|
|
1100
|
+
console.error('[Tau] Image processing failed:', e);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
renderAttachmentPreviews();
|
|
1104
|
+
}
|
|
1105
|
+
|
|
1106
|
+
attachBtn.addEventListener('click', () => imageInput.click());
|
|
1107
|
+
|
|
1108
|
+
imageInput.addEventListener('change', () => {
|
|
1109
|
+
addAttachments(imageInput.files ?? []);
|
|
1110
|
+
imageInput.value = '';
|
|
1111
|
+
});
|
|
1112
|
+
|
|
1113
|
+
// Drag & drop on input
|
|
1114
|
+
messageInput.addEventListener('dragover', (e) => { e.preventDefault(); });
|
|
1115
|
+
messageInput.addEventListener('drop', (e) => {
|
|
1116
|
+
e.preventDefault();
|
|
1117
|
+
if (e.dataTransfer && e.dataTransfer.files.length > 0) addAttachments(e.dataTransfer.files);
|
|
1118
|
+
});
|
|
1119
|
+
|
|
1120
|
+
// Paste images
|
|
1121
|
+
messageInput.addEventListener('paste', (e) => {
|
|
1122
|
+
if (!e.clipboardData) return;
|
|
1123
|
+
const files: File[] = [];
|
|
1124
|
+
for (const item of e.clipboardData.items) {
|
|
1125
|
+
if (!item.type.startsWith('image/')) continue;
|
|
1126
|
+
files.push(item.getAsFile() as File);
|
|
1127
|
+
}
|
|
1128
|
+
if (files.length) addAttachments(files);
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
function makeRemoveBtn(onClick: () => void) {
|
|
1132
|
+
const btn = document.createElement('button');
|
|
1133
|
+
btn.className = 'image-preview-remove';
|
|
1134
|
+
btn.setAttribute('aria-label', 'Remove');
|
|
1135
|
+
btn.textContent = '✕';
|
|
1136
|
+
btn.addEventListener('click', onClick);
|
|
1137
|
+
return btn;
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function renderAttachmentPreviews() {
|
|
1141
|
+
imagePreviews.innerHTML = '';
|
|
1142
|
+
const hasAny = pendingImages.length > 0 || pendingFilePaths.length > 0;
|
|
1143
|
+
if (!hasAny) { imagePreviews.classList.add('hidden'); return; }
|
|
1144
|
+
imagePreviews.classList.remove('hidden');
|
|
1145
|
+
|
|
1146
|
+
// Binary image chips
|
|
1147
|
+
pendingImages.forEach((img, i) => {
|
|
1148
|
+
const el = document.createElement('div');
|
|
1149
|
+
el.className = 'image-preview';
|
|
1150
|
+
const thumb = document.createElement('img');
|
|
1151
|
+
thumb.src = `data:${img.mimeType};base64,${img.data}`;
|
|
1152
|
+
el.appendChild(thumb);
|
|
1153
|
+
el.appendChild(makeRemoveBtn(() => { pendingImages.splice(i, 1); renderAttachmentPreviews(); }));
|
|
1154
|
+
imagePreviews.appendChild(el);
|
|
1155
|
+
});
|
|
1156
|
+
|
|
1157
|
+
// File browser path chips
|
|
1158
|
+
pendingFilePaths.forEach((fp, i) => {
|
|
1159
|
+
const el = document.createElement('div');
|
|
1160
|
+
const removeBtn = makeRemoveBtn(() => {
|
|
1161
|
+
const withSpace = fp.path + ' ';
|
|
1162
|
+
messageInput.value = messageInput.value.includes(withSpace)
|
|
1163
|
+
? messageInput.value.replace(withSpace, '')
|
|
1164
|
+
: messageInput.value.replace(fp.path, '');
|
|
1165
|
+
messageInput.dispatchEvent(new Event('input'));
|
|
1166
|
+
pendingFilePaths.splice(i, 1);
|
|
1167
|
+
renderAttachmentPreviews();
|
|
1168
|
+
});
|
|
1169
|
+
|
|
1170
|
+
if (IMAGE_EXTS.has(fp.ext)) {
|
|
1171
|
+
el.className = 'image-preview';
|
|
1172
|
+
el.title = fp.path;
|
|
1173
|
+
const thumb = document.createElement('img');
|
|
1174
|
+
thumb.style.cssText = 'width:100%;height:100%;object-fit:cover';
|
|
1175
|
+
const previewParams = new URLSearchParams({ path: fp.path });
|
|
1176
|
+
if (fp.sessionId) previewParams.set('sessionId', fp.sessionId);
|
|
1177
|
+
thumb.src = `/api/file/preview?${previewParams.toString()}`;
|
|
1178
|
+
thumb.onerror = () => {
|
|
1179
|
+
el.classList.add('file-chip');
|
|
1180
|
+
thumb.remove();
|
|
1181
|
+
const icon = document.createElement('span');
|
|
1182
|
+
icon.className = 'file-chip-icon';
|
|
1183
|
+
icon.textContent = getFileChipIcon(fp.name);
|
|
1184
|
+
const label = document.createElement('span');
|
|
1185
|
+
label.className = 'file-chip-name';
|
|
1186
|
+
label.textContent = fp.name;
|
|
1187
|
+
el.insertBefore(label, removeBtn);
|
|
1188
|
+
el.insertBefore(icon, label);
|
|
1189
|
+
};
|
|
1190
|
+
el.appendChild(thumb);
|
|
1191
|
+
} else {
|
|
1192
|
+
el.className = 'image-preview file-chip';
|
|
1193
|
+
el.title = fp.path;
|
|
1194
|
+
const icon = document.createElement('span');
|
|
1195
|
+
icon.className = 'file-chip-icon';
|
|
1196
|
+
icon.textContent = getFileChipIcon(fp.ext);
|
|
1197
|
+
const label = document.createElement('span');
|
|
1198
|
+
label.className = 'file-chip-name';
|
|
1199
|
+
label.textContent = fp.name;
|
|
1200
|
+
el.appendChild(icon);
|
|
1201
|
+
el.appendChild(label);
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
el.appendChild(removeBtn);
|
|
1205
|
+
imagePreviews.appendChild(el);
|
|
1206
|
+
});
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
// ═══════════════════════════════════════
|
|
1210
|
+
// Send message (with images)
|
|
1211
|
+
// ═══════════════════════════════════════
|
|
1212
|
+
|
|
1213
|
+
let messageQueue: QueuedCommand[] = [];
|
|
1214
|
+
|
|
1215
|
+
function sendMessage() {
|
|
1216
|
+
const message = messageInput.value.trim();
|
|
1217
|
+
if (!message && pendingImages.length === 0) return;
|
|
1218
|
+
|
|
1219
|
+
messageInput.value = '';
|
|
1220
|
+
messageInput.style.height = 'auto';
|
|
1221
|
+
|
|
1222
|
+
const cmd: QueuedCommand = { type: 'prompt', message: message || '(see attached image)' };
|
|
1223
|
+
|
|
1224
|
+
if (pendingImages.length > 0) {
|
|
1225
|
+
cmd.images = pendingImages.map(img => {
|
|
1226
|
+
console.log(`[Tau] Sending image: mimeType=${img.mimeType}, dataLen=${img.data?.length}`);
|
|
1227
|
+
return { type: 'image', data: img.data, mimeType: img.mimeType || 'image/png' };
|
|
1228
|
+
});
|
|
1229
|
+
pendingImages = [];
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
pendingFilePaths = [];
|
|
1233
|
+
renderAttachmentPreviews();
|
|
1234
|
+
|
|
1235
|
+
if (!activeLiveSessionId) {
|
|
1236
|
+
messageRenderer.renderError('Create or select a Tau tab first.');
|
|
1237
|
+
updateLiveSessionInputState();
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
cmd.sessionId = activeLiveSessionId;
|
|
1242
|
+
|
|
1243
|
+
if (state.isStreaming) {
|
|
1244
|
+
// Queue it for the current Tau tab only; do not let tab switches retarget it.
|
|
1245
|
+
messageQueue.push(cmd);
|
|
1246
|
+
lastSentMessage = message;
|
|
1247
|
+
renderQueuedMessages();
|
|
1248
|
+
return;
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
lastSentMessage = message;
|
|
1252
|
+
messageRenderer.renderUserMessage({ content: message, images: cmd.images });
|
|
1253
|
+
wsClient.send(cmd);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
const queuedMessagesEl = document.getElementById('queued-messages')!;
|
|
1257
|
+
|
|
1258
|
+
function renderQueuedMessages() {
|
|
1259
|
+
queuedMessagesEl.innerHTML = '';
|
|
1260
|
+
if (messageQueue.length === 0) {
|
|
1261
|
+
queuedMessagesEl.classList.add('hidden');
|
|
1262
|
+
return;
|
|
1263
|
+
}
|
|
1264
|
+
queuedMessagesEl.classList.remove('hidden');
|
|
1265
|
+
messageQueue.forEach((cmd, i) => {
|
|
1266
|
+
if (cmd.sessionId !== activeLiveSessionId) return;
|
|
1267
|
+
const el = document.createElement('div');
|
|
1268
|
+
el.className = 'queued-msg';
|
|
1269
|
+
el.innerHTML = `
|
|
1270
|
+
<span class="queued-msg-label">Queued</span>
|
|
1271
|
+
<span class="queued-msg-text">${escapeHtml(cmd.message || '')}</span>
|
|
1272
|
+
<button class="queued-msg-cancel" title="Cancel">×</button>
|
|
1273
|
+
`;
|
|
1274
|
+
el.querySelector('.queued-msg-cancel')?.addEventListener('click', () => {
|
|
1275
|
+
messageQueue.splice(i, 1);
|
|
1276
|
+
renderQueuedMessages();
|
|
1277
|
+
});
|
|
1278
|
+
queuedMessagesEl.appendChild(el);
|
|
1279
|
+
});
|
|
1280
|
+
queuedMessagesEl.classList.toggle('hidden', queuedMessagesEl.children.length === 0);
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
function escapeHtml(text: string) {
|
|
1284
|
+
const div = document.createElement('div');
|
|
1285
|
+
div.textContent = text;
|
|
1286
|
+
return div.innerHTML;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
function flushQueue() {
|
|
1290
|
+
if (!activeLiveSessionId || state.isStreaming) return;
|
|
1291
|
+
const idx = messageQueue.findIndex(cmd => cmd.sessionId === activeLiveSessionId);
|
|
1292
|
+
if (idx >= 0) {
|
|
1293
|
+
const [cmd] = messageQueue.splice(idx, 1);
|
|
1294
|
+
lastSentMessage = cmd.message ?? null;
|
|
1295
|
+
messageRenderer.renderUserMessage({ content: cmd.message, images: cmd.images });
|
|
1296
|
+
renderQueuedMessages();
|
|
1297
|
+
wsClient.send(cmd);
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
abortBtn.addEventListener('click', () => {
|
|
1302
|
+
if (!viewingActiveSession || !activeLiveSessionId) return;
|
|
1303
|
+
wsClient.send({ type: 'abort', sessionId: activeLiveSessionId });
|
|
1304
|
+
messageRenderer.renderError('Aborted by user');
|
|
1305
|
+
showTypingIndicator(false);
|
|
1306
|
+
});
|
|
1307
|
+
|
|
1308
|
+
// Command Palette
|
|
1309
|
+
const commandPaletteController = setupCommandPalette([
|
|
1310
|
+
{ icon: '🌳', label: 'Session tree', desc: 'Jump to any earlier point in the session tree', action: () => { void treeViewController.open(); } },
|
|
1311
|
+
{ icon: '🗜️', label: 'Compact', desc: 'Compact context to save tokens', action: () => rpcCommand({ type: 'compact' }, 'Compacting...') },
|
|
1312
|
+
{ icon: '📋', label: 'Export HTML', desc: 'Export session as HTML file', action: () => rpcExportHtml() },
|
|
1313
|
+
{ icon: '📊', label: 'Session Stats', desc: 'Show session statistics', action: () => showSessionStats() },
|
|
1314
|
+
{ icon: '⬇️', label: 'Expand All Tools', desc: 'Expand all tool cards', action: () => toolCardRenderer.expandAll() },
|
|
1315
|
+
{ icon: '⬆️', label: 'Collapse All Tools', desc: 'Collapse all tool cards', action: () => toolCardRenderer.collapseAll() },
|
|
1316
|
+
]);
|
|
1317
|
+
|
|
1318
|
+
async function rpcCommand(cmd: RpcCommand, statusMsg = '') {
|
|
1319
|
+
try {
|
|
1320
|
+
const backendLocalCommands = new Set(['get_auth', 'set_auth', 'get_available_models']);
|
|
1321
|
+
const needsLiveSession = !cmd.sessionId && !cmd.filePath && !backendLocalCommands.has(cmd.type);
|
|
1322
|
+
if (needsLiveSession && (!viewingActiveSession || !activeLiveSessionId)) {
|
|
1323
|
+
const error = 'Select a live Tau tab first.';
|
|
1324
|
+
setStatusMessage(error, wsClient.ws?.readyState === WebSocket.OPEN ? 'Connected' : 'Disconnected', 3000);
|
|
1325
|
+
return { type: 'response', command: cmd.type, success: false, error };
|
|
1326
|
+
}
|
|
1327
|
+
if (!cmd.sessionId && viewingActiveSession && activeLiveSessionId) cmd = { ...cmd, sessionId: activeLiveSessionId };
|
|
1328
|
+
if (statusMsg) setStatusMessage(statusMsg);
|
|
1329
|
+
const resp = await fetch('/api/rpc', {
|
|
1330
|
+
method: 'POST',
|
|
1331
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1332
|
+
body: JSON.stringify(cmd),
|
|
1333
|
+
});
|
|
1334
|
+
const data = await resp.json();
|
|
1335
|
+
if (data.success) {
|
|
1336
|
+
setStatusMessage('Done', 'Connected', 2000);
|
|
1337
|
+
} else {
|
|
1338
|
+
setStatusMessage(data.error || 'Failed', 'Connected', 3000);
|
|
1339
|
+
}
|
|
1340
|
+
return data;
|
|
1341
|
+
} catch (e) {
|
|
1342
|
+
setStatusMessage('Error', 'Connected', 3000);
|
|
1343
|
+
}
|
|
1344
|
+
}
|
|
1345
|
+
|
|
1346
|
+
async function rpcExportHtml() {
|
|
1347
|
+
const data = await rpcCommand({ type: 'export_html' }, 'Exporting...');
|
|
1348
|
+
if (data?.success && data.data?.path) {
|
|
1349
|
+
setStatusMessage(`Exported: ${data.data.path}`, 'Connected', 4000);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
async function showSessionStats() {
|
|
1354
|
+
const data = await rpcCommand({ type: 'get_session_stats' }, 'Loading stats...');
|
|
1355
|
+
if (data?.success && data.data) {
|
|
1356
|
+
const s = data.data;
|
|
1357
|
+
const lines = [
|
|
1358
|
+
`📊 Session Stats`,
|
|
1359
|
+
`Messages: ${s.totalMessages} (${s.userMessages} user, ${s.assistantMessages} assistant)`,
|
|
1360
|
+
`Tool calls: ${s.toolCalls}`,
|
|
1361
|
+
];
|
|
1362
|
+
if (s.tokens) {
|
|
1363
|
+
lines.push(`Context: ~${(s.tokens.input / 1000).toFixed(1)}k tokens`);
|
|
1364
|
+
}
|
|
1365
|
+
messageRenderer.renderSystemMessage(lines.join('\n'));
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// Model Picker
|
|
1370
|
+
const modelPickerController = setupModelPicker({
|
|
1371
|
+
getActiveLiveSessionId: () => activeLiveSessionId,
|
|
1372
|
+
isViewingActiveSession: () => viewingActiveSession,
|
|
1373
|
+
rpcCommand,
|
|
1374
|
+
flashStatusError,
|
|
1375
|
+
escapeHtml,
|
|
1376
|
+
setContextWindowSize(value) { contextWindowSize = value; },
|
|
1377
|
+
updateContextPill,
|
|
1378
|
+
});
|
|
1379
|
+
|
|
1380
|
+
// Session tree view (modal opened from the header git-branch button, or the
|
|
1381
|
+
// command palette). Selecting an entry moves the session leaf server-side;
|
|
1382
|
+
// the conversation re-renders when the server broadcasts a fresh snapshot.
|
|
1383
|
+
const treeViewController = setupTreeView({
|
|
1384
|
+
getActiveLiveSessionId: () => (viewingActiveSession && activeLiveSessionId ? activeLiveSessionId : null),
|
|
1385
|
+
isStreaming: () => state.isStreaming,
|
|
1386
|
+
setComposerText(text) {
|
|
1387
|
+
messageInput.value = text;
|
|
1388
|
+
// Trigger the textarea auto-resize handler.
|
|
1389
|
+
messageInput.dispatchEvent(new Event('input'));
|
|
1390
|
+
if (!isMobile()) messageInput.focus();
|
|
1391
|
+
},
|
|
1392
|
+
flashStatusError,
|
|
1393
|
+
});
|
|
1394
|
+
|
|
1395
|
+
// ═══════════════════════════════════════
|
|
1396
|
+
// Keyboard shortcuts
|
|
1397
|
+
// ═══════════════════════════════════════
|
|
1398
|
+
|
|
1399
|
+
document.addEventListener('keydown', (e) => {
|
|
1400
|
+
// Escape — Abort streaming, or close sidebar on mobile
|
|
1401
|
+
if (e.key === 'Escape') {
|
|
1402
|
+
// Close palettes/panels first
|
|
1403
|
+
if (modelPickerController.closeIfOpen()) return;
|
|
1404
|
+
if (treeViewController.closeIfOpen()) return;
|
|
1405
|
+
if (!settingsPanel.classList.contains('hidden')) {
|
|
1406
|
+
closeSettings();
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
if (commandPaletteController.closeIfOpen()) return;
|
|
1410
|
+
|
|
1411
|
+
if (state.isStreaming && viewingActiveSession && activeLiveSessionId) {
|
|
1412
|
+
wsClient.send({ type: 'abort', sessionId: activeLiveSessionId });
|
|
1413
|
+
messageRenderer.renderError('Aborted by user');
|
|
1414
|
+
showTypingIndicator(false);
|
|
1415
|
+
} else if (!sidebarEl.classList.contains('collapsed') && window.innerWidth <= 768) {
|
|
1416
|
+
toggleSidebar();
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
// / — Focus message input (when not already in an input)
|
|
1421
|
+
if (e.key === '/' && !isInInput()) {
|
|
1422
|
+
e.preventDefault();
|
|
1423
|
+
messageInput.focus();
|
|
1424
|
+
}
|
|
1425
|
+
});
|
|
1426
|
+
|
|
1427
|
+
function isInInput() {
|
|
1428
|
+
const tag = document.activeElement?.tagName;
|
|
1429
|
+
return tag === 'INPUT' || tag === 'TEXTAREA' || document.activeElement?.isContentEditable;
|
|
1430
|
+
}
|
|
1431
|
+
|
|
1432
|
+
// ═══════════════════════════════════════
|
|
1433
|
+
// Sidebar
|
|
1434
|
+
// ═══════════════════════════════════════
|
|
1435
|
+
|
|
1436
|
+
function isMobile() {
|
|
1437
|
+
return window.innerWidth <= 768;
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
function updateSidebarToggleIcon() {
|
|
1441
|
+
sidebarToggle.textContent = '☰';
|
|
1442
|
+
}
|
|
1443
|
+
|
|
1444
|
+
function toggleSidebar() {
|
|
1445
|
+
sidebarEl.classList.toggle('collapsed');
|
|
1446
|
+
sidebarOverlay.classList.toggle('visible', !sidebarEl.classList.contains('collapsed') && isMobile());
|
|
1447
|
+
updateSidebarToggleIcon();
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
sidebarToggle.addEventListener('click', toggleSidebar);
|
|
1451
|
+
|
|
1452
|
+
sidebarOverlay.addEventListener('click', () => {
|
|
1453
|
+
sidebarEl.classList.add('collapsed');
|
|
1454
|
+
sidebarOverlay.classList.remove('visible');
|
|
1455
|
+
updateSidebarToggleIcon();
|
|
1456
|
+
});
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
|
|
1460
|
+
const newSessionBtn = document.getElementById('new-session-btn')!;
|
|
1461
|
+
newSessionBtn.addEventListener('click', openNewLiveSessionModal);
|
|
1462
|
+
|
|
1463
|
+
refreshSessionsBtn.addEventListener('click', () => {
|
|
1464
|
+
if (isMobile()) {
|
|
1465
|
+
location.reload();
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
refreshSessionsBtn.classList.add('spinning');
|
|
1469
|
+
sidebar.loadSessions().then(() => {
|
|
1470
|
+
setTimeout(() => refreshSessionsBtn.classList.remove('spinning'), 600);
|
|
1471
|
+
updateLiveSessionIndicators();
|
|
1472
|
+
});
|
|
1473
|
+
});
|
|
1474
|
+
|
|
1475
|
+
// Swipe from left edge to open sidebar on mobile
|
|
1476
|
+
(function initSwipeGesture() {
|
|
1477
|
+
let touchStartX = 0;
|
|
1478
|
+
let touchStartY = 0;
|
|
1479
|
+
let tracking = false;
|
|
1480
|
+
|
|
1481
|
+
document.addEventListener('touchstart', (e) => {
|
|
1482
|
+
const touch = e.touches[0];
|
|
1483
|
+
// Only track swipes starting within 20px of left edge
|
|
1484
|
+
if (touch.clientX < 20 && isMobile() && sidebarEl.classList.contains('collapsed')) {
|
|
1485
|
+
touchStartX = touch.clientX;
|
|
1486
|
+
touchStartY = touch.clientY;
|
|
1487
|
+
tracking = true;
|
|
1488
|
+
}
|
|
1489
|
+
}, { passive: true });
|
|
1490
|
+
|
|
1491
|
+
document.addEventListener('touchmove', (e) => {
|
|
1492
|
+
if (!tracking) return;
|
|
1493
|
+
const touch = e.touches[0];
|
|
1494
|
+
const dx = touch.clientX - touchStartX;
|
|
1495
|
+
const dy = Math.abs(touch.clientY - touchStartY);
|
|
1496
|
+
// If vertical movement dominates, cancel
|
|
1497
|
+
if (dy > dx) {
|
|
1498
|
+
tracking = false;
|
|
1499
|
+
}
|
|
1500
|
+
}, { passive: true });
|
|
1501
|
+
|
|
1502
|
+
document.addEventListener('touchend', (e) => {
|
|
1503
|
+
if (!tracking) return;
|
|
1504
|
+
tracking = false;
|
|
1505
|
+
const touch = e.changedTouches[0];
|
|
1506
|
+
const dx = touch.clientX - touchStartX;
|
|
1507
|
+
if (dx > 60) {
|
|
1508
|
+
sidebarEl.classList.remove('collapsed');
|
|
1509
|
+
sidebarOverlay.classList.add('visible');
|
|
1510
|
+
}
|
|
1511
|
+
}, { passive: true });
|
|
1512
|
+
})();
|
|
1513
|
+
|
|
1514
|
+
// Session search
|
|
1515
|
+
sessionSearchInput.addEventListener('input', () => {
|
|
1516
|
+
sidebar.setSearchQuery(sessionSearchInput.value);
|
|
1517
|
+
});
|
|
1518
|
+
|
|
1519
|
+
async function newSession() {
|
|
1520
|
+
sessionTotalCost = 0;
|
|
1521
|
+
lastInputTokens = 0;
|
|
1522
|
+
lastUsage = null;
|
|
1523
|
+
updateContextPill();
|
|
1524
|
+
await switchSession(null);
|
|
1525
|
+
sidebar.clearActive();
|
|
1526
|
+
if (isMobile()) {
|
|
1527
|
+
sidebarEl.classList.add('collapsed');
|
|
1528
|
+
sidebarOverlay.classList.remove('visible');
|
|
1529
|
+
}
|
|
1530
|
+
if (!isMobile()) messageInput.focus();
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
async function handleSessionSelect(session: SidebarSession | null, project: SidebarProject | null) {
|
|
1534
|
+
if (session) sidebar.setActive(session.filePath);
|
|
1535
|
+
sessionTotalCost = 0;
|
|
1536
|
+
lastInputTokens = 0;
|
|
1537
|
+
lastUsage = null;
|
|
1538
|
+
updateContextPill();
|
|
1539
|
+
if (session) await switchSession(session.filePath, session, project);
|
|
1540
|
+
|
|
1541
|
+
// Close sidebar on mobile after selecting
|
|
1542
|
+
if (isMobile()) {
|
|
1543
|
+
sidebarEl.classList.add('collapsed');
|
|
1544
|
+
sidebarOverlay.classList.remove('visible');
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
async function switchSession(sessionFile: string | null | undefined, session: SidebarSession | null = null, project: SidebarProject | null = null) {
|
|
1549
|
+
try {
|
|
1550
|
+
// Clear any streaming state from previous session to prevent bleed
|
|
1551
|
+
currentStreamingElement = null;
|
|
1552
|
+
currentStreamingThinking = '';
|
|
1553
|
+
currentStreamingText = '';
|
|
1554
|
+
viewingActiveSession = false;
|
|
1555
|
+
|
|
1556
|
+
state.reset();
|
|
1557
|
+
showTypingIndicator(false);
|
|
1558
|
+
updateUI();
|
|
1559
|
+
messageRenderer.clear();
|
|
1560
|
+
toolCardRenderer.clear();
|
|
1561
|
+
|
|
1562
|
+
// Clicking a historical session resumes it as a live backend Tau tab.
|
|
1563
|
+
// selectLiveSession() will load the resumed tab snapshot with the same
|
|
1564
|
+
// historical entries after the backend has attached to the session file.
|
|
1565
|
+
if (sessionFile) {
|
|
1566
|
+
const live = liveSessions.find(s => s.sessionFile === sessionFile);
|
|
1567
|
+
if (live) {
|
|
1568
|
+
await selectLiveSession(live.id);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
// No live tab yet — ask the server to resume this session.
|
|
1572
|
+
messageRenderer.renderSystemMessage('Resuming session…');
|
|
1573
|
+
try {
|
|
1574
|
+
const resumeBody: Record<string, unknown> = { filePath: sessionFile };
|
|
1575
|
+
if (project?.path) resumeBody.cwd = project.path;
|
|
1576
|
+
const res = await fetch('/api/live-sessions/resume', {
|
|
1577
|
+
method: 'POST',
|
|
1578
|
+
headers: { 'Content-Type': 'application/json' },
|
|
1579
|
+
body: JSON.stringify(resumeBody),
|
|
1580
|
+
});
|
|
1581
|
+
const data = await res.json();
|
|
1582
|
+
if (!res.ok || data.error) {
|
|
1583
|
+
messageRenderer.clear();
|
|
1584
|
+
messageRenderer.renderError(data.error || 'Failed to resume session');
|
|
1585
|
+
viewingActiveSession = false;
|
|
1586
|
+
updateLiveSessionInputState();
|
|
1587
|
+
updateUI();
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1590
|
+
// If the server found an existing live tab (reused), just focus it.
|
|
1591
|
+
if (data.reused && data.session) {
|
|
1592
|
+
upsertLiveSession(data.session);
|
|
1593
|
+
await selectLiveSession(data.session.id);
|
|
1594
|
+
return;
|
|
1595
|
+
}
|
|
1596
|
+
upsertLiveSession(data.session);
|
|
1597
|
+
await selectLiveSession(data.session.id);
|
|
1598
|
+
} catch (e) {
|
|
1599
|
+
messageRenderer.clear();
|
|
1600
|
+
messageRenderer.renderError('Failed to resume session');
|
|
1601
|
+
viewingActiveSession = false;
|
|
1602
|
+
updateLiveSessionInputState();
|
|
1603
|
+
updateUI();
|
|
1604
|
+
}
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
messageRenderer.renderWelcome();
|
|
1609
|
+
} catch (error) {
|
|
1610
|
+
console.error('[App] Failed to switch session:', error);
|
|
1611
|
+
messageRenderer.renderError('Failed to switch session');
|
|
1612
|
+
}
|
|
1613
|
+
}
|
|
1614
|
+
|
|
1615
|
+
// ═══════════════════════════════════════
|
|
1616
|
+
// Live-session snapshot sync
|
|
1617
|
+
// ═══════════════════════════════════════
|
|
1618
|
+
|
|
1619
|
+
function applyLiveSessionSnapshot(data: LiveSessionSnapshotData) {
|
|
1620
|
+
console.log('[LiveSession] Received state snapshot:', data.entries?.length, 'entries');
|
|
1621
|
+
if (data.sessionId && data.sessionId !== activeLiveSessionId) return;
|
|
1622
|
+
hasReceivedInitialServerState = true;
|
|
1623
|
+
|
|
1624
|
+
// Track the active session
|
|
1625
|
+
activeLiveSessionFile = data.sessionFile || data.session?.sessionFile || null;
|
|
1626
|
+
viewingActiveSession = !!activeLiveSessionId;
|
|
1627
|
+
state.setStreaming(!!data.isStreaming);
|
|
1628
|
+
showTypingIndicator(!!data.isStreaming);
|
|
1629
|
+
updateLiveSessionInputState();
|
|
1630
|
+
updateUI();
|
|
1631
|
+
updateLiveSessionIndicators();
|
|
1632
|
+
|
|
1633
|
+
// Update model display — server is canonical, assign directly.
|
|
1634
|
+
if (data.model !== undefined) {
|
|
1635
|
+
if (data.model?.contextWindow) {
|
|
1636
|
+
contextWindowSize = Number(data.model.contextWindow) || 0;
|
|
1637
|
+
}
|
|
1638
|
+
modelPickerController.setModelState(data.model || '', data.thinkingLevel || 'off');
|
|
1639
|
+
} else if (data.thinkingLevel) {
|
|
1640
|
+
modelPickerController.setThinkingLevel(data.thinkingLevel || 'off');
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
// Clear and render message history. Reset streaming handles after the
|
|
1644
|
+
// snapshot arrives because live deltas may have created a streaming element
|
|
1645
|
+
// while the snapshot request was in flight; that element is about to be
|
|
1646
|
+
// removed from the DOM.
|
|
1647
|
+
currentStreamingElement = null;
|
|
1648
|
+
currentStreamingThinking = '';
|
|
1649
|
+
currentStreamingText = '';
|
|
1650
|
+
messageRenderer.clear();
|
|
1651
|
+
sessionTotalCost = 0;
|
|
1652
|
+
lastInputTokens = 0;
|
|
1653
|
+
lastUsage = null;
|
|
1654
|
+
|
|
1655
|
+
if (data.entries && data.entries.length > 0) {
|
|
1656
|
+
renderSessionHistory(data.entries);
|
|
1657
|
+
} else {
|
|
1658
|
+
messageRenderer.renderWelcome();
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
updateContextPill();
|
|
1662
|
+
// A snapshot means the session's entries/leaf may have moved (e.g. another
|
|
1663
|
+
// client ran navigate_tree) — refresh an open tree modal so it is not stale.
|
|
1664
|
+
treeViewController.notifyTreeChanged(data.sessionId || activeLiveSessionId);
|
|
1665
|
+
// A live session just loaded — fetch its authoritative stats from pi.
|
|
1666
|
+
void sessionStatsCard.refresh();
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
// Mark all live sessions in the sidebar with a green dot
|
|
1670
|
+
function updateLiveSessionIndicators() {
|
|
1671
|
+
const liveFiles = new Set(liveInstances.map(i => i.sessionFile));
|
|
1672
|
+
// Also include the current active live session
|
|
1673
|
+
if (activeLiveSessionFile) liveFiles.add(activeLiveSessionFile);
|
|
1674
|
+
|
|
1675
|
+
document.querySelectorAll('.session-item').forEach(el => {
|
|
1676
|
+
el.classList.toggle('has-live-session', liveFiles.has(el.dataset.filePath));
|
|
1677
|
+
});
|
|
1678
|
+
}
|
|
1679
|
+
|
|
1680
|
+
// Refresh live-session list for sidebar indicators if WS missed an update
|
|
1681
|
+
async function pollInstances() {
|
|
1682
|
+
try {
|
|
1683
|
+
const res = await fetch('/api/live-sessions');
|
|
1684
|
+
if (res.ok) {
|
|
1685
|
+
const data = await res.json();
|
|
1686
|
+
const wasActive = activeLiveSessionId;
|
|
1687
|
+
setLiveSessions(data.sessions || []);
|
|
1688
|
+
const activeSession = wasActive ? liveSessions.find(s => s.id === wasActive) : null;
|
|
1689
|
+
if (wasActive && !activeSession) {
|
|
1690
|
+
handleLiveSessionClosed(wasActive);
|
|
1691
|
+
} else if (activeSession && viewingActiveSession) {
|
|
1692
|
+
state.setStreaming(!!activeSession.isStreaming);
|
|
1693
|
+
showTypingIndicator(!!activeSession.isStreaming);
|
|
1694
|
+
applyActiveSessionMetadata(activeSession);
|
|
1695
|
+
updateLiveSessionInputState();
|
|
1696
|
+
updateUI();
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
} catch {}
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
// Poll every 10 seconds
|
|
1703
|
+
setInterval(pollInstances, 10000);
|
|
1704
|
+
|
|
1705
|
+
// Enable/disable input based on whether we're viewing a live backend Tau tab
|
|
1706
|
+
function updateLiveSessionInputState() {
|
|
1707
|
+
const inputArea = document.querySelector('.input-area');
|
|
1708
|
+
const hasLiveSession = viewingActiveSession && !!activeLiveSessionId;
|
|
1709
|
+
if (hasLiveSession) {
|
|
1710
|
+
messageInput.disabled = false;
|
|
1711
|
+
sendBtn.disabled = false;
|
|
1712
|
+
messageInput.placeholder = 'Message...';
|
|
1713
|
+
inputArea?.classList.remove('no-active-live-session');
|
|
1714
|
+
} else {
|
|
1715
|
+
messageInput.disabled = true;
|
|
1716
|
+
sendBtn.disabled = true;
|
|
1717
|
+
messageInput.placeholder = hasReceivedInitialServerState ? 'Create or select a Tau tab to chat' : 'Connecting...';
|
|
1718
|
+
inputArea?.classList.add('no-active-live-session');
|
|
1719
|
+
}
|
|
1720
|
+
document.getElementById('command-btn')!.disabled = !hasLiveSession;
|
|
1721
|
+
modelPickerController.setEnabled(hasLiveSession);
|
|
1722
|
+
treeViewController.setVisible(hasLiveSession);
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
// ═══════════════════════════════════════
|
|
1726
|
+
// Session history rendering
|
|
1727
|
+
// ═══════════════════════════════════════
|
|
1728
|
+
|
|
1729
|
+
function renderSessionHistory(entries: SessionHistoryEntry[]) {
|
|
1730
|
+
console.log(`[History] Rendering ${entries.length} entries`);
|
|
1731
|
+
let userCount = 0, assistantCount = 0, toolCardCount = 0, toolResultCount = 0;
|
|
1732
|
+
|
|
1733
|
+
for (const entry of entries) {
|
|
1734
|
+
if (entry.type !== 'message') continue;
|
|
1735
|
+
|
|
1736
|
+
const msg = entry.message;
|
|
1737
|
+
if (!msg) continue;
|
|
1738
|
+
|
|
1739
|
+
if (msg.role === 'user') {
|
|
1740
|
+
const content =
|
|
1741
|
+
typeof msg.content === 'string'
|
|
1742
|
+
? msg.content
|
|
1743
|
+
: (msg.content || [])
|
|
1744
|
+
.filter((b) => b.type === 'text')
|
|
1745
|
+
.map((b) => b.text)
|
|
1746
|
+
.join('\n');
|
|
1747
|
+
// Extract images from content blocks
|
|
1748
|
+
const images = Array.isArray(msg.content)
|
|
1749
|
+
? msg.content
|
|
1750
|
+
.filter((b) => b.type === 'image')
|
|
1751
|
+
.map((b) => ({ data: b.source?.data || b.data || '', mimeType: b.source?.media_type || b.media_type || 'image/png' }))
|
|
1752
|
+
: [];
|
|
1753
|
+
if (content || images.length > 0) {
|
|
1754
|
+
userCount++;
|
|
1755
|
+
messageRenderer.renderUserMessage({ content: content || '', images: images.length > 0 ? images : undefined }, true);
|
|
1756
|
+
}
|
|
1757
|
+
} else if (msg.role === 'assistant') {
|
|
1758
|
+
const textBlocks = ((msg.content as MessageContentBlock[]) || []).filter((b) => b.type === 'text');
|
|
1759
|
+
const thinkingBlocks = ((msg.content as MessageContentBlock[]) || []).filter((b) => b.type === 'thinking');
|
|
1760
|
+
const toolCalls = ((msg.content as MessageContentBlock[]) || []).filter((b) => b.type === 'toolCall');
|
|
1761
|
+
|
|
1762
|
+
// Build content blocks for rendering
|
|
1763
|
+
const contentBlocks = [];
|
|
1764
|
+
for (const block of (msg.content as MessageContentBlock[]) || []) {
|
|
1765
|
+
if (block.type === 'text' || block.type === 'thinking') {
|
|
1766
|
+
contentBlocks.push(block);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
|
|
1770
|
+
const text = textBlocks.map((b) => b.text).join('\n');
|
|
1771
|
+
|
|
1772
|
+
if (text || thinkingBlocks.length > 0) {
|
|
1773
|
+
assistantCount++;
|
|
1774
|
+
messageRenderer.renderAssistantMessage(
|
|
1775
|
+
{
|
|
1776
|
+
content: contentBlocks.length > 0 ? contentBlocks : text,
|
|
1777
|
+
usage: msg.usage,
|
|
1778
|
+
},
|
|
1779
|
+
false,
|
|
1780
|
+
true
|
|
1781
|
+
);
|
|
1782
|
+
|
|
1783
|
+
// Track cost and tokens from history
|
|
1784
|
+
if (msg.usage?.cost?.total) {
|
|
1785
|
+
sessionTotalCost += msg.usage.cost.total;
|
|
1786
|
+
}
|
|
1787
|
+
if (msg.usage?.input) {
|
|
1788
|
+
lastInputTokens = msg.usage.input + (msg.usage.cacheRead || 0);
|
|
1789
|
+
lastUsage = msg.usage;
|
|
1790
|
+
}
|
|
1791
|
+
}
|
|
1792
|
+
|
|
1793
|
+
// Show tool calls as compact history cards
|
|
1794
|
+
for (const tc of toolCalls) {
|
|
1795
|
+
toolCardCount++;
|
|
1796
|
+
const card = toolCardRenderer.createHistoryCard({
|
|
1797
|
+
toolCallId: tc.id,
|
|
1798
|
+
toolName: tc.name,
|
|
1799
|
+
args: tc.arguments || {},
|
|
1800
|
+
});
|
|
1801
|
+
console.log(`[History] Tool card created: ${tc.name}`, card?.offsetHeight, card?.innerHTML?.substring(0, 100));
|
|
1802
|
+
}
|
|
1803
|
+
} else if (msg.role === 'toolResult') {
|
|
1804
|
+
toolResultCount++;
|
|
1805
|
+
toolCardRenderer.addHistoryResult(
|
|
1806
|
+
msg.toolCallId ?? '',
|
|
1807
|
+
{ content: (msg.content as MessageContentBlock[]) || [] },
|
|
1808
|
+
msg.isError ?? false
|
|
1809
|
+
);
|
|
1810
|
+
}
|
|
1811
|
+
}
|
|
1812
|
+
|
|
1813
|
+
console.log(`[History] Done: ${userCount} users, ${assistantCount} assistants, ${toolCardCount} tools, ${toolResultCount} results`);
|
|
1814
|
+
console.log(`[History] DOM tool-card count:`, document.querySelectorAll('.tool-card').length);
|
|
1815
|
+
console.log(`[History] DOM thinking-block count:`, document.querySelectorAll('.thinking-block').length);
|
|
1816
|
+
|
|
1817
|
+
updateContextPill();
|
|
1818
|
+
fetchContextWindow();
|
|
1819
|
+
|
|
1820
|
+
// Jump to bottom instantly (no smooth scroll animation)
|
|
1821
|
+
messagesContainer.style.scrollBehavior = 'auto';
|
|
1822
|
+
requestAnimationFrame(() => {
|
|
1823
|
+
messagesContainer.scrollTop = messagesContainer.scrollHeight;
|
|
1824
|
+
// Restore smooth scrolling after a frame
|
|
1825
|
+
requestAnimationFrame(() => {
|
|
1826
|
+
messagesContainer.style.scrollBehavior = '';
|
|
1827
|
+
});
|
|
1828
|
+
});
|
|
1829
|
+
}
|
|
1830
|
+
|
|
1831
|
+
// ═══════════════════════════════════════
|
|
1832
|
+
// UI helpers
|
|
1833
|
+
// ═══════════════════════════════════════
|
|
1834
|
+
|
|
1835
|
+
function showTypingIndicator(show: boolean) {
|
|
1836
|
+
typingIndicator.classList.toggle('hidden', !show);
|
|
1837
|
+
}
|
|
1838
|
+
|
|
1839
|
+
// The single header pill: shows context usage %, falling back to raw tokens
|
|
1840
|
+
// (no context window info yet) or session cost (no context data yet).
|
|
1841
|
+
// Clicking it opens the session stats card.
|
|
1842
|
+
function updateContextPill() {
|
|
1843
|
+
if (lastInputTokens > 0 && contextWindowSize > 0) {
|
|
1844
|
+
const pct = Math.round((lastInputTokens / contextWindowSize) * 100);
|
|
1845
|
+
contextPillEl.textContent = pct === 0 ? '<1%' : `${pct}%`;
|
|
1846
|
+
contextPillEl.classList.add('visible');
|
|
1847
|
+
contextPillEl.classList.remove('warning', 'critical');
|
|
1848
|
+
if (pct >= 80) {
|
|
1849
|
+
contextPillEl.classList.add('critical');
|
|
1850
|
+
} else if (pct >= 60) {
|
|
1851
|
+
contextPillEl.classList.add('warning');
|
|
1852
|
+
}
|
|
1853
|
+
contextPillEl.title = `Context: ${(lastInputTokens / 1000).toFixed(1)}k / ${(contextWindowSize / 1000).toFixed(0)}k tokens — click for session stats`;
|
|
1854
|
+
if (pct >= 80) {
|
|
1855
|
+
showCompactButton();
|
|
1856
|
+
} else {
|
|
1857
|
+
hideCompactButton();
|
|
1858
|
+
}
|
|
1859
|
+
} else if (lastInputTokens > 0) {
|
|
1860
|
+
// No context window info yet, just show raw tokens
|
|
1861
|
+
contextPillEl.textContent = `${(lastInputTokens / 1000).toFixed(1)}k`;
|
|
1862
|
+
contextPillEl.classList.add('visible');
|
|
1863
|
+
contextPillEl.classList.remove('warning', 'critical');
|
|
1864
|
+
contextPillEl.title = 'Context tokens — click for session stats';
|
|
1865
|
+
} else if (sessionTotalCost > 0) {
|
|
1866
|
+
// No context data yet — show the session cost as a fallback.
|
|
1867
|
+
contextPillEl.textContent = `$${sessionTotalCost.toFixed(3)} (sub)`;
|
|
1868
|
+
contextPillEl.classList.add('visible');
|
|
1869
|
+
contextPillEl.classList.remove('warning', 'critical');
|
|
1870
|
+
contextPillEl.title = 'Session cost — click for session stats';
|
|
1871
|
+
} else {
|
|
1872
|
+
contextPillEl.classList.remove('visible', 'warning', 'critical');
|
|
1873
|
+
}
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function showCompactButton() {
|
|
1877
|
+
if (document.getElementById('compact-btn')) return;
|
|
1878
|
+
const btn = document.createElement('button');
|
|
1879
|
+
btn.id = 'compact-btn';
|
|
1880
|
+
btn.className = 'compact-btn';
|
|
1881
|
+
btn.textContent = 'Compact';
|
|
1882
|
+
btn.title = 'Context is over 80% — compact to save tokens';
|
|
1883
|
+
btn.addEventListener('click', () => {
|
|
1884
|
+
rpcCommand({ type: 'compact' }, 'Compacting...');
|
|
1885
|
+
hideCompactButton();
|
|
1886
|
+
});
|
|
1887
|
+
// Insert next to the context pill in the header
|
|
1888
|
+
const pillParent = contextPillEl.parentElement;
|
|
1889
|
+
if (pillParent) pillParent.insertBefore(btn, contextPillEl.nextSibling);
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function hideCompactButton() {
|
|
1893
|
+
const btn = document.getElementById('compact-btn');
|
|
1894
|
+
if (btn) btn.remove();
|
|
1895
|
+
}
|
|
1896
|
+
|
|
1897
|
+
async function fetchContextWindow() {
|
|
1898
|
+
// Delegate to fetchModelInfo which also updates the model button
|
|
1899
|
+
await modelPickerController.fetchModelInfo();
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
let tailscaleUrl = '';
|
|
1903
|
+
|
|
1904
|
+
function updateConnectionStatus(status: string) {
|
|
1905
|
+
statusIndicator.className = `status-indicator ${status}`;
|
|
1906
|
+
|
|
1907
|
+
if (status === 'connected') {
|
|
1908
|
+
statusText.textContent = tailscaleUrl ? 'Connected • TS' : 'Connected';
|
|
1909
|
+
statusText.title = tailscaleUrl || '';
|
|
1910
|
+
// Fetch tailscale info on first connect
|
|
1911
|
+
if (!tailscaleUrl) {
|
|
1912
|
+
fetch('/api/health').then(r => r.json()).then(data => {
|
|
1913
|
+
if (data.tailscaleUrl) {
|
|
1914
|
+
tailscaleUrl = data.tailscaleUrl;
|
|
1915
|
+
statusText.textContent = 'Connected • TS';
|
|
1916
|
+
statusText.title = tailscaleUrl;
|
|
1917
|
+
}
|
|
1918
|
+
}).catch(() => {});
|
|
1919
|
+
}
|
|
1920
|
+
} else if (status === 'disconnected') {
|
|
1921
|
+
statusText.textContent = 'Disconnected';
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
|
|
1925
|
+
function updateUI() {
|
|
1926
|
+
const hasLiveSession = !!activeLiveSessionId && viewingActiveSession;
|
|
1927
|
+
const isStreaming = state.isStreaming && hasLiveSession;
|
|
1928
|
+
|
|
1929
|
+
// Don't clobber an active red-dot error flash: it owns both the indicator
|
|
1930
|
+
// class and statusText for its full 3 s. The flash's restore callback
|
|
1931
|
+
// re-derives the current connection/streaming state, so skipping here is
|
|
1932
|
+
// safe. Other UI updates below (input enabling, abort button, etc.) still
|
|
1933
|
+
// run normally.
|
|
1934
|
+
if (statusFlashTimer === null) {
|
|
1935
|
+
if (isStreaming) {
|
|
1936
|
+
statusIndicator.classList.add('streaming');
|
|
1937
|
+
statusIndicator.classList.remove('connected');
|
|
1938
|
+
statusText.textContent = 'Working...';
|
|
1939
|
+
} else {
|
|
1940
|
+
statusIndicator.classList.remove('streaming');
|
|
1941
|
+
statusIndicator.classList.add('connected');
|
|
1942
|
+
statusText.textContent = 'Connected';
|
|
1943
|
+
}
|
|
1944
|
+
}
|
|
1945
|
+
|
|
1946
|
+
messageInput.disabled = !hasLiveSession;
|
|
1947
|
+
sendBtn.disabled = !hasLiveSession;
|
|
1948
|
+
|
|
1949
|
+
if (isStreaming) {
|
|
1950
|
+
abortBtn.classList.remove('hidden');
|
|
1951
|
+
sendBtn.classList.add('hidden');
|
|
1952
|
+
} else {
|
|
1953
|
+
abortBtn.classList.add('hidden');
|
|
1954
|
+
sendBtn.classList.remove('hidden');
|
|
1955
|
+
if (hasLiveSession) flushQueue();
|
|
1956
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
// ═══════════════════════════════════════
|
|
1960
|
+
// WebSocket session switch handler
|
|
1961
|
+
// ═══════════════════════════════════════
|
|
1962
|
+
|
|
1963
|
+
wsClient.addEventListener('sessionSwitch', () => {
|
|
1964
|
+
console.log('[App] Session switched');
|
|
1965
|
+
});
|
|
1966
|
+
|
|
1967
|
+
// ═══════════════════════════════════════
|
|
1968
|
+
// Theme / Settings
|
|
1969
|
+
// ═══════════════════════════════════════
|
|
1970
|
+
|
|
1971
|
+
|
|
1972
|
+
|
|
1973
|
+
const settingsBtn = document.getElementById('settings-btn')!;
|
|
1974
|
+
const settingsPanel = document.getElementById('settings-panel')!;
|
|
1975
|
+
const settingsOverlay = document.getElementById('settings-overlay')!;
|
|
1976
|
+
const settingsClose = document.getElementById('settings-close')!;
|
|
1977
|
+
const themeGrid = document.getElementById('theme-grid')!;
|
|
1978
|
+
|
|
1979
|
+
|
|
1980
|
+
const toggleAutoCompact = document.getElementById('toggle-auto-compact')!;
|
|
1981
|
+
const btnThinkingLevel = document.getElementById('btn-thinking-level')!;
|
|
1982
|
+
const toggleShowThinking = document.getElementById('toggle-show-thinking')!;
|
|
1983
|
+
|
|
1984
|
+
|
|
1985
|
+
function buildThemeGrid() {
|
|
1986
|
+
themeGrid.innerHTML = '';
|
|
1987
|
+
const current = getCurrentTheme();
|
|
1988
|
+
|
|
1989
|
+
for (const [id, theme] of Object.entries(themes)) {
|
|
1990
|
+
const btn = document.createElement('button');
|
|
1991
|
+
btn.className = `theme-swatch${current === id ? ' active' : ''}`;
|
|
1992
|
+
const dots = (theme.colors || []).map(c =>
|
|
1993
|
+
`<span class="swatch-dot" style="background:${c}"></span>`
|
|
1994
|
+
).join('');
|
|
1995
|
+
btn.innerHTML = `<span class="swatch-colors">${dots}</span>`;
|
|
1996
|
+
btn.addEventListener('click', () => {
|
|
1997
|
+
applyTheme(id);
|
|
1998
|
+
themeGrid.querySelectorAll('.theme-swatch').forEach(s => s.classList.remove('active'));
|
|
1999
|
+
btn.classList.add('active');
|
|
2000
|
+
});
|
|
2001
|
+
themeGrid.appendChild(btn);
|
|
2002
|
+
}
|
|
2003
|
+
}
|
|
2004
|
+
|
|
2005
|
+
async function openSettings() {
|
|
2006
|
+
buildThemeGrid();
|
|
2007
|
+
settingsPanel.classList.remove('hidden');
|
|
2008
|
+
settingsOverlay.classList.remove('hidden');
|
|
2009
|
+
|
|
2010
|
+
// Fetch current state for toggles
|
|
2011
|
+
try {
|
|
2012
|
+
const resp = await fetch('/api/rpc', {
|
|
2013
|
+
method: 'POST',
|
|
2014
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2015
|
+
body: JSON.stringify({ type: 'get_state', sessionId: activeLiveSessionId }),
|
|
2016
|
+
});
|
|
2017
|
+
const data = await resp.json();
|
|
2018
|
+
if (data.success && data.data) {
|
|
2019
|
+
const s = data.data;
|
|
2020
|
+
// Auto-compaction toggle
|
|
2021
|
+
toggleAutoCompact.className = `settings-toggle${s.autoCompactionEnabled ? ' on' : ''}`;
|
|
2022
|
+
// Thinking level
|
|
2023
|
+
btnThinkingLevel.textContent = s.thinkingLevel || 'off';
|
|
2024
|
+
modelPickerController.setThinkingLevel(s.thinkingLevel || 'off');
|
|
2025
|
+
// Session name is managed by Pi session history; no editable field in Tau settings.
|
|
2026
|
+
}
|
|
2027
|
+
} catch (e) {
|
|
2028
|
+
// Silent
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
// Fetch auth state
|
|
2032
|
+
try {
|
|
2033
|
+
const authData = await rpcCommand({ type: 'get_auth' });
|
|
2034
|
+
if (authData?.success && authData.data?.configured) {
|
|
2035
|
+
authSection.style.display = '';
|
|
2036
|
+
toggleAuth.className = `settings-toggle${authData.data.enabled ? ' on' : ''}`;
|
|
2037
|
+
} else {
|
|
2038
|
+
authSection.style.display = 'none';
|
|
2039
|
+
}
|
|
2040
|
+
} catch {
|
|
2041
|
+
authSection.style.display = 'none';
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
function closeSettings() {
|
|
2046
|
+
settingsPanel.classList.add('hidden');
|
|
2047
|
+
settingsOverlay.classList.add('hidden');
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
settingsBtn.addEventListener('click', openSettings);
|
|
2051
|
+
settingsClose.addEventListener('click', closeSettings);
|
|
2052
|
+
settingsOverlay.addEventListener('click', closeSettings);
|
|
2053
|
+
|
|
2054
|
+
// Auto-compaction toggle
|
|
2055
|
+
toggleAutoCompact.addEventListener('click', async () => {
|
|
2056
|
+
const isOn = toggleAutoCompact.classList.contains('on');
|
|
2057
|
+
toggleAutoCompact.className = `settings-toggle${isOn ? '' : ' on'}`;
|
|
2058
|
+
await rpcCommand({ type: 'set_auto_compaction', enabled: !isOn });
|
|
2059
|
+
});
|
|
2060
|
+
|
|
2061
|
+
// Thinking level cycle (settings panel button)
|
|
2062
|
+
btnThinkingLevel.addEventListener('click', async () => {
|
|
2063
|
+
const data = await rpcCommand({ type: 'cycle_thinking_level' });
|
|
2064
|
+
if (data?.success && data.data?.level) {
|
|
2065
|
+
btnThinkingLevel.textContent = data.data.level;
|
|
2066
|
+
modelPickerController.setThinkingLevel(data.data.level);
|
|
2067
|
+
}
|
|
2068
|
+
});
|
|
2069
|
+
|
|
2070
|
+
// Show thinking toggle (local pref)
|
|
2071
|
+
const showThinking = localStorage.getItem('tau-show-thinking') !== 'false';
|
|
2072
|
+
toggleShowThinking.className = `settings-toggle${showThinking ? ' on' : ''}`;
|
|
2073
|
+
if (!showThinking) document.body.classList.add('hide-thinking');
|
|
2074
|
+
|
|
2075
|
+
toggleShowThinking.addEventListener('click', () => {
|
|
2076
|
+
const isOn = toggleShowThinking.classList.contains('on');
|
|
2077
|
+
toggleShowThinking.className = `settings-toggle${isOn ? '' : ' on'}`;
|
|
2078
|
+
document.body.classList.toggle('hide-thinking', isOn);
|
|
2079
|
+
localStorage.setItem('tau-show-thinking', String(!isOn));
|
|
2080
|
+
});
|
|
2081
|
+
|
|
2082
|
+
// Auth toggle
|
|
2083
|
+
const toggleAuth = document.getElementById('toggle-auth')!;
|
|
2084
|
+
const authSection = document.getElementById('settings-auth-section')!;
|
|
2085
|
+
|
|
2086
|
+
toggleAuth.addEventListener('click', async () => {
|
|
2087
|
+
const isOn = toggleAuth.classList.contains('on');
|
|
2088
|
+
const data = await rpcCommand({ type: 'set_auth', enabled: !isOn });
|
|
2089
|
+
if (data?.success) {
|
|
2090
|
+
toggleAuth.className = `settings-toggle${!isOn ? ' on' : ''}`;
|
|
2091
|
+
}
|
|
2092
|
+
});
|
|
2093
|
+
|
|
2094
|
+
|
|
2095
|
+
|
|
2096
|
+
|
|
2097
|
+
|
|
2098
|
+
// Restore saved theme
|
|
2099
|
+
const savedTheme = getCurrentTheme();
|
|
2100
|
+
applyTheme(savedTheme);
|
|
2101
|
+
|
|
2102
|
+
// ═══════════════════════════════════════
|
|
2103
|
+
// Session stats card (opened from the context pill)
|
|
2104
|
+
// ═══════════════════════════════════════
|
|
2105
|
+
|
|
2106
|
+
// Fetch authoritative session stats from pi's get_session_stats RPC. Uses a
|
|
2107
|
+
// plain fetch (not rpcCommand) so it never flashes status-bar messages.
|
|
2108
|
+
// Resolves null when there is no live session or the fetch fails; a stale
|
|
2109
|
+
// response for a session we already switched away from is discarded, and any
|
|
2110
|
+
// authoritative numbers are synced into the pill's local state.
|
|
2111
|
+
async function fetchSessionStats(): Promise<SessionStats | null> {
|
|
2112
|
+
if (!viewingActiveSession || !activeLiveSessionId) return null;
|
|
2113
|
+
const requestSessionId = activeLiveSessionId;
|
|
2114
|
+
try {
|
|
2115
|
+
const resp = await fetch('/api/rpc', {
|
|
2116
|
+
method: 'POST',
|
|
2117
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2118
|
+
body: JSON.stringify({ type: 'get_session_stats', sessionId: requestSessionId }),
|
|
2119
|
+
});
|
|
2120
|
+
const data = await resp.json();
|
|
2121
|
+
if (!data?.success || !data.data) return null;
|
|
2122
|
+
if (activeLiveSessionId !== requestSessionId || !viewingActiveSession) return null;
|
|
2123
|
+
const stats = data.data as SessionStats;
|
|
2124
|
+
|
|
2125
|
+
// Sync authoritative numbers into the locally-tracked pill state so the
|
|
2126
|
+
// pill never drifts from what pi reports. contextUsage (and its fields)
|
|
2127
|
+
// may be null right after compaction — keep the last known values then.
|
|
2128
|
+
if (typeof stats.cost === 'number') sessionTotalCost = stats.cost;
|
|
2129
|
+
const cu = stats.contextUsage;
|
|
2130
|
+
if (cu && typeof cu.contextWindow === 'number' && cu.contextWindow > 0) {
|
|
2131
|
+
contextWindowSize = cu.contextWindow;
|
|
2132
|
+
}
|
|
2133
|
+
if (cu && typeof cu.tokens === 'number') {
|
|
2134
|
+
lastInputTokens = cu.tokens;
|
|
2135
|
+
}
|
|
2136
|
+
updateContextPill();
|
|
2137
|
+
return stats;
|
|
2138
|
+
} catch {
|
|
2139
|
+
return null;
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
|
|
2143
|
+
const sessionStatsCard = setupSessionStatsCard({
|
|
2144
|
+
pillEl: contextPillEl,
|
|
2145
|
+
cardEl: document.getElementById('session-stats-card')!,
|
|
2146
|
+
fetchStats: fetchSessionStats,
|
|
2147
|
+
getFallback: () => ({
|
|
2148
|
+
usage: lastUsage,
|
|
2149
|
+
cost: sessionTotalCost,
|
|
2150
|
+
contextTokens: lastInputTokens,
|
|
2151
|
+
contextWindow: contextWindowSize,
|
|
2152
|
+
}),
|
|
2153
|
+
});
|
|
2154
|
+
|
|
2155
|
+
// Voice Input
|
|
2156
|
+
setupVoiceInput(document.getElementById('mic-btn')!, messageInput);
|
|
2157
|
+
|
|
2158
|
+
// ═══════════════════════════════════════
|
|
2159
|
+
// Initialize
|
|
2160
|
+
// ═══════════════════════════════════════
|
|
2161
|
+
|
|
2162
|
+
// On mobile, start with the sidebar collapsed. The context pill stays in the
|
|
2163
|
+
// header on all screen sizes.
|
|
2164
|
+
if (isMobile()) {
|
|
2165
|
+
sidebarEl.classList.add('collapsed');
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
wsClient.connect();
|
|
2169
|
+
messageRenderer.renderWelcome();
|
|
2170
|
+
updateLiveSessionInputState();
|
|
2171
|
+
sidebar.loadSessions().then(() => {
|
|
2172
|
+
updateLiveSessionIndicators();
|
|
2173
|
+
});
|
|
2174
|
+
launcherPanel.init();
|
|
2175
|
+
|
|
2176
|
+
// Register service worker for PWA
|
|
2177
|
+
if ('serviceWorker' in navigator) {
|
|
2178
|
+
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// Dismiss mobile splash screen
|
|
2182
|
+
const splash = document.getElementById('mobile-splash');
|
|
2183
|
+
if (splash) {
|
|
2184
|
+
requestAnimationFrame(() => {
|
|
2185
|
+
splash.classList.add('hidden');
|
|
2186
|
+
setTimeout(() => splash.remove(), 300);
|
|
2187
|
+
});
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
console.log('🚀 Tau initialized');
|