@things-factory/ai-assistant 10.1.212 → 10.1.214

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 (127) hide show
  1. package/client/components/assistant-chat.ts +115 -2
  2. package/client/components/assistant-dock.ts +106 -3
  3. package/client/components/assistant-selection-bar.test.ts +42 -0
  4. package/client/components/assistant-selection-bar.ts +129 -0
  5. package/client/components/workspace-view.ts +56 -3
  6. package/client/index.ts +5 -0
  7. package/client/utils/assistant-ask.test.ts +38 -0
  8. package/client/utils/assistant-ask.ts +35 -1
  9. package/client/utils/assistant-proposals.ts +23 -0
  10. package/client/utils/assistant-selection.test.ts +54 -0
  11. package/client/utils/assistant-selection.ts +74 -0
  12. package/client/utils/assistant-session-controller.ts +2 -0
  13. package/client/utils/assistant-session-transport.ts +1 -1
  14. package/client/utils/invite-proposal-register.ts +31 -0
  15. package/client/utils/invite-proposal.test.ts +40 -0
  16. package/client/utils/invite-proposal.ts +54 -0
  17. package/dist-client/components/assistant-chat.d.ts +18 -0
  18. package/dist-client/components/assistant-chat.js +117 -2
  19. package/dist-client/components/assistant-chat.js.map +1 -1
  20. package/dist-client/components/assistant-dock.d.ts +11 -0
  21. package/dist-client/components/assistant-dock.js +97 -4
  22. package/dist-client/components/assistant-dock.js.map +1 -1
  23. package/dist-client/components/assistant-selection-bar.d.ts +44 -0
  24. package/dist-client/components/assistant-selection-bar.js +149 -0
  25. package/dist-client/components/assistant-selection-bar.js.map +1 -0
  26. package/dist-client/components/assistant-selection-bar.test.d.ts +1 -0
  27. package/dist-client/components/assistant-selection-bar.test.js +38 -0
  28. package/dist-client/components/assistant-selection-bar.test.js.map +1 -0
  29. package/dist-client/components/workspace-view.d.ts +2 -0
  30. package/dist-client/components/workspace-view.js +58 -3
  31. package/dist-client/components/workspace-view.js.map +1 -1
  32. package/dist-client/index.d.ts +3 -0
  33. package/dist-client/index.js +5 -0
  34. package/dist-client/index.js.map +1 -1
  35. package/dist-client/tsconfig.tsbuildinfo +1 -1
  36. package/dist-client/utils/assistant-ask.d.ts +22 -1
  37. package/dist-client/utils/assistant-ask.js +19 -0
  38. package/dist-client/utils/assistant-ask.js.map +1 -1
  39. package/dist-client/utils/assistant-ask.test.d.ts +1 -0
  40. package/dist-client/utils/assistant-ask.test.js +40 -0
  41. package/dist-client/utils/assistant-ask.test.js.map +1 -0
  42. package/dist-client/utils/assistant-proposals.d.ts +14 -0
  43. package/dist-client/utils/assistant-proposals.js +7 -0
  44. package/dist-client/utils/assistant-proposals.js.map +1 -1
  45. package/dist-client/utils/assistant-selection.d.ts +43 -0
  46. package/dist-client/utils/assistant-selection.js +59 -0
  47. package/dist-client/utils/assistant-selection.js.map +1 -0
  48. package/dist-client/utils/assistant-selection.test.d.ts +1 -0
  49. package/dist-client/utils/assistant-selection.test.js +46 -0
  50. package/dist-client/utils/assistant-selection.test.js.map +1 -0
  51. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  52. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  53. package/dist-client/utils/assistant-session-transport.js +1 -1
  54. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  55. package/dist-client/utils/invite-proposal-register.d.ts +1 -0
  56. package/dist-client/utils/invite-proposal-register.js +24 -0
  57. package/dist-client/utils/invite-proposal-register.js.map +1 -0
  58. package/dist-client/utils/invite-proposal.d.ts +17 -0
  59. package/dist-client/utils/invite-proposal.js +37 -0
  60. package/dist-client/utils/invite-proposal.js.map +1 -0
  61. package/dist-client/utils/invite-proposal.test.d.ts +1 -0
  62. package/dist-client/utils/invite-proposal.test.js +35 -0
  63. package/dist-client/utils/invite-proposal.test.js.map +1 -0
  64. package/dist-server/service/assistant-chat-resolver.js +7 -3
  65. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  66. package/dist-server/service/assistant-mode/index.d.ts +2 -1
  67. package/dist-server/service/assistant-mode/index.js +6 -1
  68. package/dist-server/service/assistant-mode/index.js.map +1 -1
  69. package/dist-server/service/assistant-mode/mode-categories.d.ts +29 -1
  70. package/dist-server/service/assistant-mode/mode-categories.js +57 -5
  71. package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
  72. package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
  73. package/dist-server/service/assistant-mode/screen-rules.js +21 -6
  74. package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
  75. package/dist-server/service/data-read/read-target.d.ts +24 -3
  76. package/dist-server/service/data-read/read-target.js +42 -10
  77. package/dist-server/service/data-read/read-target.js.map +1 -1
  78. package/dist-server/service/data-read/read-tools.d.ts +18 -1
  79. package/dist-server/service/data-read/read-tools.js +19 -5
  80. package/dist-server/service/data-read/read-tools.js.map +1 -1
  81. package/dist-server/service/index.d.ts +5 -1
  82. package/dist-server/service/index.js +10 -1
  83. package/dist-server/service/index.js.map +1 -1
  84. package/dist-server/service/people/index.d.ts +4 -0
  85. package/dist-server/service/people/index.js +22 -0
  86. package/dist-server/service/people/index.js.map +1 -0
  87. package/dist-server/service/people/invite-tools.d.ts +34 -0
  88. package/dist-server/service/people/invite-tools.js +144 -0
  89. package/dist-server/service/people/invite-tools.js.map +1 -0
  90. package/dist-server/service/people/people-keys.d.ts +8 -0
  91. package/dist-server/service/people/people-keys.js +16 -0
  92. package/dist-server/service/people/people-keys.js.map +1 -0
  93. package/dist-server/service/people/people-read.d.ts +4 -0
  94. package/dist-server/service/people/people-read.js +57 -0
  95. package/dist-server/service/people/people-read.js.map +1 -0
  96. package/dist-server/service/testing/mode-conformance.js +3 -0
  97. package/dist-server/service/testing/mode-conformance.js.map +1 -1
  98. package/dist-server/tsconfig.tsbuildinfo +1 -1
  99. package/package.json +6 -6
  100. package/server/service/assistant-chat-resolver.ts +8 -4
  101. package/server/service/assistant-mode/conversation-category.test.ts +35 -0
  102. package/server/service/assistant-mode/index.ts +3 -1
  103. package/server/service/assistant-mode/mode-categories.ts +72 -5
  104. package/server/service/assistant-mode/mode-front.test.ts +32 -4
  105. package/server/service/assistant-mode/resolve.test.ts +1 -1
  106. package/server/service/assistant-mode/screen-rules.test.ts +23 -1
  107. package/server/service/assistant-mode/screen-rules.ts +22 -5
  108. package/server/service/data-entry/data-entry.test.ts +2 -2
  109. package/server/service/data-read/detail-lists.test.ts +61 -0
  110. package/server/service/data-read/read-target.ts +54 -9
  111. package/server/service/data-read/read-tools.ts +32 -6
  112. package/server/service/index.ts +7 -1
  113. package/server/service/people/index.ts +13 -0
  114. package/server/service/people/invite-tools.ts +159 -0
  115. package/server/service/people/invite.test.ts +159 -0
  116. package/server/service/people/people-keys.ts +16 -0
  117. package/server/service/people/people-read.test.ts +82 -0
  118. package/server/service/people/people-read.ts +56 -0
  119. package/server/service/testing/mode-conformance.test.ts +14 -1
  120. package/server/service/testing/mode-conformance.ts +2 -0
  121. package/test/assistant-dock.test.ts +73 -0
  122. package/translations/en.json +25 -1
  123. package/translations/es.json +25 -1
  124. package/translations/ja.json +25 -1
  125. package/translations/ko.json +25 -1
  126. package/translations/ms.json +25 -1
  127. package/translations/zh.json +25 -1
