@things-factory/ai-assistant 10.1.210 → 10.1.213

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 (119) hide show
  1. package/client/components/assistant-chat.ts +115 -2
  2. package/client/components/assistant-dock.ts +106 -3
  3. package/client/components/workspace-view.ts +56 -3
  4. package/client/index.ts +3 -0
  5. package/client/utils/assistant-ask.test.ts +38 -0
  6. package/client/utils/assistant-ask.ts +35 -1
  7. package/client/utils/assistant-proposals.ts +23 -0
  8. package/client/utils/assistant-selection.test.ts +30 -0
  9. package/client/utils/assistant-selection.ts +47 -0
  10. package/client/utils/assistant-session-controller.ts +2 -0
  11. package/client/utils/assistant-session-transport.ts +1 -1
  12. package/client/utils/invite-proposal-register.ts +31 -0
  13. package/client/utils/invite-proposal.test.ts +40 -0
  14. package/client/utils/invite-proposal.ts +54 -0
  15. package/dist-client/components/assistant-chat.d.ts +18 -0
  16. package/dist-client/components/assistant-chat.js +117 -2
  17. package/dist-client/components/assistant-chat.js.map +1 -1
  18. package/dist-client/components/assistant-dock.d.ts +11 -0
  19. package/dist-client/components/assistant-dock.js +97 -4
  20. package/dist-client/components/assistant-dock.js.map +1 -1
  21. package/dist-client/components/workspace-view.d.ts +2 -0
  22. package/dist-client/components/workspace-view.js +58 -3
  23. package/dist-client/components/workspace-view.js.map +1 -1
  24. package/dist-client/index.d.ts +2 -0
  25. package/dist-client/index.js +3 -0
  26. package/dist-client/index.js.map +1 -1
  27. package/dist-client/tsconfig.tsbuildinfo +1 -1
  28. package/dist-client/utils/assistant-ask.d.ts +22 -1
  29. package/dist-client/utils/assistant-ask.js +19 -0
  30. package/dist-client/utils/assistant-ask.js.map +1 -1
  31. package/dist-client/utils/assistant-ask.test.d.ts +1 -0
  32. package/dist-client/utils/assistant-ask.test.js +40 -0
  33. package/dist-client/utils/assistant-ask.test.js.map +1 -0
  34. package/dist-client/utils/assistant-proposals.d.ts +14 -0
  35. package/dist-client/utils/assistant-proposals.js +7 -0
  36. package/dist-client/utils/assistant-proposals.js.map +1 -1
  37. package/dist-client/utils/assistant-selection.d.ts +19 -0
  38. package/dist-client/utils/assistant-selection.js +35 -0
  39. package/dist-client/utils/assistant-selection.js.map +1 -0
  40. package/dist-client/utils/assistant-selection.test.d.ts +1 -0
  41. package/dist-client/utils/assistant-selection.test.js +26 -0
  42. package/dist-client/utils/assistant-selection.test.js.map +1 -0
  43. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  44. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  45. package/dist-client/utils/assistant-session-transport.js +1 -1
  46. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  47. package/dist-client/utils/invite-proposal-register.d.ts +1 -0
  48. package/dist-client/utils/invite-proposal-register.js +24 -0
  49. package/dist-client/utils/invite-proposal-register.js.map +1 -0
  50. package/dist-client/utils/invite-proposal.d.ts +17 -0
  51. package/dist-client/utils/invite-proposal.js +37 -0
  52. package/dist-client/utils/invite-proposal.js.map +1 -0
  53. package/dist-client/utils/invite-proposal.test.d.ts +1 -0
  54. package/dist-client/utils/invite-proposal.test.js +35 -0
  55. package/dist-client/utils/invite-proposal.test.js.map +1 -0
  56. package/dist-server/service/assistant-chat-resolver.js +2 -0
  57. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  58. package/dist-server/service/assistant-mode/index.d.ts +2 -1
  59. package/dist-server/service/assistant-mode/index.js +6 -1
  60. package/dist-server/service/assistant-mode/index.js.map +1 -1
  61. package/dist-server/service/assistant-mode/mode-categories.d.ts +14 -1
  62. package/dist-server/service/assistant-mode/mode-categories.js +29 -4
  63. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  64. package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
  65. package/dist-server/service/assistant-mode/screen-rules.js +21 -6
  66. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  67. package/dist-server/service/data-read/read-target.d.ts +24 -3
  68. package/dist-server/service/data-read/read-target.js +42 -10
  69. package/dist-server/service/data-read/read-target.js.map +1 -1
  70. package/dist-server/service/data-read/read-tools.d.ts +18 -1
  71. package/dist-server/service/data-read/read-tools.js +19 -5
  72. package/dist-server/service/data-read/read-tools.js.map +1 -1
  73. package/dist-server/service/index.d.ts +5 -1
  74. package/dist-server/service/index.js +10 -1
  75. package/dist-server/service/index.js.map +1 -1
  76. package/dist-server/service/people/index.d.ts +4 -0
  77. package/dist-server/service/people/index.js +22 -0
  78. package/dist-server/service/people/index.js.map +1 -0
  79. package/dist-server/service/people/invite-tools.d.ts +34 -0
  80. package/dist-server/service/people/invite-tools.js +144 -0
  81. package/dist-server/service/people/invite-tools.js.map +1 -0
  82. package/dist-server/service/people/people-keys.d.ts +8 -0
  83. package/dist-server/service/people/people-keys.js +16 -0
  84. package/dist-server/service/people/people-keys.js.map +1 -0
  85. package/dist-server/service/people/people-read.d.ts +4 -0
  86. package/dist-server/service/people/people-read.js +57 -0
  87. package/dist-server/service/people/people-read.js.map +1 -0
  88. package/dist-server/service/testing/mode-conformance.js +3 -0
  89. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  90. package/dist-server/tsconfig.tsbuildinfo +1 -1
  91. package/package.json +6 -6
  92. package/server/service/assistant-chat-resolver.ts +2 -0
  93. package/server/service/assistant-mode/conversation-category.test.ts +35 -0
  94. package/server/service/assistant-mode/index.ts +3 -1
  95. package/server/service/assistant-mode/mode-categories.ts +41 -4
  96. package/server/service/assistant-mode/mode-front.test.ts +5 -3
  97. package/server/service/assistant-mode/resolve.test.ts +1 -1
  98. package/server/service/assistant-mode/screen-rules.test.ts +23 -1
  99. package/server/service/assistant-mode/screen-rules.ts +22 -5
  100. package/server/service/data-entry/data-entry.test.ts +2 -2
  101. package/server/service/data-read/detail-lists.test.ts +61 -0
  102. package/server/service/data-read/read-target.ts +54 -9
  103. package/server/service/data-read/read-tools.ts +32 -6
  104. package/server/service/index.ts +7 -1
  105. package/server/service/people/index.ts +13 -0
  106. package/server/service/people/invite-tools.ts +159 -0
  107. package/server/service/people/invite.test.ts +159 -0
  108. package/server/service/people/people-keys.ts +16 -0
  109. package/server/service/people/people-read.test.ts +82 -0
  110. package/server/service/people/people-read.ts +56 -0
  111. package/server/service/testing/mode-conformance.test.ts +14 -1
  112. package/server/service/testing/mode-conformance.ts +2 -0
  113. package/test/assistant-dock.test.ts +73 -0
  114. package/translations/en.json +18 -1
  115. package/translations/es.json +18 -1
  116. package/translations/ja.json +18 -1
  117. package/translations/ko.json +19 -2
  118. package/translations/ms.json +18 -1
  119. package/translations/zh.json +18 -1
