@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
|
@@ -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
|
-
:
|
|
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)
|
|
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
|
-
|
|
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 }
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* 「AI와 작업」 over a list (ox-assistant-selection-bar) - one bar for every application's lists. What it is held to here is
|
|
3
|
+
* its source and its words; its behaviour on a real grid is held by the applications' browser tests (operato-office
|
|
4
|
+
* test/browser/page-ai-input.test.js).
|
|
5
|
+
*/
|
|
6
|
+
import { readFileSync } from 'node:fs'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
|
|
9
|
+
const bare = readFileSync(join(__dirname, 'assistant-selection-bar.ts'), 'utf-8').replace(/\/\*[\s\S]*?\*\//g, '')
|
|
10
|
+
|
|
11
|
+
describe('the selection bar', () => {
|
|
12
|
+
it('★ counts every way a grid picks - a row, the header\'s select-all, a new page of rows', () => {
|
|
13
|
+
expect(bare).toMatch(/GRID_EVENTS = \['select-record-change', 'select-all-change', 'list-adopted'\]/)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('★ asks for its grid when it needs it - a page finds its grid only after its first render', () => {
|
|
17
|
+
expect(bare).toMatch(/gridOf\?: \(\) => SelectionGrid \| undefined/)
|
|
18
|
+
expect(bare).not.toMatch(/@property\(\{ attribute: false \}\) grist/)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
it('★ hands the rows as keys of the list, to this screen\'s conversation, sent at once', () => {
|
|
22
|
+
expect(bare).toMatch(/selection: \{ title: this\.name, list: this\.list, records: selectedRecords\(rows, \{ axis: this\.axis, labelOf: this\.labelOf \}\) \}/)
|
|
23
|
+
expect(bare).toMatch(/ask: request\?\.trim\(\) \|\| i18next\.t\('ai-assistant\.selection\.ask'/)
|
|
24
|
+
expect(bare).not.toMatch(/draft|continueCurrent|transcript/)
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('keeps the words unless the request was sent', () => {
|
|
28
|
+
expect(bare).toMatch(/if \(outcome\?\.sent && this\.request === request\) this\.request = ''/)
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
it('every word it shows reads in every language', () => {
|
|
32
|
+
const keys = [...new Set([...bare.matchAll(/'(ai-assistant\.selection\.[\w-]+)'/g)].map(([, key]) => key))]
|
|
33
|
+
expect(keys.length).toBe(7)
|
|
34
|
+
for (const locale of ['en', 'ko', 'ja', 'zh', 'ms', 'es']) {
|
|
35
|
+
const words = JSON.parse(readFileSync(join(__dirname, `../../translations/${locale}.json`), 'utf-8'))
|
|
36
|
+
for (const key of keys) expect([locale, key, typeof words[key]]).toEqual([locale, key, 'string'])
|
|
37
|
+
expect(words['ai-assistant.selection.ask']).toMatch(/\{n\}/)
|
|
38
|
+
expect(words['ai-assistant.selection.ask']).toMatch(/\{title\}/)
|
|
39
|
+
expect(words['ai-assistant.selection.count']).toMatch(/\{n\}/)
|
|
40
|
+
}
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { css, html, LitElement, nothing } from 'lit'
|
|
2
|
+
import { customElement, property, state } from 'lit/decorators.js'
|
|
3
|
+
|
|
4
|
+
import { i18next } from '@operato/i18n'
|
|
5
|
+
import { notify } from '@operato/layout'
|
|
6
|
+
import { ButtonStyles, FormFieldStyles } from '@operato/styles'
|
|
7
|
+
|
|
8
|
+
import { askAssistant } from '../utils/assistant-ask.js'
|
|
9
|
+
import { selectedRecords } from '../utils/assistant-selection.js'
|
|
10
|
+
|
|
11
|
+
/** What a grid says when its picks or its rows change - a row's picker, the header's select-all, a new page of rows. */
|
|
12
|
+
const GRID_EVENTS = ['select-record-change', 'select-all-change', 'list-adopted']
|
|
13
|
+
|
|
14
|
+
/** The part of a grid (data-grist `ox-grist`) the bar reads. */
|
|
15
|
+
export interface SelectionGrid extends HTMLElement {
|
|
16
|
+
selected?: any[]
|
|
17
|
+
updateComplete?: Promise<unknown>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 「AI와 작업」 over a list - one bar for every application's lists (design/ai/shared-dock.md §2-11-1). While rows are
|
|
22
|
+
* picked it shows how many, what the list itself does with them, and a request sent to this screen's conversation with
|
|
23
|
+
* the rows as the selection. Begun on the Office ledgers; the user, 2026-10-25: 「'AI와 작업' 기능을 사용하려면, 그리드
|
|
24
|
+
* 선택기능이 필요한데, 주요 리스트 화면에서 선택기능이 없는 경우가 꽤 있다」, and it is to serve plant, warehouse and twin.
|
|
25
|
+
*
|
|
26
|
+
* A screen gives the grid (asked for when needed - a page finds its grid only after its first render), the list as the
|
|
27
|
+
* person knows it, the list query and its axis (the last part of the list's read key, so a row goes as `<axis>:<uuid>`),
|
|
28
|
+
* and, if it likes, its own actions and words for a row. The grid shows `SELECTION_ROW_PICKER`.
|
|
29
|
+
*/
|
|
30
|
+
@customElement('ox-assistant-selection-bar')
|
|
31
|
+
export class OxAssistantSelectionBar extends LitElement {
|
|
32
|
+
static styles = [
|
|
33
|
+
ButtonStyles,
|
|
34
|
+
FormFieldStyles,
|
|
35
|
+
css`
|
|
36
|
+
:host { display:block; }
|
|
37
|
+
.selection-work { display:flex; flex-wrap:wrap; align-items:center; gap:var(--spacing-medium); padding:var(--spacing-medium) var(--spacing-large); border-bottom:1px solid var(--ui-line); background:var(--ui-panel); }
|
|
38
|
+
.scope { font-size:var(--ui-t-body); font-weight:600; white-space:nowrap; }
|
|
39
|
+
form { display:flex; flex:1 1 320px; min-width:0; gap:var(--spacing-small); }
|
|
40
|
+
input { flex:1; min-width:0; width:100%; }
|
|
41
|
+
.tools { display:flex; flex-wrap:wrap; gap:var(--spacing-small); }
|
|
42
|
+
`
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
/** The grid whose picked rows these are, asked for when needed. */
|
|
46
|
+
@property({ attribute: false }) gridOf?: () => SelectionGrid | undefined
|
|
47
|
+
/** The list as the person knows it (「계약 대장」). */
|
|
48
|
+
@property() name = ''
|
|
49
|
+
/** The list query the rows came from (`contracts`) - one the assistant reads. */
|
|
50
|
+
@property() list = ''
|
|
51
|
+
/** The last part of that list's read key (`office.contract` → `contract`): each row goes as `<axis>:<uuid>`. */
|
|
52
|
+
@property() axis = ''
|
|
53
|
+
/** What the list itself does with the picked rows, before 「AI와 작업」. */
|
|
54
|
+
@property({ attribute: false }) actions: { title: string; action: () => any }[] = []
|
|
55
|
+
/** A row in the person's words, when the code and name do not say it (`selectionLabel`). */
|
|
56
|
+
@property({ attribute: false }) labelOf?: (row: any) => string
|
|
57
|
+
|
|
58
|
+
@state() count = 0
|
|
59
|
+
@state() request = ''
|
|
60
|
+
|
|
61
|
+
private get grid() {
|
|
62
|
+
return this.gridOf?.()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/* The grid's own events reach the page's root (bubbling, composed); the bar counts those that come from its grid. */
|
|
66
|
+
private root?: Node
|
|
67
|
+
private recount = async (event?: Event) => {
|
|
68
|
+
const grid = this.grid
|
|
69
|
+
if (event && (!grid || !event.composedPath().includes(grid))) return
|
|
70
|
+
await grid?.updateComplete
|
|
71
|
+
this.count = grid?.selected?.length ?? 0
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
connectedCallback() {
|
|
75
|
+
super.connectedCallback()
|
|
76
|
+
this.root = this.getRootNode()
|
|
77
|
+
for (const event of GRID_EVENTS) this.root.addEventListener(event, this.recount)
|
|
78
|
+
void this.recount()
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
disconnectedCallback() {
|
|
82
|
+
super.disconnectedCallback()
|
|
83
|
+
for (const event of GRID_EVENTS) this.root?.removeEventListener(event, this.recount)
|
|
84
|
+
this.root = undefined
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
willUpdate(changes: Map<string, unknown>) {
|
|
88
|
+
if (changes.has('gridOf')) void this.recount()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/*
|
|
92
|
+
* A screen request (design/ai/shared-dock.md §2-11-1, the user: 「"AI와 작업" 기능이 unstable 하다 … 새로운 세션을 여는지,
|
|
93
|
+
* 기존 세션을 활용하는지 등등을 명확하게 하라」): it goes to this screen's conversation with the rows picked now, in
|
|
94
|
+
* screen order, and is sent at once - what was written on the bar, or the set request. The bar is cleared only when it
|
|
95
|
+
* was sent; otherwise the dock has said why and the words stay to press again.
|
|
96
|
+
*/
|
|
97
|
+
async ask(request: string = this.request) {
|
|
98
|
+
const rows = this.grid?.selected ?? []
|
|
99
|
+
if (!rows.length) {
|
|
100
|
+
notify({ message: i18next.t('ai-assistant.selection.pick-rows'), level: 'info' })
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
if (rows.some(row => !row.id || row.__dirty__)) {
|
|
104
|
+
notify({ message: i18next.t('ai-assistant.selection.save-first'), level: 'warn' })
|
|
105
|
+
return
|
|
106
|
+
}
|
|
107
|
+
const outcome = await askAssistant({
|
|
108
|
+
selection: { title: this.name, list: this.list, records: selectedRecords(rows, { axis: this.axis, labelOf: this.labelOf }) },
|
|
109
|
+
ask: request?.trim() || i18next.t('ai-assistant.selection.ask', { n: rows.length, title: this.name })
|
|
110
|
+
})
|
|
111
|
+
if (outcome?.sent && this.request === request) this.request = ''
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
render() {
|
|
115
|
+
return this.count
|
|
116
|
+
? html`<div class="selection-work" role="region" aria-label=${i18next.t('ai-assistant.selection.region')}>
|
|
117
|
+
<span class="scope" role="status">${i18next.t('ai-assistant.selection.count', { n: this.count })}</span>
|
|
118
|
+
${this.actions.length
|
|
119
|
+
? html`<div class="tools">${this.actions.map(action => html`<button quiet small @click=${action.action}>${action.title}</button>`)}</div>`
|
|
120
|
+
: nothing}
|
|
121
|
+
<form @submit=${(event: Event) => { event.preventDefault(); this.ask() }}>
|
|
122
|
+
<input aria-label=${i18next.t('ai-assistant.selection.request')} placeholder=${i18next.t('ai-assistant.selection.request')}
|
|
123
|
+
.value=${this.request} @input=${(event: any) => (this.request = event.target.value)} />
|
|
124
|
+
<button quiet small type="submit">${i18next.t('ai-assistant.selection.work-with-ai')}</button>
|
|
125
|
+
</form>
|
|
126
|
+
</div>`
|
|
127
|
+
: nothing
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -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">${
|
|
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,12 @@ 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'
|
|
27
|
+
/* 「AI와 작업」 over a list - every application's lists hand their picked rows by it (shared-dock §2-11-1). */
|
|
28
|
+
export * from './components/assistant-selection-bar.js'
|
|
24
29
|
export * from './utils/assistant-activity.js'
|
|
25
30
|
export * from './utils/assistant-request-context.js'
|
|
26
31
|
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
|
+
})
|