@@ -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,54 @@
1
+ import { noteForDisplay, selectedRecords, selectionLabel, selectionNote, SELECTION_KEYS_PREFIX, SELECTION_ROW_PICKER } 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
+ })
31
+
32
+ describe('the rows a list hands over (ox-assistant-selection-bar)', () => {
33
+ it('★ each row goes as the key its list\'s read tool takes - `<axis>:<uuid>`', () => {
34
+ expect(selectedRecords([{ id: 'u1', code: 'H1', name: 'A' }], { axis: 'contract' })).toEqual([{ id: 'contract:u1', label: 'H1 · A' }])
35
+ expect(selectedRecords([{ id: 'u1', name: 'A' }])).toEqual([{ id: 'u1', label: 'A' }])
36
+ })
37
+
38
+ it('saved rows only, each once, in screen order; never the whole row', () => {
39
+ const row = { id: 'a', number: 'INV-1', title: 'T', bankDetails: 'secret' }
40
+ const records = selectedRecords([row, row, { id: 'b', __dirty__: 'M', name: 'B' }, { name: 'no id' }, { id: 'c', name: 'C' }])
41
+ expect(records).toEqual([{ id: 'a', label: 'INV-1 · T' }, { id: 'c', label: 'C' }])
42
+ expect(JSON.stringify(records)).not.toMatch(/secret/)
43
+ })
44
+
45
+ it('a screen may say a row in its own words', () => {
46
+ expect(selectedRecords([{ id: 'q', number: 'Q-7', version: 2 }], { labelOf: row => `${row.number} v${row.version}` })).toEqual([{ id: 'q', label: 'Q-7 v2' }])
47
+ expect(selectionLabel({ code: 'C', description: 'D' })).toBe('C · D')
48
+ expect(selectionLabel({})).toBe('')
49
+ })
50
+
51
+ it('the row picker is the grid\'s own, many at once', () => {
52
+ expect(SELECTION_ROW_PICKER).toEqual({ type: 'gutter', gutterName: 'row-selector', multiple: true })
53
+ })
54
+ })
@@ -0,0 +1,74 @@
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
+ /**
11
+ * The grid's own row picker - a list that hands its rows to the assistant shows it beside `ox-assistant-selection-bar`
12
+ * (data-grist gutter `row-selector`, many at once).
13
+ */
14
+ export const SELECTION_ROW_PICKER = { type: 'gutter', gutterName: 'row-selector', multiple: true }
15
+
16
+ /** A picked row as the person reads it, when the screen gives no words of its own - its code or number, then its name. */
17
+ export function selectionLabel(row: any): string {
18
+ return [row?.code || row?.number, row?.name || row?.title || row?.description]
19
+ .filter(Boolean)
20
+ .join(' · ')
21
+ }
22
+
23
+ /**
24
+ * The rows a person picked, as the assistant is handed them (`askAssistant({ selection })`): saved rows only (an id, no
25
+ * pending edit), each once, in screen order - never whole rows. Given the list's axis, each id is the record's key
26
+ * `<axis>:<uuid>`, the one that list's read tool takes (read-target.ts `recordIdOfKey`): a bare uuid names no tool, and
27
+ * a model read a contract's id with a dashboard's card tool (2026-10-25, Office 미수금).
28
+ */
29
+ export function selectedRecords(rows: any[], options: { axis?: string; labelOf?: (row: any) => string } = {}): { id: string; label: string }[] {
30
+ const { axis, labelOf = selectionLabel } = options
31
+ const seen = new Set<string>()
32
+ return (rows ?? [])
33
+ .filter(row => row?.id && !row.__dirty__ && !seen.has(row.id) && !!seen.add(row.id))
34
+ .map(row => ({ id: axis ? `${axis}:${row.id}` : row.id, label: labelOf(row) || '' }))
35
+ }
36
+
37
+ /** The line the model reads begins with this; the conversation does not draw it. */
38
+ export const SELECTION_KEYS_PREFIX = '[keys]'
39
+
40
+ /** Labels named on the person's line - the rest are counted. */
41
+ export const SELECTION_LABELS_SHOWN = 5
42
+
43
+ export interface AssistantSelection {
44
+ /** The list the records were picked from, as the person knows it (「계약 대장」). */
45
+ title: string
46
+ /** The list's query (`contracts`) - which list the ids belong to, for the model. */
47
+ list?: string
48
+ records: { id: string; label?: string }[]
49
+ }
50
+
51
+ export type SelectionTranslate = (key: string, defaultValue: string, options?: Record<string, unknown>) => string
52
+
53
+ /** The note stored in the conversation - the person's line, then the key line. Empty when nothing was picked. */
54
+ export function selectionNote(selection: AssistantSelection, t: SelectionTranslate): string {
55
+ const seen = new Set<string>()
56
+ const records = (selection?.records ?? []).filter(one => one?.id && !seen.has(one.id) && !!seen.add(one.id))
57
+ if (!records.length) return ''
58
+ const labels = records.map(one => (one.label || '').replace(/\s+/g, ' ').trim()).filter(Boolean)
59
+ const shown = labels.slice(0, SELECTION_LABELS_SHOWN).join(', ')
60
+ const rest = labels.length - Math.min(labels.length, SELECTION_LABELS_SHOWN)
61
+ const line = t('selection-note', 'Selected {count} · {title}', { count: records.length, title: selection.title })
62
+ const named = shown ? `${line}: ${shown}${rest > 0 ? ` ${t('selection-more', 'and {count} more', { count: rest })}` : ''}` : line
63
+ const keys = `${SELECTION_KEYS_PREFIX} ${selection.list ? `${selection.list} ` : ''}${records.map(one => one.id).join(' ')}`
64
+ return `${named}\n${keys}`
65
+ }
66
+
67
+ /** A note as the conversation draws it - without the line only the model reads. */
68
+ export function noteForDisplay(content: string): string {
69
+ return (content ?? '')
70
+ .split('\n')
71
+ .filter(line => !line.startsWith(SELECTION_KEYS_PREFIX))
72
+ .join('\n')
73
+ .trim()
74
+ }
@@ -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. */
@@ -20,7 +20,7 @@ export const assistantSessionTransport: AssistantSessionTransport = {
20
20
  * Every readable conversation of this kind on the anchor, not only mine - the same scope the manager
21
21
  * shows by default (design/ai/shared-dock.md §2-6). Without a kind: every kind, as before.
22
22
  */
23
- query: gql`query($type:String!,$id:String!,$kind:String){chatSessionInbox(anchorType:$type,anchorId:$id,kind:$kind,limit:100){id name kind visibility}}`,
23
+ query: gql`query($type:String!,$id:String!,$kind:String){chatSessionInbox(anchorType:$type,anchorId:$id,kind:$kind,limit:100){id name kind visibility participant}}`,
24
24
  variables: { type: target.anchorType, id: target.anchorId, kind: target.kind || null }, fetchPolicy: 'no-cache'
25
25
  })
