@design.estate/dees-catalog 10.0.0 → 11.1.0

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.
Files changed (26) hide show
  1. package/dist_bundle/bundle.js +914 -780
  2. package/dist_bundle/bundle.js.map +1 -1
  3. package/dist_ts_web/00_commitinfo_data.js +1 -1
  4. package/dist_ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.demo.js +24 -1
  5. package/dist_ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.js +13 -1
  6. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.d.ts +62 -48
  7. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.demo.js +97 -57
  8. package/dist_ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.js +541 -666
  9. package/dist_ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.js +19 -1
  10. package/dist_ts_web/elements/00group-harness/dees-terminal-view/dees-terminal-view.d.ts +45 -6
  11. package/dist_ts_web/elements/00group-harness/dees-terminal-view/dees-terminal-view.js +184 -27
  12. package/dist_ts_web/elements/00group-harness/interfaces.d.ts +59 -20
  13. package/dist_ts_web/services/DeesServiceLibLoader.d.ts +7 -0
  14. package/dist_ts_web/services/DeesServiceLibLoader.js +45 -22
  15. package/package.json +1 -2
  16. package/readme.md +37 -17
  17. package/ts_web/00_commitinfo_data.ts +1 -1
  18. package/ts_web/elements/00group-harness/dees-harness-chat/dees-harness-chat.demo.ts +23 -0
  19. package/ts_web/elements/00group-harness/dees-harness-message-list/dees-harness-message-list.ts +12 -0
  20. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.demo.ts +107 -55
  21. package/ts_web/elements/00group-harness/dees-harness-session-list/dees-harness-session-list.ts +622 -756
  22. package/ts_web/elements/00group-harness/dees-harness-tool-card/dees-harness-tool-card.ts +18 -0
  23. package/ts_web/elements/00group-harness/dees-terminal-view/dees-terminal-view.ts +196 -24
  24. package/ts_web/elements/00group-harness/interfaces.ts +63 -19
  25. package/ts_web/services/DeesServiceLibLoader.ts +150 -102
  26. package/.smartconfig.json +0 -86
