@things-factory/ai-assistant 10.1.79 → 10.1.87

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 (89) hide show
  1. package/client/components/assistant-chat.ts +102 -13
  2. package/client/components/assistant-dock.ts +206 -18
  3. package/client/components/assistant-mode.test.ts +16 -2
  4. package/client/components/assistant-session-manager.ts +58 -26
  5. package/client/components/assistant-session-toolbar.ts +4 -2
  6. package/client/components/chat-echo-dedup.ts +4 -0
  7. package/client/components/proposal-outcome-line.ts +36 -0
  8. package/client/components/proposal-request.test.ts +52 -0
  9. package/client/utils/assistant-navigation.test.ts +38 -0
  10. package/client/utils/assistant-navigation.ts +6 -0
  11. package/client/utils/assistant-session-controller.ts +2 -0
  12. package/client/utils/assistant-session-transport.ts +2 -2
  13. package/client/utils/assistant-surface.ts +21 -0
  14. package/dist-client/components/assistant-chat.d.ts +15 -0
  15. package/dist-client/components/assistant-chat.js +106 -13
  16. package/dist-client/components/assistant-chat.js.map +1 -1
  17. package/dist-client/components/assistant-dock.d.ts +16 -0
  18. package/dist-client/components/assistant-dock.js +221 -20
  19. package/dist-client/components/assistant-dock.js.map +1 -1
  20. package/dist-client/components/assistant-mode.test.js +14 -2
  21. package/dist-client/components/assistant-mode.test.js.map +1 -1
  22. package/dist-client/components/assistant-session-manager.d.ts +4 -0
  23. package/dist-client/components/assistant-session-manager.js +74 -27
  24. package/dist-client/components/assistant-session-manager.js.map +1 -1
  25. package/dist-client/components/assistant-session-toolbar.d.ts +1 -0
  26. package/dist-client/components/assistant-session-toolbar.js +8 -2
  27. package/dist-client/components/assistant-session-toolbar.js.map +1 -1
  28. package/dist-client/components/chat-echo-dedup.d.ts +2 -0
  29. package/dist-client/components/chat-echo-dedup.js +2 -0
  30. package/dist-client/components/chat-echo-dedup.js.map +1 -1
  31. package/dist-client/components/proposal-outcome-line.d.ts +29 -0
  32. package/dist-client/components/proposal-outcome-line.js +15 -0
  33. package/dist-client/components/proposal-outcome-line.js.map +1 -1
  34. package/dist-client/components/proposal-request.test.d.ts +1 -0
  35. package/dist-client/components/proposal-request.test.js +45 -0
  36. package/dist-client/components/proposal-request.test.js.map +1 -0
  37. package/dist-client/tsconfig.tsbuildinfo +1 -1
  38. package/dist-client/utils/assistant-navigation.d.ts +6 -0
  39. package/dist-client/utils/assistant-navigation.js +9 -0
  40. package/dist-client/utils/assistant-navigation.js.map +1 -0
  41. package/dist-client/utils/assistant-navigation.test.d.ts +1 -0
  42. package/dist-client/utils/assistant-navigation.test.js +37 -0
  43. package/dist-client/utils/assistant-navigation.test.js.map +1 -0
  44. package/dist-client/utils/assistant-session-controller.d.ts +2 -0
  45. package/dist-client/utils/assistant-session-controller.js.map +1 -1
  46. package/dist-client/utils/assistant-session-transport.js +2 -2
  47. package/dist-client/utils/assistant-session-transport.js.map +1 -1
  48. package/dist-client/utils/assistant-surface.d.ts +19 -0
  49. package/dist-client/utils/assistant-surface.js.map +1 -1
  50. package/dist-server/service/assistant-chat-resolver.js +1 -0
  51. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  52. package/dist-server/service/chat-message/chat-message.d.ts +10 -0
  53. package/dist-server/service/chat-message/chat-message.js +29 -0
  54. package/dist-server/service/chat-message/chat-message.js.map +1 -1
  55. package/dist-server/service/chat-message/proposal-outcome.d.ts +8 -0
  56. package/dist-server/service/chat-message/proposal-outcome.js +35 -1
  57. package/dist-server/service/chat-message/proposal-outcome.js.map +1 -1
  58. package/dist-server/service/chat-session/chat-session.d.ts +5 -5
  59. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  60. package/dist-server/service/chat-session/session-inbox.d.ts +7 -0
  61. package/dist-server/service/chat-session/session-inbox.js +7 -0
  62. package/dist-server/service/chat-session/session-inbox.js.map +1 -1
  63. package/dist-server/service/chat-session/session-visibility.d.ts +8 -5
  64. package/dist-server/service/chat-session/session-visibility.js +9 -15
  65. package/dist-server/service/chat-session/session-visibility.js.map +1 -1
  66. package/dist-server/service/chat-session-resolver.d.ts +1 -0
  67. package/dist-server/service/chat-session-resolver.js +64 -10
  68. package/dist-server/service/chat-session-resolver.js.map +1 -1
  69. package/dist-server/tsconfig.tsbuildinfo +1 -1
  70. package/package.json +3 -3
  71. package/server/service/assistant-chat-resolver.ts +1 -0
  72. package/server/service/chat-message/chat-message.ts +32 -0
  73. package/server/service/chat-message/proposal-outcome.test.ts +53 -2
  74. package/server/service/chat-message/proposal-outcome.ts +33 -0
  75. package/server/service/chat-session/chat-session.ts +5 -5
  76. package/server/service/chat-session/session-inbox.ts +7 -0
  77. package/server/service/chat-session/session-visibility.test.ts +6 -6
  78. package/server/service/chat-session/session-visibility.ts +9 -15
  79. package/server/service/chat-session-resolver.ts +72 -13
  80. package/server/service/session-anchor.test.ts +2 -2
  81. package/test/assistant-dock.test.ts +8 -3
  82. package/test/assistant-session-toolbar.test.ts +7 -1
  83. package/test/open-doors.test.ts +2 -1
  84. package/test/session-discovery.test.ts +42 -0
  85. package/translations/en.json +28 -2
  86. package/translations/ja.json +28 -2
  87. package/translations/ko.json +28 -2
  88. package/translations/ms.json +28 -2
  89. package/translations/zh.json +28 -2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.79",
