@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.
- package/client/components/assistant-chat.ts +115 -2
- package/client/components/assistant-dock.ts +106 -3
- package/client/components/assistant-selection-bar.test.ts +42 -0
- package/client/components/assistant-selection-bar.ts +129 -0
- package/client/components/workspace-view.ts +56 -3
- package/client/index.ts +5 -0
- package/client/utils/assistant-ask.test.ts +38 -0
- package/client/utils/assistant-ask.ts +35 -1
- package/client/utils/assistant-proposals.ts +23 -0
- package/client/utils/assistant-selection.test.ts +54 -0
- package/client/utils/assistant-selection.ts +74 -0
- package/client/utils/assistant-session-controller.ts +2 -0
- package/client/utils/assistant-session-transport.ts +1 -1
- package/client/utils/invite-proposal-register.ts +31 -0
- package/client/utils/invite-proposal.test.ts +40 -0
- package/client/utils/invite-proposal.ts +54 -0
- package/dist-client/components/assistant-chat.d.ts +18 -0
- package/dist-client/components/assistant-chat.js +117 -2
- package/dist-client/components/assistant-chat.js.map +1 -1
- package/dist-client/components/assistant-dock.d.ts +11 -0
- package/dist-client/components/assistant-dock.js +97 -4
- package/dist-client/components/assistant-dock.js.map +1 -1
- package/dist-client/components/assistant-selection-bar.d.ts +44 -0
- package/dist-client/components/assistant-selection-bar.js +149 -0
- package/dist-client/components/assistant-selection-bar.js.map +1 -0
- package/dist-client/components/assistant-selection-bar.test.d.ts +1 -0
- package/dist-client/components/assistant-selection-bar.test.js +38 -0
- package/dist-client/components/assistant-selection-bar.test.js.map +1 -0
- package/dist-client/components/workspace-view.d.ts +2 -0
- package/dist-client/components/workspace-view.js +58 -3
- package/dist-client/components/workspace-view.js.map +1 -1
- package/dist-client/index.d.ts +3 -0
- package/dist-client/index.js +5 -0
- package/dist-client/index.js.map +1 -1
- package/dist-client/tsconfig.tsbuildinfo +1 -1
- package/dist-client/utils/assistant-ask.d.ts +22 -1
- package/dist-client/utils/assistant-ask.js +19 -0
- package/dist-client/utils/assistant-ask.js.map +1 -1
- package/dist-client/utils/assistant-ask.test.d.ts +1 -0
- package/dist-client/utils/assistant-ask.test.js +40 -0
- package/dist-client/utils/assistant-ask.test.js.map +1 -0
- package/dist-client/utils/assistant-proposals.d.ts +14 -0
- package/dist-client/utils/assistant-proposals.js +7 -0
- package/dist-client/utils/assistant-proposals.js.map +1 -1
- package/dist-client/utils/assistant-selection.d.ts +43 -0
- package/dist-client/utils/assistant-selection.js +59 -0
- package/dist-client/utils/assistant-selection.js.map +1 -0
- package/dist-client/utils/assistant-selection.test.d.ts +1 -0
- package/dist-client/utils/assistant-selection.test.js +46 -0
- package/dist-client/utils/assistant-selection.test.js.map +1 -0
- package/dist-client/utils/assistant-session-controller.d.ts +2 -0
- package/dist-client/utils/assistant-session-controller.js.map +1 -1
- package/dist-client/utils/assistant-session-transport.js +1 -1
- package/dist-client/utils/assistant-session-transport.js.map +1 -1
- package/dist-client/utils/invite-proposal-register.d.ts +1 -0
- package/dist-client/utils/invite-proposal-register.js +24 -0
- package/dist-client/utils/invite-proposal-register.js.map +1 -0
- package/dist-client/utils/invite-proposal.d.ts +17 -0
- package/dist-client/utils/invite-proposal.js +37 -0
- package/dist-client/utils/invite-proposal.js.map +1 -0
- package/dist-client/utils/invite-proposal.test.d.ts +1 -0
- package/dist-client/utils/invite-proposal.test.js +35 -0
- package/dist-client/utils/invite-proposal.test.js.map +1 -0
- package/dist-server/service/assistant-chat-resolver.js +7 -3
- package/dist-server/service/assistant-chat-resolver.js.map +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +2 -1
- package/dist-server/service/assistant-mode/index.js +6 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +29 -1
- package/dist-server/service/assistant-mode/mode-categories.js +57 -5
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/assistant-mode/screen-rules.d.ts +6 -0
- package/dist-server/service/assistant-mode/screen-rules.js +21 -6
- package/dist-server/service/assistant-mode/screen-rules.js.map +1 -1
- package/dist-server/service/data-read/read-target.d.ts +24 -3
- package/dist-server/service/data-read/read-target.js +42 -10
- package/dist-server/service/data-read/read-target.js.map +1 -1
- package/dist-server/service/data-read/read-tools.d.ts +18 -1
- package/dist-server/service/data-read/read-tools.js +19 -5
- package/dist-server/service/data-read/read-tools.js.map +1 -1
- package/dist-server/service/index.d.ts +5 -1
- package/dist-server/service/index.js +10 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/people/index.d.ts +4 -0
- package/dist-server/service/people/index.js +22 -0
- package/dist-server/service/people/index.js.map +1 -0
- package/dist-server/service/people/invite-tools.d.ts +34 -0
- package/dist-server/service/people/invite-tools.js +144 -0
- package/dist-server/service/people/invite-tools.js.map +1 -0
- package/dist-server/service/people/people-keys.d.ts +8 -0
- package/dist-server/service/people/people-keys.js +16 -0
- package/dist-server/service/people/people-keys.js.map +1 -0
- package/dist-server/service/people/people-read.d.ts +4 -0
- package/dist-server/service/people/people-read.js +57 -0
- package/dist-server/service/people/people-read.js.map +1 -0
- package/dist-server/service/testing/mode-conformance.js +3 -0
- package/dist-server/service/testing/mode-conformance.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +6 -6
- package/server/service/assistant-chat-resolver.ts +8 -4
- package/server/service/assistant-mode/conversation-category.test.ts +35 -0
- package/server/service/assistant-mode/index.ts +3 -1
- package/server/service/assistant-mode/mode-categories.ts +72 -5
- package/server/service/assistant-mode/mode-front.test.ts +32 -4
- package/server/service/assistant-mode/resolve.test.ts +1 -1
- package/server/service/assistant-mode/screen-rules.test.ts +23 -1
- package/server/service/assistant-mode/screen-rules.ts +22 -5
- package/server/service/data-entry/data-entry.test.ts +2 -2
- package/server/service/data-read/detail-lists.test.ts +61 -0
- package/server/service/data-read/read-target.ts +54 -9
- package/server/service/data-read/read-tools.ts +32 -6
- package/server/service/index.ts +7 -1
- package/server/service/people/index.ts +13 -0
- package/server/service/people/invite-tools.ts +159 -0
- package/server/service/people/invite.test.ts +159 -0
- package/server/service/people/people-keys.ts +16 -0
- package/server/service/people/people-read.test.ts +82 -0
- package/server/service/people/people-read.ts +56 -0
- package/server/service/testing/mode-conformance.test.ts +14 -1
- package/server/service/testing/mode-conformance.ts +2 -0
- package/test/assistant-dock.test.ts +73 -0
- package/translations/en.json +25 -1
- package/translations/es.json +25 -1
- package/translations/ja.json +25 -1
- package/translations/ko.json +25 -1
- package/translations/ms.json +25 -1
- 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
|
-
|
|
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
|
-
:
|
|
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)));
|