@@ -3,6 +3,8 @@ import { rosterName } from './roster-identity'
3
3
  import { notify } from '@operato/layout'
4
4
  import './assistant-invite.js'
5
5
  import { TypingRoom } from '../utils/assistant-typing.js'
6
+ import { isInviteProposal, personLine, type InvitePerson } from '../utils/invite-proposal.js'
7
+ import { noteForDisplay } from '../utils/assistant-selection.js'
6
8
  /**
7
9
  * <ox-assistant-chat> — AI 도우미 채팅 컴포넌트 (Lit).
8
10
  *
@@ -79,6 +81,7 @@ import './entry-review.js'
79
81
  import { entryGroupOf, entryReadyCount, entryTitle, isChangeProposal, isEntryProposal, latestEntryKeys } from '../utils/entry-proposal.js'
80
82
  import { isWorkspaceProposal, versionOfCard, workspaceAddress } from '../utils/workspace.js'
81
83
  import { isOpenPageProposal, openPageNote } from '../utils/open-page.js'
84
+ import { proposalViewOf } from '../utils/assistant-proposals.js'
82
85
 
83
86
  interface ChatLine {
84
87
  role: 'user' | 'assistant' | 'system'
@@ -380,6 +383,12 @@ const CHAT_TYPING_SUBSCRIPTION = gql`
380
383
  }
381
384
  }
382
385
  `
386
+ /* The selection a screen request carries, noted before the request (design/ai/shared-dock.md §2-11-1). */
387
+ const APPEND_SESSION_NOTE_MUTATION = gql`
388
+ mutation AppendSessionNote($sessionId: String!, $content: String!) {
389
+ appendSessionNote(sessionId: $sessionId, content: $content)
390
+ }
391
+ `
383
392
  const TOUCH_PRESENCE_MUTATION = gql`
384
393
  mutation TouchPresence($sessionId: String!) {
385
394
  touchPresence(sessionId: $sessionId)
@@ -968,6 +977,41 @@ export class OxAssistantChat extends LitElement {
968
977
  this.attachments = draft.attachments
969
978
  }
970
979
 
980
+ /**
981
+ * A request made on the screen (「AI와 작업」, design/ai/shared-dock.md §2-11-1) - in this conversation, made now if it
982
+ * has none yet: the selection's note first, then the request, sent at once. What the person was writing is set aside
983
+ * while it goes and put back after, whatever happened. Says whether it was sent and, if not, why.
984
+ */
985
+ async sendScreenRequest(text: string, note: string): Promise<{ sent: boolean; reason?: string }> {
986
+ if (this.busy) return { sent: false, reason: i18next.t('ai-assistant.text.screen-request-busy', { defaultValue: 'an answer is still being written.' }) }
987
+ const kept = this.takeDraft()
988
+ try {
989
+ if (!this.sessionId && this.prepareSession) {
990
+ await this.prepareSession(id => {
991
+ this.adoptingSession = true
992
+ this.sessionId = id
993
+ })
994
+ await this.updateComplete
995
+ }
996
+ if (!this.sessionId) return { sent: false, reason: i18next.t('ai-assistant.text.screen-request-no-session', { defaultValue: 'this screen has no conversation to send to.' }) }
997
+ if (note) {
998
+ const noted = await client.mutate({ mutation: APPEND_SESSION_NOTE_MUTATION, variables: { sessionId: this.sessionId, content: note } })
999
+ if (noted.errors?.length) throw new Error(noted.errors[0].message)
1000
+ await this.loadHistory()
1001
+ }
1002
+ this.errorMessage = undefined
1003
+ this.input = text
1004
+ await this.send()
1005
+ return this.errorMessage ? { sent: false, reason: this.errorMessage } : { sent: true }
1006
+ } catch (error) {
1007
+ return { sent: false, reason: error instanceof Error ? error.message : String(error) }
1008
+ } finally {
1009
+ /* The request is the screen's - it never stays in the box; the box gets back what the person had written. */
1010
+ if (this.input.trim() === text.trim()) this.input = ''
1011
+ if (kept && !this.input.trim() && !this.attachments.length) this.restoreDraft(kept)
1012
+ }
1013
+ }
1014
+
971
1015
  @state()
972
1016
  private errorMessage?: string
973
1017
 
@@ -2029,6 +2073,16 @@ export class OxAssistantChat extends LitElement {
2029
2073
  --md-icon-size: 13px;
2030
2074
  color: inherit;
2031
2075
  }
2076
+ /* The invite card's people - where each works, quieter than the name; a picked card greys every button. */
2077
+ .proposal-choices .choice-chip .invite-where {
2078
+ margin-left: 4px;
2079
+ opacity: 0.7;
2080
+ font-size: 0.92em;
2081
+ }
2082
+ .proposal-choices .choice-chip:disabled {
2083
+ opacity: 0.5;
2084
+ cursor: default;
2085
+ }
2032
2086
 
2033
2087
  /* The entry card's way to the large review - a quiet action under its table, never a second run button. */
2034
2088
  .workspace-review {
@@ -3285,7 +3339,7 @@ export class OxAssistantChat extends LitElement {
3285
3339
  class="sys-text"
3286
3340
  title=${i18next.t('ai-assistant.text.toggle-system-note', { defaultValue: 'click to expand' })}
3287
3341
  @click=${() => this.toggleSystemNote(idx)}
3288
- >${content}</span
3342
+ >${noteForDisplay(content)}</span
3289
3343
  >`