3
+ "version": "10.1.87",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -33,7 +33,7 @@
33
33
  "@operato/popup": "^10.16.0",
34
34
  "@operato/shell": "^10.0.0",
35
35
  "@operato/styles": "^10.0.0",
36
- "@things-factory/ai-client-base": "^10.1.53",
36
+ "@things-factory/ai-client-base": "^10.1.81",
37
37
  "@things-factory/apptool-base": "^10.1.79",
38
38
  "@things-factory/attachment-base": "^10.1.79",
39
39
  "@things-factory/auth-base": "^10.1.79",
@@ -51,5 +51,5 @@
51
51
  "copyfiles": "^2.4.1",
52
52
  "rimraf": "^5.0.0"
53
53
  },
54
- "gitHead": "94cde9ba17dfdddb0a16139b71e5a7e370977e4a"
54
+ "gitHead": "dc552c2a7b7f97e3377a26c378ee15d3a1de612a"
55
55
  }
@@ -521,6 +521,7 @@ export class AssistantChatResolver {
521
521
  */
522
522
  doors: answerDoors,
523
523
  toolUsages: result.loop.toolUsages?.length ? JSON.stringify(result.loop.toolUsages) : undefined,
524
+ proposals: result.loop.proposals?.length ? JSON.stringify(result.loop.proposals) : undefined,
524
525
  groundingWarnings: result.groundingWarnings?.length ? JSON.stringify(result.groundingWarnings) : undefined,
525
526
  creator: user,
526
527
  updater: user,
@@ -151,6 +151,38 @@ export class ChatMessage {
151
151
  }
152
152
  }
153
153
 
154
+ /**
155
+ * The proposals this answer made - JSON-stringified, like `toolUsages` (ADR-0097 Q-D4 ④).
156
+ *
157
+ * A card lived only in the mutation's answer, so a refresh took it away, and with it the one place the
158
+ * person could see that a proposal was requested and never answered. Stored, the card comes back with
159
+ * its state read from the conversation's rows. Cleared with the body when the row is redacted
160
+ * (`message-doors.ts` VALUE_CARRYING) - a proposal carries the same values as the answer.
161
+ */
162
+ @Column({
163
+ type:
164
+ DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
165
+ ? 'longtext'
166
+ : DATABASE_TYPE == 'oracle'
167
+ ? 'clob'
168
+ : DATABASE_TYPE == 'mssql'
169
+ ? 'nvarchar'
170
+ : 'text',
171
+ length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined,
172
+ nullable: true
173
+ })
174
+ proposals?: string
175
+
176
+ @Field(() => GraphQLJSON, { nullable: true, description: 'Proposals this answer made - the cards the panel draws. Only on assistant messages.' })
177
+ get proposalsJson(): any {
178
+ if (!this.proposals) return null
179
+ try {
180
+ return JSON.parse(this.proposals)
181
+ } catch {
182
+ return null
183
+ }
184
+ }
185
+
154
186
  /**
155
187
  * 접지 경고 — 이 답이 언급했으나 근거(프롬프트·보드 문맥·이력·도구 결과)에 없던 식별자 목록.
156
188
  *
@@ -10,8 +10,8 @@
10
10
  * root and move every emitted path (`dist-client/client/…`). The server build excludes tests, so
11
11
  * reaching the other way costs nothing.
12
12
  */
13
- import { formatProposalOutcomeLine, PROPOSAL_VERDICTS } from './proposal-outcome'
14
- import { parseProposalOutcomeLine } from '../../../client/components/proposal-outcome-line'
13
+ import { formatProposalOutcomeLine, formatProposalRequestedLine, proposalAlreadyTaken, PROPOSAL_VERDICTS } from './proposal-outcome'
14
+ import { parseProposalOutcomeLine, parseProposalRequestLine, proposalCardState } from '../../../client/components/proposal-outcome-line'
15
15
 
16
16
  const key = 'proposeSceneEdit|{"kind":"scene-edit"}'
17
17
 
@@ -46,3 +46,54 @@ it('a key containing colons and spaces still parses', () => {
46
46
 
47
47
  expect(parseProposalOutcomeLine(line)?.key).toBe(awkward)
48
48
  })
