-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathapp.js
More file actions
8804 lines (8476 loc) · 360 KB
/
Copy pathapp.js
File metadata and controls
8804 lines (8476 loc) · 360 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
"use strict";
const $ = (id) => document.getElementById(id);
const PROJECT_COLLAPSE_KEY = "giskard.collapsedProjects";
const WS_RECONNECT_BASE_MS = 600;
const WS_RECONNECT_MAX_MS = 8000;
const WS_PROBLEM_NOTICE_INTERVAL_MS = 30000;
const WS_BACKGROUND_CLOSE_GRACE_MS = 10000;
const WS_FOREGROUND_PROBE_TIMEOUT_MS = 1200;
const TRANSCRIPT_BOTTOM_STICKY_PX = 96;
// Whether the composer is on a touch/coarse-pointer device (mobile keyboard). On touch there's no
// Shift modifier reachable while typing, so the newline key fires a plain Enter; the composer must
// let it insert a newline rather than sending the half-typed message. Detected once at load and
// cached: it does not change mid-session.
//
// `pointer: coarse` is the standard media query for "no precise pointer" and is what a phone
// reports. The maxTouchPoints fallback is only used when the primary pointer is not already known
// to be fine+hover-capable, so a touch-enabled laptop (e.g. a Surface, which reports
// maxTouchPoints > 0 but has a fine, hover-capable trackpad/mouse as the primary pointer) keeps
// desktop Enter-sends behavior.
const COMPOSER_IS_TOUCH = (() => {
try {
if (window.matchMedia && window.matchMedia("(pointer: coarse)").matches) return true;
} catch { /* matchMedia unsupported; fall through */ }
// Only fall back to maxTouchPoints when we don't have a positive desktop signal. A fine, hover-
// capable primary pointer means a desktop-class pointing device is present; treat it as desktop
// regardless of whether the screen also happens to accept touch.
let finePointer = false;
let hoverCapable = false;
try {
if (window.matchMedia) {
finePointer = window.matchMedia("(pointer: fine)").matches;
hoverCapable = window.matchMedia("(hover: hover)").matches;
}
} catch { /* matchMedia unsupported; assume unknown */ }
if (finePointer && hoverCapable) return false;
try {
if (navigator.maxTouchPoints && navigator.maxTouchPoints > 0) return true;
} catch { /* navigator.maxTouchPoints unavailable */ }
return false;
})();
// Hint text shown in the composer placeholder and the draft-empty state, adapting to touch vs
// desktop so the user knows how Enter behaves on their keyboard.
const COMPOSER_HINT = COMPOSER_IS_TOUCH
? "Tap Send to send"
: "Enter to send, Shift+Enter for newline";
// Keep the app shell sized to the visible area above the on-screen keyboard.
//
// The bars (`#mobileBar`, `header.thr`) and the transcript are flex children of `.center`, a
// column sized to 100vh / 100dvh. On Android and Firefox the `interactive-widget=resizes-content`
// viewport meta (see index.html) makes 100dvh shrink when the keyboard opens, so the flex reflows
// and the bars stay pinned with no JS. But iOS Safari does NOT support interactive-widget: it
// overlays the keyboard without resizing the layout, then offsets the layout viewport to reveal
// the focused composer — a visual-viewport scroll that pushes the top bars off-screen, which
// position:sticky cannot counter (sticky sticks within a scroll container, not against a
// layout-viewport shift).
//
// The portable fix is to drive the shell height from window.visualViewport.height (the visible
// region above the keyboard) via a --app-height CSS variable: the whole app fits the visible area,
// so Safari has nothing to scroll away and the bars stay put. visualViewport exists on all modern
// mobile browsers (including iOS Safari 13+); where it's missing we leave the 100vh/100dvh CSS as
// the fallback. We listen on the visualViewport (not window) resize because the keyboard shrinks
// the visual viewport without firing a window resize on iOS.
//
// The visual viewport can also PAN: when iOS Safari shifts the layout viewport to reveal the
// focused composer, visualViewport.offsetTop becomes non-zero (the layout viewport's top edge
// moves down relative to the visible region). The shell is anchored to the layout-viewport top
// (top:0), so without compensation the bars would sit above the visible area and get clipped.
// We mirror offsetTop into --app-top and the CSS translates the shell by it, keeping the bars
// aligned with the visible region's top edge. (resize alone doesn't cover the pan; the scroll
// event is what fires when offsetTop changes, so both events run apply.)
(function syncAppHeight() {
const vv = window.visualViewport;
if (!vv) return; // older browser; fall back to the 100vh/100dvh CSS
const transcript = document.getElementById("transcript");
let transcriptScrollIntent = 0;
if (transcript) {
const recordScrollIntent = () => { transcriptScrollIntent += 1; };
// scrollTop can change as a consequence of the flex reflow itself, so it cannot distinguish a
// reader gesture from browser layout. Record the input events that initiate manual scrolling.
transcript.addEventListener("wheel", recordScrollIntent, { passive:true });
transcript.addEventListener("touchmove", recordScrollIntent, { passive:true });
transcript.addEventListener("pointerdown", recordScrollIntent, { passive:true });
transcript.addEventListener("keydown", recordScrollIntent);
}
const apply = () => {
// Resizing the shell also shrinks #transcript, which is its own scroll container. Browsers
// preserve that element's scrollTop, not its distance from the bottom, so a transcript that
// was following the latest row would otherwise appear to jump backwards as the keyboard
// opened: the newest rows remain below the shortened scrollport and look keyboard-covered.
// Capture the existing bottom-following intent before changing the height and restore it after
// flex layout has reflowed. Leave readers who deliberately scrolled up exactly where they are.
const followTranscriptBottom = !!(transcript
&& transcript.scrollHeight - transcript.scrollTop - transcript.clientHeight
<= TRANSCRIPT_BOTTOM_STICKY_PX);
const scrollIntentAtResize = transcriptScrollIntent;
// Use px (not vh) so it tracks the live visual viewport, not the layout viewport. Round to
// avoid sub-pixel jitter from the fractional heights visualViewport reports. offsetTop is
// the layout-viewport top's offset from the visual-viewport top (the pan); 0 when the
// layout viewport isn't shifted (the common case, including desktop and Android).
document.documentElement.style.setProperty("--app-height", Math.round(vv.height) + "px");
document.documentElement.style.setProperty("--app-top", Math.round(vv.offsetTop) + "px");
if (followTranscriptBottom) {
requestAnimationFrame(() => {
// A reader can scroll between the viewport event and this frame. Only restore the bottom
// anchor if no newer manual scroll began in the meantime.
if (transcriptScrollIntent === scrollIntentAtResize) {
transcript.scrollTop = transcript.scrollHeight;
}
});
}
};
apply();
vv.addEventListener("resize", apply);
// The layout can change (scroll bar appearing, orientation) without a resize; scroll is the
// reliable signal that the visible region moved — and, crucially, the event that fires when
// visualViewport.offsetTop changes (the iOS layout-viewport pan).
vv.addEventListener("scroll", apply);
})();
// History is paginated by turn on the server, but a turn can hold an arbitrary number of items, so a
// turn count is a poor proxy for screen height. On open we render the live turn first, then top up
// persisted history in small batches until the transcript holds roughly this many viewports of
// scrollback — measuring pixels the server can't see. `clientHeight` makes this adapt to phone vs
// desktop for free. The cap stops pathologically tiny turns from paging forever.
const HISTORY_FILL_SCREENS = 2;
const HISTORY_FILL_BATCH = 5;
const HISTORY_FILL_MAX_TURNS = 200;
const PICKER_TYPEAHEAD_RESET_MS = 1000;
const NOTIFICATION_PROMPT_NOTICE_INTERVAL_MS = 30000;
const BROWSER_DIAGNOSTIC_LIMIT = 120;
const NOTIFICATION_DEDUP_MS = 15000;
const ACTIVE_THREAD_COMPLETED_MARK_MS = 2500;
// Debounce for re-fetching thread lists after activity arrives for a thread the browser has never
// seen (a sub-agent the server just materialized). Short enough that the sidebar catches up while
// the child is still blocked, long enough that a burst of child events costs one refresh.
const STALE_THREAD_LIST_REFRESH_MS = 400;
// Refresh attempts spent on any one unresolved thread id before giving up on it.
const STALE_THREAD_LIST_REFRESH_MAX_ATTEMPTS = 3;
const BROWSER_DIAGNOSTIC_VERSION = "browser-diagnostics-v1";
const MAX_ATTACHMENTS_PER_MESSAGE = 8;
const MAX_ATTACHMENT_BYTES = 25 * 1024 * 1024;
const MAX_TOTAL_ATTACHMENT_BYTES = 25 * 1024 * 1024;
const MAX_ATTACHMENT_NAME_BYTES = 255;
const MAX_ATTACHMENT_MIME_BYTES = 127;
const THREAD_DELETE_TIMEOUT_MS = 30000;
let state = {
projectId:null, threadId:null, mode:"build", ws:null, wsStatus:"closed", wsConnectId:0,
wsReconnectTimer:null, wsReconnectAttempt:0, wsStatusDetail:"WebSocket disconnected",
wsLastProblem:"", wsLastProblemNotice:"", wsLastProblemNoticeAt:0,
wsProbeTimer:null, wsProbeToken:0, wsProbeSocket:null,
draftThread:null, firstTurnStartingThreadId:null, inputDrafts:new Map(),
// Per-turn DOM identity (foundation for incremental reconnect): `currentRenderTurnId` is the turn
// whose rows are being stamped right now (a persisted turn being rendered, or the live turn being
// streamed); `newestPersistedTurnId` is the id of the newest turn known to have completed — the
// high-water mark a future resync will use as its "give me turns after this" cursor.
currentRenderTurnId:null, newestPersistedTurnId:null,
globalModels:[], models:[], modelsProject:null, modelsLoadingProject:null, pendingModelBeforeSelect:null, streamEl:null, streamItemId:null, pendingUserEl:null, pendingUserText:null,
streamElsByItemId:new Map(), renderedItemIds:new Set(), renderedHarnessItemIds:new Set(), renderedItemBodyByKey:new Map(), itemKindsByItemId:new Map(),
pendingApprovals:new Map(), answeredApprovals:new Map(), answeredApprovalsById:new Map(), renderedApprovalStateKeys:new Set(), pendingServerRequests:new Map(), answeredServerRequests:new Set(),
runningCommands:new Map(), commandBodyElsByItemId:new Map(), commandMsgElsByItemId:new Map(), commandStopRequestedByItemId:new Set(), selectedCommandId:null,
commandPayloadsByItemId:new Map(), endedCommandsByItemId:new Map(),
toolPayloadsByItemId:new Map(), toolBodyElsByItemId:new Map(),
activeTaskGroup:null, taskGroupSeq:0, taskItemSeq:0, taskGroupsById:new Map(), taskGroupsByItemId:new Map(),
expandedTaskGroups:new Set(), manuallyToggledTaskGroups:new Set(), expandedTaskDetails:new Map(),
linkifyCache:new Map(), markdownCache:new Map(), codePath:null, codeLine:null, codeOverlaySource:null, outputOverlay:null, activeTurn:false, interruptPending:false, compactPending:false,
awaitingInitialThreadState:false, awaitingThreadResync:false, awaitingIncrementalResync:false, resyncStickBottom:false, contextWindow:0, contextUsed:null, permissionPreset:"ask_first", currentModel:null,
pendingLiveSnapshotReconcile:false,
diffOverlayText:null,
gitStatus:null, gitLoading:false, gitError:null, gitRequestSeq:0,
gitExpanded:false, gitRepoByProject:new Map(), gitResizeTimer:null, gitBodyHtml:null, gitDiffPending:false, gitRefreshTimer:null,
mcpServers:[], mcpCapabilities:{ status:false, reload:false, oauth_login:false }, mcpLoading:false, mcpError:null, expandedMcps:new Set(),
threadReadOnly:false, readOnlyProvider:null, readOnlyMessage:null,
pickerTypeahead:"", pickerTypeaheadTimer:null, pickerSelectedRow:null,
currentPlan:null, planExpanded:localStorage.getItem("giskard.planExpanded")==="1",
threadActivity:new Map(), pendingWaitingFocus:null, notifiedRequests:new Map(), bootstrapNotifiedRequests:new Set(), waitingNotifications:new Map(), browserDiagnostics:[],
subagentImports:new Map(), projectThreads:new Map(), threadIndex:new Map(),
lastNotificationPromptNoticeAt:0, swRegistration:null, pendingAttachments:[],
attachmentGeneration:0, pendingAttachmentOperations:new Map(),
collapsedProjects:new Set(loadCollapsedProjects()), pendingRemoveProject:null,
pendingRemoveThread:null, removeThreadRequestSeq:0, projectDirs:{}
};
let attachmentIngestQueue = Promise.resolve();
const activeAttachmentReaders = new Set();
// The inline transcript row shows only a compact preview of a command's/tool's output — the most
// recent lines (its live "progress"), capped to whichever of these limits is hit first. The full
// text is always one click away in the output overlay.
const INLINE_PREVIEW_LINES = 7;
const INLINE_PREVIEW_BYTES = 2 * 1024;
const THREAD_TITLE_MAX = 120;
const EFFORT_OPTIONS = [
{ value:"minimal", label:"Minimal" },
{ value:"low", label:"Low" },
{ value:"medium", label:"Medium" },
{ value:"high", label:"High" },
{ value:"xhigh", label:"Extra High" }
];
setInterval(updateRunningCommandDurations, 1000);
async function api(method, path, body, options) {
const opts = { method, headers:{} };
const timeoutMs = options && Number(options.timeoutMs) > 0 ? Number(options.timeoutMs) : 0;
let timeoutId = null;
let timedOut = false;
let timeoutPromise = null;
if (body !== undefined) { opts.headers["Content-Type"]="application/json"; opts.body=JSON.stringify(body); }
if (timeoutMs && typeof AbortController === "function") {
const controller = new AbortController();
opts.signal = controller.signal;
timeoutId = setTimeout(() => {
timedOut = true;
controller.abort();
}, timeoutMs);
} else if (timeoutMs) {
timeoutPromise = new Promise((_, reject) => {
timeoutId = setTimeout(() => {
timedOut = true;
reject(new Error(`Request timed out after ${Math.round(timeoutMs / 1000)} seconds.`));
}, timeoutMs);
});
}
try {
const fetchPromise = fetch(path, opts);
if (timeoutPromise) fetchPromise.catch(() => {});
const r = timeoutPromise ? await Promise.race([fetchPromise, timeoutPromise]) : await fetchPromise;
if (!r.ok) {
const err = new Error((await r.text()) || `HTTP ${r.status}`);
err.status = r.status;
throw err;
}
const ct = r.headers.get("content-type")||"";
return ct.includes("json") ? r.json() : r.text();
} catch (e) {
if (timedOut) throw new Error(`Request timed out after ${Math.round(timeoutMs / 1000)} seconds.`);
throw e;
} finally {
if (timeoutId !== null) clearTimeout(timeoutId);
}
}
function apiFailureMessage(e) {
const msg = e && e.message ? e.message : String(e);
if (e && e.status === 401) {
return "401 unauthorized. Log in again. If you are using plain HTTP, set server.secure_cookies = false and restart Giskard.";
}
if (msg === "Failed to fetch" || e instanceof TypeError) {
return `${msg}. The browser could not reach Giskard for this API request. Check that the server is still running and that you are using the same URL you logged in with.`;
}
return msg;
}
/* ---------- auth ---------- */
$("loginForm").onsubmit = async (e) => {
e.preventDefault();
try {
const res = await api("POST","/api/login",{ password:$("pw").value });
if (res && res.ok === false) { $("loginErr").textContent="Wrong password."; return; }
startApp();
} catch (err) { $("loginErr").textContent = "Login failed: "+err.message; }
};
async function startApp() {
$("login").style.display="none";
$("app").classList.add("open");
initServiceWorkerNotifications();
initNotificationSettings();
try { state.globalModels = (await api("GET","/api/models")).models || []; } catch { state.globalModels=[]; }
renderModelSelect();
await loadProjects();
refreshModels(); // background: merge in any provider /v1/models discovery (§8.3)
}
// The global (no-project) model list: configured models merged with each `model_listing`
// provider's /v1/models discovery. Used for the startup baseline and the new-project modal. Once a
// project is open its per-project list (with harness names) is authoritative, so this does not
// clobber it. Best-effort; on failure the current list stays.
let _refreshingModels = false;
async function refreshModels(opts) {
opts = opts || {};
if (_refreshingModels) return;
_refreshingModels = true;
const btn = $("refreshModels"); if (btn) btn.disabled = true;
try {
const res = await api("POST","/api/models/refresh");
if (res && Array.isArray(res.models)) {
state.globalModels = res.models;
populateModalModels();
}
// Surface per-provider discovery failures (e.g. a 401 from a misconfigured api_key) so they
// aren't silent. Suppressed on the modal-open auto-refresh to avoid duplicate toasts.
if (opts.announce !== false && res && Array.isArray(res.warnings)) {
for (const w of res.warnings) notice(`Model discovery — ${w.source}: ${w.message}`, "warning");
}
} catch (e) {
notice("Could not refresh models: "+e.message, "warning");
} finally {
_refreshingModels = false;
if (btn) btn.disabled = false;
}
}
// The per-project model list is authoritative when a project is open: configured models + each
// provider's /v1/models discovery + the project harness's (Codex) friendly names, all resolved
// server-side. Loaded once per project (not per thread switch — the list is the same across a
// project's threads) unless opts.force is set (the "Reload models" button). opts.announce surfaces
// discovery warnings. Best-effort; on failure the current list stays. Guards against a stale
// project's response landing after a project switch.
let _loadingProjectModels = false;
let _pendingProjectModelLoad = null;
function projectModelCatalogReady() {
return !!state.projectId &&
state.modelsProject === state.projectId &&
state.modelsLoadingProject !== state.projectId;
}
function prepareProjectModelCatalog(pid) {
if (state.modelsProject === pid) return;
state.models = [];
state.modelsProject = null;
closeModelPicker();
renderModelSelect();
updateComposerControls();
}
async function loadProjectModels(pid, opts) {
opts = opts || {};
if (!pid) return;
// A load is in flight: remember the latest requested project instead of dropping it, so switching
// A→B while A is loading still fetches B's authoritative list once A settles.
if (_loadingProjectModels) { _pendingProjectModelLoad = { pid, opts }; return; }
if (!opts.force && pid === state.modelsProject) return; // already loaded for this project
_loadingProjectModels = true;
if (pid === state.projectId) {
state.modelsLoadingProject = pid;
updateComposerControls();
}
const btn = $("refreshModels"); if (btn) btn.disabled = true;
try {
const res = await api("GET", `/api/projects/${pid}/models`);
if (res && Array.isArray(res.models) && pid === state.projectId) {
state.models = res.models;
state.modelsProject = pid;
renderModelSelect();
updateModelButton();
}
// Only surface warnings/errors while `pid` is still the active project — a switch mid-request
// must not misattribute the previous project's discovery failures to the new one.
if (opts.announce && res && Array.isArray(res.warnings) && pid === state.projectId) {
for (const w of res.warnings) notice(`Model discovery — ${w.source}: ${w.message}`, "warning");
}
} catch (e) {
// Always surfaced for the active project, unlike the per-source discovery warnings above: those
// are noise outside an explicit reload, but a hard failure leaves the picker with no options at
// all. On a draft that means the project's default model is the only one available, and the
// user cannot pick another — they need to know why rather than find an empty list.
if (pid === state.projectId) {
notice("Could not load this project's models: " + e.message, "warning");
}
} finally {
_loadingProjectModels = false;
if (state.modelsLoadingProject === pid) state.modelsLoadingProject = null;
updateComposerControls();
if (btn) btn.disabled = false;
const pending = _pendingProjectModelLoad;
_pendingProjectModelLoad = null;
if (pending && pending.pid === state.projectId) {
void loadProjectModels(pending.pid, pending.opts);
}
}
}
// Reload re-runs discovery and re-pulls this project's harness names for the current project.
$("refreshModels").onclick = () => loadProjectModels(state.projectId, { force:true, announce:true });
function initNotificationSettings() {
const buttons = notificationPermissionButtons();
if (!buttons.length) {
recordNotificationDiagnostic("init_no_buttons");
return;
}
if (!("Notification" in window)) {
for (const btn of buttons) {
setNotificationButtonState(btn, "Notifications unavailable", true);
}
recordNotificationDiagnostic("init_unsupported", { button_count:buttons.length });
return;
}
refreshNotificationButton();
for (const btn of buttons) btn.onclick = requestNotificationPermission;
recordNotificationDiagnostic("init_ready", { button_count:buttons.length });
}
function notificationPermissionButtons() {
return Array.from(document.querySelectorAll(".notify-permission-btn"));
}
// Register the notification service worker (see sw.js). Required on Chrome for Android, where
// `new Notification()` throws — notifications must be shown via registration.showNotification() and
// their clicks arrive as a postMessage from the worker. Best-effort: a non-secure context (plain
// http over a LAN IP) has no service worker, and we fall back to the Notification constructor.
function initServiceWorkerNotifications() {
if (!("serviceWorker" in navigator)) {
recordNotificationDiagnostic("sw_unsupported");
return;
}
navigator.serviceWorker.addEventListener("message", (event) => {
const data = event && event.data;
if (data && data.type === "giskard-notification-click") {
handleNotificationClick(data.notification || {});
}
});
navigator.serviceWorker.register("/sw.js").then((reg) => {
state.swRegistration = reg;
recordNotificationDiagnostic("sw_registered", { scope: reg && reg.scope });
}).catch((e) => {
recordNotificationDiagnostic("sw_register_failed", { error: e && e.message ? e.message : String(e) });
});
}
// The service-worker registration once it can show notifications, or null to fall back to the
// Notification constructor. Waits briefly for an in-flight registration so the first notification
// after startup isn't lost to the race.
async function notificationRegistration() {
if (state.swRegistration && state.swRegistration.active) return state.swRegistration;
if (!("serviceWorker" in navigator)) return null;
try {
const reg = await Promise.race([
navigator.serviceWorker.ready,
new Promise((resolve) => setTimeout(() => resolve(null), 1500)),
]);
if (reg && typeof reg.showNotification === "function") {
state.swRegistration = reg;
return reg;
}
} catch {}
return null;
}
// A notification was clicked — delivered by the service worker as a postMessage, or by the desktop
// Notification's onclick. The click jumps to whatever the thread is waiting on — an approval card
// or a server-request card.
function handleNotificationClick(data) {
if (data && data.threadId && data.requestId) {
recordNotificationDiagnostic("waiting_notify_clicked", {
tid: data.threadId,
request_id: data.requestId
});
closeWaitingNotification(data.threadId, data.requestId);
focusWaitingRequest(data.threadId, data.requestId);
}
}
async function requestNotificationPermission() {
recordNotificationDiagnostic("permission_request_click");
if (!("Notification" in window)) {
recordNotificationDiagnostic("permission_request_unsupported");
return;
}
if (Notification.permission === "granted") {
recordNotificationDiagnostic("permission_request_already_granted");
return;
}
if (!window.isSecureContext) {
recordNotificationDiagnostic("permission_request_insecure_context");
notice("Browser notifications require HTTPS or localhost.", "warning");
return;
}
try {
const permission = await Notification.requestPermission();
recordNotificationDiagnostic("permission_request_resolved", { permission });
} catch (e) {
recordNotificationDiagnostic("permission_request_failed", { error: e && e.message ? e.message : String(e) });
notice("Notification permission request failed: " + e.message, "warning");
}
refreshNotificationButton();
}
function setNotificationButtonState(btn, label, disabled) {
if (!btn) return;
if (btn.id === "notifyTopBtn") {
btn.textContent = "!";
btn.title = label;
btn.setAttribute("aria-label", label);
btn.hidden = label === "Notifications enabled" || label === "Notifications unavailable";
} else {
btn.textContent = label;
btn.title = label;
}
btn.disabled = !!disabled;
}
function refreshNotificationButton() {
const buttons = notificationPermissionButtons();
if (!buttons.length || !("Notification" in window)) return;
let label = "Enable notifications";
let disabled = false;
if (!window.isSecureContext) {
label = "Notifications require HTTPS or localhost";
disabled = true;
} else if (Notification.permission === "granted") {
label = "Notifications enabled";
disabled = true;
} else if (Notification.permission === "denied") {
label = "Notifications blocked by browser";
disabled = true;
}
for (const btn of buttons) {
setNotificationButtonState(btn, label, disabled);
}
recordNotificationDiagnostic("permission_button_refreshed", { label, disabled, button_count:buttons.length });
}
function notificationPermissionState() {
if (!("Notification" in window)) return "unsupported";
return Notification.permission;
}
function browserDiagnosticsSnapshot() {
const diagnostics = state.browserDiagnostics.slice();
return {
version: BROWSER_DIAGNOSTIC_VERSION,
permission: notificationPermissionState(),
secure_context: !!window.isSecureContext,
visibility: document.visibilityState,
focused: document.hasFocus ? document.hasFocus() : null,
thread_id: state.threadId || null,
ws_status: state.wsStatus,
notified_count: state.notifiedRequests.size,
dedup_window_ms: NOTIFICATION_DEDUP_MS,
button_count: notificationPermissionButtons().length,
last_waiting_notification: lastNotificationDiagnostic(isWaitingNotificationDiagnostic),
recent_waiting_notifications: recentNotificationDiagnostics(isWaitingNotificationDiagnostic, 6),
diagnostics
};
}
function notificationDebugSnapshot() {
return browserDiagnosticsSnapshot();
}
function isWaitingNotificationDiagnostic(entry) {
const reason = entry && entry.reason ? entry.reason : "";
const detail = entry && entry.detail ? entry.detail : {};
return reason.startsWith("waiting_notify_") ||
(reason.startsWith("browser_notification_") && detail.kind === "waiting_request");
}
function lastNotificationDiagnostic(predicate) {
for (let i = state.browserDiagnostics.length - 1; i >= 0; i--) {
const entry = state.browserDiagnostics[i];
if (!predicate || predicate(entry)) return entry;
}
return null;
}
function recentNotificationDiagnostics(predicate, limit) {
const recent = [];
for (let i = state.browserDiagnostics.length - 1; i >= 0 && recent.length < limit; i--) {
const entry = state.browserDiagnostics[i];
if (!predicate || predicate(entry)) recent.push(entry);
}
return recent.reverse();
}
function recordBrowserDiagnostic(category, reason, detail) {
const entry = {
at: new Date().toISOString(),
category: category || "browser",
reason,
detail: detail || {},
permission: notificationPermissionState(),
secure_context: !!window.isSecureContext,
visibility: document.visibilityState,
focused: document.hasFocus ? document.hasFocus() : null,
thread_id: state.threadId || null,
ws_status: state.wsStatus
};
state.browserDiagnostics.push(entry);
if (state.browserDiagnostics.length > BROWSER_DIAGNOSTIC_LIMIT) {
state.browserDiagnostics.splice(0, state.browserDiagnostics.length - BROWSER_DIAGNOSTIC_LIMIT);
}
console.info("[Giskard browser diagnostics]", entry);
renderBrowserDiagnosticsPanel();
}
function recordNotificationDiagnostic(reason, detail) {
recordBrowserDiagnostic("notification", reason, detail);
}
function browserNowMs() {
return (window.performance && typeof window.performance.now === "function")
? window.performance.now()
: Date.now();
}
function elapsedMsSince(startMs) {
return Number.isFinite(startMs) ? Math.max(0, Math.round(browserNowMs() - startMs)) : null;
}
function wsReconnectDiagnostics(ws) {
return ws && ws._giskardReconnectDiagnostics ? ws._giskardReconnectDiagnostics : null;
}
function reconnectDiagnosticBase(metrics) {
if (!metrics) return {};
return {
connect_id: metrics.connectId,
reconnect: !!metrics.reconnect,
reason: metrics.reason || null,
cursor: metrics.cursor || null,
elapsed_ms: elapsedMsSince(metrics.startedAtMs)
};
}
function recordReconnectDiagnostic(ws, reason, detail) {
const metrics = wsReconnectDiagnostics(ws);
if (!metrics) return;
recordBrowserDiagnostic("websocket", reason, {
...reconnectDiagnosticBase(metrics),
...(detail || {})
});
}
function recordReconnectMessageReceived(ws, msgType) {
const metrics = wsReconnectDiagnostics(ws);
if (!metrics) return;
if (metrics.resyncComplete) return;
if (!metrics.firstMessageAtMs) {
metrics.firstMessageAtMs = browserNowMs();
recordReconnectDiagnostic(ws, "ws_resync_first_message", { message_type:msgType });
}
recordReconnectDiagnostic(ws, "ws_resync_message_received", { message_type:msgType });
}
function recordReconnectMessageRendered(ws, msgType, startedAtMs, msg) {
const metrics = wsReconnectDiagnostics(ws);
if (!metrics) return;
if (metrics.resyncComplete) return;
const detail = {
message_type:msgType,
duration_ms: elapsedMsSince(startedAtMs)
};
if (msg && Array.isArray(msg.turns)) detail.turn_count = msg.turns.length;
if (msgType === "live_turn_snapshot" && msg) {
detail.accumulated_events = Array.isArray(msg.accumulated) ? msg.accumulated.length : 0;
}
if (msgType === "running_tasks" && msg) {
detail.task_count = Array.isArray(msg.tasks) ? msg.tasks.length : 0;
}
recordReconnectDiagnostic(ws, "ws_resync_message_rendered", detail);
}
function reconnectResyncComplete(metrics, msgType) {
if (!metrics) return false;
if (metrics.subscribeMode === "incremental") return msgType === "running_tasks";
if (metrics.subscribeMode === "full") return msgType === "history_page";
return false;
}
function showBrowserDiagnostics() {
const snapshot = browserDiagnosticsSnapshot();
console.info("[Giskard browser diagnostics] snapshot", snapshot);
if (console.table) console.table(snapshot.diagnostics);
renderBrowserDiagnosticsPanel(snapshot, true);
}
function renderBrowserDiagnosticsPanel(snapshot, reveal) {
const panel = $("browserDiagnosticsPanel");
if (!panel) return;
const log = $("browserDiagnosticsLog");
if (!log) return;
snapshot = snapshot || browserDiagnosticsSnapshot();
const last = snapshot.diagnostics[snapshot.diagnostics.length - 1];
const lastWaiting = snapshot.last_waiting_notification;
const waitingDetail = lastWaiting && lastWaiting.detail ? lastWaiting.detail : {};
const lines = [
`version: ${snapshot.version}`,
`permission: ${snapshot.permission}`,
`secure: ${snapshot.secure_context}`,
`visibility: ${snapshot.visibility}`,
`focused: ${snapshot.focused}`,
`thread: ${snapshot.thread_id || "none"}`,
`ws: ${snapshot.ws_status}`,
`dedupMs: ${snapshot.dedup_window_ms}`,
`lastRequest: ${lastWaiting ? lastWaiting.reason : "none"}`,
`requestSource: ${waitingDetail.source || "none"}`,
`requestId: ${waitingDetail.request_id || "none"}`,
`last: ${last ? last.reason : "none"}`
];
const recent = snapshot.recent_waiting_notifications || [];
if (recent.length) {
lines.push("recentRequests:");
for (const entry of recent) {
const detail = entry.detail || {};
const suffix = detail.age_ms !== undefined ? ` age=${detail.age_ms}ms` : "";
lines.push(`- ${entry.reason} source=${detail.source || "none"} id=${detail.request_id || "none"} visible=${entry.visibility} focused=${entry.focused}${suffix}`);
}
}
const latest = snapshot.diagnostics.slice(-20);
if (latest.length) {
lines.push("recentBrowserEvents:");
for (const entry of latest) {
const detail = entry.detail || {};
const fields = [];
if (detail.source) fields.push(`source=${detail.source}`);
if (detail.reason) fields.push(`reason=${detail.reason}`);
if (detail.request_id !== undefined && detail.request_id !== null) fields.push(`request=${detail.request_id}`);
if (detail.status) fields.push(`status=${detail.status}`);
if (detail.mode) fields.push(`mode=${detail.mode}`);
if (detail.message_type) fields.push(`message=${detail.message_type}`);
if (detail.elapsed_ms !== undefined && detail.elapsed_ms !== null) fields.push(`elapsed=${detail.elapsed_ms}ms`);
if (detail.duration_ms !== undefined && detail.duration_ms !== null) fields.push(`duration=${detail.duration_ms}ms`);
if (detail.backgrounded !== undefined && detail.backgrounded !== null) fields.push(`backgrounded=${detail.backgrounded}`);
if (detail.backgrounded_ms !== undefined && detail.backgrounded_ms !== null) fields.push(`backgrounded=${detail.backgrounded_ms}ms`);
if (detail.timeout_ms !== undefined && detail.timeout_ms !== null) fields.push(`timeout=${detail.timeout_ms}ms`);
if (detail.turn_count !== undefined && detail.turn_count !== null) fields.push(`turns=${detail.turn_count}`);
if (detail.accumulated_events !== undefined && detail.accumulated_events !== null) fields.push(`events=${detail.accumulated_events}`);
if (detail.task_count !== undefined && detail.task_count !== null) fields.push(`tasks=${detail.task_count}`);
if (detail.error) fields.push(`error=${detail.error}`);
lines.push(`- ${entry.at} ${entry.category}:${entry.reason} visible=${entry.visibility} focused=${entry.focused}${fields.length ? " " + fields.join(" ") : ""}`);
}
}
log.textContent = lines.join("\n");
if (reveal || !panel.hidden) panel.hidden = false;
}
async function copyBrowserDiagnostics() {
const snapshot = browserDiagnosticsSnapshot();
const text = JSON.stringify(snapshot, null, 2);
try {
await navigator.clipboard.writeText(text);
notice("Browser diagnostics copied.", "info");
} catch (e) {
console.info("[Giskard browser diagnostics] copy fallback", text);
notice("Could not copy diagnostics; logged them to the console.", "warning");
}
}
function clearBrowserDiagnostics() {
state.browserDiagnostics = [];
renderBrowserDiagnosticsPanel(browserDiagnosticsSnapshot(), true);
}
window.giskardBrowserDiagnostics = browserDiagnosticsSnapshot;
window.giskardNotificationDebug = notificationDebugSnapshot;
const browserDiagnosticsBtn = $("browserDiagnosticsBtn");
if (browserDiagnosticsBtn) browserDiagnosticsBtn.onclick = showBrowserDiagnostics;
const copyBrowserDiagnosticsBtn = $("copyBrowserDiagnosticsBtn");
if (copyBrowserDiagnosticsBtn) copyBrowserDiagnosticsBtn.onclick = copyBrowserDiagnostics;
const clearBrowserDiagnosticsBtn = $("clearBrowserDiagnosticsBtn");
if (clearBrowserDiagnosticsBtn) clearBrowserDiagnosticsBtn.onclick = clearBrowserDiagnostics;
const testNotificationBtn = $("testNotificationBtn");
if (testNotificationBtn) testNotificationBtn.onclick = sendTestNotification;
async function sendTestNotification() {
if (!("Notification" in window)) {
recordNotificationDiagnostic("test_notify_unsupported");
notice("Browser notifications are unavailable.", "warning");
return;
}
if (Notification.permission !== "granted") {
recordNotificationDiagnostic("test_notify_suppressed_permission");
notice("Notification permission is not granted.", "warning");
return;
}
const tag = `giskard-test-${Date.now()}`;
let result;
try {
result = await showAppNotification("Giskard test notification", {
body: "Browser notification display test.",
tag,
renotify: true,
requireInteraction: true,
data: { test:true }
}, {
kind: "test",
tag
});
} catch (e) {
recordNotificationDiagnostic("test_notify_constructor_failed", {
tag,
error: e && e.message ? e.message : String(e)
});
notice("Test notification failed: " + e.message, "warning");
return;
}
if (result) recordNotificationDiagnostic("test_notify_created", { tag, via: result.via });
}
/* ---------- projects & threads ---------- */
async function loadProjects() {
const { projects } = await api("GET","/api/projects");
const box = $("projects"); box.innerHTML="";
state.projectNames = {}; // id → name, for the mobile "project / thread" breadcrumb
state.projectDirs = {}; // id → workspace root, for display-only relative file-change paths
const pending = [];
for (const p of projects) {
state.projectNames[p.id] = p.name;
state.projectDirs[p.id] = p.dir || "";
const d = document.createElement("div"); d.className="proj";
d.dataset.pid = p.id;
const collapsed = state.collapsedProjects.has(p.id);
d.classList.toggle("collapsed", collapsed);
const name = document.createElement("div"); name.className="name";
const toggle = document.createElement("button");
toggle.type = "button"; toggle.className = "project-toggle";
toggle.setAttribute("aria-label", collapsed ? "Expand project" : "Collapse project");
toggle.setAttribute("aria-expanded", String(!collapsed));
toggle.textContent = collapsed ? ">" : "v";
toggle.title = collapsed ? "Expand project" : "Collapse project";
toggle.onclick = (e) => {
e.stopPropagation();
setProjectCollapsed(p.id, !state.collapsedProjects.has(p.id));
};
const label = document.createElement("button");
label.type = "button"; label.className = "project-name";
label.textContent = p.name; label.title = p.name;
label.onclick = () => setProjectCollapsed(p.id, !state.collapsedProjects.has(p.id));
const add = document.createElement("button"); add.className="project-add"; add.textContent="+";
add.title="New thread";
add.onclick = (e) => {
e.stopPropagation();
setProjectCollapsed(p.id, false);
newThread(p.id);
};
const menuBtn = document.createElement("button");
menuBtn.type = "button"; menuBtn.className = "project-menu-btn";
menuBtn.textContent = "..."; menuBtn.title = "Project actions";
menuBtn.setAttribute("aria-label", "Project actions");
const menu = document.createElement("div"); menu.className = "project-menu"; menu.hidden = true;
const remove = document.createElement("button");
remove.type = "button"; remove.textContent = "Remove project"; remove.className = "danger";
remove.onclick = (e) => {
e.stopPropagation();
closeThreadMenus();
openRemoveProjectModal(p);
};
menu.append(remove);
menuBtn.onclick = (e) => {
e.stopPropagation();
const wasHidden = menu.hidden;
closeThreadMenus();
menu.hidden = !wasHidden;
};
name.append(toggle, label, add, menuBtn, menu); d.append(name);
const threads = document.createElement("div");
threads.id = "threads-"+p.id; threads.className = "project-threads";
threads.hidden = collapsed;
d.append(threads);
box.append(d);
pending.push(loadThreads(p.id));
}
await Promise.all(pending);
restoreLastThread();
}
// Reopen the thread that was last active in this browser (H: persisted client-side, not on the
// server). Silently ignored if it no longer exists (e.g. deleted since).
function restoreLastThread() {
if (state.threadId) return; // already viewing a thread
let last;
try { last = JSON.parse(localStorage.getItem("giskard.lastThread") || "null"); } catch { last = null; }
if (!last || !last.pid || !last.tid) return;
const el = document.querySelector(`.thread[data-tid="${last.tid}"]`);
const meta = knownProjectThreads(last.pid).find(t => String(t.id) === String(last.tid));
if (!meta) { localStorage.removeItem("giskard.lastThread"); return; }
openThread(last.pid, last.tid, el ? currentThreadTitle(el) : (meta.title || "Thread"), { silent:true });
}
async function loadThreads(pid) {
const box = $("threads-"+pid); if (!box) return false;
try {
const { threads } = await api("GET",`/api/projects/${pid}/threads`);
rememberProjectThreads(pid, threads);
box.innerHTML="";
appendThreadRows(box, pid, threads.filter(t => !t.archived && !isManagedSubagentThread(t, threads)));
const archived = threads.filter(t => t.archived && !isManagedSubagentThread(t, threads));
if (archived.length) {
const label = document.createElement("div");
label.className = "thread-section-label";
label.textContent = "Archived";
box.append(label);
appendThreadRows(box, pid, archived);
}
// Rebuilding the rows discards the selection highlight with the old DOM. Callers that reload as
// part of opening a thread re-derive it themselves, but a reload triggered by anything else
// (say, catching up on a sub-agent the server just materialized) would otherwise leave the list
// with no visibly selected thread.
syncActiveThreadHighlight();
return true;
} catch {
return false;
}
}
function rememberProjectThreads(pid, threads) {
if (!pid || !Array.isArray(threads)) return;
const projectId = String(pid);
const normalized = threads.map(t => Object.assign({}, t, {
id:String(t.id),
parent_thread_id:t.parent_thread_id ? String(t.parent_thread_id) : null,
spawned_by_turn_id:t.spawned_by_turn_id ? String(t.spawned_by_turn_id) : null
}));
state.projectThreads.set(projectId, normalized);
reindexProjectThreads(projectId, normalized);
// Link results are browser-local accelerators only. Discard them when the authoritative thread
// list reloads; a later click resolves the trusted item coordinates idempotently on the server.
const projectPrefix = `${projectId}:`;
for (const key of Array.from(state.subagentImports.keys())) {
if (key.startsWith(projectPrefix)) state.subagentImports.delete(key);
}
renderParentThreadButton();
renderSubagentsButton();
}
function knownProjectThreads(pid) {
return state.projectThreads.get(String(pid || state.projectId)) || [];
}
// Thread id → { pid, thread }, rebuilt whenever a project's list is remembered. Activity hoisting
// resolves ids constantly — once per activity entry, then once per ancestor hop, then again for
// every sidebar row — so scanning each project's array per lookup is quadratic in thread count for
// a single repaint. The entries hold the same objects as `projectThreads`, so in-place edits (a
// renamed thread) stay visible through both.
function reindexProjectThreads(pid, threads) {
const projectId = String(pid);
for (const [tid, entry] of state.threadIndex) {
if (entry.pid === projectId) state.threadIndex.delete(tid);
}
for (const thread of threads) state.threadIndex.set(String(thread.id), { pid:projectId, thread });
}
function appendThreadRows(box, pid, threads) {
const byParent = new Map();
const ids = new Set(threads.map(t => String(t.id)));
const roots = [];
for (const t of threads) {
const parent = t.parent_thread_id ? String(t.parent_thread_id) : "";
if (parent && ids.has(parent)) {
if (!byParent.has(parent)) byParent.set(parent, []);
byParent.get(parent).push(t);
} else {
roots.push(t);
}
}
const rendered = new Set();
const appendOne = (t) => {
const id = String(t.id);
if (rendered.has(id)) return;
rendered.add(id);
box.append(threadRow(pid, t));
for (const child of byParent.get(id) || []) appendOne(child);
};
for (const t of roots) appendOne(t);
// A corrupted parent cycle has no root and would otherwise vanish; keep every visible thread
// rendered so malformed records stay reachable for repair or deletion.
for (const t of threads) appendOne(t);
}
// Hide only sub-agents whose ownership chain is complete and terminates at a primary root.
// Dangling, malformed, and cyclic metadata stays in the main sidebar as a recovery path.
function isManagedSubagentThread(t, threads) {
if (!t || t.kind !== "subagent" || !t.parent_thread_id) return false;
const byId = new Map((threads || []).map(thread => [String(thread.id), thread]));
const seen = new Set();
let current = t;
while (current) {
const id = String(current.id || "");
if (!id || seen.has(id)) return false;
seen.add(id);
const parentId = current.parent_thread_id ? String(current.parent_thread_id) : "";
// `ThreadKind::Primary` is the serde default and may be omitted from summaries.
if (!parentId) return !current.kind || current.kind === "primary";
if (current.kind !== "subagent") return false;
current = byId.get(parentId);
if (!current) return false;
}
return false;
}
function loadCollapsedProjects() {
try {
const ids = JSON.parse(localStorage.getItem(PROJECT_COLLAPSE_KEY) || "[]");
return Array.isArray(ids) ? ids.filter(Boolean) : [];
} catch {
return [];
}
}
function saveCollapsedProjects() {
try {
localStorage.setItem(PROJECT_COLLAPSE_KEY, JSON.stringify([...state.collapsedProjects]));
} catch {}
}
function setProjectCollapsed(pid, collapsed) {
if (!pid) return;
if (collapsed) state.collapsedProjects.add(pid);
else state.collapsedProjects.delete(pid);
saveCollapsedProjects();
const project = document.querySelector(`.proj[data-pid="${pid}"]`);
if (!project) return;
project.classList.toggle("collapsed", collapsed);
const threads = $("threads-"+pid);
if (threads) threads.hidden = collapsed;
const toggle = project.querySelector(".project-toggle");
if (toggle) {
toggle.textContent = collapsed ? ">" : "v";
toggle.title = collapsed ? "Expand project" : "Collapse project";
toggle.setAttribute("aria-label", toggle.title);
toggle.setAttribute("aria-expanded", String(!collapsed));
}