26
26
  if (result.errors?.length) throw new Error(result.errors[0].message)
@@ -0,0 +1,31 @@
1
+ /*
2
+ * Pressing an invite card seats the person through the invite door, as the one who pressed (ADR-0073 decision 3).
3
+ * The card is ai-assistant's own tool (server: people/invite-tools.ts), so its handler is registered here, by the
4
+ * package that declared it - the dock knows no proposal kind (design/ai/shared-dock.md §4).
5
+ */
6
+ import gql from 'graphql-tag'
7
+
8
+ import { i18next } from '@operato/i18n'
9
+ import { client } from '@operato/graphql'
10
+
11
+ import { registerProposalHandler } from './assistant-proposals.js'
12
+ import { inviteProposalHandler } from './invite-proposal.js'
13
+
14
+ registerProposalHandler(
15
+ 'proposeInvite',
16
+ inviteProposalHandler(
17
+ async (sessionId, userIds) => {
18
+ const result = await client.mutate({
19
+ mutation: gql`
20
+ mutation ($id: String!, $users: [String!]!) {
21
+ inviteToAISession(sessionId: $id, userIds: $users)
22
+ }
23
+ `,
24
+ variables: { id: sessionId, users: userIds }
25
+ })
26
+ if (result.errors?.length) throw new Error(result.errors[0].message)
27
+ return result.data?.inviteToAISession ?? []
28
+ },
29
+ (key, options) => String(i18next.t(key, options))
30
+ )
31
+ )
@@ -0,0 +1,40 @@
1
+ import { inviteProposalHandler, invitedBy, isInviteProposal } from './invite-proposal'
2
+
3
+ const card = (people: any[], extra: any = {}) => ({ tool: 'proposeInvite', kind: 'invite', people, ...extra })
4
+ const kim = { userId: 'u-1', name: '정연희', department: 'Sales' }
5
+ const lee = { userId: 'u-2', name: '정연희', department: 'Finance' }
6
+ const t = (_key: string, options: any) => String(options.defaultValue).replace('{names}', options.names)
7
+ const context = (sessionId?: string) => ({ sessionId, confirm: async () => true, notify: () => {} }) as any
8
+
9
+ describe('the invite card', () => {
10
+ it('is the invite tool\'s card with someone on it', () => {
11
+ expect(isInviteProposal(card([kim]))).toBe(true)
12
+ expect(isInviteProposal(card([]))).toBe(false)
13
+ expect(isInviteProposal({ ...card([kim]), tool: 'proposeAction' })).toBe(false)
14
+ })
15
+
16
+ it('★ one person: pressing invites them; several: only the one picked - nothing is guessed', () => {
17
+ expect(invitedBy(card([kim]))).toEqual([kim])
18
+ expect(invitedBy(card([kim, lee]))).toEqual([])
19
+ expect(invitedBy(card([kim, lee], { chosen: 'u-2' }))).toEqual([lee])
20
+ })
21
+
22
+ it('★ pressing calls the invite door with the conversation and the person picked', async () => {
23
+ const calls: any[] = []
24
+ const handler = inviteProposalHandler(async (sessionId, userIds) => (calls.push([sessionId, userIds]), userIds), t)
25
+ expect(await handler(card([kim, lee], { chosen: 'u-2' }), context('s1'))).toMatchObject({ verdict: 'accepted', applied: 1, note: '정연희 joined this conversation and was told.' })
26
+ expect(calls).toEqual([['s1', ['u-2']]])
27
+ })
28
+
29
+ it('no pick, no conversation, or nobody seated - said, never accepted', async () => {
30
+ const handler = inviteProposalHandler(async (_s, userIds) => userIds, t)
31
+ expect(await handler(card([kim, lee]), context('s1'))).toEqual({ verdict: 'refused', errorCode: 'no-person-chosen' })
32
+ expect(await handler(card([kim]), context())).toEqual({ verdict: 'unavailable', errorCode: 'no-conversation' })
33
+ expect(await inviteProposalHandler(async () => [], t)(card([kim]), context('s1'))).toEqual({ verdict: 'refused', errorCode: 'already-here' })
34
+ })
35
+
36
+ it("the door's refusal is not swallowed - it is thrown to the dock, which reports it", async () => {
37
+ const handler = inviteProposalHandler(async () => { throw new Error('only the person who started this group conversation can bring people in.') }, t)
38
+ await expect(handler(card([kim]), context('s1'))).rejects.toThrow(/only the person who started/)
39
+ })
40
+ })
@@ -0,0 +1,54 @@
1
+ /*
2
+ * The invite card (server: people/invite-tools.ts) - who it offers, and what pressing it does.
3
+ *
4
+ * Pressing is the person's act: the dock calls `inviteToAISession` with their own request, and the door decides
5
+ * whether they may seat anyone here (`maySeat`). The model's card seats nobody by itself. A card with several people
6
+ * of one name is pressed on one of them - the press carries `chosen`; nothing is guessed.
7
+ */
8
+ import type { ProposalHandler } from './assistant-proposals.js'
9
+
10
+ export interface InvitePerson {
11
+ userId: string
12
+ name: string
13
+ department?: string
14
+ position?: string
15
+ }
16
+
17
+ export function isInviteProposal(proposal: any): boolean {
18
+ return proposal?.kind === 'invite' && proposal?.tool === 'proposeInvite' && Array.isArray(proposal?.people) && proposal.people.length > 0
19
+ }
20
+
21
+ /** The people a press brings in: the one picked, or the only one on the card. */
22
+ export function invitedBy(proposal: any): InvitePerson[] {
23
+ const people: InvitePerson[] = Array.isArray(proposal?.people) ? proposal.people.filter((one: any) => one?.userId) : []
24
+ if (proposal?.chosen) return people.filter(one => one.userId === proposal.chosen)
25
+ return people.length === 1 ? people : []
26
+ }
27
+
28
+ /** One line under a person's name - where they work. */
29
+ export function personLine(person: InvitePerson): string {
30
+ return [person.department, person.position].filter(Boolean).join(' · ')
31
+ }
32
+
33
+ /**
34
+ * Presses the card through the invite door. `invite` is the door - `inviteToAISession(sessionId, userIds)`, answering the
35
+ * ids seated now; it throws the door's refusal, which the dock reports as `refused` with that message.
36
+ */
37
+ export function inviteProposalHandler(
38
+ invite: (sessionId: string, userIds: string[]) => Promise<string[]>,
39
+ t: (key: string, options: Record<string, unknown>) => string
40
+ ): ProposalHandler {
41
+ return async (proposal, context) => {
42
+ const people = invitedBy(proposal)
43
+ if (!people.length) return { verdict: 'refused', errorCode: 'no-person-chosen' }
44
+ if (!context.sessionId) return { verdict: 'unavailable', errorCode: 'no-conversation' }
45
+ const seated = await invite(context.sessionId, people.map(one => one.userId))
46
+ if (!seated.length) return { verdict: 'refused', errorCode: 'already-here' }
47
+ const names = people.filter(one => seated.includes(one.userId)).map(one => one.name).join(', ')
48
+ return {
49
+ verdict: 'accepted',
50
+ applied: seated.length,
51
+ note: t('ai-assistant.people.invited', { names, defaultValue: '{names} joined this conversation and was told.' })
52
+ }
53
+ }
54
+ }
@@ -309,6 +309,15 @@ export declare class OxAssistantChat extends LitElement {
309
309
  text: string;
310
310
  attachments: ChatAttachment[];
311
311
  }): void;
