-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathworkspace.js
More file actions
1564 lines (1412 loc) · 64.1 KB
/
Copy pathworkspace.js
File metadata and controls
1564 lines (1412 loc) · 64.1 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
/**
* workspace.js — full dockview workspace
*
* Phase 2: the entire main area (chat + tools + canvas) lives inside ONE
* dockview instance mounted on #main-dock. The sidebar stays outside.
*
* Default layout
* ──────────────
* [ chat (left, flex-1) ] | [ tool group (right, 440px) ]
* Preview · Diff · Terminal
* Files · Tasks · Plan
* Git · MCP · Context · Shortcuts
*
* Canvas panels split below the tool group on first pin, then tab in.
*
* Bridge API (window.Workspace)
* ─────────────────────────────
* .activatePanel(id) — open / focus a tool panel
* .isVisible() — always true in new layout
* .pinArtifact(title, html, sandbox) — pin a Canvas iframe panel
*/
(function () {
'use strict';
const DV = window['dockview-core'];
if (!DV) {
console.warn('[workspace] dockview-core not loaded — workspace disabled');
window.Workspace = { activatePanel() {}, isVisible() { return true; }, pinArtifact() {} };
return;
}
/* ── Workspace persistence ───────────────────────────────────────── */
const WS_STORE_KEY = 'ccmod.workspaces';
const WS_ACTIVE_KEY = 'ccmod.activeWorkspace';
let _wsList = [];
let _wsActiveId = null;
let _wsSaveTimer = null;
// True between a switch/create/delete and the reload it triggers. The
// beforeunload flush checks this so it doesn't save the CURRENT (old) dv
// layout into the NEW active workspace after the active id was already
// re-pointed. Those paths call saveCurrentLayout() explicitly first.
let _wsSwitching = false;
function _wsLoadStore() {
try { _wsList = JSON.parse(localStorage.getItem(WS_STORE_KEY) || '[]'); } catch { _wsList = []; }
_wsActiveId = localStorage.getItem(WS_ACTIVE_KEY) || null;
// Bootstrap: ensure at least one workspace exists
if (!_wsList.length) {
const id = 'ws-' + Date.now();
_wsList = [{ id, name: 'Workspace 1', layout: null, updatedAt: null }];
_wsActiveId = id;
_wsWriteStore();
}
if (!_wsActiveId || !_wsList.find(w => w.id === _wsActiveId)) {
_wsActiveId = _wsList[0].id;
localStorage.setItem(WS_ACTIVE_KEY, _wsActiveId);
}
}
function _wsWriteStore() {
localStorage.setItem(WS_STORE_KEY, JSON.stringify(_wsList));
localStorage.setItem(WS_ACTIVE_KEY, _wsActiveId);
}
function _wsGetActive() {
return _wsList.find(w => w.id === _wsActiveId) || null;
}
// ── Strip non-restorable panels before saving (Phase 23 rewrite) ─────────
// PTYs can't survive a reload, so terminal panels must be removed from the
// serialized layout — otherwise they restore as blank fresh terminals (and
// Claude terminals re-launch `claude` on every start).
//
// The OLD version targeted node.data.panels / node.children — fields that
// DON'T EXIST in dockview's serialization, so it was a silent no-op for
// years. dockview's real shape (dockview-core SerializedDockview):
// layout = { grid: { root, width, height, orientation }, panels: {id:{}}, activeGroup, floatingGroups?, popoutGroups? }
// grid node = { type:'leaf'|'branch', data, size?, visible? }
// leaf: data = { views: string[], activeView?, id }
// branch: data = node[] (child nodes)
function _isEphemeralPanelId(id) {
// Terminals only. Artifacts persist via artifactHtml; tool panels rebuild
// from their id; split-chat is left alone (separate concern).
return typeof id === 'string' && id.startsWith('term-');
}
// Recursively prune ephemeral panels from a grid node.
// Returns the cleaned node, or null if it became empty (caller drops it).
function _pruneGridNode(node) {
if (!node || typeof node !== 'object') return node;
if (node.type === 'leaf') {
const d = node.data || {};
const views = Array.isArray(d.views) ? d.views.filter(v => !_isEphemeralPanelId(v)) : [];
if (!views.length) return null; // empty group → drop
const activeView = views.includes(d.activeView) ? d.activeView : views[views.length - 1];
return { ...node, data: { ...d, views, activeView } };
}
if (node.type === 'branch') {
const kids = (Array.isArray(node.data) ? node.data : []).map(_pruneGridNode).filter(Boolean);
if (!kids.length) return null; // empty branch → drop
return { ...node, data: kids }; // single-child branch is valid in dockview
}
return node;
}
// Strip ephemeral panels from a full serialized layout (operates on the
// toJSON() copy, which is fresh — no risk of mutating live dockview state).
function _stripEphemeralPanels(layout) {
if (!layout || typeof layout !== 'object') return layout;
// 1. top-level panel definitions map
if (layout.panels && typeof layout.panels === 'object') {
for (const id of Object.keys(layout.panels)) {
if (_isEphemeralPanelId(id)) delete layout.panels[id];
}
}
// 2. grid tree — dockview REQUIRES the root to be a BRANCH. If pruning
// removed everything (all-ephemeral) or reduced the root to a bare leaf,
// null the grid so saveCurrentLayout persists no layout and restore
// cleanly builds the default — instead of dockview throwing
// "root must be of type branch" on fromJSON and losing the layout.
if (layout.grid && layout.grid.root) {
const root = _pruneGridNode(layout.grid.root);
if (root && root.type === 'branch') layout.grid.root = root;
else layout.grid = null;
}
// 3. floating / popout groups that hold ONLY terminals
for (const k of ['floatingGroups', 'popoutGroups']) {
if (Array.isArray(layout[k])) {
layout[k] = layout[k].filter(g => {
const views = g?.data?.views || g?.views;
if (!Array.isArray(views)) return true;
return views.some(v => !_isEphemeralPanelId(v));
});
}
}
return layout;
}
function saveCurrentLayout() {
if (!dv) return;
try {
const layout = dv.toJSON(); // fresh serialized copy — safe to mutate
_stripEphemeralPanels(layout);
const ws = _wsGetActive();
// grid===null means nothing restorable survived the strip — persist null
// so restore builds the default cleanly (no invalid-layout throw).
if (ws) { ws.layout = layout.grid ? layout : null; ws.updatedAt = Date.now(); _wsWriteStore(); }
} catch (e) { console.warn('[workspace] saveCurrentLayout failed:', e); }
}
function _wsScheduleSave() {
if (_wsSaveTimer) clearTimeout(_wsSaveTimer);
_wsSaveTimer = setTimeout(saveCurrentLayout, 600);
}
function wsCreate(name) {
saveCurrentLayout();
const id = 'ws-' + Date.now();
_wsList.push({ id, name, layout: null, updatedAt: null });
_wsActiveId = id;
_wsWriteStore();
_wsSwitching = true;
window.location.reload();
}
function wsSwitch(id) {
if (id === _wsActiveId) return;
saveCurrentLayout();
_wsActiveId = id;
_wsWriteStore();
_wsSwitching = true;
window.location.reload();
}
function wsRename(id, name) {
const ws = _wsList.find(w => w.id === id);
if (ws) { ws.name = name.trim() || ws.name; _wsWriteStore(); }
}
function wsDelete(id) {
if (_wsList.length <= 1) return; // can't delete last
const wasActive = id === _wsActiveId;
_wsList = _wsList.filter(w => w.id !== id);
if (wasActive) {
_wsActiveId = _wsList[0].id;
_wsWriteStore();
_wsSwitching = true;
window.location.reload();
} else {
_wsWriteStore();
}
}
function wsGetList() { return _wsList.slice(); }
function wsGetActiveId() { return _wsActiveId; }
/* ── Panel catalogue ─────────────────────────────────────────────── */
const PANEL_TITLES = {
chat: 'Chat',
apercu: 'Preview',
diff: 'Diff',
terminal: 'Terminal',
fichiers: 'Files',
taches: 'Tasks',
plan: 'Plan',
notes: 'Notes',
skills: 'Skills',
shortcuts: 'Shortcuts',
mcp: 'MCP',
git: 'Git',
github: 'GitHub',
screenshots: 'Screenshots',
context: 'Context',
};
// Ordered list of tool panels that appear in the right group
const TOOL_IDS = [
'apercu', 'diff', 'terminal', 'fichiers',
'taches', 'plan', 'notes', 'skills', 'git', 'github',
'screenshots', 'browser', 'mcp', 'context', 'shortcuts',
];
let dv = null;
let _artifactSeq = 0;
let _chatReadding = false; // re-entrancy guard for the chat-never-lost re-add
// ── Bug C fix: queue openTerminal calls that arrive before dv is ready ───
const _pendingTerminals = [];
// id → panel's own .right-panel__body div (for app.js compatibility)
const panelBodies = {};
// Suppress re-entrant _dvTabChange while workspace is activating a panel
let _suppressTabChange = false;
/* ── Helpers ─────────────────────────────────────────────────────── */
function assignBodyId(id) {
const target = panelBodies[id];
if (!target) return;
const prev = document.getElementById('right-panel-body');
if (prev && prev !== target) prev.removeAttribute('id');
target.id = 'right-panel-body';
}
// Return the id of the first canvas-* panel that exists, or null
function firstCanvasId() {
if (!dv) return null;
for (const p of dv.panels) {
if (p.id.startsWith('canvas-')) return p.id;
}
return null;
}
/* ── Chat component ──────────────────────────────────────────────── */
// Adopts the hidden #chat-slot content (chat-scroll + composer + ctx-strip)
// so all existing IDs that app.js relies on remain reachable.
function makeChatComponent() {
const wrap = document.createElement('div');
wrap.className = 'dv-chat-wrap';
return {
element: wrap,
init() {
// Adopt the live chat DOM (chat-scroll + composer + ctx-strip) into this
// panel. It lives in a hidden #chat-slot holder between mounts — moving
// (never destroying) the nodes keeps every app.js reference + listener +
// scroll/history state intact across close/reopen.
const slot = document.getElementById('chat-slot');
if (slot) {
while (slot.firstChild) wrap.appendChild(slot.firstChild);
// Keep the holder hidden but PRESENT (was previously .remove()'d, which
// made a reopened chat blank — there was nothing left to re-adopt).
slot.style.display = 'none';
}
// The floating toggle buttons are gone in the new layout
wrap.querySelector('.right-panel-toggle-wrap')?.remove();
},
// Closing the Chat tab must NOT destroy the chat DOM — stash it back into
// the hidden #chat-slot holder so activatePanel('chat') can re-adopt it.
dispose() {
let slot = document.getElementById('chat-slot');
if (!slot) {
slot = document.createElement('div');
slot.id = 'chat-slot';
slot.style.display = 'none';
document.body.appendChild(slot);
}
while (wrap.firstChild) slot.appendChild(wrap.firstChild);
},
};
}
/* ── Split-chat component ────────────────────────────────────────── */
// Each instance is an independent SplitChat — its own session, messages, stream.
let _splitSeq = 0;
function makeSplitChatComponent(options) {
const wrap = document.createElement('div');
wrap.style.cssText = 'width:100%;height:100%;overflow:hidden;';
const params = options?.params || {};
let _sc = null;
return {
element: wrap,
init(panelApi) {
if (window.SplitChat) {
_sc = new window.SplitChat(wrap, panelApi, params);
} else {
wrap.textContent = 'SplitChat not loaded';
}
},
// Called by dockview when panel is closed
dispose() { _sc?.destroy(); },
};
}
function openSplitChat(params) {
if (!dv) return;
_splitSeq++;
const id = 'split-chat-' + _splitSeq;
const ref = dv.activePanel?.id || 'chat';
const title = params?.sessionTitle || ('Agent ' + _splitSeq);
dv.addPanel({
id,
component: 'split-chat',
title,
position: { direction: 'right', referencePanel: ref },
size: 480,
params: params || {},
});
try { dv.getPanel(id)?.api.setActive(); } catch (_) {}
}
/* ── Session drag-drop onto dockview ─────────────────────────────── */
function _initDockDrop() {
const dockEl = document.getElementById('main-dock');
if (!dockEl) return;
// Overlay lives inside #main-dock so inset:0 fills it exactly
const overlay = document.createElement('div');
overlay.id = 'dock-drop-overlay';
overlay.innerHTML = `
<div class="ddo-inner">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
<polyline points="8 17 12 21 16 17"></polyline>
<line x1="12" y1="3" x2="12" y2="21"></line>
</svg>
<span>Drop to open as agent panel</span>
</div>`;
dockEl.appendChild(overlay);
let _dragSession = null;
// ── KEY FIX: show overlay IMMEDIATELY on dragstart so it sits on top
// of dockview inner panels which would otherwise swallow drag events.
document.addEventListener('dragstart', e => {
const row = e.target.closest('[data-session-id]');
if (!row) { _dragSession = null; return; }
_dragSession = {
id: row.dataset.sessionId,
title: (row.querySelector('.session__title')?.textContent || row.dataset.sessionId).trim(),
};
// Show overlay immediately — pointer-events:auto means all subsequent
// dragover/drop events hit the overlay, not dockview panels underneath.
overlay.classList.add('is-active');
});
document.addEventListener('dragend', () => {
_dragSession = null;
overlay.classList.remove('is-active', 'is-over');
});
// The overlay is on top, so it receives all drag events directly
overlay.addEventListener('dragover', e => {
if (!_dragSession) return;
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
overlay.classList.add('is-over');
});
overlay.addEventListener('dragleave', e => {
if (!overlay.contains(e.relatedTarget)) {
overlay.classList.remove('is-over');
}
});
overlay.addEventListener('drop', e => {
e.preventDefault();
overlay.classList.remove('is-active', 'is-over');
if (!_dragSession) return;
openSplitChat({
sessionId: _dragSession.id,
sessionTitle: _dragSession.title,
});
_dragSession = null;
});
}
/* ── Artifact (Canvas) component ─────────────────────────────────── */
function makeArtifactComponent(options) {
const wrap = document.createElement('div');
wrap.className = 'dv-artifact-wrap';
// toolbar
const toolbar = document.createElement('div');
toolbar.className = 'artifact-toolbar';
toolbar.innerHTML = `
<span class="artifact-toolbar__badge">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="16 18 22 12 16 6"/><polyline points="8 6 2 12 8 18"/>
</svg>
<span class="artifact-toolbar__name">Canvas</span>
</span>
<span class="artifact-toolbar__id" style="display:none"></span>
<span class="artifact-toolbar__spacer"></span>
<div class="artifact-zoom">
<button class="artifact-toolbar__btn artifact-zoom__btn" data-art="zoom-out" title="Zoom out (−)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
<button class="artifact-zoom__label" data-art="zoom-reset" title="Reset zoom to 100%">100%</button>
<button class="artifact-toolbar__btn artifact-zoom__btn" data-art="zoom-in" title="Zoom in (+)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/></svg>
</button>
</div>
<button class="artifact-toolbar__btn artifact-toolbar__btn--edit" data-art="edit" title="Edit this codeblock" style="display:none">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="artifact-toolbar__btn" data-art="reload" title="Reload">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="23 4 23 10 17 10"/>
<path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/>
</svg>
</button>
<button class="artifact-toolbar__btn" data-art="popout" title="Open in browser">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>
<polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/>
</svg>
</button>
`;
// iframe
const frameWrap = document.createElement('div');
frameWrap.className = 'artifact-frame-wrap';
const frame = document.createElement('iframe');
frame.className = 'artifact-frame';
frame.title = 'Canvas artifact';
frameWrap.appendChild(frame);
wrap.appendChild(toolbar);
wrap.appendChild(frameWrap);
let _cbId = null;
return {
element: wrap,
init(params) {
const p = params.params || {};
frame.setAttribute('sandbox', p.sandbox || 'allow-scripts');
if (p.artifactHtml) frame.srcdoc = p.artifactHtml;
const nameEl = toolbar.querySelector('.artifact-toolbar__name');
const idBadge = toolbar.querySelector('.artifact-toolbar__id');
const editBtn = toolbar.querySelector('[data-art="edit"]');
const zoomLabel = toolbar.querySelector('[data-art="zoom-reset"]');
// ── Zoom state ───────────────────────────────────────────────
let _zoom = 100; // percent
const ZOOM_STEP = 10;
const ZOOM_MIN = 25;
const ZOOM_MAX = 300;
function applyZoom(pct) {
_zoom = Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, pct));
const scale = _zoom / 100;
// CSS transform doesn't affect layout, so the parent won't scroll
// unless we add margins equal to the visual overflow.
// extra = how much larger the visual size is vs the layout box (in %)
const extra = Math.max(0, scale - 1) * 100;
frame.style.transformOrigin = 'top left';
frame.style.transform = scale === 1 ? '' : `scale(${scale})`;
frame.style.width = '100%';
frame.style.height = '100%';
frame.style.marginRight = extra > 0 ? `${extra}%` : '';
frame.style.marginBottom = extra > 0 ? `${extra}%` : '';
if (zoomLabel) zoomLabel.textContent = `${_zoom}%`;
}
if (nameEl && p.artifactTitle) nameEl.textContent = p.artifactTitle;
if (p.cbId) {
_cbId = p.cbId;
if (idBadge) { idBadge.textContent = `codeblock_${_cbId}`; idBadge.style.display = ''; }
if (editBtn) editBtn.style.display = '';
}
// Listen for async cbId assigned after save completes
const onCreated = (e) => {
if (e.detail?.panelId === p.id || (!_cbId && e.detail?.cbId)) {
_cbId = e.detail.cbId;
if (idBadge) { idBadge.textContent = `codeblock_${_cbId}`; idBadge.style.display = ''; }
if (editBtn) editBtn.style.display = '';
}
};
document.addEventListener('codeblock:created', onCreated, { once: true });
toolbar.addEventListener('click', e => {
const btn = e.target.closest('[data-art]');
if (!btn) return;
if (btn.dataset.art === 'zoom-in') { applyZoom(_zoom + ZOOM_STEP); return; }
if (btn.dataset.art === 'zoom-out') { applyZoom(_zoom - ZOOM_STEP); return; }
if (btn.dataset.art === 'zoom-reset') { applyZoom(100); return; }
if (btn.dataset.art === 'reload') {
const src = frame.srcdoc;
frame.srcdoc = '';
requestAnimationFrame(() => { frame.srcdoc = src; });
btn.classList.add('is-spinning');
setTimeout(() => btn.classList.remove('is-spinning'), 600);
}
if (btn.dataset.art === 'popout') {
const blob = new Blob([frame.srcdoc || ''], { type: 'text/html' });
const url = URL.createObjectURL(blob);
window.open(url, '_blank', 'noopener');
setTimeout(() => URL.revokeObjectURL(url), 5000);
}
if (btn.dataset.art === 'edit' && _cbId) {
document.dispatchEvent(new CustomEvent('codeblock:edit-request', {
detail: { cbId: _cbId, title: p.artifactTitle || nameEl?.textContent },
}));
}
});
// Mouse-wheel zoom on the frame (Ctrl + scroll)
wrap.addEventListener('wheel', e => {
if (!e.ctrlKey) return;
e.preventDefault();
applyZoom(_zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP));
}, { passive: false });
},
};
}
/* ── Default layout builder ──────────────────────────────────────── */
function _buildDefaultLayout() {
dv.addPanel({
id: 'chat',
component: 'chat',
title: 'Chat',
params: { id: 'chat' },
});
dv.addPanel({
id: TOOL_IDS[0],
component: 'panel',
title: PANEL_TITLES[TOOL_IDS[0]],
params: { id: TOOL_IDS[0] },
position: { direction: 'right', referencePanel: 'chat' },
size: 440,
});
for (const id of TOOL_IDS.slice(1)) {
dv.addPanel({
id,
component: 'panel',
title: PANEL_TITLES[id],
params: { id },
position: { direction: 'within', referencePanel: TOOL_IDS[0] },
});
}
try { dv.getPanel(TOOL_IDS[0])?.api.setActive(); } catch (_) {}
}
/* ── Terminal instance component ────────────────────────────────── */
// Each call to openTerminal() creates one of these as its own dockview panel.
let _termShellSeq = 0;
let _termClaudeSeq = 0;
const _XTERM_THEME = {
background:'#0e0e10', foreground:'#d4d4da', cursor:'#d97757', cursorAccent:'#0e0e10',
// Windows Terminal / PowerShell-style blue selection highlight.
// selectionBackground is opaque blue; selectionForeground keeps text readable.
selectionBackground:'#264f78', selectionForeground:'#ffffff',
selectionInactiveBackground:'#1f3a55',
black:'#141416', red:'#e06c75', green:'#7ab389', yellow:'#c9a96e',
blue:'#6a86c3', magenta:'#c678dd', cyan:'#56b6c2', white:'#abb2bf',
brightBlack:'#5a5a63', brightRed:'#e06c75', brightGreen:'#98c379',
brightYellow:'#e5c07b', brightBlue:'#61afef', brightMagenta:'#c678dd',
brightCyan:'#56b6c2', brightWhite:'#ffffff',
};
function makeTerminalInstanceComponent(options) {
const wrap = document.createElement('div');
wrap.style.cssText = 'width:100%;height:100%;background:#0e0e10;overflow:hidden;';
let _inst = null;
return {
element: wrap,
async init(panelApi) {
const p = panelApi.params || {};
const isClaude = !!p.isClaude;
const isWorktree = !!p.isWorktree;
const api = window.electronAPI;
if (!api?.terminal) { wrap.textContent = 'No terminal API'; return; }
const Terminal = window.Terminal;
const FitAddon = window.FitAddon?.FitAddon;
if (!Terminal || !FitAddon) { wrap.textContent = 'xterm.js not loaded'; return; }
const term = new Terminal({
theme: _XTERM_THEME,
fontFamily: '"Cascadia Code","Fira Code","JetBrains Mono",Consolas,monospace',
fontSize: 13, lineHeight: 1.4, cursorBlink: true,
cursorInactiveStyle: 'block',
scrollback: 5000, allowProposedApi: true,
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
term.open(wrap);
const result = await api.terminal.create({});
if (!result.ok) {
term.write(`\x1b[31mFailed: ${result.error}\x1b[0m\r\n`);
return;
}
const { termId } = result;
// Drive channel (Phase 26f): if this is an agent terminal, tell main
// which termId backs this role so the Director can inject prompts into it.
if (p.agentRole) {
try { window.electronAPI?.team?.registerAgent?.(p.agentRole, termId); } catch (_) {}
}
// For Claude sessions: detect when the shell prompt first appears, then
// send `claude\r`. This is more reliable than a fixed timeout because
// PowerShell 7 can take 0.5–2s to fully initialize.
let _claudeLaunched = false;
// Build the claude launch command. Agent terminals inject a role system
// prompt via --append-system-prompt. We HARD-STRIP shell-hostile chars
// (" ` $ \ and newlines) so the single typed command can't break out of
// the double-quoted arg, whatever shell the PTY runs (PowerShell/bash).
const agentSystem = typeof p.agentSystem === 'string'
? p.agentSystem.replace(/["`$\\\r\n]/g, ' ').replace(/\s+/g, ' ').trim().slice(0, 1600)
: '';
const _wtFlag = isWorktree ? ' --worktree' : '';
const claudeCmd = agentSystem
? `claude${_wtFlag} --append-system-prompt "${agentSystem}"\r\n`
: (isWorktree ? 'claude --worktree\r\n' : 'claude\r\n');
function _maybeAutoLaunch(chunk) {
if (!isClaude || _claudeLaunched) return;
// Shell prompt patterns: ends with "> " or "$ " (PowerShell / bash)
// Strip ANSI codes before checking
const plain = chunk.replace(/\x1b\[[0-9;]*[A-Za-z]/g, '');
if (/[>$]\s*$/.test(plain)) {
_claudeLaunched = true;
// Small extra delay so the prompt line is fully rendered
setTimeout(() => api.terminal.input(termId, claudeCmd), 120);
}
}
const offData = api.terminal.onData(termId, t => { term.write(t); _maybeAutoLaunch(t); });
const offExit = api.terminal.onExit(termId, code =>
term.write(`\r\n\x1b[2m[process exited ${code}]\x1b[0m\r\n`));
const offInput = term.onData(d => api.terminal.input(termId, d));
// ── Windows Terminal-style keyboard shortcuts ───────────────────────
// Ctrl+Shift+C → copy selection (doesn't conflict with SIGINT)
// Ctrl+Shift+V → paste from clipboard
// Ctrl+Insert → copy selection (alternate)
// Shift+Insert → paste (alternate)
// Bare Ctrl+C also copies WHEN there's a selection (Windows convention) —
// if no selection, Ctrl+C falls through to xterm/PTY and sends SIGINT.
term.attachCustomKeyEventHandler((e) => {
if (e.type !== 'keydown') return true;
const ctrl = e.ctrlKey || e.metaKey;
const shift = e.shiftKey;
const k = e.key;
// Copy
if (
(ctrl && shift && (k === 'C' || k === 'c')) ||
(ctrl && k === 'Insert') ||
(ctrl && !shift && (k === 'C' || k === 'c') && term.hasSelection())
) {
const text = term.getSelection();
if (text) {
navigator.clipboard.writeText(text).catch(() => {});
term.clearSelection();
window.showToast?.('Copied · ' + text.length + ' chars', 'success', 1200);
}
return false; // swallow the chord — don't send to PTY
}
// Paste
if (
(ctrl && shift && (k === 'V' || k === 'v')) ||
(shift && k === 'Insert')
) {
navigator.clipboard.readText().then(text => {
if (text) api.terminal.input(termId, text);
}).catch(() => {});
return false;
}
// Select all — Ctrl+Shift+A is the standard terminal binding
if (ctrl && shift && (k === 'A' || k === 'a')) {
term.selectAll();
return false;
}
return true;
});
// ── Right-click behavior (Windows Terminal / PowerShell style) ──────
// - Selection present → copy + clear + toast (no menu)
// - No selection → paste from clipboard at cursor
wrap.addEventListener('contextmenu', (e) => {
e.preventDefault();
e.stopPropagation();
if (term.hasSelection()) {
const text = term.getSelection();
navigator.clipboard.writeText(text).catch(() => {});
term.clearSelection();
window.showToast?.('Copied · ' + text.length + ' chars', 'success', 1200);
} else {
navigator.clipboard.readText().then(text => {
if (text) api.terminal.input(termId, text);
}).catch(() => {});
}
});
// Wire resize: when xterm reflows, tell the PTY so line-wrapping matches
const ro = new ResizeObserver(() => {
try {
fitAddon.fit();
if (term.cols && term.rows) {
api.terminal.resize?.(termId, term.cols, term.rows);
}
} catch { /**/ }
});
ro.observe(wrap);
fitAddon.fit();
if (term.cols && term.rows) api.terminal.resize?.(termId, term.cols, term.rows);
// Refit when this panel becomes active (tab switch, resize, etc.)
try {
panelApi.onDidActiveChange?.(ev => {
if (ev.isActive) setTimeout(() => { try { fitAddon.fit(); } catch { /**/ } }, 40);
});
} catch { /**/ }
// Fallback: if no prompt detected within 3s, send claude anyway
if (isClaude) setTimeout(() => {
if (!_claudeLaunched) { _claudeLaunched = true; api.terminal.input(termId, claudeCmd); }
}, 3000);
_inst = {
termId, term, fitAddon,
cleanup() {
offData(); offExit(); offInput.dispose(); ro.disconnect();
if (p.agentRole) { try { window.electronAPI?.team?.unregisterAgent?.(p.agentRole, termId); } catch { /**/ } }
try { api.terminal.close(termId); } catch { /**/ }
try { term.dispose(); } catch { /**/ }
},
};
},
dispose() { _inst?.cleanup(); },
};
}
function openTerminal(isClaude = false, opts = {}) {
if (!dv) { _pendingTerminals.push({ isClaude, opts }); return; }
const agent = opts.agent || null;
if (agent) isClaude = true; // agents are always Claude sessions
if (isClaude) _termClaudeSeq++; else _termShellSeq++;
const isWorktree = isClaude && !!opts.worktree;
const label = agent
? (agent.name || agent.role || `Agent ${_termClaudeSeq}`)
: isWorktree ? `Worktree ${_termClaudeSeq}`
: isClaude ? `Claude ${_termClaudeSeq}` : `Shell ${_termShellSeq}`;
const id = `term-${Date.now()}-${_termClaudeSeq + _termShellSeq}`;
// Find a reference panel to dock beside (prefer existing terminal panels)
let refId = null;
for (const p of dv.panels) {
if (p.id.startsWith('term-') || p.id === 'terminal') { refId = p.id; break; }
}
if (!refId) {
// Fall back to right-side tool group (use first TOOL_ID panel that exists)
for (const tid of TOOL_IDS) {
if (dv.getPanel(tid)) { refId = tid; break; }
}
}
dv.addPanel({
id,
component: 'terminal-instance',
title: label,
params: { isClaude, label, isWorktree,
agentSystem: agent?.system, agentRole: agent?.role, agentColor: agent?.color },
position: refId
? { direction: 'within', referencePanel: refId }
: { direction: 'right', referencePanel: 'chat' },
});
// During a staggered team spawn we don't steal focus on every agent.
if (opts.activate !== false) { try { dv.getPanel(id)?.api.setActive(); } catch { /**/ } }
}
/* ── Spawn a ready-to-go team workspace (Phase 26) ───────────────────────── */
// Lays out the shared task board + a Director terminal + one role-injected
// Claude terminal per agent. Terminals dock as tabs in one group (auto-ref);
// the user can tear roles out side-by-side. Spawns are STAGGERED so we don't
// spin up a dozen PTYs + `claude` launches in the same tick.
let _pendingTeam = null;
function spawnTeam(payload) {
if (!dv) { _pendingTeam = payload; return; }
const team = payload || {};
const agents = Array.isArray(team.agents) ? team.agents : [];
try { window.showToast?.(`Spawning ${team.team || 'team'} — ${agents.length} agents…`, 'info', 3500); } catch (_) {}
// 1) Surface the task board (the coordination bus the Director writes to).
try { activatePanel('taches'); } catch (_) {}
// 2) Director terminal first.
if (team.director) {
openTerminal(true, { agent: {
role: 'director',
name: team.director.name || 'Director',
system: team.director.system,
color: team.director.color || '#d97757',
} });
}
// 3) Agents, staggered ~350ms apart; don't steal focus from the Director.
agents.forEach((a, i) => {
setTimeout(() => { try { openTerminal(true, { agent: a, activate: false }); } catch (_) {} }, 350 * (i + 1));
});
return { ok: true, agents: agents.length };
}
/* ── Pre-render all panels after layout restore ─────────────────── */
// Called once after dv.fromJSON() so every visible panel has content,
// not just the globally-active one. Skips panels already rendered.
// ── Render + init a single panel's body ──────────────────────────────
// Used by:
// • _renderAllPanelBodies (post layout-restore — for every panel)
// • The "+ Add panel" context menu (when right-panel sidebar isn't open
// so _dvTabChange would bail). Without this call, dynamically-added
// panels would show their skeleton ("Loading servers…") forever.
//
// No-op when:
// • bodyDiv is missing (panel wasn't found in panelBodies)
// • bodyDiv already has content AND force=false (don't blow away state)
function _renderAndInitPanel(pid, { force = false } = {}) {
if (typeof window.renderPanelContent !== 'function') return;
const bodyDiv = panelBodies[pid];
if (!bodyDiv) return;
if (!force && bodyDiv.children.length > 0) return;
try {
bodyDiv.innerHTML = window.renderPanelContent(pid);
window.renderIcons?.(bodyDiv);
window.wirePlanTabEvents?.(pid, bodyDiv);
requestAnimationFrame(() => {
try {
switch (pid) {
case 'fichiers': window.initFilesPanel?.(); break;
case 'notes': window.initNotesPanel?.(); break;
case 'skills': window.initSkillsDockPanel?.(); break;
case 'taches': window.initTachesPanel?.(); break;
case 'mcp': window.initMcpPanel?.(bodyDiv); break;
case 'git': window.initGitPanel?.(bodyDiv); break;
case 'github': window.initGithubPanel?.(bodyDiv); break;
case 'screenshots': window.initScreenshotsPanel?.(bodyDiv); break;
case 'browser': window.initBrowserPanel?.(bodyDiv); break;
case 'apercu':
window.initApercuScaling?.(bodyDiv);
window.initApercuServer?.(bodyDiv);
break;
// diff, plan, context, shortcuts, terminal — no init needed
// (terminal: per-instance from openTerminal())
}
} catch (e) { console.warn('[workspace] panel init error', pid, e); }
});
} catch (e) { console.warn('[workspace] renderPanelContent error', pid, e); }
}
function _renderAllPanelBodies() {
for (const pid of TOOL_IDS) {
_renderAndInitPanel(pid);
}
}
/* ── Init ────────────────────────────────────────────────────────── */
function init() {
_wsLoadStore(); // bootstrap workspace state before anything else
const el = document.getElementById('main-dock');
if (!el) return;
el.classList.add('dockview-theme-ccmod');
dv = DV.createDockview(el, {
className: 'dockview-theme-ccmod',
// (dv assigned above — _pendingTerminals drained after layout restore below)
createComponent(options) {
if (options.name === 'chat') return makeChatComponent(options);
if (options.name === 'artifact') return makeArtifactComponent(options);
if (options.name === 'split-chat') return makeSplitChatComponent(options);
if (options.name === 'terminal-instance') return makeTerminalInstanceComponent(options);
// Standard tool panel — owns its own .right-panel__body
const panelEl = document.createElement('div');
panelEl.className = 'dv-panel-body';
const bodyDiv = document.createElement('div');
bodyDiv.className = 'right-panel__body';
panelEl.appendChild(bodyDiv);
return {
element: panelEl,
init(params) {
const pid = params.params?.id;
if (pid) {
panelBodies[pid] = bodyDiv;
panelEl.dataset.panelId = pid;
}
},
dispose() {
// When user clicks the tab X, dockview calls this. We need to run
// panel-specific teardown — without this, native overlays (the
// browser's WebContentsView) get orphaned after the HTML panel
// disappears.
const pid = panelEl.dataset.panelId;
try {
if (pid === 'browser' && typeof window._browserPanelCleanup === 'function') {
window._browserPanelCleanup();
}
} catch (_) { /* swallow — dispose must not throw */ }
if (pid) delete panelBodies[pid];
},
};
},
});
/* ── Layout — restore saved or build default ─────────────────── */
const _savedLayout = _wsGetActive()?.layout;
if (_savedLayout) {
try {
dv.fromJSON(_savedLayout);
// After layout restore, non-active panels have empty bodies — pre-render them
// so every panel shows content immediately, not just the globally active one.
setTimeout(_renderAllPanelBodies, 0);
} catch (e) {
console.warn('[workspace] fromJSON failed, falling back to default layout:', e);
_buildDefaultLayout();
}
} else {
_buildDefaultLayout();
}
// Auto-save layout on any panel or size change (debounced 600 ms)
dv.onDidLayoutChange(() => _wsScheduleSave());
dv.onDidAddPanel(() => _wsScheduleSave());
dv.onDidRemovePanel((e) => {
_wsScheduleSave();
// The Chat is the primary panel — never let it be lost. If its X is
// clicked, re-add it (re-adopting the chat DOM that makeChatComponent's
// dispose stashed into #chat-slot). NOTE: dragging the tab to rearrange
// fires a MOVE, not a remove — so this never interferes with placing
// chat-left / browser-right.
if (e?.panel?.id === 'chat' && !_chatReadding) {
_chatReadding = true;
setTimeout(() => {
try { activatePanel('chat'); } catch (_) {}
_chatReadding = false;
try { window.showToast?.('Chat is your main panel — drag its tab to rearrange instead of closing', 'info', 3000); } catch (_) {}
}, 0);
}
});
// ── Flush-on-close (Phase 23) ───────────────────────────────────────────
// The 600ms debounce means a layout change made just before quitting was
// lost — the timer never fired. Flush synchronously on unload / when the
// window is hidden, so the last arrangement always persists. Skip when a
// switch/create/delete is mid-reload (those already saved to the correct
// workspace; saving here would write the old layout into the new ws).
const _flushSave = () => {
if (_wsSwitching) return;
if (_wsSaveTimer) { clearTimeout(_wsSaveTimer); _wsSaveTimer = null; }
saveCurrentLayout();
};
window.addEventListener('beforeunload', _flushSave);
window.addEventListener('pagehide', _flushSave);
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') _flushSave();
});
// ── Bug C fix: drain any openTerminal calls queued before dv was ready ──
if (_pendingTerminals.length) {
setTimeout(() => {