@@ -131,7 +131,21 @@ export class DeesHarnessToolCard extends DeesElement {
131
131
  :host {
132
132
  display: block;
133
133
  container-type: inline-size;
134
+ margin-inline: auto;
135
+ max-width: 100%;
134
136
  font-family: var(--dees-font-family);
137
+ transition: max-width var(--dees-transition-default) var(--dees-ease-standard);
138
+ }
139
+
140
+ /*
141
+ * A collapsed card may be held to a narrower measure than its row. The host — not the inner
142
+ * surface — carries the constraint so the card's own inline-size container keeps measuring
143
+ * what is actually rendered. The width comes from the context the card sits in: a transcript
144
+ * row that holds this card alone sets it, a multi-card grid cell leaves it unset, and a
145
+ * direct host falls back to the full row.
146
+ */
147
+ :host([data-collapsed]) {
148
+ max-width: var(--dees-harness-collapsed-tool-width, 100%);
135
149
  }
136
150
 
137
151
  .harness-card.subtaskPreviewCard {
@@ -1006,6 +1020,10 @@ export class DeesHarnessToolCard extends DeesElement {
1006
1020
 
1007
1021
  public updated(changedProperties: Map<PropertyKey, unknown>): void {
1008
1022
  super.updated(changedProperties);
1023
+ // Mirrors the rendered disclosure state onto the host so the width rule can follow it. Set
1024
+ // during the update that produced the render, so a card that mounts collapsed reaches its
1025
+ // first style resolution already constrained and never animates on mount.
1026
+ this.toggleAttribute('data-collapsed', !this.isOpen);
1009
1027
  if (!this.isConnected) return;
1010
1028
  this.terminalScrollHydrationFrame ??= requestAnimationFrame(() => {
1011
1029
  this.terminalScrollHydrationFrame = null;
@@ -29,13 +29,46 @@ export interface ITerminalResizeDetail {
29
29
  cols: number;
30
30
  }
31
31
 
32
+ /**
33
+ * Detail of `terminal-error`: xterm could not be loaded, or the live terminal
34
+ * refused output. The element stays usable and keeps buffering.
35
+ */
36
+ export interface ITerminalErrorDetail {
37
+ error: unknown;
38
+ }
39
+
40
+ /**
41
+ * Upper bound on output buffered while no terminal is live, in bytes.
42
+ * `Uint8Array` chunks count their `byteLength`, strings their UTF-16 memory
43
+ * (two bytes per code unit). Beyond the bound the oldest chunks are dropped,
44
+ * and the flush then emits RIS before the survivors so a truncated prefix is
45
+ * never rendered as if it were contiguous output.
46
+ */
47
+ export const maxPendingWriteBytes = 8 * 1024 * 1024;
48
+
49
+ /**
50
+ * RIS (ESC c), a hard reset carried *inside* the write stream: xterm parses it
51
+ * in order and routes it to the very same `Terminal.reset()` (InputHandler's
52
+ * ESC-c handler calls `fullReset()`, which fires `onRequestReset`, which
53
+ * `Terminal` handles with `reset()`), whereas calling `reset()` directly is
54
+ * documented as out of band with the pending write queue.
55
+ */
56
+ const RIS = '\x1bc';
57
+
58
+ /** Memory cost of one buffered chunk, in bytes. */
59
+ const chunkByteLength = (chunkArg: string | Uint8Array): number =>
60
+ typeof chunkArg === 'string' ? chunkArg.length * 2 : chunkArg.byteLength;
61
+
32
62
  /**
33
63
  * Transport-agnostic xterm.js surface: the host pipes remote PTY output into
34
64
  * `write()` and receives keystrokes via `terminal-input` — no process,
35
65
  * runtime, or socket coupling. `terminal-resize` reports the grid size after
36
- * every refit so the host can negotiate a PTY size. xterm loads through
37
- * DeesServiceLibLoader; CSP-locked hosts can pre-register bundled modules
38
- * with `provideXtermModules()` to avoid the CDN.
66
+ * every refit so the host can negotiate a PTY size. Output that arrives while
67
+ * no terminal is live is buffered up to `maxPendingWriteBytes` and replayed in
68
+ * order on the next connection, so a replay may start before the element is
69
+ * ready and survives being detached. xterm loads through DeesServiceLibLoader;
70
+ * CSP-locked hosts can pre-register bundled modules with
71
+ * `provideXtermModules()` to avoid the CDN.
39
72
  */
40
73
  @customElement('dees-terminal-view')
41
74
  export class DeesTerminalView extends DeesElement {
@@ -54,9 +87,37 @@ export class DeesTerminalView extends DeesElement {
54
87
  private resizeObserver: ResizeObserver | undefined;
55
88
  private inputDisposable: { dispose(): void } | undefined;
56
89
  private lastReportedSize = '';
57
- // Output that arrives before xterm finished loading is replayed in order.
90
+ // Output that arrives before xterm is live is replayed in order, bounded by
91
+ // maxPendingWriteBytes and retained across a disconnect.
58
92
  private pendingWrites: Array<string | Uint8Array> = [];
93
+ private pendingWriteBytes = 0;
94
+ private pendingWritesDropped = false;
59
95
  private setupPromise: Promise<void> | undefined;
96
+ private readyPending = true;
97
+ private readyResolve: () => void = () => undefined;
98
+ private readyReject: (reasonArg: unknown) => void = () => undefined;
99
+
100
+ /**
101
+ * Resolves once the terminal of the current connection is live; a fresh
102
+ * promise is created for every connection. It rejects with the load error
103
+ * when xterm cannot be loaded (`terminal-error` carries the same error), and
104
+ * when the element is disconnected before the terminal came up.
105
+ */
106
+ public ready: Promise<void>;
107
+
108
+ constructor() {
109
+ super();
110
+ this.ready = this.createReadyPromise();
111
+ // The house lifecycle: startup runs on every connection with an abort
112
+ // signal, garbage on every disconnection, so a reattached element is a
113
+ // live surface again instead of a permanently dead one.
114
+ this.registerStartupFunction(async (signalArg) => {
115
+ await this.ensureTerminal(signalArg);
116
+ });
117
+ this.registerGarbageFunction(() => {
118
+ this.teardownTerminal();
119
+ });
120
+ }
60
121
 
61
122
  public static styles = [
62
123
  themeDefaultStyles,
@@ -209,43 +270,133 @@ export class DeesTerminalView extends DeesElement {
209
270
  return html`<div class="terminalHost"></div>`;
210
271
  }
211
272
 
212
- public async firstUpdated(): Promise<void> {
213
- this.setupPromise = this.setupTerminal();
214
- await this.setupPromise;
273
+ public connectedCallback(): Promise<void> {
274
+ // A consumer awaiting readiness after reattaching must not be handed the
275
+ // settled promise of the previous connection.
276
+ if (!this.readyPending) this.ready = this.createReadyPromise();
277
+ return super.connectedCallback();
215
278
  }
216
279
 
217
280
  /** Feed remote output into the terminal; safe to call before setup finishes. */
218
281
  public write(dataArg: string | Uint8Array): void {
219
- if (this.terminal) {
220
- this.terminal.write(dataArg);
221
- } else {
222
- this.pendingWrites.push(dataArg);
282
+ // While detached the terminal is about to be disposed, so output belongs in
283
+ // the buffer rather than in a surface nobody will see again.
284
+ if (this.terminal && this.isConnected) {
285
+ this.writeToTerminal(this.terminal, dataArg);
286
+ return;
223
287
  }
288
+ this.bufferWrite(dataArg);
224
289
  }
225
290
 
226
291
  /** Reset the screen and scrollback, e.g. before a full replay. */
227
292
  public clear(): void {
228
- this.pendingWrites = [];
229
- this.terminal?.reset();
293
+ const terminal = this.terminal;
294
+ if (terminal && this.isConnected) {
295
+ this.writeToTerminal(terminal, RIS);
296
+ return;
297
+ }
298
+ // Without a terminal the reset is expressed as a buffered sentinel, so the
299
+ // flush of the next connection still performs it in the right order.
300
+ this.pendingWrites = [RIS];
301
+ this.pendingWriteBytes = chunkByteLength(RIS);
302
+ this.pendingWritesDropped = false;
230
303
  }
231
304
 
232
305
  public focus(): void {
233
306
  this.terminal?.focus();
234
307
  }
235
308
 
236
- /** Current grid size, or undefined while xterm is still loading. */
309
+ /** Current grid size, or undefined while no terminal is live. */
237
310
  public get currentSize(): ITerminalResizeDetail | undefined {
238
311
  if (!this.terminal) return undefined;
239
312
  return { rows: this.terminal.rows, cols: this.terminal.cols };
240
313
  }
241
314
 
242
- private async setupTerminal(): Promise<void> {
315
+ private createReadyPromise(): Promise<void> {
316
+ this.readyPending = true;
317
+ const promise = new Promise<void>((resolveArg, rejectArg) => {
318
+ this.readyResolve = () => {
319
+ this.readyPending = false;
320
+ resolveArg();
321
+ };
322
+ this.readyReject = (reasonArg) => {
323
+ this.readyPending = false;
324
+ rejectArg(reasonArg);
325
+ };
326
+ });
327
+ // A readiness promise nobody awaits must never surface as an unhandled
328
+ // rejection; consumers that do await it still observe the reason.
329
+ promise.catch(() => undefined);
330
+ return promise;
331
+ }
332
+
333
+ private bufferWrite(chunkArg: string | Uint8Array): void {
334
+ this.pendingWrites.push(chunkArg);
335
+ this.pendingWriteBytes += chunkByteLength(chunkArg);
336
+ while (this.pendingWriteBytes > maxPendingWriteBytes && this.pendingWrites.length > 1) {
337
+ const droppedChunk = this.pendingWrites.shift()!;
338
+ this.pendingWriteBytes -= chunkByteLength(droppedChunk);
339
+ this.pendingWritesDropped = true;
340
+ }
341
+ }
342
+
343
+ private flushPendingWrites(terminalArg: Terminal): void {
344
+ const pending = this.pendingWrites;
345
+ const dropped = this.pendingWritesDropped;
346
+ this.pendingWrites = [];
347
+ this.pendingWriteBytes = 0;
348
+ this.pendingWritesDropped = false;
349
+ if (dropped && !this.writeToTerminal(terminalArg, RIS)) return;
350
+ for (const chunk of pending) {
351
+ if (!this.writeToTerminal(terminalArg, chunk)) return;
352
+ }
353
+ }
354
+
355
+ /** xterm throws at its own flow-control watermark; that has to reach the host. */
356
+ private writeToTerminal(terminalArg: Terminal, chunkArg: string | Uint8Array): boolean {
357
+ try {
358
+ terminalArg.write(chunkArg);
359
+ return true;
360
+ } catch (error) {
361
+ this.reportError(error);
362
+ return false;
363
+ }
364
+ }
365
+
366
+ private reportError(errorArg: unknown): void {
367
+ if (this.readyPending) this.readyReject(errorArg);
368
+ const detail: ITerminalErrorDetail = { error: errorArg };
369
+ this.dispatchEvent(
370
+ new CustomEvent('terminal-error', { detail, bubbles: true, composed: true }),
371
+ );
372
+ }
373
+
374
+ private ensureTerminal(signalArg: AbortSignal): Promise<void> {
375
+ if (this.terminal) return Promise.resolve();
376
+ if (!this.setupPromise) this.setupPromise = this.setupTerminal(signalArg);
377
+ return this.setupPromise;
378
+ }
379
+
380
+ private async setupTerminal(signalArg: AbortSignal): Promise<void> {
381
+ try {
382
+ await this.createTerminal(signalArg);
383
+ } catch (error) {
384
+ this.reportError(error);
385
+ } finally {
386
+ // Nothing came up: release the guard so the next connection retries.
387
+ if (!this.terminal) this.setupPromise = undefined;
388
+ }
389
+ }
390
+
391
+ private async createTerminal(signalArg: AbortSignal): Promise<void> {
392
+ await this.updateComplete;
243
393
  const libLoader = DeesServiceLibLoader.getInstance();
244
394
  const [xterm, fit] = await Promise.all([
245
395
  libLoader.loadXterm(),
246
396
  libLoader.loadXtermFitAddon(),
247
397
  ]);
248
- if (!this.isConnected) return;
398
+ // The load was awaited: the element may have been detached meanwhile.
399
+ if (signalArg.aborted || !this.isConnected) return;
249
400
  const host = this.shadowRoot?.querySelector<HTMLElement>('.terminalHost');
250
401
  if (!host) return;
251
402
  const computed = getComputedStyle(this);
@@ -265,9 +416,21 @@ export class DeesTerminalView extends DeesElement {
265
416
  cursor: themeColor('--dees-color-accent-primary', '#e6e6e6'),
266
417
  },
267
418
  });
268
- const fitAddon = new fit.FitAddon();
269
- terminal.loadAddon(fitAddon);
270
- terminal.open(host);
419
+ let fitAddon: FitAddon;
420
+ try {
421
+ fitAddon = new fit.FitAddon();
422
+ terminal.loadAddon(fitAddon);
423
+ terminal.open(host);
424
+ } catch (error) {
425
+ // The element does not own this instance yet, so teardown cannot reach
426
+ // it; a half-opened terminal would otherwise leak with its DOM attached.
427
+ try {
428
+ terminal.dispose();
429
+ } catch {
430
+ // Disposing a half-opened terminal must not mask the real failure.
431
+ }
432
+ throw error;
433
+ }
271
434
  this.terminal = terminal;
272
435
  this.fitAddon = fitAddon;
273
436
  this.inputDisposable = terminal.onData((dataArg) => {
@@ -277,11 +440,11 @@ export class DeesTerminalView extends DeesElement {
277
440
  new CustomEvent('terminal-input', { detail, bubbles: true, composed: true }),
278
441
  );
279
442
  });
280
- for (const pending of this.pendingWrites) terminal.write(pending);
281
- this.pendingWrites = [];
443
+ this.flushPendingWrites(terminal);
282
444
  this.refit();
283
445
  this.resizeObserver = new ResizeObserver(() => this.refit());
284
446
  this.resizeObserver.observe(host);
447
+ this.readyResolve();
285
448
  }
286
449
 
287
450
  private refit(): void {
@@ -303,8 +466,7 @@ export class DeesTerminalView extends DeesElement {
303
466
  );
304
467
  }
305
468
 
306
- public async disconnectedCallback(): Promise<void> {
307
- await super.disconnectedCallback();
469
+ private teardownTerminal(): void {
308
470
  this.resizeObserver?.disconnect();
309
471
  this.resizeObserver = undefined;
310
472
  this.inputDisposable?.dispose();
@@ -312,6 +474,16 @@ export class DeesTerminalView extends DeesElement {
312
474
  this.terminal?.dispose();
313
475
  this.terminal = undefined;
314
476
  this.fitAddon = undefined;
315
- this.pendingWrites = [];
477
+ // Instance state of the finished connection: a reattached element has to
478
+ // re-announce its grid and may retry a load that failed.
479
+ this.lastReportedSize = '';
480
+ this.setupPromise = undefined;
481
+ // Buffered output deliberately survives, so a replay that arrived while the
482
+ // element was detached still reaches the next connection.
483
+ if (this.readyPending) {
484
+ this.readyReject(
485
+ new Error('dees-terminal-view was disconnected before the terminal became ready'),
486
+ );
487
+ }
316
488
  }
317
489
  }
@@ -355,12 +355,31 @@ export interface IHarnessSessionMeta {
355
355
  statusLabel?: string;
356
356
  /** Harness running this session, e.g. Codex, OpenCode or FlexHarness. */
357
357
  harness?: IHarnessAgentIdentity;
358
+ /** Host-owned project or workspace this session belongs to, shown on the identity line. */
359
+ projectLabel?: string;
358
360
  /** Pulse the normal/working indicator. Defaults to state === 'working'; never pulses attention/error/finished. */
359
361
  working?: boolean;
360
362
  /** Set false for pinned, non-sortable entries such as terminals. Defaults to true. */
361
363
  draggable?: boolean;
362
364
  }
363
365
 
366
+ /** Discriminates the two kinds of rows the session list orders as peers. */
367
+ export type THarnessSessionListItemKind = 'session' | 'resource';
368
+
369
+ /**
370
+ * Stable reference to one ordered row. Session ids and resource ids are
371
+ * separate namespaces, so ordering always carries the kind alongside the id.
372
+ */
373
+ export interface IHarnessSessionListItemRef {
374
+ kind: THarnessSessionListItemKind;
375
+ id: string;
376
+ }
377
+
378
+ /** Detail of `harness-session-select` and `harness-session-open`. */
379
+ export interface IHarnessSessionEventDetail {
380
+ session: IHarnessSessionMeta;
381
+ }
382
+
364
383
  /** Host-owned metadata for a resource that can be associated with a harness session. */
365
384
  export interface IHarnessResourceMeta<TState extends string = string> {
366
385
  /** Stable identity used by selection and association records. */
@@ -373,43 +392,55 @@ export interface IHarnessResourceMeta<TState extends string = string> {
373
392
  preview?: string;
374
393
  createdAt?: number;
375
394
  updatedAt?: number;
395
+ /** Host-owned project or workspace this resource belongs to, shown on the metadata line. */
396
+ projectLabel?: string;
376
397
  /** Optional host-defined display state. */
377
398
  state?: TState;
399
+ /** Set false for pinned, non-sortable entries. Defaults to true. */
400
+ draggable?: boolean;
378
401
  }
379
402
 
380
- /** A controlled zero-or-one session association for one resource. */
403
+ /**
404
+ * A controlled zero-or-one association for one resource. The target is any
405
+ * other list item — a session or another resource such as a terminal.
406
+ */
381
407
  export interface IHarnessResourceAssociation {
382
408
  resourceId: string;
383
- sessionId: string;
409
+ target: IHarnessSessionListItemRef;
384
410
  }
385
411
 
386
412
  export interface IHarnessResourceEventDetail<TState extends string = string> {
387
413
  resource: IHarnessResourceMeta<TState>;
388
- sessionId: string | null;
414
+ /** The controlled association target, or null when the resource is unattached. */
415
+ target: IHarnessSessionListItemRef | null;
389
416
  }
390
417
 
391
- export type THarnessResourceAssociationInteractionSource = 'drop' | 'action' | 'keyboard';
418
+ /**
419
+ * Association is an explicit host action, never a drop: dragging a resource
420
+ * only moves it inside the ordered list.
421
+ */
422
+ export type THarnessResourceAssociationInteractionSource = 'action' | 'keyboard';
392
423
 
393
424
  export type THarnessResourceAssociationRequestDetail<TState extends string = string> =
394
425
  | {
395
426
  intent: 'attach';
396
427
  resource: IHarnessResourceMeta<TState>;
397
- fromSessionId: null;
398
- toSessionId: string;
428
+ fromTarget: null;
429
+ toTarget: IHarnessSessionListItemRef;
399
430
  source: THarnessResourceAssociationInteractionSource;
400
431
  }
401
432
  | {
402
433
  intent: 'detach';
403
434
  resource: IHarnessResourceMeta<TState>;
404
- fromSessionId: string;
405
- toSessionId: null;
435
+ fromTarget: IHarnessSessionListItemRef;
436
+ toTarget: null;
406
437
  source: THarnessResourceAssociationInteractionSource;
407
438
  }
408
439
  | {
409
440
  intent: 'reassign';
410
441
  resource: IHarnessResourceMeta<TState>;
411
- fromSessionId: string;
412
- toSessionId: string;
442
+ fromTarget: IHarnessSessionListItemRef;
443
+ toTarget: IHarnessSessionListItemRef;
413
444
  source: THarnessResourceAssociationInteractionSource;
414
445
  };
415
446
 
@@ -423,7 +454,7 @@ export interface IHarnessResourceContextDetail<TState extends string = string>
423
454
  source: THarnessResourceContextSource;
424
455
  originalEvent: MouseEvent | KeyboardEvent;
425
456
  /** Routes a host menu choice through the component's validated controlled request event. */
426
- requestAssociation: (toSessionId: string | null) => boolean;
457
+ requestAssociation: (toTarget: IHarnessSessionListItemRef | null) => boolean;
427
458
  /** Closes the external-menu disclosure state and restores focus to its invoking control. */
428
459
  close: () => void;
429
460
  }
@@ -438,22 +469,35 @@ export interface IHarnessSessionContextDetail {
438
469
  originalEvent: MouseEvent;
439
470
  }
440
471
 
441
- /** A user-defined session group; ordering of `sessionIds` is the display order. */
472
+ /**
473
+ * A user-defined group of list items; ordering of `itemIds` is the display
474
+ * order. Sessions and resources may be members in any interleaving.
475
+ */
442
476
  export interface IHarnessSessionGroup {
443
477
  id: string;
444
478
  name: string;
445
- sessionIds: string[];
479
+ itemIds: IHarnessSessionListItemRef[];
446
480
  }
447
481
 
448
- /** Detail of `harness-session-move`: a session was dropped into a group slot. */
449
- export interface IHarnessSessionMoveDetail {
450
- sessionId: string;
482
+ export type THarnessItemMoveSource = 'pointer' | 'keyboard';
483
+
484
+ /** Detail of `harness-item-move`: one item landed in a new ordered position. */
485
+ export interface IHarnessItemMoveDetail {
486
+ item: IHarnessSessionListItemRef;
487
+ /** Group the item came from, or null for the ungrouped section. */
488
+ fromGroupId: string | null;
451
489
  /** Target group, or null for the ungrouped section. */
490
+ toGroupId: string | null;
491
+ /** Stable target anchor in the dragged-item-excluded order; null means append. */
492
+ beforeItem: IHarnessSessionListItemRef | null;
493
+ source: THarnessItemMoveSource;
494
+ }
495
+
496
+ /** The open insertion slot a drag currently targets. */
497
+ export interface IDropSlot {
452
498
  groupId: string | null;
453
- /** Compatibility insertion index using the pre-drag order for same-section moves. */
499
+ /** Index within the section's sortable, dragged-item-excluded order. */
454
500
  index: number;
455
- /** Stable target anchor in the dragged-card-excluded order; absent means append. */
456
- beforeSessionId?: string;
457
501
  }
458
502
 
459
503
  /** Detail of `harness-group-context`, fired on right-click of a group header. */