3290
3344
  : content}
3291
3345
  ${line.attachments && line.attachments.length > 0
@@ -4468,7 +4522,11 @@ export class OxAssistantChat extends LitElement {
4468
4522
  /* A screen card opens the application's list with the question's conditions; it runs nothing (design/ai/read-targets.md §5). */
4469
4523
  if (isOpenPageProposal(p)) return this.renderOpenPageCard(p)
4470
4524
  const card = proposalCardState(key, { outcomes, requested, sentHere: this.sentProposals.has(key) })
4525
+ /* An invite card: one person, one button; several of one name, one button each - the person picks (ADR-0073 decision 3). */
4526
+ if (isInviteProposal(p)) return this.renderInviteCard(p, key, card.state)
4471
4527
  const entry = isEntryProposal(p)
4528
+ /* A package's own card - its preview under the line, its words on the button (assistant-proposals.ts ProposalView). */
4529
+ const view = entry ? undefined : proposalViewOf(p.tool)
4472
4530
  const replaced = entry && latestEntry?.get(entryGroupOf(p)) !== key && card.state === 'ready'
4473
4531
  const ready = entry ? entryReadyCount(p) : 0
4474
4532
  const sent = card.state !== 'ready' || replaced || (entry && ready === 0)
@@ -4515,7 +4573,9 @@ export class OxAssistantChat extends LitElement {
4515
4573
  ? i18next.t('ai-assistant.entry.change', { defaultValue: 'change {count} records', count: ready })
4516
4574
  : i18next.t('ai-assistant.entry.enter', { defaultValue: 'enter {count} rows', count: ready })
4517
4575
  : i18next.t('ai-assistant.entry.nothing-to-enter', { defaultValue: 'nothing to enter' })
4518
- : i18next.t('ai-assistant.button.run-proposal', { defaultValue: 'run' })}
4576
+ : view?.runLabelKey || view?.runLabel
4577
+ ? i18next.t(view.runLabelKey ?? '', { defaultValue: view.runLabel ?? 'run' })
4578
+ : i18next.t('ai-assistant.button.run-proposal', { defaultValue: 'run' })}
4519
4579
  </button>
4520
4580
  `}
4521
4581
  ${/*
@@ -4532,6 +4592,7 @@ export class OxAssistantChat extends LitElement {
4532
4592
  ` : nothing}
4533
4593
  </div>
4534
4594
  ${entry ? html`<ox-assistant-entry-review .proposal=${p}></ox-assistant-entry-review>` : nothing}
4595
+ ${view?.previewTag ? this.renderProposalPreview(view.previewTag, p, key) : nothing}
4535
4596
  ${entry && this.sessionId && offersWorkspaceReview(p)
4536
4597
  ? html`<button
4537
4598
  class="workspace-review"
@@ -4561,6 +4622,58 @@ export class OxAssistantChat extends LitElement {
4561
4622
  `
4562
4623
  }
4563
4624
 
4625
+ /** A package's preview under its card - the element is the package's; it is handed the proposal and where it is. */
4626
+ private renderProposalPreview(tag: string, p: any, key: string) {
4627
+ const element = (this.proposalPreviews.get(key) as any) ?? document.createElement(tag)
4628
+ this.proposalPreviews.set(key, element)
4629
+ element.proposal = p
4630
+ element.sessionId = this.sessionId
4631
+ element.proposalKey = key
4632
+ return html`${element}`
4633
+ }
4634
+
4635
+ /** One preview element per card, kept across renders so it does not read again on every keystroke. */
4636
+ private proposalPreviews = new Map<string, HTMLElement>()
4637
+
4638
+ /**
4639
+ * The invite card. Pressing it is the person's act - the dock calls the invite door with their request. With several
4640
+ * people of one name each is a button with where they work; the press carries whom they picked.
4641
+ */
4642
+ private renderInviteCard(p: any, key: string, state: string) {
4643
+ const people: InvitePerson[] = p.people
4644
+ const sent = state !== 'ready'
4645
+ const status = sent ? this.proposalStateText(key) : ''
4646
+ return html`
4647
+ <div class="proposal invite ${sent ? 'sent' : ''}">
4648
+ <md-icon>${p.icon || 'person_add'}</md-icon>
4649
+ <span class="pbody">
4650
+ <span class="plabel">${p.label}</span>
4651
+ ${p.reason ? html`<span class="preason">${p.reason}</span>` : nothing}
4652
+ ${sent && people.length > 1 ? html`<span class="preason">${status}</span>` : nothing}
4653
+ </span>
4654
+ ${people.length === 1
4655
+ ? html`<button class="run" ?disabled=${sent} @click=${() => this.executeProposal(p, key)}>
4656
+ ${sent ? status : i18next.t('ai-assistant.button.invite', { defaultValue: 'Invite' })}
4657
+ </button>`
4658
+ : nothing}
4659
+ </div>
4660
+ ${people.length > 1
4661
+ ? html`<div class="proposal-choices invite-people" role="group" aria-label=${p.label}>
4662
+ ${people.map(
4663
+ person => html`<button
4664
+ class="choice-chip"
4665
+ ?disabled=${sent}
4666
+ title=${personLine(person)}
4667
+ @click=${() => this.executeProposal({ ...p, chosen: person.userId }, key)}>
4668
+ <md-icon>person_add</md-icon>
4669
+ <span>${person.name}${personLine(person) ? html` <span class="invite-where">${personLine(person)}</span>` : nothing}</span>
4670
+ </button>`
4671
+ )}
4672
+ </div>`
4673
+ : nothing}
4674
+ `
4675
+ }
4676
+
4564
4677
  /** The screen card - which list, the conditions the screen does not take, and the way to open it. */