49
+
50
+ /*
51
+ * ADR-0097 Q-D4 ④ — a proposal is requested once, and a lost answer never becomes a second application.
52
+ */
53
+ describe('requested', () => {
54
+ const uuid = '3f1c2a90-3b4d-4e5f-8a6b-7c8d9e0f1a2b'
55
+
56
+ it('★ the requested line round-trips, and neither line is read as the other', () => {
57
+ const requested = formatProposalRequestedLine(uuid)
58
+ expect(parseProposalRequestLine(requested)).toBe(uuid)
59
+ expect(parseProposalOutcomeLine(requested)).toBeUndefined()
60
+ expect(parseProposalRequestLine(formatProposalOutcomeLine(uuid, { verdict: 'accepted' }))).toBeUndefined()
61
+ })
62
+
63
+ it('★ a proposal is taken once it is requested or answered — then the host is not asked again', () => {
64
+ expect(proposalAlreadyTaken(uuid, [])).toBe(false)
65
+ expect(proposalAlreadyTaken(uuid, ['User directly edited the board', null])).toBe(false)
66
+ expect(proposalAlreadyTaken(uuid, [formatProposalRequestedLine(uuid)])).toBe(true)
67
+ for (const verdict of PROPOSAL_VERDICTS) {
68
+ expect(proposalAlreadyTaken(uuid, [formatProposalOutcomeLine(uuid, { verdict, errorCode: 'x', applied: 1, missed: 1 })])).toBe(true)
69
+ }
70
+ })
71
+
72
+ it('another proposal is not taken by this one, even when one key begins with the other', () => {
73
+ expect(proposalAlreadyTaken('a', [formatProposalRequestedLine('a: b')])).toBe(false)
74
+ expect(proposalAlreadyTaken('a', [formatProposalOutcomeLine('a: b', { verdict: 'accepted' })])).toBe(false)
75
+ expect(proposalAlreadyTaken(uuid, [formatProposalRequestedLine('other')])).toBe(false)
76
+ })
77
+ })
78
+
79
+ describe('the card state', () => {
80
+ const none = { outcomes: new Map(), requested: new Set<string>(), sentHere: false }
81
+
82
+ it('nothing taken — the run button', () => {
83
+ expect(proposalCardState('k', none).state).toBe('ready')
84
+ })
85
+
86
+ it('this page asked and no answer came — applying, however long it has been', () => {
87
+ expect(proposalCardState('k', { ...none, requested: new Set(['k']), sentHere: true }).state).toBe('applying')
88
+ })
89
+
90
+ it('★ requested before a refresh or elsewhere, with no answer — "check result", never "run"', () => {
91
+ expect(proposalCardState('k', { ...none, requested: new Set(['k']) }).state).toBe('unconfirmed')
92
+ })
93
+
94
+ it('★ an answer wins over everything', () => {
95
+ const outcome = parseProposalOutcomeLine(formatProposalOutcomeLine('k', { verdict: 'refused', errorCode: 'stale' }))!
96
+ const state = proposalCardState('k', { outcomes: new Map([['k', outcome]]), requested: new Set(['k']), sentHere: true })
97
+ expect(state).toEqual({ state: 'answered', outcome })
98
+ })
99
+ })
@@ -40,3 +40,36 @@ export function formatProposalOutcomeLine(key: string, outcome: ProposalOutcome)
40
40
  : ''
