@things-factory/ai-assistant 10.1.109 → 10.1.111

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.
@@ -34,6 +34,7 @@ import {
34
34
  } from './markdown.js'
35
35
  import { acceptOf, chatAttachmentUrl, refuseAttachments, textCharsOf } from '../utils/attachment-policy.js'
36
36
  import { pastedTableFile } from '../utils/pasted-table.js'
37
+ import { appendWords, composerHeight, composerKeyAction, sendShortcut, tableAsWords } from '../utils/composer-frame.js'
37
38
  import { readSpreadsheet, spreadsheetKindOf, type SpreadsheetRefusal } from '../utils/spreadsheet-text.js'
38
39
  import { entryReviewAddress, offersWorkspaceReview } from '../utils/entry-workspace.js'
39
40
  import { buildAssistantChatInput, buildChatMutationInput } from './chat-input-builder.js'
@@ -141,6 +142,8 @@ export interface ChatAttachment {
141
142
  type: string
142
143
  url: string
143
144
  base64?: string
145
+ /** A table pasted into the composer - its shape for the chip, its rows for the preview, its words to give back. */
146
+ table?: { rows: string[][]; columns: number; text?: string }
144
147
  }
145
148
 
146
149
  function formatFileSize(bytes: number): string {
@@ -605,6 +608,14 @@ export class OxAssistantChat extends LitElement {
605
608
  @state()
606
609
  private dragOver = false
607
610
 
611
+ /** The wide box (넓게 쓰기) covers the conversation inside the dock - shared-dock.md §10. */
612
+ @state()
613
+ private composerWide = false
614
+
615
+ /** The pasted table whose rows cover the conversation, or null. */
616
+ @state()
617
+ private previewAttachmentId: string | null = null
618
+
608
619
  /*
609
620
  * ── 왜 이미지만 받나 ──────────────────────────────────────────────────────
610
621
  * `.json` · `.txt` · `.csv` 도 받고 있었다. 그 파일들은 `hostContext.attachments` 로 실려 갔고
@@ -640,7 +651,7 @@ export class OxAssistantChat extends LitElement {
640
651
  return String(i18next.t(`ai-assistant.text.${refusal.code}`, { ...params, defaultValue: refusal.code }))
641
652
  }
642
653
 
643
- private async onAttachFiles(files: FileList | File[] | null | undefined) {
654
+ private async onAttachFiles(files: FileList | File[] | null | undefined, table?: ChatAttachment['table']) {
644
655
  if (!files || files.length === 0) return
645
656
  let list = Array.from(files)
646
657
  if (this.usesAttachmentPolicy && this.modeView?.attachments?.kinds?.includes('spreadsheet')) {
@@ -678,7 +689,8 @@ export class OxAssistantChat extends LitElement {
678
689
  size: file.size,
679
690
  type: file.type || 'application/octet-stream',
680
691
  url,
681
- base64
692
+ base64,
693
+ table
682
694
  })
683
695
  }
684
696
 
@@ -722,6 +734,39 @@ export class OxAssistantChat extends LitElement {
722
734
  URL.revokeObjectURL(target.url)
723
735
  }
724
736
  this.attachments = this.attachments.filter(a => a.id !== id)
737
+ if (this.previewAttachmentId === id) this.previewAttachmentId = null
738
+ }
739
+
740
+ /** A pasted table goes back into the box as the words it was copied as - the conversion was not the person's choice. */
741
+ private tableToWords(id: string) {
742
+ const target = this.attachments.find(a => a.id === id)
743
+ if (!target?.table) return
744
+ this.input = appendWords(this.input, tableAsWords(target.table))
745
+ this.removeAttachment(id)
746
+ this.updateComplete.then(() => (this.renderRoot.querySelector('textarea') as HTMLTextAreaElement | null)?.focus())
747
+ }
748
+
749
+ /** The small box stands as tall as its text, up to 40% of the chat; the wide box fills what it covers. */
750
+ private fitComposer() {
751
+ const ta = this.renderRoot.querySelector('.input-row textarea') as HTMLTextAreaElement | null
752
+ if (!ta) return
753
+ if (this.composerWide) {
754
+ ta.style.height = ''
755
+ ta.style.overflowY = ''
756
+ return
757
+ }
758
+ ta.style.height = '0px'
759
+ const border = ta.offsetHeight - ta.clientHeight
760
+ const resting = parseFloat(getComputedStyle(ta).minHeight) || 0
761
+ const { height, scrolls } = composerHeight(ta.scrollHeight + border, this.clientHeight, resting)
762
+ ta.style.height = `${height}px`
763
+ ta.style.overflowY = scrolls ? 'auto' : 'hidden'
764
+ }
765
+
766
+ private setComposerWide(wide: boolean) {
767
+ this.composerWide = wide
768
+ if (wide) this.previewAttachmentId = null
769
+ this.updateComplete.then(() => (this.renderRoot.querySelector('.input-row textarea') as HTMLTextAreaElement | null)?.focus())
725
770
  }
726
771
 
727
772
  private onComposerPaste(e: ClipboardEvent) {
@@ -744,7 +789,8 @@ export class OxAssistantChat extends LitElement {
744
789
  this.errorMessage = String(i18next.t(`ai-assistant.text.${pasted.refusal.code}`, { ...pasted.refusal, defaultValue: pasted.refusal.code }))
745
790
  return
746
791
  }
747
- this.onAttachFiles([new File([pasted.text], pasted.name, { type: 'text/csv' })])
792
+ const words = e.clipboardData?.getData('text/plain')
793
+ this.onAttachFiles([new File([pasted.text], pasted.name, { type: 'text/csv' })], { ...pasted.table, text: words })
748
794
  return
749
795
  }
750
796
  }
@@ -2363,6 +2409,128 @@ export class OxAssistantChat extends LitElement {
2363
2409
  flex-direction: column;
2364
2410
  transition: background 0.15s, border-color 0.15s;
2365
2411
  }
2412
+ /* The wide box and a table's rows cover the conversation, inside the dock - the page beside it stays. */
2413
+ .messages.covered {
2414
+ flex: 0 0 0;
2415
+ padding: 0;
2416
+ overflow: hidden;
2417
+ visibility: hidden;
2418
+ }
2419
+ .composer.wide,
2420
+ .composer.previewing {
2421
+ flex: 1 1 auto;
2422
+ min-height: 0;
2423
+ }
2424
+ .composer.wide .input-row {
2425
+ flex: 1;
2426
+ min-height: 0;
2427
+ align-items: stretch;
2428
+ }
2429
+ .composer.wide .input-row > :not(.textarea-wrap) {
2430
+ align-self: flex-end;
2431
+ }
2432
+ .composer.wide textarea {
2433
+ min-height: 0;
2434
+ }
2435
+ .composer-size {
2436
+ margin-left: auto;
2437
+ }
2438
+ /* Named above the send button, out of the row - it takes no width from the box. */
2439
+ .composer.wide .input-row {
2440
+ position: relative;
2441
+ }
2442
+ .send-shortcut {
2443
+ position: absolute;
2444
+ right: 14px;
2445
+ bottom: 48px;
2446
+ font-size: 10px;
2447
+ color: var(--md-sys-color-outline, #64748b);
2448
+ white-space: nowrap;
2449
+ pointer-events: none;
2450
+ }
2451
+ /* A finger has no Ctrl or ⌘ - the send button is the way. */
2452
+ @media (pointer: coarse) {
2453
+ .send-shortcut {
2454
+ display: none;
2455
+ }
2456
+ }
2457
+ .table-preview {
2458
+ flex: 1;
2459
+ min-height: 0;
2460
+ display: flex;
2461
+ flex-direction: column;
2462
+ margin: 4px 14px;
2463
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
2464
+ border-radius: 8px;
2465
+ background: var(--md-sys-color-surface, #ffffff);
2466
+ overflow: hidden;
2467
+ }
2468
+ .table-preview-head {
2469
+ display: flex;
2470
+ align-items: center;
2471
+ gap: 6px;
2472
+ padding: 6px 8px;
2473
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
2474
+ flex-wrap: wrap;
2475
+ }
2476
+ .table-preview-head > md-icon {
2477
+ --md-icon-size: 18px;
2478
+ color: var(--md-sys-color-primary, #0284c7);
2479
+ }
2480
+ .table-preview-name {
2481
+ font-weight: 500;
2482
+ color: var(--md-sys-color-on-surface, #0f172a);
2483
+ min-width: 0;
2484
+ overflow: hidden;
2485
+ text-overflow: ellipsis;
2486
+ white-space: nowrap;
2487
+ }
2488
+ .table-preview-shape {
2489
+ color: var(--md-sys-color-on-surface-variant, #475569);
2490
+ margin-right: auto;
2491
+ }
2492
+ .table-preview-body {
2493
+ flex: 1;
2494
+ min-height: 0;
2495
+ overflow: auto;
2496
+ }
2497
+ .table-preview-body table {
2498
+ border-collapse: collapse;
2499
+ font-size: 11px;
2500
+ }
2501
+ .table-preview-body th,
2502
+ .table-preview-body td {
2503
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
2504
+ padding: 4px 8px;
2505
+ text-align: left;
2506
+ white-space: nowrap;
2507
+ color: var(--md-sys-color-on-surface, #0f172a);
2508
+ }
2509
+ .table-preview-body th {
2510
+ position: sticky;
2511
+ left: 0;
2512
+ font-weight: 400;
2513
+ text-align: right;
2514
+ color: var(--md-sys-color-on-surface-variant, #475569);
2515
+ background: var(--md-sys-color-surface-container, #f1f5f9);
2516
+ }
2517
+ .attachment-open {
2518
+ all: unset;
2519
+ display: flex;
2520
+ align-items: center;
2521
+ gap: 6px;
2522
+ min-width: 0;
2523
+ flex: 1;
2524
+ cursor: pointer;
2525
+ border-radius: 6px;
2526
+ }
2527
+ .attachment-open:focus-visible {
2528
+ outline: 2px solid var(--md-sys-color-primary, #0284c7);
2529
+ outline-offset: 2px;
2530
+ }
2531
+ .attachment-chip.open {
2532
+ border-color: var(--md-sys-color-primary, #0284c7);
2533
+ }
2366
2534
  .composer.drag-over {
2367
2535
  background: var(--md-sys-color-surface-container, #f1f5f9);
2368
2536
  border-top-color: var(--md-sys-color-primary, #0284c7);
@@ -2629,8 +2797,9 @@ export class OxAssistantChat extends LitElement {
2629
2797
  font: inherit;
2630
2798
  letter-spacing: inherit;
2631
2799
  resize: none;
2632
- min-height: 36px;
2633
- max-height: 140px;
2800
+ /* The resting height of one line as it stood; the box grows from here by composerHeight (shared-dock.md §10). */
2801
+ box-sizing: border-box;
2802
+ min-height: 54px;
2634
2803
  outline: none;
2635
2804
  transition:
2636
2805
  border-color 0.2s,
@@ -2733,6 +2902,15 @@ export class OxAssistantChat extends LitElement {
2733
2902
  /* 모드가 이미 붙어 있으면 무엇을 주는지 먼저 묻는다 — 바뀔 때는 updated 가 다시 묻는다. */
2734
2903
  if (this.mode?.kind) this.loadModeView()
2735
2904
  if (this.subject) this.onSubjectChanged()
2905
+ /*
2906
+ * Put back after the dock was closed: what is being written stays, the conversation is read again and its
2907
+ * broadcast and presence start again - they were stopped when it was taken out.
2908
+ */
2909
+ if (this.hasUpdated && this.sessionId) {
2910
+ this.loadHistory()
2911
+ this._startPresence()
2912
+ this._startMessageSubscription()
2913
+ }
2736
2914
  }
2737
2915
 
2738
2916
  /**
@@ -2750,6 +2928,8 @@ export class OxAssistantChat extends LitElement {
2750
2928
  }
2751
2929
 
2752
2930
  updated(changed: Map<string, any>) {
2931
+ if (changed.has('input') || changed.has('composerWide') || changed.has('previewAttachmentId') || changed.has('attachments')) this.fitComposer()
2932
+
2753
2933
  /*
2754
2934
  * 밖에서 넣어 준 질문을 자동 전송한다(오류 토스트의 "AI에게 묻기" 등).
2755
2935
  *
@@ -2989,7 +3169,7 @@ export class OxAssistantChat extends LitElement {
2989
3169
  ${this.renderModeSubject()}
2990
3170
  ${this.renderSessionTabs()}
2991
3171
  ${this.renderRoster()}
2992
- <div class="messages" @click=${this.onBubbleClick}>
3172
+ <div class="messages ${this.composerWide || this.previewedTable ? 'covered' : ''}" @click=${this.onBubbleClick}>
2993
3173
  ${!hasMessages ? this.renderEmpty() : nothing}
2994
3174
  ${(() => {
2995
3175
  const myEmail = this._myEmail
@@ -3112,7 +3292,7 @@ export class OxAssistantChat extends LitElement {
3112
3292
  : nothing}
3113
3293
  ${this.renderFoldNotice()}
3114
3294
  <div
3115
- class="composer ${this.dragOver ? 'drag-over' : ''}"
3295
+ class="composer ${this.dragOver ? 'drag-over' : ''} ${this.composerWide ? 'wide' : ''} ${this.previewedTable ? 'previewing' : ''}"
3116
3296
  @dragover=${(e: DragEvent) => this.onComposerDragOver(e)}
3117
3297
  @dragleave=${(e: DragEvent) => this.onComposerDragLeave(e)}
3118
3298
  @drop=${(e: DragEvent) => this.onComposerDrop(e)}
@@ -3164,21 +3344,44 @@ export class OxAssistantChat extends LitElement {
3164
3344
  </button>
3165
3345
  `
3166
3346
  : nothing}
3347
+ <button
3348
+ type="button"
3349
+ class="action composer-size"
3350
+ aria-pressed=${this.composerWide ? 'true' : 'false'}
3351
+ @click=${() => this.setComposerWide(!this.composerWide)}>
3352
+ <md-icon>${this.composerWide ? 'close_fullscreen' : 'open_in_full'}</md-icon>
3353
+ ${this.composerWide
3354
+ ? i18next.t('ai-assistant.button.write-narrow', { defaultValue: 'Narrow' })
3355
+ : i18next.t('ai-assistant.button.write-wide', { defaultValue: 'Write wide' })}
3356
+ </button>
3167
3357
  </div>
3358
+ ${this.renderTablePreview()}
3168
3359
  ${this.examplesOpen ? this.renderInlineExamples() : nothing}
3169
3360
  ${this.attachments.length > 0
3170
3361
  ? html`
3171
3362
  <div class="attachment-strip">
3172
3363
  ${this.attachments.map(
3173
3364
  att => html`
3174
- <div class="attachment-chip" title=${att.name}>
3175
- ${att.type.startsWith('image/')
3176
- ? html`<img class="attachment-thumb" src=${att.url} alt=${att.name} />`
3177
- : html`<md-icon class="attachment-icon">description</md-icon>`}
3178
- <div class="attachment-meta">
3179
- <span class="attachment-name">${att.name}</span>
3180
- <span class="attachment-size">${formatFileSize(att.size)}</span>
3181
- </div>
3365
+ <div class="attachment-chip ${att.id === this.previewAttachmentId ? 'open' : ''}" title=${att.name}>
3366
+ ${att.table
3367
+ ? html`<button
3368
+ type="button"
3369
+ class="attachment-open"
3370
+ aria-expanded=${att.id === this.previewAttachmentId ? 'true' : 'false'}
3371
+ @click=${() => (this.previewAttachmentId = att.id === this.previewAttachmentId ? null : att.id)}>
3372
+ <md-icon class="attachment-icon">table_chart</md-icon>
3373
+ <span class="attachment-meta">
3374
+ <span class="attachment-name">${att.name}</span>
3375
+ <span class="attachment-size">${this.tableShape(att.table)}</span>
3376
+ </span>
3377
+ </button>`
3378
+ : html`${att.type.startsWith('image/')
3379
+ ? html`<img class="attachment-thumb" src=${att.url} alt=${att.name} />`
3380
+ : html`<md-icon class="attachment-icon">description</md-icon>`}
3381
+ <div class="attachment-meta">
3382
+ <span class="attachment-name">${att.name}</span>
3383
+ <span class="attachment-size">${formatFileSize(att.size)}</span>
3384
+ </div>`}
3182
3385
  <button
3183
3386
  type="button"
3184
3387
  class="attachment-remove"
@@ -3250,10 +3453,16 @@ export class OxAssistantChat extends LitElement {
3250
3453
  </button>
3251
3454
  `
3252
3455
  : nothing}
3456
+ ${this.composerWide
3457
+ ? html`<span class="send-shortcut">${i18next.t('ai-assistant.text.send-shortcut', { key: sendShortcut(navigator.platform), defaultValue: '{key} to send' })}</span>`
3458
+ : nothing}
3253
3459
  <button
3254
3460
  ?disabled=${this.busy || !!this.sendBlocked || (!this.input.trim() && this.attachments.length === 0)}
3255
3461
  @click=${this.send}
3256
- title=${this.sendBlocked || i18next.t('ai-assistant.text.send-tooltip')}>
3462
+ title=${this.sendBlocked ||
3463
+ (this.composerWide
3464
+ ? i18next.t('ai-assistant.text.send-tooltip-wide', { key: sendShortcut(navigator.platform), defaultValue: 'Send ({key})' })
3465
+ : i18next.t('ai-assistant.text.send-tooltip'))}>
3257
3466
  ${this.busy
3258
3467
  ? html`<span class="spinner"></span>`
3259
3468
  : html`<md-icon>arrow_upward</md-icon>`}
@@ -3340,6 +3549,60 @@ export class OxAssistantChat extends LitElement {
3340
3549
  `
3341
3550
  }
3342
3551
 
3552
+ private get previewedTable(): ChatAttachment | undefined {
3553
+ return this.previewAttachmentId ? this.attachments.find(a => a.id === this.previewAttachmentId && a.table) : undefined
3554
+ }
3555
+
3556
+ private tableShape(table: NonNullable<ChatAttachment['table']>) {
3557
+ return i18next.t('ai-assistant.text.table-shape', { rows: table.rows.length, columns: table.columns, defaultValue: '{rows} rows × {columns} columns' })
3558
+ }
3559
+
3560
+ /*
3561
+ The rows of a pasted table, over the conversation - numbered as the file numbers them, which is the number the
3562
+ entry card names. Every row: the paste is held under the mode's limit already.
3563
+ */
3564
+ private renderTablePreview() {
3565
+ const att = this.previewedTable
3566
+ if (!att?.table) return nothing
3567
+ const { rows, columns } = att.table
3568
+ return html`
3569
+ <div
3570
+ class="table-preview"
3571
+ role="region"
3572
+ aria-label=${att.name}
3573
+ @keydown=${(e: KeyboardEvent) => {
3574
+ if (e.key === 'Escape') {
3575
+ e.stopPropagation()
3576
+ this.previewAttachmentId = null
3577
+ }
3578
+ }}>
3579
+ <div class="table-preview-head">
3580
+ <md-icon>table_chart</md-icon>
3581
+ <span class="table-preview-name">${att.name}</span>
3582
+ <span class="table-preview-shape">${this.tableShape(att.table)}</span>
3583
+ <button type="button" class="action" @click=${() => this.tableToWords(att.id)}>
3584
+ <md-icon>notes</md-icon>
3585
+ ${i18next.t('ai-assistant.button.table-to-words', { defaultValue: 'Put in as words' })}
3586
+ </button>
3587
+ <button type="button" class="action" @click=${() => (this.previewAttachmentId = null)}>
3588
+ <md-icon>close</md-icon>
3589
+ ${i18next.t('ai-assistant.button.close')}
3590
+ </button>
3591
+ </div>
3592
+ <div class="table-preview-body">
3593
+ <table>
3594
+ ${rows.map(
3595
+ (row, i) => html`<tr>
3596
+ <th scope="row">${i + 1}</th>
3597
+ ${Array.from({ length: columns }, (_, c) => html`<td>${row[c] ?? ''}</td>`)}
3598
+ </tr>`
3599
+ )}
3600
+ </table>
3601
+ </div>
3602
+ </div>
3603
+ `
3604
+ }
3605
+
3343
3606
  private onKeyDown(e: KeyboardEvent) {
3344
3607
  /*
3345
3608
  The Enter that ends a Korean (or Japanese, Chinese) syllable belongs to the input method - sending on it
@@ -3382,9 +3645,15 @@ export class OxAssistantChat extends LitElement {
3382
3645
  return
3383
3646
  }
3384
3647
  }
3385
- if (e.key === 'Enter' && !e.shiftKey && !this.busy) {
3648
+ const action = composerKeyAction(e, this.composerWide)
3649
+ if (action === 'narrow') {
3650
+ /* The Escape that narrows the box is spent here - the floating dock closes on the next one. */
3386
3651
  e.preventDefault()
3387
- this.send()
3652
+ e.stopPropagation()
3653
+ this.setComposerWide(false)
3654
+ } else if (action === 'send') {
3655
+ e.preventDefault()
3656
+ if (!this.busy) this.send()
3388
3657
  }
3389
3658
  }
3390
3659
 
@@ -4687,6 +4956,9 @@ export class OxAssistantChat extends LitElement {
4687
4956
  const attachmentsToSend = [...this.attachments]
4688
4957
  if ((!text && attachmentsToSend.length === 0) || this.busy) return
4689
4958
  this.attachments = []
4959
+ /* What was written wide is sent to be answered - the conversation comes back to show it. */
4960
+ this.composerWide = false
4961
+ this.previewAttachmentId = null
4690
4962
 
4691
4963
  const promptText = text || (attachmentsToSend.length > 0
4692
4964
  ? (attachmentsToSend[0].type.startsWith('image/')
@@ -360,16 +360,21 @@ export class AssistantDock extends localize(i18next)(LitElement) {
360
360
  if (activity.kind === 'meta' && isAnchor(this.anchor) && activity.anchorId === this.anchor.anchorId) void this.reloadSessions()
361
361
  })
362
362
  )
363
- this.follow(true)
363
+ if (!this.openedIdentity) return this.follow(true)
364
+ /*
365
+ * The same dock opened again - the aside bar takes it out while it is closed. The conversation and what was
366
+ * being written in it are still here; only what moved while it was closed (the screen, the person) is followed.
367
+ */
368
+ setOpenSession(this.sessionId)
369
+ this.follow()
364
370
  }
365
371
 
372
+ /* Closing keeps the conversation: the dock comes back as it was left (design/ai/shared-dock.md §2-1, §2-7). */
366
373
  disconnectedCallback() {
367
- this.revision++
368
374
  this.removeEventListener('assistant-start-new-session', this.onStartNewSession)
369
375
  this.removeEventListener('assistant-open-page', this.onOpenPage as EventListener)
370
376
  this.unsubscribers.splice(0).forEach(unsubscribe => unsubscribe())
371
377
  setOpenSession('')
372
- this.controller.clear()
373
378
  super.disconnectedCallback()
374
379
  }
375
380
 
@@ -124,6 +124,14 @@ export function setupAssistantDock(options: { viewpart?: string } = {}): void {
124
124
 
125
125
  startSessionActivityFeed()
126
126
 
127
+ /*
128
+ * One dock for the application's life. The aside bar drops a closed viewpart's template, so a template that
129
+ * makes the element would make a new dock on every opening - and a new composer, the text being written gone
130
+ * (design/ai/shared-dock.md §2-1, §2-7). The same element is put back instead, as the entity360 dock is.
131
+ */
132
+ const dock = document.createElement('ox-assistant-dock') as HTMLElement & { viewpart: string }
133
+ dock.viewpart = viewpart
134
+
127
135
  appendViewpart({
128
136
  name: viewpart,
129
137
  /*
@@ -134,7 +142,7 @@ export function setupAssistantDock(options: { viewpart?: string } = {}): void {
134
142
  show: false,
135
143
  persistent: true,
136
144
  level: VIEWPART_LEVEL.TOPMOST,
137
- template: html`<ox-assistant-dock .viewpart=${viewpart}></ox-assistant-dock>`
145
+ template: html`${dock}`
138
146
  },
139
147
  position: VIEWPART_POSITION.ASIDEBAR
140
148
  })
@@ -0,0 +1,49 @@
1
+ /*
2
+ * The composer's frame (operato-application/design/ai/shared-dock.md §10) - how tall it stands, what Enter does,
3
+ * and what a pasted table gives back as words. Pure: the chat hands in its measures and the key.
4
+ *
5
+ * - The box grows with its text up to 40% of the chat's height, then scrolls. It never stands under its resting
6
+ * height, and on a chat too short for 40% to hold that height the resting height wins.
7
+ * - The wide box (넓게 쓰기) covers the conversation inside the dock; the page beside it stays where it was.
8
+ * - In the small box Enter sends and Shift+Enter breaks the line, as in every chat. In the wide box Enter breaks
9
+ * the line - long writing is made of lines - and Ctrl/Cmd+Enter sends. Ctrl/Cmd+Enter sends in both.
10
+ */
11
+
12
+ /** The part of the chat's height the small box may take before it scrolls. */
13
+ export const COMPOSER_GROWTH = 0.4
14
+
15
+ /** The box's height for its content, both border-box pixels. */
16
+ export function composerHeight(content: number, chat: number, resting: number): { height: number; scrolls: boolean } {
17
+ const cap = Math.max(resting, Math.floor(chat * COMPOSER_GROWTH))
18
+ return { height: Math.min(Math.max(content, resting), cap), scrolls: content > cap }
19
+ }
20
+
21
+ export type ComposerKey = { key: string; shiftKey?: boolean; ctrlKey?: boolean; metaKey?: boolean; altKey?: boolean }
22
+
23
+ /** What a key does in the box: send, close the wide box, or nothing of ours (the textarea keeps it). */
24
+ export function composerKeyAction(e: ComposerKey, wide: boolean): 'send' | 'narrow' | null {
25
+ if (e.key === 'Escape') return wide ? 'narrow' : null
26
+ if (e.key !== 'Enter' || e.altKey) return null
27
+ if (e.ctrlKey || e.metaKey) return 'send'
28
+ if (wide || e.shiftKey) return null
29
+ return 'send'
30
+ }
31
+
32
+ /** The shortcut the wide box's send button names, in the keys of this machine. */
33
+ export function sendShortcut(platform: string | undefined): string {
34
+ return /mac|iphone|ipad/i.test(platform ?? '') ? '⌘ Enter' : 'Ctrl+Enter'
35
+ }
36
+
37
+ /**
38
+ * A pasted table given back as words: what the clipboard held as text, or - when it held only HTML - the rows with
39
+ * a tab between cells, as a spreadsheet copies them.
40
+ */
41
+ export function tableAsWords(table: { rows: string[][]; text?: string | null }): string {
42
+ const text = (table.text ?? '').replace(/\r\n?/g, '\n').replace(/\n+$/, '')
43
+ return text.trim() ? text : table.rows.map(row => row.join('\t')).join('\n')
44
+ }
45
+
46
+ /** The words appended to what is already written - on a line of their own. */
47
+ export function appendWords(written: string, words: string): string {
48
+ return written.trim() ? `${written.replace(/\s+$/, '')}\n${words}` : words
49
+ }
@@ -133,7 +133,7 @@ export function pastedTableFile(
133
133
  clipboard: { html?: string | null; text?: string | null },
134
134
  policy: AttachmentPolicy | null | undefined,
135
135
  nameOf: (rows: number) => string
136
- ): { name: string; text: string } | { refusal: PasteRefusal } | null {
136
+ ): { name: string; text: string; table: PastedTable } | { refusal: PasteRefusal } | null {
137
137
  if (!policy?.kinds?.some(kind => kind === 'text' || kind === 'spreadsheet')) return null
138
138
  const table = pastedTable(clipboard)
139
139
  if (!table) return null
@@ -141,5 +141,5 @@ export function pastedTableFile(
141
141
  const text = pastedTableText(table, name)
142
142
  const maxChars = textCharsOf(policy)
143
143
  if (text.length > maxChars) return { refusal: { code: 'attachment-paste-too-long', rows: table.rows.length, maxChars } }
144
- return { name: `${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`, text }
144
+ return { name: `${name.replace(/[\\/:*?"<>|]/g, '_')}.csv`, text, table }
145
145
  }
@@ -29,6 +29,12 @@ export interface ChatAttachment {
29
29
  type: string;
30
30
  url: string;
31
31
  base64?: string;
32
+ /** A table pasted into the composer - its shape for the chip, its rows for the preview, its words to give back. */
33
+ table?: {
34
+ rows: string[][];
35
+ columns: number;
36
+ text?: string;
37
+ };
32
38
  }
33
39
  export declare class OxAssistantChat extends LitElement {
34
40
  /** 영속 세션 id. 없으면 ad-hoc (메시지 영속 안 됨). */
@@ -227,6 +233,10 @@ export declare class OxAssistantChat extends LitElement {
227
233
  private attachments;
228
234
  /** 드래그 오버 상태 (시각 피드백용) */
229
235
  private dragOver;
236
+ /** The wide box (넓게 쓰기) covers the conversation inside the dock - shared-dock.md §10. */
237
+ private composerWide;
238
+ /** The pasted table whose rows cover the conversation, or null. */
239
+ private previewAttachmentId;
230
240
  /** This conversation talks to the neutral door - its attachments follow the mode's policy (§9). */
231
241
  private get usesAttachmentPolicy();
232
242
  /** Whether the attach button stands: a mode that declares nothing takes nothing (the board door keeps its images). */
@@ -238,6 +248,11 @@ export declare class OxAssistantChat extends LitElement {
238
248
  private readSpreadsheets;
239
249
  private readFileAsBase64;
240
250
  private removeAttachment;
251
+ /** A pasted table goes back into the box as the words it was copied as - the conversion was not the person's choice. */
252
+ private tableToWords;
253
+ /** The small box stands as tall as its text, up to 40% of the chat; the wide box fills what it covers. */
254
+ private fitComposer;
255
+ private setComposerWide;
241
256
  private onComposerPaste;
242
257
  private onComposerDragOver;
243
258
  private onComposerDragLeave;
@@ -427,6 +442,9 @@ export declare class OxAssistantChat extends LitElement {
427
442
  * 일어나지 않는 것이 가장 나쁘다.
428
443
  */
429
444
  private renderInviteNotice;
445
+ private get previewedTable();
446
+ private tableShape;
447
+ private renderTablePreview;
430
448
  private onKeyDown;
431
449
  /** input 이벤트 — value 즉시 반영 + popup 갱신은 150ms 디바운스 (F-Finder 와 동일).
432
450
  * big board 에서 매 keystroke filter 비용 절감 + 한국어 IME 조합 중 깜빡임 완화. */