4565
4678
  private renderOpenPageCard(p: any) {
4566
4679
  const note = openPageNote(p, (key, options) => String(i18next.t(key, options)))
@@ -31,8 +31,11 @@ import {
31
31
  type AssistantSurface
32
32
  } from '../utils/assistant-surface.js'
33
33
  import { runProposal, setEntryProposalHandler, type ProposalContext, type ProposalOutcome } from '../utils/assistant-proposals.js'
34
+ /* The invite card's handler - ai-assistant's own tool, registered by its own module (design/ai/shared-dock.md §4). */
35
+ import '../utils/invite-proposal-register.js'
34
36
  import { entryProposalHandler, type EntryApplyAnswer } from '../utils/entry-proposal.js'
35
- import { holdAssistantAsk, subscribeAssistantAsk, takeAssistantAsk, type AssistantAsk } from '../utils/assistant-ask.js'
37
+ import { holdAssistantAsk, peekAssistantAsk, settleAssistantAsk, subscribeAssistantAsk, takeAssistantAsk, type AssistantAsk } from '../utils/assistant-ask.js'
38
+ import { selectionNote } from '../utils/assistant-selection.js'
36
39
  import { surfaceTransition } from '../utils/assistant-navigation.js'
37
40
 
38
41
  export const ASSISTANT_DOCK_VIEWPART = 'assistant-dock'
@@ -381,6 +384,7 @@ export class AssistantDock extends localize(i18next)(LitElement) {
381
384
  this.removeEventListener('assistant-start-new-session', this.onStartNewSession)
382
385
  this.removeEventListener('assistant-open-page', this.onOpenPage as EventListener)
383
386
  this.unsubscribers.splice(0).forEach(unsubscribe => unsubscribe())
387
+ clearTimeout(this.takeTimer)
384
388
  setOpenSession('')
385
389
  super.disconnectedCallback()
386
390
  }
@@ -442,7 +446,11 @@ export class AssistantDock extends localize(i18next)(LitElement) {
442
446
  if (transition === 'reset') {
443
447
  this.asking = undefined
444
448
  this.capability = 'unknown'
445
- if (this.openedIdentity) takeAssistantAsk()
449
+ if (this.openedIdentity) {
450
+ const dropped = takeAssistantAsk()
451
+ /* Another account or domain - the request was not this one's to send; the screen is told. */
452
+ if (dropped) settleAssistantAsk(dropped, { sent: false, reason: 'identity-changed' })
453
+ }
446
454
  }
447
455
  this.surface = next
448
456
  void this.open()
@@ -557,7 +565,18 @@ export class AssistantDock extends localize(i18next)(LitElement) {
557
565
 
558
566
  private async takeAsk() {
559
567
  if (this.loading || !this.anchor) return
560
- if (this.navigationState.busy || this.navigationState.draft) {
568
+ /* A request made on the screen with what was picked there (「AI와 작업」) has rules of its own (§2-11-1). */
569
+ if (peekAssistantAsk()?.selection) return this.takeScreenRequest()
570
+ /*
571
+ * A request the person made on this screen is never dropped for what the dock is doing (2026-10-24: 「AI와 작업」
572
+ * pressed again was thrown away because the input still held the first press's draft). An answer being written
573
+ * finishes first, then the request is taken. Unsent words are asked about - the person keeps writing or lets them go.
574
+ */
575
+ if (this.navigationState.busy) {
576
+ this.takeWhenIdle()
577
+ return
578
+ }
579
+ if (this.navigationState.draft && !(await this.mayLeaveConversation())) {
561
580
  const waiting = takeAssistantAsk()
562
581
  if (waiting) notify({ level: 'info', message: t('ask-kept-current', 'The current conversation was kept. Switch to the intended subject before asking again.') })
563
582
  return
@@ -594,6 +613,90 @@ export class AssistantDock extends localize(i18next)(LitElement) {
594
613
  this.asking = { ask: ask.ask, draft: ask.draft, requestId: String(Date.now()) }
595
614
  }
596
615
 
616
+ private takeTimer?: ReturnType<typeof setTimeout>
617
+
618
+ /**
619
+ * This person's own conversation on this screen - the most recent they sit in; none, a new one made on sending. What
620
+ * they were writing comes along.
621
+ */
622
+ private async goToOwnConversation() {
623
+ const kept = (this.renderRoot.querySelector('ox-assistant-chat') as any)?.takeDraft?.()
624
+ const own = this.sessions.find(one => one.participant !== false && one.id !== this.sessionId)
625
+ const revision = this.controller.state.revision
626
+ if (own) {
627
+ if (this.controller.select(own.id)) this.conversationView++
628
+ } else {
629
+ await this.controller.reset()
630
+ if (revision !== this.controller.state.revision) this.conversationView++
631
+ }
632
+ await this.updateComplete
633
+ if (kept) (this.renderRoot.querySelector('ox-assistant-chat') as any)?.restoreDraft?.(kept)
634
+ }
635
+
636
+ /*
637
+ * ── A screen request (「AI와 작업」) - design/ai/shared-dock.md §2-11-1 ─────────────────────────────────────────
638
+ * The user (2026-10-24): 「"AI와 작업" 기능이 unstable 하다. 작동 방식을 신뢰할 수 있도록 새로운 세션을 여는지, 기존 세션을
639
+ * 활용하는지 등등을 명확하게 하라.」 One way, every time:
640
+ * 1. this screen's conversation - the one the dock opens for this screen; none yet, one is made on sending. The dock
641
+ * showing another screen's conversation, or its list, comes back here. Never a new conversation per press.
642
+ * 2. the selection at the press is noted for that turn - a line the person reads, the ids for the model; a later
643
+ * press's note replaces it (SELECTION_RULE).
644
+ * 3. the request is sent, always - never left as a draft.
645
+ * 4. what the person was writing in the box is set aside while the request goes, and put back.
646
+ * 5. not sent: said in a notice, and the screen is told so it keeps the request on its bar.
647
+ * 6. an answer being written: said, and the request goes right after it - never dropped.
648
+ */
649
+ private waitingNoticeFor?: AssistantAsk
650
+
651
+ private async takeScreenRequest() {
652
+ const waiting = peekAssistantAsk()
653
+ if (!waiting?.selection) return
654
+ if (this.navigationState.busy) {
655
+ if (this.waitingNoticeFor !== waiting) {
656
+ this.waitingNoticeFor = waiting
657
+ notify({ level: 'info', message: t('screen-request-waits', 'An answer is being written - your request goes right after it.') })
658
+ }
659
+ this.takeWhenIdle()
660
+ return
661
+ }
662
+ const here = currentAssistantSurface()
663
+ if (this.scopeKey(here) !== this.openedFor || this.openedIdentity !== this.identityKey()) {
664
+ /* Another screen's conversation is open - back to this screen's; open() takes the request again when it stands. */
665
+ this.keepDraft()
666
+ this.surface = here
667
+ await this.open()
668
+ return
669
+ }
670
+ const ask = takeAssistantAsk()!
671
+ this.waitingNoticeFor = undefined
672
+ const fail = (reason: string) => {
673
+ notify({ level: 'error', message: reason })
674
+ settleAssistantAsk(ask, { sent: false, reason })
675
+ }
676
+ if (!isAnchor(this.anchor)) return fail(this.anchor?.message || t('no-conversation', 'there is no conversation for this screen.'))
677
+ this.showingHistory = false
678
+ /*
679
+ * The dock opens the first conversation it can read when this person has none here yet - someone else's open one,
680
+ * found 2026-10-24: the request went into Admin's public 「회사가치를 제시해보라」. A screen request is the person's own
681
+ * work: a conversation they do not sit in is not this screen's conversation for them. Their own on this screen is
682
+ * used - the most recent one; none yet, one is started (made on sending).
683
+ */
684
+ if (this.sessions.find(one => one.id === this.sessionId)?.participant === false) await this.goToOwnConversation()
685
+ await this.updateComplete
686
+ const chat = this.renderRoot.querySelector('ox-assistant-chat') as any
687
+ if (!chat?.sendScreenRequest) return fail(t('screen-request-not-ready', 'The conversation is not ready yet. Try again in a moment.'))
688
+ const request = ask.ask?.trim() || t('screen-request-default', 'Read the selected records and suggest what can be done with them now.')
689
+ const outcome = await chat.sendScreenRequest(request, selectionNote(ask.selection!, t))
690
+ if (!outcome?.sent) return fail(outcome?.reason || t('screen-request-failed', 'The request was not sent. Try again.'))
691
+ settleAssistantAsk(ask, { sent: true })
692
+ }
693
+
694
+ /** The request waits (it is still pending) while an answer is written, then is taken. */
695
+ private takeWhenIdle() {
696
+ clearTimeout(this.takeTimer)
697
+ this.takeTimer = setTimeout(() => (this.navigationState.busy ? this.takeWhenIdle() : void this.takeAsk()), 300)
698
+ }
699
+
597
700
  /** A new conversation carrying what the other screen had - its name, a note, earlier turns. */
598
701
  private async startCarried(ask: AssistantAsk): Promise<string | undefined> {
599
702
  const anchor = this.anchor as { anchorType: string; anchorId: string }
@@ -26,6 +26,29 @@ const t = (key: string, defaultValue: string, options: Record<string, unknown> =
26
26
  const shown = (value: unknown) =>
27
27
  value === null || value === undefined ? '' : typeof value === 'number' ? value.toLocaleString(i18next.language || undefined) : String(value)
28
28
 
29
+ /*
30
+ * A label cut to the width it has, at the axis size: a wide (Hangul, CJK) character about 11 units, any other about 6.5.
31
+ * The whole label is its title - 「CJ올리브네트웍스」 under a narrow bar ran into the next one (2026-10-24).
32
+ */
33
+ const charWidth = (char: string) => (/[\u1100-\u11ff\u2e80-\u9fff\uac00-\ud7af\uff00-\uffef]/.test(char) ? 11 : 6.5)
34
+ const fitted = (label: string, width: number) => {
35
+ const room = width * 0.92
36
+ let used = 0
37
+ const chars = [...label]
38
+ for (let i = 0; i < chars.length; i++) {
39
+ used += charWidth(chars[i])
40
+ if (used > room) return `${chars.slice(0, Math.max(1, i - 1)).join('')}…`
41
+ }
42
+ return label
43
+ }
44
+
45
+ /*
46
+ * An axis step in the reader's short form - 300,000,000 is 「3억」 in Korean and 「300M」 in English - so the axis fits its
47
+ * margin; a mark's own title keeps the whole number. Below ten thousand the number is written whole.
48
+ */
49
+ const tickShown = (value: number) =>
50
+ Math.abs(value) < 10_000 ? shown(value) : new Intl.NumberFormat(i18next.language || undefined, { notation: 'compact', maximumFractionDigits: 1 }).format(value)
51
+
29
52
  /** Four round steps from zero to above the largest value. */
30
53
  function ticksFor(max: number): number[] {
31
54
  if (!(max > 0)) return [0, 1]
@@ -246,10 +269,40 @@ export class AssistantWorkspaceView extends LitElement {
246
269
  .markdown :first-child {
247
270
  margin-top: 0;
248
271
  }
272
+
273
+ /*
274
+ * One block in a small frame - a dashboard card (ADR-0160 decision 5). No outer margin, a figure sized to be read
275
+ * across a room, and a table that fills the frame and scrolls inside it.
276
+ */
277
+ :host([compact]) section {
278
+ margin: 0;
279
+ }
280
+
281
+ :host([compact]) .metrics {
282
+ grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
283
+ }
284
+
285
+ :host([compact]) .metric {
286
+ border: 0;
287
+ padding: 4px 0;
288
+ background: transparent;
289
+ }
290
+
291
+ :host([compact]) .metric .value {
292
+ font-size: 30px;
293
+ line-height: 1.2;
294
+ letter-spacing: -0.01em;
295
+ }
296
+
297
+ :host([compact]) .scroll {
298
+ max-height: var(--assistant-workspace-table-max-height, 320px);
299
+ }
249
300
  `
250
301
  ]
251
302
 
252
303
  @property({ attribute: false }) workspace?: WorkspaceView
304
+ /** One block in a small frame - a dashboard card: no outer margin, a large figure, a lower chart. */
305
+ @property({ type: Boolean, reflect: true }) compact = false
253
306
 
254
307
  @state() private sorts: Record<number, { column: string; direction: 'asc' | 'desc' }> = {}
255
308
  @state() private asTable = new Set<number>()
@@ -373,7 +426,7 @@ export class AssistantWorkspaceView extends LitElement {
373
426
  }
374
427
 
375
428
  const W = 640
376
- const H = 240
429
+ const H = this.compact ? 200 : 240
377
430
  const left = 56
378
431
  const bottom = 28
379
432
  const plotW = W - left - 8
@@ -411,9 +464,9 @@ export class AssistantWorkspaceView extends LitElement {
411
464
  return html`<section class="chart" role="img" aria-label=${block.caption || series.map(name).join(', ')}>
412
465
  ${bar}
413
466
  <svg viewBox="0 0 ${W} ${H}">
414
- ${ticks.map(tick => svg`<line class="grid" x1=${left} x2=${W - 8} y1=${y(tick)} y2=${y(tick)}></line><text class="tick" x=${left - 6} y=${y(tick) + 4} text-anchor="end">${shown(tick)}</text>`)}
467
+ ${ticks.map(tick => svg`<line class="grid" x1=${left} x2=${W - 8} y1=${y(tick)} y2=${y(tick)}></line><text class="tick" x=${left - 6} y=${y(tick) + 4} text-anchor="end">${tickShown(tick)}</text>`)}
415
468
  ${marks}
416
- ${x.map((label, j) => (j % everyNth ? nothing : svg`<text class="tick" x=${left + band * (j + 0.5)} y=${H - 8} text-anchor="middle">${label}</text>`))}
469
+ ${x.map((label, j) => (j % everyNth ? nothing : svg`<text class="tick" x=${left + band * (j + 0.5)} y=${H - 8} text-anchor="middle"><title>${label}</title>${fitted(label, band * everyNth)}</text>`))}
417
470
  </svg>
418
471
  ${series.length > 1
419
472
  ? html`<div class="legend">${series.map((one, s) => html`<span><span class="swatch" style="background:${colours[s % 8]}"></span>${name(one)}</span>`)}</div>`
package/client/index.ts CHANGED
@@ -20,7 +20,10 @@ export * from './components/assistant-dock.js'
20
20
  export * from './setup-assistant-dock.js'
21
21
  export * from './utils/assistant-surface.js'
22
22
  export * from './utils/assistant-proposals.js'
23
+ /* The AI 작업판's blocks, drawn - also a dashboard card's (ADR-0160 decision 5). */
24
+ export * from './components/workspace-view.js'
23
25
  export * from './utils/assistant-ask.js'
26
+ export * from './utils/assistant-selection.js'
24
27
  export * from './utils/assistant-activity.js'
25
28
  export * from './utils/assistant-request-context.js'
26
29
  export * from './utils/assistant-capability.js'
@@ -0,0 +1,38 @@
1
+ /*
2
+ * What becomes of a request a screen hands the dock - the screen waits on the answer and keeps the person's words
3
+ * unless it was sent (design/ai/shared-dock.md §2-11-1, rule 5).
4
+ */
5
+ import { askAssistant, peekAssistantAsk, settleAssistantAsk, takeAssistantAsk } from './assistant-ask'
6
+
7
+ beforeAll(() => {
8
+ ;(globalThis as any).window = (globalThis as any).window ?? { dispatchEvent: () => true }
9
+ ;(globalThis as any).CustomEvent = (globalThis as any).CustomEvent ?? class { constructor(public type: string) {} }
10
+ })
11
+ afterEach(() => takeAssistantAsk())
12
+
13
+ describe('a request and its outcome', () => {
14
+ it('★ the screen hears whether it was sent, and why not', async () => {
15
+ const ask = { ask: 'read them', selection: { title: 'L', records: [{ id: 'a' }] } }
16
+ const outcome = askAssistant(ask)
17
+ expect(peekAssistantAsk()).toBe(ask)
18
+ settleAssistantAsk(takeAssistantAsk()!, { sent: false, reason: 'not ready' })
19
+ expect(await outcome).toEqual({ sent: false, reason: 'not ready' })
20
+ })
21
+
22
+ it('peeking leaves it waiting; it is settled once', async () => {
23
+ const ask = { ask: 'x' }
24
+ const outcome = askAssistant(ask)
25
+ expect(peekAssistantAsk()).toBe(ask)
26
+ expect(peekAssistantAsk()).toBe(ask)
27
+ settleAssistantAsk(ask, { sent: true })
28
+ settleAssistantAsk(ask, { sent: false, reason: 'late' })
29
+ expect(await outcome).toEqual({ sent: true })
30
+ })
31
+
32
+ it('a request replaced before the dock took it is told it was not sent', async () => {
33
+ const first = askAssistant({ ask: 'first' })
34
+ askAssistant({ ask: 'second' })
35
+ expect(await first).toEqual({ sent: false, reason: 'replaced' })
36
+ expect(peekAssistantAsk()?.ask).toBe('second')
37
+ })
38
+ })
@@ -5,6 +5,7 @@
5
5
  * header ask box, a performance page carrying its context. The request waits here until the dock takes it,
6
6
  * so a dock that is closed (not mounted) still gets it when it opens.
7
7
  */
8
+ import type { AssistantSelection } from './assistant-selection.js'
8
9
  export interface AssistantAsk {
9
10
  /** Sent as the next question. */
10
11
  ask?: string
@@ -18,17 +19,50 @@ export interface AssistantAsk {
18
19
  transcript?: { role: 'user' | 'ai'; content: string }[]
19
20
  /** A note stored first: what the screen was showing when the person asked. */
20
21
  context?: string
22
+ /**
23
+ * The records the person picked on the screen - a screen request (「AI와 작업」, design/ai/shared-dock.md §2-11-1). It
24
+ * goes to this screen's conversation, never a new one; the selection is noted for that turn (a line the person reads,
25
+ * the ids for the model - assistant-selection.ts) and `ask` is sent at once.
26
+ */
27
+ selection?: AssistantSelection
28
+ }
29
+
30
+ /** What became of a request - a screen keeps what the person wrote when it was not sent, and says nothing twice. */
31
+ export interface AssistantAskOutcome {
32
+ sent: boolean
33
+ /** Why it was not sent, in the person's words - the dock has already said it. */
34
+ reason?: string
21
35
  }
22
36
 
23
37
  type Listener = () => void
24
38
 
25
39
  let pending: AssistantAsk | undefined
26
40
  const listeners = new Set<Listener>()
41
+ const settlers = new WeakMap<AssistantAsk, (outcome: AssistantAskOutcome) => void>()
27
42
 
28
- export function askAssistant(ask: AssistantAsk): void {
43
+ /**
44
+ * Hands the request to the dock. The promise says what became of it once the dock has dealt with it - sent, or not and
45
+ * why. A request replaced before the dock took it is not sent, and says so.
46
+ */
47
+ export function askAssistant(ask: AssistantAsk): Promise<AssistantAskOutcome> {
48
+ if (pending && pending !== ask) settleAssistantAsk(pending, { sent: false, reason: 'replaced' })
49
+ const outcome = new Promise<AssistantAskOutcome>(resolve => settlers.set(ask, resolve))
29
50
  pending = ask
30
51
  listeners.forEach(listener => listener())
31
52
  window.dispatchEvent(new CustomEvent('assistant-dock-open'))
53
+ return outcome
54
+ }
55
+
56
+ /** The dock tells the screen what became of its request. Once; later calls do nothing. */
57
+ export function settleAssistantAsk(ask: AssistantAsk, outcome: AssistantAskOutcome): void {
58
+ const settle = settlers.get(ask)
59
+ settlers.delete(ask)
60
+ settle?.(outcome)
61
+ }
62
+
63
+ /** The waiting request, left waiting - the dock decides how to take it before taking it. */
64
+ export function peekAssistantAsk(): AssistantAsk | undefined {
65
+ return pending
32
66
  }
33
67
 
34
68
  /**
@@ -37,6 +37,29 @@ export function proposalHandlerOf(tool: string | undefined): ProposalHandler | u
37
37
  return tool ? handlers.get(tool) : undefined
38
38
  }
39
39
 
40
+ /**
41
+ * How a package's card is drawn in the conversation, besides the framework's line: an element drawn under it (given
42
+ * `proposal`, `sessionId` and `proposalKey`), and the words of its button. A dashboard card draws its preview as the
43
+ * one looking at the conversation (ADR-0160 decision 6). Registered by the package that declares the tool.
44
+ */
45
+ export interface ProposalView {
46
+ /** A custom element's tag name - defined by the registering package. */
47
+ previewTag?: string
48
+ /** i18n key of the button, and its words when the key has none. */
49
+ runLabelKey?: string
50
+ runLabel?: string
51
+ }
52
+
53
+ const views = new Map<string, ProposalView>()
54
+
55
+ export function registerProposalView(tool: string, view: ProposalView): void {
56
+ views.set(tool, view)
57
+ }
58
+
59
+ export function proposalViewOf(tool: string | undefined): ProposalView | undefined {
60
+ return tool ? views.get(tool) : undefined
61
+ }
62
+
40
63
  /*
41
64
  * Data entry cards are the framework's, whatever record they enter (design/ai/data-entry.md §5): one
42
65
  * handler asks the server to enter the stored rows. The dock sets it; no app registers one per record.
@@ -0,0 +1,30 @@
1
+ import { noteForDisplay, selectionNote, SELECTION_KEYS_PREFIX } from './assistant-selection'
2
+
3
+ const t = (_key: string, defaultValue: string, options: any = {}) => defaultValue.replace(/\{(\w+)\}/g, (_, name) => String(options[name]))
4
+ const contract = (id: string, label: string) => ({ id, label })
5
+
6
+ describe('the records picked on a screen, as a note', () => {
7
+ it('★ the person reads a line of names; the model reads the list and the ids', () => {
8
+ const note = selectionNote({ title: '계약 대장', list: 'contracts', records: [contract('c-1', 'H2022019 · 브라이스톤 IT3.0 Website')] }, t)
9
+ expect(note).toBe(`Selected 1 · 계약 대장: H2022019 · 브라이스톤 IT3.0 Website\n${SELECTION_KEYS_PREFIX} contracts c-1`)
10
+ expect(note).not.toMatch(/[{}"]|explicit-page-selection/)
11
+ })
12
+
13
+ it('★ the screen draws the person\'s line only', () => {
14
+ const note = selectionNote({ title: '계약 대장', list: 'contracts', records: [contract('c-1', 'H2022019')] }, t)
15
+ expect(noteForDisplay(note)).toBe('Selected 1 · 계약 대장: H2022019')
16
+ expect(noteForDisplay('a plain note\nsecond line')).toBe('a plain note\nsecond line')
17
+ })
18
+
19
+ it('every id reaches the model; the names are cut after a few, the rest counted', () => {
20
+ const records = Array.from({ length: 7 }, (_, i) => contract(`c-${i}`, `C${i}`))
21
+ const note = selectionNote({ title: 'L', records }, t)
22
+ expect(note.split('\n')[0]).toBe('Selected 7 · L: C0, C1, C2, C3, C4 and 2 more')
23
+ expect(note.split('\n')[1]).toBe(`${SELECTION_KEYS_PREFIX} ${records.map(one => one.id).join(' ')}`)
24
+ })
25
+
26
+ it('a record picked twice is one; nothing picked is no note', () => {
27
+ expect(selectionNote({ title: 'L', records: [contract('a', 'A'), contract('a', 'A')] }, t)).toBe(`Selected 1 · L: A\n${SELECTION_KEYS_PREFIX} a`)
28
+ expect(selectionNote({ title: 'L', records: [] }, t)).toBe('')
29
+ })
30
+ })
@@ -0,0 +1,47 @@
1
+ /*
2
+ * The records a person picked on a screen and handed to the assistant - `askAssistant({ selection })`.
3
+ *
4
+ * The conversation keeps them as a note: one line the person reads (「선택 1건 · 계약 대장: H2022019 · 브라이스톤 IT3.0
5
+ * Website」) and one line the model reads - the list and the ids, after `[keys]`. The screen hides the key line; the
6
+ * model is handed the note as stored. Until 2026-10-24 the screen sent the selection as JSON and the conversation
7
+ * opened on `{"source":"explicit-page-selection",…}`.
8
+ */
9
+
10
+ /** The line the model reads begins with this; the conversation does not draw it. */
11
+ export const SELECTION_KEYS_PREFIX = '[keys]'
12
+
13
+ /** Labels named on the person's line - the rest are counted. */
14
+ export const SELECTION_LABELS_SHOWN = 5
15
+
16
+ export interface AssistantSelection {
17
+ /** The list the records were picked from, as the person knows it (「계약 대장」). */
18
+ title: string
19
+ /** The list's query (`contracts`) - which list the ids belong to, for the model. */
20
+ list?: string
21
+ records: { id: string; label?: string }[]
22
+ }
23
+
24
+ export type SelectionTranslate = (key: string, defaultValue: string, options?: Record<string, unknown>) => string
25
+
26
+ /** The note stored in the conversation - the person's line, then the key line. Empty when nothing was picked. */
27
+ export function selectionNote(selection: AssistantSelection, t: SelectionTranslate): string {
28
+ const seen = new Set<string>()
29
+ const records = (selection?.records ?? []).filter(one => one?.id && !seen.has(one.id) && !!seen.add(one.id))
30
+ if (!records.length) return ''
31
+ const labels = records.map(one => (one.label || '').replace(/\s+/g, ' ').trim()).filter(Boolean)
32
+ const shown = labels.slice(0, SELECTION_LABELS_SHOWN).join(', ')
33
+ const rest = labels.length - Math.min(labels.length, SELECTION_LABELS_SHOWN)
34
+ const line = t('selection-note', 'Selected {count} · {title}', { count: records.length, title: selection.title })
35
+ const named = shown ? `${line}: ${shown}${rest > 0 ? ` ${t('selection-more', 'and {count} more', { count: rest })}` : ''}` : line
36
+ const keys = `${SELECTION_KEYS_PREFIX} ${selection.list ? `${selection.list} ` : ''}${records.map(one => one.id).join(' ')}`
37
+ return `${named}\n${keys}`
38
+ }
39
+
40
+ /** A note as the conversation draws it - without the line only the model reads. */
41
+ export function noteForDisplay(content: string): string {
42
+ return (content ?? '')
43
+ .split('\n')
44
+ .filter(line => !line.startsWith(SELECTION_KEYS_PREFIX))
45
+ .join('\n')
46
+ .trim()
47
+ }
@@ -13,6 +13,8 @@ export interface AssistantSession {
13
13
  kind?: string
14
14
  /** Who reads it: 'open' (everyone in the domain), 'group' (the people the owner brought in) or 'private' (the owner alone). The dock draws an open one apart. */
15
15
  visibility?: string
16
+ /** Whether this person sits in it - `false` for someone else's open conversation the dock showed; absent on one just made. */
17
+ participant?: boolean
16
18
  }
17
19
  export interface AssistantSessionTarget {
18
20
  /** Account/domain boundary supplied by the authenticated host. */