312
+ /**
313
+ * A request made on the screen (「AI와 작업」, design/ai/shared-dock.md §2-11-1) - in this conversation, made now if it
314
+ * has none yet: the selection's note first, then the request, sent at once. What the person was writing is set aside
315
+ * while it goes and put back after, whatever happened. Says whether it was sent and, if not, why.
316
+ */
317
+ sendScreenRequest(text: string, note: string): Promise<{
318
+ sent: boolean;
319
+ reason?: string;
320
+ }>;
312
321
  private errorMessage?;
313
322
  /** C4 presence/roster — 세션 참여자 목록. lastSeenAt 으로 online 판정. */
314
323
  private participants;
@@ -560,6 +569,15 @@ export declare class OxAssistantChat extends LitElement {
560
569
  * 실행 결과는 호스트가 **대화에 기록**한다 — 누가 무엇을 실행했는지가 협의 이력에 남아야 한다.
561
570
  */
562
571
  private renderProposals;
572
+ /** A package's preview under its card - the element is the package's; it is handed the proposal and where it is. */
573
+ private renderProposalPreview;
574
+ /** One preview element per card, kept across renders so it does not read again on every keystroke. */
575
+ private proposalPreviews;
576
+ /**
577
+ * The invite card. Pressing it is the person's act - the dock calls the invite door with their request. With several
578
+ * people of one name each is a button with where they work; the press carries whom they picked.
579
+ */
580
+ private renderInviteCard;
563
581
  /** The screen card - which list, the conditions the screen does not take, and the way to open it. */
564
582
  private renderOpenPageCard;
565
583
  /** The AI 작업판 card - what it holds, which version, and the way to open it. The person opens it; the model does not. */
@@ -5,6 +5,8 @@ import { rosterName } from './roster-identity';
5
5
  import { notify } from '@operato/layout';
6
6
  import './assistant-invite.js';
7
7
  import { TypingRoom } from '../utils/assistant-typing.js';
8
+ import { isInviteProposal, personLine } from '../utils/invite-proposal.js';
9
+ import { noteForDisplay } from '../utils/assistant-selection.js';
8
10
  /**
9
11
  * <ox-assistant-chat> — AI 도우미 채팅 컴포넌트 (Lit).
10
12
  *
@@ -63,6 +65,7 @@ import './entry-review.js';
63
65
  import { entryGroupOf, entryReadyCount, entryTitle, isChangeProposal, isEntryProposal, latestEntryKeys } from '../utils/entry-proposal.js';
64
66
  import { isWorkspaceProposal, versionOfCard, workspaceAddress } from '../utils/workspace.js';
65
67
  import { isOpenPageProposal, openPageNote } from '../utils/open-page.js';
68
+ import { proposalViewOf } from '../utils/assistant-proposals.js';
66
69
  function formatFileSize(bytes) {
67
70
  if (!bytes || bytes < 1024)
68
71
  return `${bytes || 0} B`;
@@ -279,6 +282,12 @@ const CHAT_TYPING_SUBSCRIPTION = gql `
279
282
  }
280
283
  }
281
284
  `;
285
+ /* The selection a screen request carries, noted before the request (design/ai/shared-dock.md §2-11-1). */
286
+ const APPEND_SESSION_NOTE_MUTATION = gql `
287
+ mutation AppendSessionNote($sessionId: String!, $content: String!) {
288
+ appendSessionNote(sessionId: $sessionId, content: $content)
289
+ }
290
+ `;
282
291
  const TOUCH_PRESENCE_MUTATION = gql `
283
292
  mutation TouchPresence($sessionId: String!) {
284
293
  touchPresence(sessionId: $sessionId)
@@ -442,6 +451,8 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
442
451
  this.foldNoticeDismissed = true;
443
452
  this.dispatchEvent(new CustomEvent('assistant-start-new-session', { bubbles: true, composed: true }));
444
453
  };
454
+ /** One preview element per card, kept across renders so it does not read again on every keystroke. */
455
+ this.proposalPreviews = new Map();
445
456
  /**
446
457
  * 실행은 호스트가 한다 — 이 컴포넌트는 도메인(트윈 명령 채널)을 모른다.
447
458
  * 확인 대화·명령 전송·결과 기록은 호스트의 몫이고, 여기서는 중복 클릭만 막는다.
@@ -715,6 +726,47 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
715
726
  this.input = draft.text;
716
727
  this.attachments = draft.attachments;
717
728
  }
729
+ /**
730
+ * A request made on the screen (「AI와 작업」, design/ai/shared-dock.md §2-11-1) - in this conversation, made now if it
731
+ * has none yet: the selection's note first, then the request, sent at once. What the person was writing is set aside
732
+ * while it goes and put back after, whatever happened. Says whether it was sent and, if not, why.
733
+ */
734
+ async sendScreenRequest(text, note) {
735
+ if (this.busy)
736
+ return { sent: false, reason: i18next.t('ai-assistant.text.screen-request-busy', { defaultValue: 'an answer is still being written.' }) };
737
+ const kept = this.takeDraft();
738
+ try {
739
+ if (!this.sessionId && this.prepareSession) {
740
+ await this.prepareSession(id => {
741
+ this.adoptingSession = true;
742
+ this.sessionId = id;
743
+ });
744
+ await this.updateComplete;
745
+ }
746
+ if (!this.sessionId)
747
+ return { sent: false, reason: i18next.t('ai-assistant.text.screen-request-no-session', { defaultValue: 'this screen has no conversation to send to.' }) };
748
+ if (note) {
749
+ const noted = await client.mutate({ mutation: APPEND_SESSION_NOTE_MUTATION, variables: { sessionId: this.sessionId, content: note } });
750
+ if (noted.errors?.length)
751
+ throw new Error(noted.errors[0].message);
752
+ await this.loadHistory();
753
+ }
754
+ this.errorMessage = undefined;
755
+ this.input = text;
756
+ await this.send();
757
+ return this.errorMessage ? { sent: false, reason: this.errorMessage } : { sent: true };
758
+ }
759
+ catch (error) {
760
+ return { sent: false, reason: error instanceof Error ? error.message : String(error) };
761
+ }
762
+ finally {
763
+ /* The request is the screen's - it never stays in the box; the box gets back what the person had written. */
764
+ if (this.input.trim() === text.trim())
765
+ this.input = '';
766
+ if (kept && !this.input.trim() && !this.attachments.length)
767
+ this.restoreDraft(kept);
768
+ }
769
+ }
718
770
  /** 현재 로그인 사용자 email — 그룹챗에서 내 메시지 vs 타인 메시지 구분.
719
771
  * email 을 쓰는 이유: /auth/profile 이 항상 내려주는 로그인 식별자이고(비어 있지 않다),
720
772
  * 내부 관리키(User.id)를 클라이언트 판정에 의존하지 않기 위함. */
@@ -1664,6 +1716,16 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
1664
1716
  --md-icon-size: 13px;
1665
1717
  color: inherit;
1666
1718
  }
1719
+ /* The invite card's people - where each works, quieter than the name; a picked card greys every button. */
1720
+ .proposal-choices .choice-chip .invite-where {
1721
+ margin-left: 4px;
1722
+ opacity: 0.7;
1723
+ font-size: 0.92em;
1724
+ }
1725
+ .proposal-choices .choice-chip:disabled {
1726
+ opacity: 0.5;
1727
+ cursor: default;
1728
+ }
1667
1729
 
1668
1730
  /* The entry card's way to the large review - a quiet action under its table, never a second run button. */
1669
1731
  .workspace-review {
@@ -2924,7 +2986,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
2924
2986
  class="sys-text"
2925
2987
  title=${i18next.t('ai-assistant.text.toggle-system-note', { defaultValue: 'click to expand' })}
2926
2988
  @click=${() => this.toggleSystemNote(idx)}
2927
- >${content}</span
2989
+ >${noteForDisplay(content)}</span
2928
2990
  >`
2929
2991
  : content}