41
41
  return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${outcome.verdict}${reason}${counted}`
42
42
  }
43
+
44
+ /*
45
+ * "Requested" — written before the host is asked to run a proposal (ADR-0097 Q-D4 ④).
46
+ *
47
+ * The card used to remember that it was pressed in the page's memory only. After a refresh a
48
+ * proposal whose outcome never came back had its button again, and pressing it asked the host to
49
+ * apply it a second time. The request is now a row, like the outcome, so every reader sees it: the
50
+ * card after a refresh, another window, and the model ("requested, no answer yet").
51
+ *
52
+ * It is not a verdict — nothing happened yet. It only says the host was asked.
53
+ */
54
+ export const PROPOSAL_REQUESTED = 'requested'
55
+
56
+ /** `proposal <key>: requested` */
57
+ export function formatProposalRequestedLine(key: string): string {
58
+ return `${PROPOSAL_OUTCOME_PREFIX}${key}: ${PROPOSAL_REQUESTED}`
59
+ }
60
+
61
+ /**
62
+ * Whether this proposal was already taken in this conversation — requested, or answered.
63
+ * The caller passes the conversation's system lines; the answer is the rule, kept testable.
64
+ */
65
+ export function proposalAlreadyTaken(key: string, systemLines: readonly (string | null | undefined)[]): boolean {
66
+ const requested = formatProposalRequestedLine(key)
67
+ const answered = `${PROPOSAL_OUTCOME_PREFIX}${key}: `
68
+ return systemLines.some(line => {
69
+ const text = (line ?? '').trim()
70
+ if (text === requested) return true
71
+ if (!text.startsWith(answered)) return false
72
+ const rest = text.slice(answered.length)
73
+ return PROPOSAL_VERDICTS.some(verdict => rest === verdict || rest.startsWith(`${verdict} `))
74
+ })
75
+ }
@@ -146,12 +146,12 @@ export class ChatSession {
146
146
  * `open` 도메인 안이면 누구나 들어와 읽고 말한다. 초대 절차는 없다.
147
147
  * `private` 참여자만 읽는다.
148
148
  *
149
- * 앵커가 있는 세션(space · board · station)은 공유된 무엇을 놓고 하는 이야기이므로 `open` 으로
150
- * 시작한다. 앵커가 없는 세션과 **옮겨 적어 만든 세션**은 `private` 으로 시작한다 — 가벼운 질문을
151
- * 승격하면서 앞선 대화가 새 세션에 옮겨 적히는데(`appendSessionTurns`), 그것이 공개로 태어나면
152
- * 사적으로 오간 원문이 소급해서 공개된다.
149
+ * 새 세션은 **모두** `private` 으로 시작한다(2026-09-30 — `initialVisibility`). 앵커는 무엇에 대한
150
+ * 이야기인지를 말할 뿐 누구에게 보이는지를 정하지 않는다. 칸의 기본값 `open` 은 칸이 서기 전의 옛 행을
151
+ * 그날까지의 규칙대로 읽기 위한 것이고, 새 행은 늘 값을 적는다.
153
152
  *
154
- * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보인다.
153
+ * owner 가 양방향으로 바꿀 수 있다. 공개로 바꾸기 전에 화면이 한 줄을 먼저 보이고, 공개 대화는 도크가
154
+ * 다르게 그린다.
155
155
  *
156
156
  * open 이어도 답 행의 문 판정(ADR-0073 판정 1)은 그대로다 — 방에 들어온 것과 값을 읽는 것은 다르다.
157
157
  */
@@ -179,6 +179,13 @@ export function searchInbox(entries: InboxEntry[], search?: string): InboxEntry[
179
179
  )
180
180
  }
181
181
 
182
+ /** Only readable user/assistant prose participates; never match a redaction notice or execution log. */
183
+ export function searchReadableMessages(rows: readonly { sessionId?: string; content?: string; role?: string; redacted?: boolean }[], search: string): Set<string> {
184
+ const q = search.trim().toLowerCase()
185
+ return new Set(rows.filter(row => q && !row.redacted && (row.role === 'user' || row.role === 'assistant') && row.content?.toLowerCase().includes(q))
186
+ .map(row => row.sessionId).filter((id): id is string => !!id))
187
+ }
188
+
182
189
  /** 한 번에 훑는 세션 상한 — 이보다 많으면 잘렸다고 알린다(정직). */
183
190
  export const INBOX_SCAN_CAP = 500
184
191
 
@@ -6,10 +6,10 @@
6
6
  import { initialVisibility, mayReadSession } from './session-visibility'
7
7
 
8
8
  describe('session visibility', () => {
9
- it('앵커가 있는 대화는 공개로 시작한다 — 공유된 대상을 놓고 하는 이야기다', () => {
10
- expect(initialVisibility({ anchorType: 'space' })).toBe('open')
11
- expect(initialVisibility({ anchorType: 'board' })).toBe('open')
12
- expect(initialVisibility({ anchorType: 'station' })).toBe('open')
9
+ it('★ 앵커가 있어도 비공개로 시작한다 — 앵커는 무엇에 대한 이야기인지이지 누구에게 보이는지가 아니다', () => {
10
+ for (const anchorType of ['space', 'board', 'station', 'application', 'domain', 'subject', 'figure', 'instance']) {
11
+ expect(initialVisibility({ anchorType })).toBe('private')
12
+ }
13
13
  })
14
14
 
15
15
  it('★ 옮겨 적어 만든 대화는 비공개로 시작한다 — 원문이 소급 공개되지 않는다', () => {
@@ -46,8 +46,8 @@ describe("'none' is not an anchor", () => {
46
46
  expect(initialVisibility({ anchorType: 'none' })).toBe('private')
47
47
  })
48
48
 
49
- it('대상이 있는 대화는 그대로 공개다', () => {
50
- expect(initialVisibility({ anchorType: 'space' })).toBe('open')
49
+ it('대상이 있는 대화도 비공개다', () => {
50
+ expect(initialVisibility({ anchorType: 'space' })).toBe('private')
51
51
  })
52
52
  })
53
53
 
@@ -9,23 +9,17 @@ export type SessionVisibility = 'open' | 'private'
9
9
  export const SESSION_VISIBILITIES: readonly SessionVisibility[] = ['open', 'private']
10
10
 
11
11
  /**
12
- * 이렇게 만들어진 세션은 무엇으로 시작하나.
12
+ * 이렇게 만들어진 세션은 무엇으로 시작하나 — **언제나 비공개.**
13
13
  *
14
- * 앵커가 있으면 공유된 대상을 놓고 하는 이야기다 — 공개. 앵커가 없거나 앞선 대화를 옮겨 적어 만든
15
- * 것이면 비공개. `copyOf` 가 있다는 것은 다른 대화의 원문이 이 안에 들어왔다는 뜻이고, 그 원문은
16
- * 자기가 있던 자리의 규칙으로 쓰였다.
14
+ * ── 앵커가 있으면 공개로 태어나던 것을 거둔 이유 (2026-09-30, 제품 오너) ──
15
+ * operato-office 의 대화는 앱 행(`application`)에 매달린다. 그래서 원장 · 급여를 묻는 경영자의 질문이 도메인
16
+ * 안 모든 직원에게 열린 방으로 태어났다. 목록은 그것을 「사업장 공개」 한 줄로만 말해서 공개와 비공개가 한눈에
17
+ * 갈리지 않았다. 앵커는 「무엇에 대한 이야기인가」이지 「누구에게 보이나」가 아니다.
18
+ *
19
+ * 공개는 owner 가 고른다(`setSessionVisibility`) — 화면이 여는 순간 되묻고, 공개 대화는 도크가 다르게 그린다.
17
20
  */
18
- export function initialVisibility(session: { anchorType?: string | null; copyOf?: string | null }): SessionVisibility {
19
- if (session.copyOf) return 'private'
20
-
21
- /*
22
- * `'none'` 은 **앵커가 아니다.** 저장되는 앵커 종류 목록에 그 글자가 들어 있어서(`session-anchor.ts`
23
- * — 대상이 없다는 것도 행에 적어야 하므로), 「값이 있으면 앵커가 있다」로 읽으면 대상 없는 개인 질문이
24
- * 공개 방으로 태어난다. ADR-0073 결정 2 가 그것을 `private` 로 못박았다 — 「앵커 없는 세션(`twin.none`
25
- * 같은 개인 질문)」.
26
- */
27
- const anchored = !!session.anchorType && session.anchorType !== 'none'
28
- return anchored ? 'open' : 'private'
21
+ export function initialVisibility(_session: { anchorType?: string | null; copyOf?: string | null } = {}): SessionVisibility {
22
+ return 'private'
29
23
  }
30
24
 
31
25
  /** 이 사람이 이 대화를 읽을 수 있나. open 이면 도메인 안 누구나, private 이면 참여자만. */
@@ -10,11 +10,11 @@ import { User } from '@things-factory/auth-base'
10
10
  import { buildDomainUsersQueryBuilder } from '@things-factory/auth-base/dist-server/utils/get-domain-users.js'
11
11
 
12
12
  import { getRepository } from '@things-factory/shell'
13
- import { In } from 'typeorm'
13
+ import { In, Like } from 'typeorm'
14
14
 
15
15
  import { ChatSession } from './chat-session/chat-session.js'
16
16
  import { anchorColumns, anchorWhere } from './chat-session/session-anchor.js'
17
- import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
17
+ import { buildInbox, applyHidden, activityPatch, searchInbox, filterInbox, searchReadableMessages, INBOX_SCAN_CAP, type InboxFilter } from './chat-session/session-inbox.js'
18
18
  import { carriedOriginalWhere, planCarriedTurns, quoteCarriedAnswer } from './chat-message/carried-turns.js'
19
19
  import { roomMessageBody } from './chat-message/room-message.js'
20
20
  import { readerGate } from './chat-message/reader-gate.js'
@@ -51,7 +51,14 @@ async function writeGate(session: any, context: any, tx?: any): Promise<void> {
51
51
  t: (key, options) => (context.t ? context.t(key, options) : String(options?.defaultValue ?? key))
52
52
  })
53
53
  }
54
- import { formatProposalOutcomeLine, PROPOSAL_VERDICTS, type ProposalOutcome } from './chat-message/proposal-outcome.js'
54
+ import {
55
+ formatProposalOutcomeLine,
56
+ formatProposalRequestedLine,
57
+ proposalAlreadyTaken,
58
+ PROPOSAL_OUTCOME_PREFIX,
59
+ PROPOSAL_VERDICTS,
60
+ type ProposalOutcome
61
+ } from './chat-message/proposal-outcome.js'
55
62
  import { publishSessionActivity } from './chat-session/session-activity-publish.js'
56
63
  import { ChatMessage } from './chat-message/chat-message.js'
57
64
  import { ChatSessionParticipant } from './chat-session-participant/chat-session-participant.js'
@@ -262,7 +269,21 @@ export class ChatSessionResolver {
262
269
  const all = await this._inboxEntries(anchorType, anchorId, context)
263
270
  let entries = opts.showHidden ? all : all.filter(e => !e.hidden)
264
271
  entries = filterInbox(entries as any, opts) as any
265
- return searchInbox(entries as any, opts.search) as any
272
+ if (!opts.search?.trim() || !entries.length) return entries
273
+ // Search only readable rooms, then pass their messages through the SAME reader gate.
274
+ // A match in a redacted answer must not reveal even that a conversation contains it.
275
+ const messages = await getRepository(ChatMessage).find({
276
+ where: { session: { id: In(entries.map(entry => entry.id)) } },
277
+ order: { createdAt: 'DESC', id: 'DESC' },
278
+ take: 2001
279
+ })
280
+ if (messages.length > 2000) {
281
+ throw new Error(context.t('ai-assistant.session.search-too-broad', { defaultValue: 'Too many messages to search here. Narrow the conversation filters and try again.' }))
282
+ }
283
+ const readable = await readerGate(context)(messages)
284
+ const matched = searchReadableMessages(readable, opts.search)
285
+ const names = new Set(searchInbox(entries as any, opts.search).map(entry => entry.id))
286
+ return entries.filter(entry => names.has(entry.id) || matched.has(entry.id))
266
287
  }
267
288
 
268
289
  /**
@@ -637,7 +658,7 @@ export class ChatSessionResolver {
637
658
  const session = await repo.save({
638
659
  domain,
639
660
  ...anchorColumns(anchorType, anchorId),
640
- /* 앵커가 있는 대화는 공개로 태어난다 — ADR-0073 판정 2. */
661
+ /* 새 대화는 비공개로 태어난다 — 공개는 owner 가 고른다(ADR-0073 결정 2, 2026-09-30 고침). */
641
662
  visibility: initialVisibility({ anchorType }),
642
663
  creator: user,
643
664
  updater: user
@@ -663,19 +684,14 @@ export class ChatSessionResolver {
663
684
  async createAISession(
664
685
  @Arg('anchorType') anchorType: string,
665
686
  @Arg('anchorId') anchorId: string,
666
- @Arg('name', { nullable: true, description: 'Optional name. Defaults to a generated one, numbered within the anchor.' }) name: string | undefined,
687
+ @Arg('name', { nullable: true, description: 'Optional name. If omitted, the first question names the conversation.' }) name: string | undefined,
667
688
  @Ctx() context: ResolverContext
668
689
  ): Promise<ChatSession> {
669
690
  const { domain, user, tx } = context.state
670
691
  const repo = getRepository(ChatSession, tx)
671
692
 
672
- // 이름 미지정 시 자동 생성 — **같은 앵커**의 기존 세션 수 + 1.
673
- let finalName = name?.trim()
674
- if (!finalName) {
675
- const count = await repo.count({ where: anchorWhere(domain.id, anchorType, anchorId) })
676
- /* The name is stored, so it is written in the language of whoever created it (context.t), and read back as is. */
677
- finalName = context.t('ai-assistant.session.default-name', { n: count + 1, defaultValue: 'conversation {n}' })
678
- }
693
+ // Do not prefill a numbered name: that prevents the first question from naming it.
694
+ const finalName = name?.trim() || null
679
695
 
680
696
  const session = await repo.save({
681
697
  domain,
@@ -767,6 +783,49 @@ export class ChatSessionResolver {
767
783
  return true
768
784
  }
769
785
 
786
+ @Directive('@transaction')
787
+ @Mutation(() => Boolean, {
788
+ description:
789
+ "Ask before running a proposal: true when this conversation has not taken it yet, and the request is recorded as a system row; false when it was already requested or answered - then the host must not be asked again. The card reads its state from these rows, so a refreshed panel or another window does not offer the same proposal twice (ADR-0097)."
790
+ })
791
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
792
+ async requestProposal(
793
+ @Arg('sessionId') sessionId: string,
794
+ @Arg('proposalKey') proposalKey: string,
795
+ @Ctx() context: ResolverContext
796
+ ): Promise<boolean> {
797
+ const { domain, user, tx } = context.state
798
+ const session = await getRepository(ChatSession, tx).findOneBy({ id: sessionId, domain: { id: domain.id } })
799
+ if (!session) throw new Error(`ChatSession ${sessionId} not found`)
800
+ await writeGate(session, context, tx)
801
+
802
+ const key = (proposalKey ?? '').trim()
803
+ if (!key) return false
804
+
805
+ /* Only this conversation's proposal lines - a conversation is bounded, and the rule reads them all. */
806
+ const lines = await getRepository(ChatMessage, tx).find({
807
+ select: ['content'] as any,
808
+ where: { session: { id: session.id } as any, role: 'system', content: Like(`${PROPOSAL_OUTCOME_PREFIX}%`) } as any
809
+ })
810
+ if (proposalAlreadyTaken(key, lines.map(line => (line as any).content))) return false
811
+
812
+ const previousLast = await getRepository(ChatMessage, tx).findOne({
813
+ where: { session: { id: session.id } as any },
814
+ order: { createdAt: 'DESC' }
815
+ })
816
+ const row = await getRepository(ChatMessage, tx).save({
817
+ session: { id: session.id } as any,
818
+ role: 'system',
819
+ content: formatProposalRequestedLine(key),
820
+ creator: user,
821
+ updater: user,
822
+ parentMessage: previousLast ? ({ id: previousLast.id } as any) : undefined
823
+ } as any)
824
+
825
+ publishChatMessage({ domainId: domain.id, sessionId: session.id, message: row })
826
+ return true
827
+ }
828
+
770
829
  /**
771
830
  * Stores files with a conversation before the turn that carries them (design/ai/shared-dock.md §9).
772
831
  * Passes the write gate - a private conversation takes files from its people only - and the mode's
@@ -192,7 +192,7 @@ describe('the subject anchor', () => {
192
192
  expect(anchorColumns('application', 'operato-figure').anchorId).toBe('operato-figure')
193
193
  })
194
194
 
195
- it('★ a subject conversation is born open — an anchored conversation is about a shared thing (ADR-0073 decision 2)', () => {
196
- expect(initialVisibility({ anchorType: 'subject' })).toBe('open')
195
+ it('★ a subject conversation is born private — opening it is the owner\'s choice (ADR-0073 decision 2, 2026-09-30)', () => {
196
+ expect(initialVisibility({ anchorType: 'subject' })).toBe('private')
197
197
  })
198
198
  })
@@ -24,6 +24,11 @@ describe('no empty panel', () => {
24
24
  it('an unreadable capability offers a retry', () => {
25
25
  assert.match(dock, /this\.capability === 'unknown'\s*\?\s*html`<div><button class="sbtn" @click=\$\{\(\) => this\.open\(\)\}>/)
