@things-factory/ai-assistant 10.1.108 → 10.1.110

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
  }
@@ -2019,6 +2065,42 @@ export class OxAssistantChat extends LitElement {
2019
2065
  color: inherit;
2020
2066
  }
2021
2067
 
2068
+ /* The entry card's way to the large review - a quiet action under its table, never a second run button. */
2069
+ .workspace-review {
2070
+ display: flex;
2071
+ align-items: center;
2072
+ gap: 5px;
2073
+ margin: 6px 0 0 auto;
2074
+ padding: 5px 11px 5px 9px;
2075
+ border: 1px solid transparent;
2076
+ border-radius: 999px;
2077
+ background: transparent;
2078
+ color: var(--md-sys-color-primary, #0f766e);
2079
+ font-family: inherit;
2080
+ font-size: 12px;
2081
+ font-weight: 600;
2082
+ letter-spacing: 0.01em;
2083
+ cursor: pointer;
2084
+ transition:
2085
+ background-color 0.14s ease-in-out,
2086
+ border-color 0.14s ease-in-out;
2087
+ }
2088
+ .workspace-review md-icon {
2089
+ --md-icon-size: 15px;
2090
+ color: inherit;
2091
+ }
2092
+ .workspace-review:hover {
2093
+ background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 8%, transparent);
2094
+ border-color: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 24%, transparent);
2095
+ }
2096
+ .workspace-review:active {
2097
+ background: color-mix(in srgb, var(--md-sys-color-primary, #0f766e) 14%, transparent);
2098
+ }
2099
+ .workspace-review:focus-visible {
2100
+ outline: 2px solid var(--md-sys-color-primary, #0f766e);
2101
+ outline-offset: 1px;
2102
+ }
2103
+
2022
2104
  /* ── 접지 경고 — 근거에 없는 대상을 지목한 답 ───── */
2023
2105
  .grounding-warning {
2024
2106
  display: flex;
@@ -2327,6 +2409,115 @@ export class OxAssistantChat extends LitElement {
2327
2409
  flex-direction: column;
2328
2410
  transition: background 0.15s, border-color 0.15s;
2329
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
+ .send-shortcut {
2439
+ font-size: 10px;
2440
+ color: var(--md-sys-color-outline, #64748b);
2441
+ white-space: nowrap;
2442
+ padding-bottom: 10px;
2443
+ }
2444
+ .table-preview {
2445
+ flex: 1;
2446
+ min-height: 0;
2447
+ display: flex;
2448
+ flex-direction: column;
2449
+ margin: 4px 14px;
2450
+ border: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
2451
+ border-radius: 8px;
2452
+ background: var(--md-sys-color-surface, #ffffff);
2453
+ overflow: hidden;
2454
+ }
2455
+ .table-preview-head {
2456
+ display: flex;
2457
+ align-items: center;
2458
+ gap: 6px;
2459
+ padding: 6px 8px;
2460
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
2461
+ flex-wrap: wrap;
2462
+ }
2463
+ .table-preview-head > md-icon {
2464
+ --md-icon-size: 18px;
2465
+ color: var(--md-sys-color-primary, #0284c7);
2466
+ }
2467
+ .table-preview-name {
2468
+ font-weight: 500;
2469
+ color: var(--md-sys-color-on-surface, #0f172a);
2470
+ min-width: 0;
2471
+ overflow: hidden;
2472
+ text-overflow: ellipsis;
2473
+ white-space: nowrap;
2474
+ }
2475
+ .table-preview-shape {
2476
+ color: var(--md-sys-color-on-surface-variant, #475569);
2477
+ margin-right: auto;
2478
+ }
2479
+ .table-preview-body {
2480
+ flex: 1;
2481
+ min-height: 0;
2482
+ overflow: auto;
2483
+ }
2484
+ .table-preview-body table {
2485
+ border-collapse: collapse;
2486
+ font-size: 11px;
2487
+ }
2488
+ .table-preview-body th,
2489
+ .table-preview-body td {
2490
+ border-bottom: 1px solid var(--md-sys-color-outline-variant, #e2e8f0);
2491
+ padding: 4px 8px;
2492
+ text-align: left;
2493
+ white-space: nowrap;
2494
+ color: var(--md-sys-color-on-surface, #0f172a);
2495
+ }
2496
+ .table-preview-body th {
2497
+ position: sticky;
2498
+ left: 0;
2499
+ font-weight: 400;
2500
+ text-align: right;
2501
+ color: var(--md-sys-color-on-surface-variant, #475569);
2502
+ background: var(--md-sys-color-surface-container, #f1f5f9);
2503
+ }
2504
+ .attachment-open {
2505
+ all: unset;
2506
+ display: flex;
2507
+ align-items: center;
2508
+ gap: 6px;
2509
+ min-width: 0;
2510
+ flex: 1;
2511
+ cursor: pointer;
2512
+ border-radius: 6px;
2513
+ }
2514
+ .attachment-open:focus-visible {
2515
+ outline: 2px solid var(--md-sys-color-primary, #0284c7);
2516
+ outline-offset: 2px;
2517
+ }
2518
+ .attachment-chip.open {
2519
+ border-color: var(--md-sys-color-primary, #0284c7);
2520
+ }
2330
2521
  .composer.drag-over {
2331
2522
  background: var(--md-sys-color-surface-container, #f1f5f9);
2332
2523
  border-top-color: var(--md-sys-color-primary, #0284c7);
@@ -2593,8 +2784,9 @@ export class OxAssistantChat extends LitElement {
2593
2784
  font: inherit;
2594
2785
  letter-spacing: inherit;
2595
2786
  resize: none;
2596
- min-height: 36px;
2597
- max-height: 140px;
2787
+ /* The resting height of one line as it stood; the box grows from here by composerHeight (shared-dock.md §10). */
2788
+ box-sizing: border-box;
2789
+ min-height: 54px;
2598
2790
  outline: none;
2599
2791
  transition:
2600
2792
  border-color 0.2s,
@@ -2714,6 +2906,8 @@ export class OxAssistantChat extends LitElement {
2714
2906
  }
2715
2907
 
2716
2908
  updated(changed: Map<string, any>) {
2909
+ if (changed.has('input') || changed.has('composerWide') || changed.has('previewAttachmentId') || changed.has('attachments')) this.fitComposer()
2910
+
2717
2911
  /*
2718
2912
  * 밖에서 넣어 준 질문을 자동 전송한다(오류 토스트의 "AI에게 묻기" 등).
2719
2913
  *
@@ -2953,7 +3147,7 @@ export class OxAssistantChat extends LitElement {
2953
3147
  ${this.renderModeSubject()}
2954
3148
  ${this.renderSessionTabs()}
2955
3149
  ${this.renderRoster()}
2956
- <div class="messages" @click=${this.onBubbleClick}>
3150
+ <div class="messages ${this.composerWide || this.previewedTable ? 'covered' : ''}" @click=${this.onBubbleClick}>
2957
3151
  ${!hasMessages ? this.renderEmpty() : nothing}
2958
3152
  ${(() => {
2959
3153
  const myEmail = this._myEmail
@@ -3076,7 +3270,7 @@ export class OxAssistantChat extends LitElement {
3076
3270
  : nothing}
3077
3271
  ${this.renderFoldNotice()}
3078
3272
  <div
3079
- class="composer ${this.dragOver ? 'drag-over' : ''}"
3273
+ class="composer ${this.dragOver ? 'drag-over' : ''} ${this.composerWide ? 'wide' : ''} ${this.previewedTable ? 'previewing' : ''}"
3080
3274
  @dragover=${(e: DragEvent) => this.onComposerDragOver(e)}
3081
3275
  @dragleave=${(e: DragEvent) => this.onComposerDragLeave(e)}
3082
3276
  @drop=${(e: DragEvent) => this.onComposerDrop(e)}
@@ -3128,21 +3322,44 @@ export class OxAssistantChat extends LitElement {
3128
3322
  </button>
3129
3323
  `
3130
3324
  : nothing}
3325
+ <button
3326
+ type="button"
3327
+ class="action composer-size"
3328
+ aria-pressed=${this.composerWide ? 'true' : 'false'}
3329
+ @click=${() => this.setComposerWide(!this.composerWide)}>
3330
+ <md-icon>${this.composerWide ? 'close_fullscreen' : 'open_in_full'}</md-icon>
3331
+ ${this.composerWide
3332
+ ? i18next.t('ai-assistant.button.write-narrow', { defaultValue: 'Narrow' })
3333
+ : i18next.t('ai-assistant.button.write-wide', { defaultValue: 'Write wide' })}
3334
+ </button>
3131
3335
  </div>
3336
+ ${this.renderTablePreview()}
3132
3337
  ${this.examplesOpen ? this.renderInlineExamples() : nothing}
3133
3338
  ${this.attachments.length > 0
3134
3339
  ? html`
3135
3340
  <div class="attachment-strip">
3136
3341
  ${this.attachments.map(
3137
3342
  att => html`
3138
- <div class="attachment-chip" title=${att.name}>
3139
- ${att.type.startsWith('image/')
3140
- ? html`<img class="attachment-thumb" src=${att.url} alt=${att.name} />`
3141
- : html`<md-icon class="attachment-icon">description</md-icon>`}
3142
- <div class="attachment-meta">
3143
- <span class="attachment-name">${att.name}</span>
3144
- <span class="attachment-size">${formatFileSize(att.size)}</span>
3145
- </div>
3343
+ <div class="attachment-chip ${att.id === this.previewAttachmentId ? 'open' : ''}" title=${att.name}>
3344
+ ${att.table
3345
+ ? html`<button
3346
+ type="button"
3347
+ class="attachment-open"
3348
+ aria-expanded=${att.id === this.previewAttachmentId ? 'true' : 'false'}
3349
+ @click=${() => (this.previewAttachmentId = att.id === this.previewAttachmentId ? null : att.id)}>
3350
+ <md-icon class="attachment-icon">table_chart</md-icon>
3351
+ <span class="attachment-meta">
3352
+ <span class="attachment-name">${att.name}</span>
3353
+ <span class="attachment-size">${this.tableShape(att.table)}</span>
3354
+ </span>
3355
+ </button>`
3356
+ : html`${att.type.startsWith('image/')
3357
+ ? html`<img class="attachment-thumb" src=${att.url} alt=${att.name} />`
3358
+ : html`<md-icon class="attachment-icon">description</md-icon>`}
3359
+ <div class="attachment-meta">
3360
+ <span class="attachment-name">${att.name}</span>
3361
+ <span class="attachment-size">${formatFileSize(att.size)}</span>
3362
+ </div>`}
3146
3363
  <button
3147
3364
  type="button"
3148
3365
  class="attachment-remove"
@@ -3214,10 +3431,16 @@ export class OxAssistantChat extends LitElement {
3214
3431
  </button>
3215
3432
  `
3216
3433
  : nothing}
3434
+ ${this.composerWide
3435
+ ? html`<span class="send-shortcut">${i18next.t('ai-assistant.text.send-shortcut', { key: sendShortcut(navigator.platform), defaultValue: '{key} to send' })}</span>`
3436
+ : nothing}
3217
3437
  <button
3218
3438
  ?disabled=${this.busy || !!this.sendBlocked || (!this.input.trim() && this.attachments.length === 0)}
3219
3439
  @click=${this.send}
3220
- title=${this.sendBlocked || i18next.t('ai-assistant.text.send-tooltip')}>
3440
+ title=${this.sendBlocked ||
3441
+ (this.composerWide
3442
+ ? i18next.t('ai-assistant.text.send-tooltip-wide', { key: sendShortcut(navigator.platform), defaultValue: 'Send ({key})' })
3443
+ : i18next.t('ai-assistant.text.send-tooltip'))}>
3221
3444
  ${this.busy
3222
3445
  ? html`<span class="spinner"></span>`
3223
3446
  : html`<md-icon>arrow_upward</md-icon>`}
@@ -3304,7 +3527,57 @@ export class OxAssistantChat extends LitElement {
3304
3527
  `
3305
3528
  }
3306
3529
 
3530
+ private get previewedTable(): ChatAttachment | undefined {
3531
+ return this.previewAttachmentId ? this.attachments.find(a => a.id === this.previewAttachmentId && a.table) : undefined
3532
+ }
3533
+
3534
+ private tableShape(table: NonNullable<ChatAttachment['table']>) {
3535
+ return i18next.t('ai-assistant.text.table-shape', { rows: table.rows.length, columns: table.columns, defaultValue: '{rows} rows × {columns} columns' })
3536
+ }
3537
+
3538
+ /*
3539
+ The rows of a pasted table, over the conversation - numbered as the file numbers them, which is the number the
3540
+ entry card names. Every row: the paste is held under the mode's limit already.
3541
+ */
3542
+ private renderTablePreview() {
3543
+ const att = this.previewedTable
3544
+ if (!att?.table) return nothing
3545
+ const { rows, columns } = att.table
3546
+ return html`
3547
+ <div class="table-preview" role="region" aria-label=${att.name}>
3548
+ <div class="table-preview-head">
3549
+ <md-icon>table_chart</md-icon>
3550
+ <span class="table-preview-name">${att.name}</span>
3551
+ <span class="table-preview-shape">${this.tableShape(att.table)}</span>
3552
+ <button type="button" class="action" @click=${() => this.tableToWords(att.id)}>
3553
+ <md-icon>notes</md-icon>
3554
+ ${i18next.t('ai-assistant.button.table-to-words', { defaultValue: 'Put in as words' })}
3555
+ </button>
3556
+ <button type="button" class="action" @click=${() => (this.previewAttachmentId = null)}>
3557
+ <md-icon>close</md-icon>
3558
+ ${i18next.t('ai-assistant.button.close')}
3559
+ </button>
3560
+ </div>
3561
+ <div class="table-preview-body">
3562
+ <table>
3563
+ ${rows.map(
3564
+ (row, i) => html`<tr>
3565
+ <th scope="row">${i + 1}</th>
3566
+ ${Array.from({ length: columns }, (_, c) => html`<td>${row[c] ?? ''}</td>`)}
3567
+ </tr>`
3568
+ )}
3569
+ </table>
3570
+ </div>
3571
+ </div>
3572
+ `
3573
+ }
3574
+
3307
3575
  private onKeyDown(e: KeyboardEvent) {
3576
+ /*
3577
+ The Enter that ends a Korean (or Japanese, Chinese) syllable belongs to the input method - sending on it
3578
+ sends the line without its last syllable, which then lands in the emptied box.
3579
+ */
3580
+ if (e.isComposing || e.keyCode === 229) return
3308
3581
  // Mention popup 활성 시 — 화살표/Enter/Tab/Esc 가로채서 popup 네비게이션
3309
3582
  if (this.mentionOpen) {
3310
3583
  if (e.key === 'ArrowDown') {
@@ -3341,9 +3614,13 @@ export class OxAssistantChat extends LitElement {
3341
3614
  return
3342
3615
  }
3343
3616
  }
3344
- if (e.key === 'Enter' && !e.shiftKey && !this.busy) {
3617
+ const action = composerKeyAction(e, this.composerWide)
3618
+ if (action === 'narrow') {
3345
3619
  e.preventDefault()
3346
- this.send()
3620
+ this.setComposerWide(false)
3621
+ } else if (action === 'send') {
3622
+ e.preventDefault()
3623
+ if (!this.busy) this.send()
3347
3624
  }
3348
3625
  }
3349
3626
 
@@ -4078,7 +4355,7 @@ export class OxAssistantChat extends LitElement {
4078
4355
  ${entry ? html`<ox-assistant-entry-review .proposal=${p}></ox-assistant-entry-review>` : nothing}
4079
4356
  ${entry && this.sessionId && offersWorkspaceReview(p)
4080
4357
  ? html`<button
4081
- class="choice-chip"
4358
+ class="workspace-review"
4082
4359
  @click=${() =>
4083
4360
  this.dispatchEvent(new CustomEvent('assistant-open-page', { detail: { href: entryReviewAddress(this.sessionId!, key) }, bubbles: true, composed: true }))}
4084
4361
  >
@@ -4646,6 +4923,9 @@ export class OxAssistantChat extends LitElement {
4646
4923
  const attachmentsToSend = [...this.attachments]
4647
4924
  if ((!text && attachmentsToSend.length === 0) || this.busy) return
4648
4925
  this.attachments = []
4926
+ /* What was written wide is sent to be answered - the conversation comes back to show it. */
4927
+ this.composerWide = false
4928
+ this.previewAttachmentId = null
4649
4929
 
4650
4930
  const promptText = text || (attachmentsToSend.length > 0
4651
4931
  ? (attachmentsToSend[0].type.startsWith('image/')
@@ -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 조합 중 깜빡임 완화. */