2930
2992
  ${line.attachments && line.attachments.length > 0
@@ -4022,7 +4084,12 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
4022
4084
  if (isOpenPageProposal(p))
4023
4085
  return this.renderOpenPageCard(p);
4024
4086
  const card = proposalCardState(key, { outcomes, requested, sentHere: this.sentProposals.has(key) });
4087
+ /* An invite card: one person, one button; several of one name, one button each - the person picks (ADR-0073 decision 3). */
4088
+ if (isInviteProposal(p))
4089
+ return this.renderInviteCard(p, key, card.state);
4025
4090
  const entry = isEntryProposal(p);
4091
+ /* A package's own card - its preview under the line, its words on the button (assistant-proposals.ts ProposalView). */
4092
+ const view = entry ? undefined : proposalViewOf(p.tool);
4026
4093
  const replaced = entry && latestEntry?.get(entryGroupOf(p)) !== key && card.state === 'ready';
4027
4094
  const ready = entry ? entryReadyCount(p) : 0;
4028
4095
  const sent = card.state !== 'ready' || replaced || (entry && ready === 0);
@@ -4068,7 +4135,9 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
4068
4135
  ? i18next.t('ai-assistant.entry.change', { defaultValue: 'change {count} records', count: ready })
4069
4136
  : i18next.t('ai-assistant.entry.enter', { defaultValue: 'enter {count} rows', count: ready })
4070
4137
  : i18next.t('ai-assistant.entry.nothing-to-enter', { defaultValue: 'nothing to enter' })
4071
- : i18next.t('ai-assistant.button.run-proposal', { defaultValue: 'run' })}
4138
+ : view?.runLabelKey || view?.runLabel
4139
+ ? i18next.t(view.runLabelKey ?? '', { defaultValue: view.runLabel ?? 'run' })
4140
+ : i18next.t('ai-assistant.button.run-proposal', { defaultValue: 'run' })}
4072
4141
  </button>