26
26
  })
27
+
28
+ it('a surface can give the person one concrete next route', () => {
29
+ assert.match(dock, /surface\?\.nextStep \? html`<div class="next-step" role="status">/)
30
+ assert.match(dock, /<a href=\$\{surface\.nextStep\.href\}>\$\{surface\.nextStep\.label\(\)\}/)
31
+ })
27
32
  })
28
33
 
29
34
  describe('the dock opens on every screen', () => {
@@ -52,7 +57,7 @@ describe('every request the chat makes is taken', () => {
52
57
 
53
58
  describe('every proposal ends in one reported outcome', () => {
54
59
  it('the dock reports whatever the handler returned', () => {
55
- assert.match(dock, /const outcome = await runProposal\(proposal, this\.proposalContext\(sessionId\)\)[\s\S]*await this\.report\(sessionId, proposalKey, outcome\)/)
60
+ assert.match(dock, /const outcome: ProposalOutcome =[\s\S]*await runProposal\(proposal, this\.proposalContext\(sessionId\)\)[\s\S]*await this\.report\(sessionId, proposalKey, outcome\)/)
56
61
  })
57
62
 
58
63
  it('a kind nobody handles is unavailable, by name', async () => {
@@ -89,7 +94,7 @@ describe('sessions', () => {
89
94
 
90
95
  describe('the screen is checked at send time', () => {
91
96
  it('a send after the screen moved on is refused', () => {
92
- assert.match(dock, /if \(openedFor !== this\.scopeKey\(currentAssistantSurface\(\) \?\? surface\)\) throw new Error/)
97
+ assert.match(dock, /if \(openedFor !== this\.scopeKey\(currentAssistantSurface\(\)\) \|\| this\.openedIdentity !== this\.identityKey\(\)\) throw new Error/)
93
98
  })
94
99
 
95
100
  it('the draft is read at send time, never cached', () => {
@@ -125,7 +130,7 @@ describe('attachments in the chat follow the mode', () => {
125
130
 
126
131
  describe('what twin needed of the dock', () => {
127
132
  it('a surface that says its words changed repaints the header without reopening the conversation', () => {
128
- assert.match(dock, /next === this\.surface && this\.scopeKey\(next\) === this\.openedFor\) \{\s*this\.requestUpdate\(\)\s*return/)
133
+ assert.match(dock, /transition === 'refresh'\) \{\s*this\.surface = next\s*this\.screenChanged = false\s*this\.requestUpdate\(\)\s*return/)
129
134
  })
130
135
 
131
136
  it('a question with no conversation to go to is said now, never sent later; a draft waits', () => {
@@ -10,7 +10,7 @@ test('toolbar emits domain-neutral intent and never calls persistence APIs', ()
10
10
  })
11
11
  test('busy/error/temporary states and accessible controls are explicit', () => {
12
12
  assert.match(toolbar, /this\.disabled \|\| this\.loading \|\| !!this\.error/)
13
- assert.match(toolbar, /this\.temporary \? nothing/)
13
+ assert.match(toolbar, /this\.temporary \|\| this\.singleConversation \? nothing/)
14
14
  assert.match(toolbar, /aria-label=/)
15
15
  assert.match(toolbar, /role="tablist"/)
16
16
  assert.match(toolbar, /role="tab"/)
@@ -18,6 +18,12 @@ test('busy/error/temporary states and accessible controls are explicit', () => {
18
18
  assert.match(toolbar, /role="alert"/)
19
19
  assert.match(toolbar, /role="status"/)
20
20
  })
21
+ test('shared dock has one list entry, not tabs and a second history link', () => {
22
+ assert.match(toolbar, /this\.historyEnabled && !this\.singleConversation/)
23
+ const dock = read('../client/components/assistant-dock.ts')
24
+ assert.match(dock, /historyEnabled\s+singleConversation/)
25
+ assert.match(dock, /ready && this\.showingHistory/)
26
+ })
21
27
  test('legacy Board chat forwards shared controls through existing host events', () => {
22
28
  const chat = read('../client/components/assistant-chat.ts')
23
29
  assert.match(chat, /import '\.\/assistant-session-toolbar\.js'/)
@@ -141,11 +141,12 @@ describe('what the walk itself has to be true for', () => {
141
141
  /*
142
142
  * 2026-09-24: 27 root fields, 25 declared, 2 open — both streams.
143
143
  * 2026-09-25: 28 — attachToConversation (a conversation's files, shared dock §9), declared.
144
+ * 2026-09-28: 29 — requestProposal (a proposal is requested once, ADR-0097 Q-D4), declared.
144
145
  */
145
146
  const all = fieldsOf('Query').length + fieldsOf('Mutation').length + fieldsOf('Subscription').length
146
147
  const open = openFields('Query').length + openFields('Mutation').length + openFields('Subscription').length
147
148
 
148
- expect(all).toBe(28)
149
+ expect(all).toBe(29)
149
150
  expect(open).toBe(2)
150
151
  })
151
152
  })
@@ -0,0 +1,42 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { join } from 'node:path'
3
+ import { autoSessionName, searchReadableMessages } from '../server/service/chat-session/session-inbox.js'
4
+
5
+ describe('returning to work by its subject', () => {
6
+ it('finds an earlier question, not just the final execution log', () => {
7
+ expect([...searchReadableMessages([
8
+ { sessionId: 'lift', role: 'user', content: '리프트 테이블을 만들어 줘' },
9
+ { sessionId: 'lift', role: 'system', content: 'accepted 13/13' }
10
+ ], '리프트')]).toEqual(['lift'])
11
+ })
12
+ it('cannot discover redacted answers or internal execution notes', () => {
13
+ expect([...searchReadableMessages([
14
+ { sessionId: 'secret', role: 'assistant', content: 'restricted value', redacted: true },
15
+ { sessionId: 'log', role: 'system', content: 'restricted value' },
16
+ { sessionId: 'public', role: 'assistant', content: 'Restricted value is unavailable' }
17
+ ], 'restricted')]).toEqual(['public'])
18
+ expect(searchReadableMessages([{ sessionId: 'a', role: 'user', content: 'hi' }], ' ').size).toBe(0)
19
+ })
20
+ it('names a new conversation from the first question', () => {
21
+ expect(autoSessionName('리프트 테이블을 만들어 줘. 폭은 600')).toBe('리프트 테이블을 만들어 줘')
22
+ const resolver = readFileSync(join(__dirname, '../server/service/chat-session-resolver.ts'), 'utf8')
23
+ expect(resolver).toContain("const finalName = name?.trim() || null")
24
+ expect(resolver).toContain('await readerGate(context)(messages)')
25
+ expect(resolver).toContain('take: 2001')
26
+ })
27
+ })
28
+
29
+ describe('discoverable conversation controls', () => {
30
+ const manager = readFileSync(join(__dirname, '../client/components/assistant-session-manager.ts'), 'utf8')
31
+ it('uses a native button to open a conversation and a visible disclosure to manage it', () => {
32
+ expect(manager).toContain('<button class="open-row"')
33
+ expect(manager).toContain('<summary aria-label=')
34
+ expect(manager).not.toContain('.row:hover .acts')
35
+ })
36
+ it('distinguishes filtered emptiness, supplies reset, and folds advanced filters', () => {
37
+ expect(manager).toContain("t('filtered-empty'")
38
+ expect(manager).toContain('this.resetFilters()')
39
+ expect(manager).toContain('?hidden=${!this.filtersOpen}')
40
+ expect(manager).toContain('e.stopPropagation(); this.onSearch')
41
+ })
42
+ })
@@ -1,4 +1,25 @@
1
1
  {
2
+ "ai-assistant.dock.wait-before-switch": "Wait for the current response before changing conversations.",
3
+ "ai-assistant.dock.switch-title": "Change conversation?",
4
+ "ai-assistant.dock.switch-draft": "Your unsent text and attachments will not move to the other conversation.",
5
+ "ai-assistant.dock.switch-confirm": "Discard draft and switch",
6
+ "ai-assistant.dock.stay": "Keep writing",
7
+ "ai-assistant.dock.now-about": "Conversation subject: {name}",
8
+ "ai-assistant.dock.ask-kept-current": "The current conversation was kept. Switch to the intended subject before asking again.",
9
+ "ai-assistant.dock.return-to-subject": "Return to the conversation subject before sending or applying changes.",
10
+ "ai-assistant.dock.screen-and-conversation": "Screen: {screen}. Conversation: {conversation}. Your conversation has been kept.",
11
+ "ai-assistant.dock.no-subject": "No subject",
12
+ "ai-assistant.dock.switch-to-screen": "Talk about this screen",
13
+ "ai-assistant.session.filters": "Filters",
14
+ "ai-assistant.session.reset-filters": "Clear search and filters",
15
+ "ai-assistant.session.filtered-empty": "No conversations match these filters here.",
16
+ "ai-assistant.session.scope-empty": "No conversations for this subject yet.",
17
+ "ai-assistant.session.audience-private": "Participants only",
18
+ "ai-assistant.session.audience-open": "Everyone in this domain",
19
+ "ai-assistant.session.manage": "Manage conversation",
20
+ "ai-assistant.session.list": "Conversations",
21
+ "ai-assistant.dock.conversation-scope": "Conversations · {name}",
22
+ "ai-assistant.session.search-too-broad": "Too many messages to search here. Narrow the conversation filters and try again.",
2
23
  "ai-assistant.session.new": "New conversation",
3
24
  "ai-assistant.session.history": "Conversation history",
4
25
  "ai-assistant.session.reset": "New conversation",
@@ -102,6 +123,10 @@
102
123
  "ai-assistant.verdict.unavailable": "could not run",
103
124
  "ai-assistant.verdict.declined": "cancelled",
104
125
  "ai-assistant.text.proposal-waiting": "sending — {seconds}s, no answer yet",
126
+ "ai-assistant.text.proposal-unconfirmed": "sent earlier — no answer received",
127
+ "ai-assistant.button.check-proposal": "check result",
128
+ "ai-assistant.dock.proposal-result-uncheckable": "this screen cannot check that result. open the screen where it was applied.",
129
+ "ai-assistant.dock.proposal-result-unknown": "the result is still unknown. it was not applied again.",
105
130
  "ai-assistant.text.proposal-applied-count": " {applied} of {total}",
106
131
  "ai-assistant.text.say-to-room-tooltip": "say this to the people here — the assistant does not answer",
107
132
  "ai-assistant.text.no-room-yet": "this conversation is not saved yet, so there is nobody else in it to talk to",
@@ -111,7 +136,7 @@
111
136
  "ai-assistant.label.conversation": "conversation",
112
137
  "ai-assistant.text.switch-conversation": "switch to {name}",
113
138
  "ai-assistant.text.session-kind-mismatch": "this conversation is {sessionKind}, and you are writing as {requested}. open the conversation you mean.",
114
- "ai-assistant.session.search": "Search names and last messages",
139
+ "ai-assistant.session.search": "Search conversation names and messages",
115
140
  "ai-assistant.session.filter": "Which conversations",
116
141
  "ai-assistant.session.filter-all": "All",
117
142
  "ai-assistant.session.filter-mine": "Started by me",
@@ -171,5 +196,6 @@
171
196
  "ai-assistant.session.filter-open": "Public",
172
197
  "ai-assistant.session.filter-private": "Private",
173
198
  "ai-assistant.session.filter-unread": "Unread",
174
- "ai-assistant.dock.ask-needs-conversation": "the question was not sent."
199
+ "ai-assistant.dock.ask-needs-conversation": "the question was not sent.",
200
+ "ai-assistant.dock.audience-open-band": "Public conversation - everyone in this domain can read and write here."
175
201
  }