4073
4142
  `}
4074
4143
  ${ /*
@@ -4085,6 +4154,7 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
4085
4154
  ` : nothing}
4086
4155
  </div>
4087
4156
  ${entry ? html `<ox-assistant-entry-review .proposal=${p}></ox-assistant-entry-review>` : nothing}
4157
+ ${view?.previewTag ? this.renderProposalPreview(view.previewTag, p, key) : nothing}
4088
4158
  ${entry && this.sessionId && offersWorkspaceReview(p)
4089
4159
  ? html `<button
4090
4160
  class="workspace-review"
@@ -4112,6 +4182,51 @@ let OxAssistantChat = class OxAssistantChat extends LitElement {
4112
4182
  </div>
4113
4183
  `;
4114
4184
  }
4185
+ /** A package's preview under its card - the element is the package's; it is handed the proposal and where it is. */
4186
+ renderProposalPreview(tag, p, key) {
4187
+ const element = this.proposalPreviews.get(key) ?? document.createElement(tag);
4188
+ this.proposalPreviews.set(key, element);
4189
+ element.proposal = p;
4190
+ element.sessionId = this.sessionId;
4191
+ element.proposalKey = key;
4192
+ return html `${element}`;
4193
+ }
4194
+ /**
4195
+ * The invite card. Pressing it is the person's act - the dock calls the invite door with their request. With several
4196
+ * people of one name each is a button with where they work; the press carries whom they picked.
4197
+ */
4198
+ renderInviteCard(p, key, state) {
4199
+ const people = p.people;
4200
+ const sent = state !== 'ready';
4201
+ const status = sent ? this.proposalStateText(key) : '';
4202
+ return html `
4203
+ <div class="proposal invite ${sent ? 'sent' : ''}">
4204
+ <md-icon>${p.icon || 'person_add'}</md-icon>
4205
+ <span class="pbody">
4206
+ <span class="plabel">${p.label}</span>
4207
+ ${p.reason ? html `<span class="preason">${p.reason}</span>` : nothing}
4208
+ ${sent && people.length > 1 ? html `<span class="preason">${status}</span>` : nothing}
4209
+ </span>
4210
+ ${people.length === 1
4211
+ ? html `<button class="run" ?disabled=${sent} @click=${() => this.executeProposal(p, key)}>
4212
+ ${sent ? status : i18next.t('ai-assistant.button.invite', { defaultValue: 'Invite' })}
4213
+ </button>`
4214
+ : nothing}
4215
+ </div>
4216
+ ${people.length > 1
4217
+ ? html `<div class="proposal-choices invite-people" role="group" aria-label=${p.label}>
4218
+ ${people.map(person => html `<button
4219
+ class="choice-chip"
4220
+ ?disabled=${sent}
4221
+ title=${personLine(person)}
4222
+ @click=${() => this.executeProposal({ ...p, chosen: person.userId }, key)}>
4223
+ <md-icon>person_add</md-icon>
4224
+ <span>${person.name}${personLine(person) ? html ` <span class="invite-where">${personLine(person)}</span>` : nothing}</span>
4225
+ </button>`)}
4226
+ </div>`
4227
+ : nothing}
4228
+ `;
4229
+ }
4115
4230
  /** The screen card - which list, the conditions the screen does not take, and the way to open it. */
4116
4231
  renderOpenPageCard(p) {
4117
4232
  const note = openPageNote(p, (key, options) => String(i18next.t(key, options)));