@jskit-ai/assistant-core 0.1.169 → 0.1.171

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.
@@ -8,9 +8,9 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.169",
12
- "@jskit-ai/http-runtime": "0.1.189",
13
- "@jskit-ai/kernel": "0.1.191",
11
+ "@jskit-ai/assistant-core": "0.1.171",
12
+ "@jskit-ai/http-runtime": "0.1.191",
13
+ "@jskit-ai/kernel": "0.1.193",
14
14
  "@mdi/js": "^7.4.47",
15
15
  "vue": "^3.5.13",
16
16
  "vuetify": "^4.0.0"
@@ -1,106 +1,44 @@
1
1
  <script setup>
2
2
  import { computed, ref } from "vue";
3
- import AssistantClientElement from "../../src/client/components/AssistantClientElement.vue";
4
-
5
- const longConversation = Array.from(
6
- { length: 90 },
7
- (_, index) => `Conversation line ${index + 1}: responsive scroll containment must keep the composer visible.`
8
- ).join("\n\n");
9
-
10
- const messages = ref([
11
- {
12
- id: "message-1",
13
- role: "assistant",
14
- kind: "chat",
15
- status: "completed",
16
- text: longConversation
3
+ import AssistantConversationElement from "../../src/client/conversation/AssistantConversationElement.vue";
4
+ import { conversationTurnsFromMessages } from "../../src/shared/conversation/turns.js";
5
+ const draft = ref("");
6
+ const controls = new URLSearchParams(location.search).has("controls");
7
+ const scope = ref("one");
8
+ const messages = ref(Array.from({ length: 70 }, (_, index) => ({
9
+ id: `message-${index}`, role: index % 2 ? "assistant" : "user", status: "completed",
10
+ text: `Conversation line ${index + 1}: responsive scroll containment keeps the composer visible.\n\nMore text with **formatting**, a [link](https://example.com), and a_long_word_that_must_wrap_in_narrow_panes_without_overflow.`
11
+ })));
12
+ if (controls) messages.value[0].text = messages.value[0].text.repeat(4);
13
+ const adapter = computed(() => ({
14
+ conversation: { turns: conversationTurnsFromMessages(messages.value), scrollKey: scope.value, hasMoreBefore: controls },
15
+ composer: { draft: draft.value, canSend: Boolean(draft.value.trim()), rows: 2 },
16
+ actions: {
17
+ setDraft(value) { draft.value = value; },
18
+ submit() { draft.value = ""; },
19
+ loadMore({ complete }) {
20
+ messages.value.unshift({ id: "older", role: "assistant", text: "Older conversation paragraph. ".repeat(40), status: "completed" });
21
+ complete({ changed: true });
22
+ }
17
23
  }
18
- ]);
19
- const input = ref("");
20
- const isStreaming = ref(false);
21
- const isRestoringConversation = ref(false);
22
- const conversationId = ref("conversation-1");
23
- const conversationHistory = ref([
24
- {
25
- id: "conversation-1",
26
- title: "Responsive layout regression",
27
- status: "completed",
28
- startedAt: "2026-08-24T00:00:00.000Z",
29
- messageCount: 1
30
- }
31
- ]);
32
- const conversationHistoryLoading = ref(false);
33
- const conversationHistoryLoadingMore = ref(false);
34
- const conversationHistoryHasMore = ref(false);
35
- const conversationHistoryError = ref("");
36
- const pendingToolEvents = ref([
37
- {
38
- id: "tool-1",
39
- name: "assistant_action_search",
40
- status: "completed"
41
- }
42
- ]);
43
-
44
- const state = Object.freeze({
45
- messages,
46
- input,
47
- isStreaming,
48
- isRestoringConversation,
49
- pendingToolEvents,
50
- conversationId,
51
- conversationHistory,
52
- conversationHistoryLoading,
53
- conversationHistoryLoadingMore,
54
- conversationHistoryHasMore,
55
- conversationHistoryError,
56
- isAdminSurface: ref(false),
57
- canSend: computed(() => input.value.trim().length > 0),
58
- canStartNewConversation: ref(true)
59
- });
60
-
61
- const actions = Object.freeze({
62
- async sendMessage() {},
63
- handleInputKeydown() {},
64
- async cancelStream() {},
65
- async startNewConversation() {},
66
- async selectConversation() {},
67
- async refreshConversationHistory() {},
68
- async loadMoreConversationHistory() {}
69
- });
70
-
71
- const meta = Object.freeze({
72
- formatConversationStartedAt() {
73
- return "Aug 24, 2026";
74
- }
75
- });
24
+ }));
76
25
  </script>
77
-
78
26
  <template>
79
27
  <v-app>
80
28
  <v-main>
81
29
  <main class="assistant-responsive-fixture">
82
- <AssistantClientElement
83
- :meta="meta"
84
- :state="state"
85
- :actions="actions"
86
- :viewer="{ displayName: 'Ada Lovelace' }"
87
- />
30
+ <nav v-if="controls">
31
+ <button @click="scope = scope === 'one' ? 'two' : 'one'">Change conversation</button>
32
+ <button @click="messages.push({ id: `added-${messages.length}`, role: 'assistant', text: 'New reply. '.repeat(30), status: 'completed' })">Append reply</button>
33
+ </nav>
34
+ <AssistantConversationElement :adapter="adapter" class="assistant-responsive-fixture__conversation" />
88
35
  </main>
89
36
  </v-main>
90
37
  </v-app>
91
38
  </template>
92
-
93
39
  <style>
94
- html,
95
- body,
96
- #app {
97
- block-size: 100%;
98
- margin: 0;
99
- min-block-size: 0;
100
- }
101
-
102
- .assistant-responsive-fixture {
103
- block-size: 100%;
104
- min-block-size: 0;
105
- }
40
+ html, body, #app { height: 100%; margin: 0; min-height: 0; }
41
+ .assistant-responsive-fixture { display: flex; flex-direction: column; height: 100dvh; min-height: 0; padding: .5rem; }
42
+ .assistant-responsive-fixture nav { display: flex; flex: 0 0 auto; gap: 1rem; }
43
+ .assistant-responsive-fixture__conversation { flex: 1 1 auto; }
106
44
  </style>
@@ -5,11 +5,13 @@ const phase = ref("idle");
5
5
  const draft = ref("");
6
6
  const narrow = ref(false);
7
7
  const submissions = ref(0);
8
+ const feedback = ref(false);
8
9
  const adapter = reactive({
9
10
  conversation: { turns: [], visible: true, scrollKey: "fixture", welcomeMessage: "Composer responsiveness fixture" },
10
11
  composer: {
11
12
  draft,
12
13
  rows: 2,
14
+ density: computed(() => narrow.value ? "compact" : "default"),
13
15
  submitOnEnter: true,
14
16
  canSend: computed(() => Boolean(draft.value.trim()) && ["idle", "active", "stopped"].includes(phase.value)),
15
17
  canStop: computed(() => ["active", "stopping"].includes(phase.value)),
@@ -29,10 +31,13 @@ const adapter = reactive({
29
31
  <div class="controls">
30
32
  <button v-for="state in ['idle', 'active', 'reconnecting', 'stopping', 'stopped']" :key="state" @click="phase = state">External {{ state }}</button>
31
33
  <button @click="narrow = !narrow">Resize pane</button>
34
+ <button @click="feedback = !feedback">Toggle action feedback</button>
32
35
  <output>{{ phase }}; submitted {{ submissions }}</output>
33
36
  </div>
34
37
  <main class="fixture" :class="{ narrow }">
35
- <AssistantConversationElement :adapter="adapter" />
38
+ <AssistantConversationElement :adapter="adapter">
39
+ <template #composer-feedback><p v-if="feedback" class="feedback" role="status">Describe what you want to change.</p></template>
40
+ </AssistantConversationElement>
36
41
  </main>
37
42
  </v-main>
38
43
  </v-app>
@@ -42,4 +47,5 @@ const adapter = reactive({
42
47
  .controls button { border: 1px solid; padding: 4px; }
43
48
  .fixture { height: 70vh; width: min(900px, 100%); margin: auto; }
44
49
  .fixture.narrow { width: min(320px, 100%); }
50
+ .feedback { flex: 1 1 24rem; min-width: 0; margin: 0; }
45
51
  </style>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.169",
3
+ "version": "0.1.171",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -21,18 +21,16 @@
21
21
  "./testing/conversation-storage": "./src/testing/conversationStorageContract.js"
22
22
  },
23
23
  "dependencies": {
24
- "@jskit-ai/resource-core": "0.1.133",
25
- "@jskit-ai/resource-crud-core": "0.1.133",
26
- "dompurify": "^3.4.13",
24
+ "@jskit-ai/resource-core": "0.1.135",
25
+ "@jskit-ai/resource-crud-core": "0.1.135",
27
26
  "json-rest-schema": "^1.0.17",
28
- "marked": "^17.0.4",
29
27
  "openai": "^6.22.0",
30
28
  "@mdi/js": "^7.4.47",
31
29
  "ws": "^8.20.1"
32
30
  },
33
31
  "peerDependencies": {
34
- "@jskit-ai/http-runtime": "0.1.189",
35
- "@jskit-ai/kernel": "0.1.191",
32
+ "@jskit-ai/http-runtime": "0.1.191",
33
+ "@jskit-ai/kernel": "0.1.193",
36
34
  "vue": "^3.5.13",
37
35
  "vuetify": "^4.0.0"
38
36
  },
@@ -1,5 +1,5 @@
1
1
  <template>
2
- <div class="assistant-composer-actions">
2
+ <div class="assistant-composer-actions" :class="{ 'assistant-composer-actions--compact': state.density === 'compact' }">
3
3
  <div class="assistant-composer-actions__tools"><slot /></div>
4
4
  <div class="assistant-composer-actions__delivery">
5
5
  <v-btn
@@ -18,6 +18,7 @@
18
18
  {{ state.submitLabel || (state.pending ? 'Sending…' : 'Send') }}
19
19
  </v-btn>
20
20
  </div>
21
+ <slot name="feedback" />
21
22
  </div>
22
23
  </template>
23
24
  <script setup>
@@ -36,7 +37,8 @@ defineExpose({ focus() { (sendButton.value?.$el || sendButton.value)?.focus?.();
36
37
  .assistant-composer-actions__tools { flex: 1 1 auto; flex-wrap: wrap; }
37
38
  .assistant-composer-actions__delivery { flex: 0 0 auto; margin-left: auto; }
38
39
  .assistant-composer-actions__send { min-width: 5.25rem; }
39
- @media (pointer: coarse) {
40
+ .assistant-composer-actions--compact .assistant-composer-actions__delivery > .v-btn { min-height: 3rem; }
41
+ @media (max-width: 600px), (pointer: coarse) {
40
42
  .assistant-composer-actions__delivery > .v-btn { min-height: 3rem; min-width: 3rem; }
41
43
  .assistant-composer-actions__delivery > .assistant-composer-actions__send { min-width: 5.25rem; }
42
44
  }
@@ -11,7 +11,7 @@
11
11
  <slot name="hints" :adapter="adapter" />
12
12
  <slot name="composer" :adapter="adapter">
13
13
  <AssistantPromptInput
14
- v-if="adapter.composer" ref="input" v-bind="adapter.composer"
14
+ v-if="adapter.composer" ref="input" v-bind="adapter.composer" class="assistant-conversation__composer"
15
15
  :model-value="adapter.composer.draft || ''" :submit-enabled="adapter.composer.canSend"
16
16
  :aria-label="adapter.composer.ariaLabel || 'Message AI assistant'" :rows="adapter.composer.rows || 2"
17
17
  tab-to-submit @update:model-value="adapter.actions.setDraft($event)"
@@ -34,6 +34,7 @@
34
34
  />
35
35
  </slot>
36
36
  <slot name="composer-tools" :adapter="adapter" />
37
+ <template #feedback><slot name="composer-feedback" :adapter="adapter" /></template>
37
38
  </AssistantComposerActions>
38
39
  </template>
39
40
  </AssistantPromptInput>
@@ -53,7 +54,7 @@ const props = defineProps({
53
54
  configurationMode: { type: String, default: "hidden", validator: (value) => ["hidden", "readonly", "editable"].includes(value) }
54
55
  });
55
56
  const slots = useSlots();
56
- const transcriptSlots = computed(() => ["attachments", "system-message", "message-actions"].filter((name) => slots[name]));
57
+ const transcriptSlots = computed(() => ["welcome", "attachments", "system-message", "message-actions"].filter((name) => slots[name]));
57
58
  const input = ref(null);
58
59
  const delivery = ref(null);
59
60
  function submit() {
@@ -74,5 +75,6 @@ defineExpose({ focus: () => input.value?.focus(), submit, stop });
74
75
  <style scoped>
75
76
  .assistant-conversation { display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; }
76
77
  .assistant-conversation__transcript { flex: 1 1 auto; min-height: 0; }
78
+ .assistant-conversation__composer { flex: 0 0 auto; }
77
79
  .assistant-conversation__setting { min-width: 8rem; max-width: 16rem; }
78
80
  </style>
@@ -72,7 +72,7 @@
72
72
  <span>{{ assistantLabel }}</span>
73
73
  </div>
74
74
  </div>
75
- <p>{{ welcomeMessage }}</p>
75
+ <slot name="welcome"><p>{{ welcomeMessage }}</p></slot>
76
76
  </div>
77
77
 
78
78
  <div
@@ -873,6 +873,11 @@ onBeforeUnmount(() => {
873
873
  clearLoadMoreScrollSnapshot();
874
874
  });
875
875
 
876
+ watch(() => props.scrollKey, () => {
877
+ expandedUserMessages.value = new Set();
878
+ displayMessageCache.clear();
879
+ });
880
+
876
881
  watch(() => [
877
882
  timelineScrollTrigger.value,
878
883
  latestRenderedTailKey.value
@@ -1073,21 +1078,21 @@ watch(timelineScrollTrigger, () => {
1073
1078
  }
1074
1079
 
1075
1080
  .assistant-transcript__avatar--user {
1076
- background: #e9f2fc;
1077
- color: #2f79ca;
1081
+ background: rgba(var(--v-theme-primary), 0.1);
1082
+ color: rgb(var(--v-theme-primary));
1078
1083
  margin-top: 0.2rem;
1079
1084
  }
1080
1085
 
1081
1086
  .assistant-transcript__avatar--assistant {
1082
- background: #5b9ce1;
1083
- color: #ffffff;
1087
+ background: rgb(var(--v-theme-primary));
1088
+ color: rgb(var(--v-theme-on-primary));
1084
1089
  margin-top: 0.05rem;
1085
1090
  }
1086
1091
 
1087
1092
  .assistant-transcript__message--user {
1088
- background: #e3ebf5;
1093
+ background: rgba(var(--v-theme-primary), 0.1);
1089
1094
  border-radius: 16px;
1090
- color: #202936;
1095
+ color: rgb(var(--v-theme-on-surface));
1091
1096
  gap: 0.75rem;
1092
1097
  justify-content: space-between;
1093
1098
  overflow-x: auto;
@@ -1,5 +1,3 @@
1
- export { default as AssistantClientElement } from "./components/AssistantClientElement.vue";
2
1
  export { default as AssistantSettingsFormCard } from "./components/AssistantSettingsFormCard.vue";
3
2
  export { assistantHttpClient } from "./lib/assistantHttpClient.js";
4
3
  export { createAssistantApi, buildStreamEventError } from "./lib/assistantApi.js";
5
- export { renderMarkdownToSafeHtml } from "./lib/markdownRenderer.js";
@@ -14,94 +14,31 @@ const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
14
14
  const FIXTURE_ROOT = path.resolve(TEST_DIRECTORY, "../fixtures/responsive-assistant");
15
15
  const RUN_BROWSER_TEST = process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION === "1";
16
16
  const VIEWPORTS = Object.freeze([
17
- Object.freeze({ name: "phone", width: 390, height: 844, sidePanelsVisible: false }),
18
- Object.freeze({ name: "compact", width: 800, height: 900, sidePanelsVisible: false }),
19
- Object.freeze({ name: "medium", width: 1224, height: 900, sidePanelsVisible: true }),
20
- Object.freeze({ name: "expanded", width: 1365, height: 900, sidePanelsVisible: true })
17
+ Object.freeze({ name: "phone", width: 390, height: 844 }),
18
+ Object.freeze({ name: "compact", width: 800, height: 900 }),
19
+ Object.freeze({ name: "medium", width: 1224, height: 900 }),
20
+ Object.freeze({ name: "expanded", width: 1365, height: 900 }),
21
+ Object.freeze({ name: "short", width: 1280, height: 600 }),
22
+ Object.freeze({ name: "drawer", width: 360, height: 600 })
21
23
  ]);
22
24
 
23
- async function readLayoutMetrics(page) {
24
- return page.evaluate(() => {
25
- const root = document.querySelector(".assistant-client-element");
26
- const layout = document.querySelector(".assistant-layout");
27
- const mainColumn = document.querySelector(".assistant-main-col");
28
- const sideColumn = document.querySelector(".assistant-side-col");
29
- const messages = document.querySelector(".messages-panel");
30
- const composer = document.querySelector(".assistant-composer-shell");
31
- const history = document.querySelector(".assistant-history-card");
32
- const tools = document.querySelector(".assistant-tools-card");
33
- const rect = (element) => element?.getBoundingClientRect() || null;
34
-
35
- return {
36
- viewportHeight: window.innerHeight,
37
- documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
38
- root: rect(root),
39
- layout: rect(layout),
40
- mainColumn: rect(mainColumn),
41
- sideColumn: rect(sideColumn),
42
- messages: {
43
- ...rect(messages),
44
- clientHeight: messages?.clientHeight || 0,
45
- scrollHeight: messages?.scrollHeight || 0,
46
- scrollTop: messages?.scrollTop || 0
47
- },
48
- composer: rect(composer),
49
- sideColumnDisplay: sideColumn ? window.getComputedStyle(sideColumn).display : "missing",
50
- historyDisplay: history ? window.getComputedStyle(history).display : "missing",
51
- toolsDisplay: tools ? window.getComputedStyle(tools).display : "missing"
52
- };
53
- });
54
- }
55
-
56
25
  async function assertResponsiveAssistant(page, viewport) {
57
26
  await page.setViewportSize({ width: viewport.width, height: viewport.height });
58
- await page.goto("/", { waitUntil: "networkidle" });
59
-
60
- const root = page.getByTestId("assistant-client-element");
61
- const messages = page.getByTestId("assistant-messages-panel");
62
- const compactConversationControl = page.getByRole("button", { name: "Conversations", exact: true });
63
- await root.waitFor({ state: "visible" });
64
-
65
- const metrics = await readLayoutMetrics(page);
66
- assert.ok(
67
- metrics.messages.scrollHeight > metrics.messages.clientHeight,
68
- `${viewport.name}: messages did not become an internal scroll container.`
69
- );
70
- assert.ok(metrics.composer, `${viewport.name}: composer was not rendered.`);
71
- assert.ok(
72
- metrics.composer.bottom <= metrics.viewportHeight + 1,
73
- `${viewport.name}: composer bottom ${metrics.composer.bottom}px exceeded the ${metrics.viewportHeight}px viewport.`
74
- );
75
- assert.ok(
76
- metrics.root.bottom <= metrics.viewportHeight + 1,
77
- `${viewport.name}: assistant root escaped the viewport.`
78
- );
79
- assert.ok(
80
- metrics.mainColumn.bottom <= metrics.root.bottom + 1,
81
- `${viewport.name}: main column escaped its bounded root.`
82
- );
83
- assert.ok(metrics.documentOverflow <= 1, `${viewport.name}: page overflowed horizontally.`);
84
-
85
- if (viewport.sidePanelsVisible) {
86
- assert.equal(metrics.sideColumnDisplay, "flex", `${viewport.name}: sidebar column was hidden.`);
87
- assert.equal(metrics.historyDisplay, "flex", `${viewport.name}: conversation history was hidden.`);
88
- assert.equal(metrics.toolsDisplay, "flex", `${viewport.name}: tool timeline was hidden.`);
89
- await expect(compactConversationControl).toBeHidden();
90
- } else {
91
- assert.equal(metrics.sideColumnDisplay, "none", `${viewport.name}: sidebar column still occupied the layout.`);
92
- await expect(compactConversationControl).toBeVisible();
93
- }
94
-
95
- await messages.evaluate((element) => {
96
- element.scrollTop = 0;
97
- element.dispatchEvent(new Event("scroll"));
98
- });
99
- await messages.hover();
100
- await page.mouse.wheel(0, 420);
101
- await expect.poll(() => messages.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
27
+ await page.goto("/");
28
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
29
+ await expect(input).toBeVisible();
30
+ const body = page.locator(".assistant-transcript__body");
31
+ await expect(page.getByText("Conversation line 70:", { exact: false })).toBeAttached();
32
+ await expect.poll(() => body.evaluate((element) => element.scrollHeight > element.clientHeight)).toBe(true);
33
+ const bounds = await input.boundingBox();
34
+ assert.ok(bounds.y + bounds.height <= viewport.height);
35
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
36
+ await body.hover();
37
+ await page.mouse.wheel(0, -600);
38
+ await expect.poll(() => body.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)).toBeGreaterThan(100);
102
39
  }
103
40
 
104
- test("AssistantClientElement keeps long conversations scrollable across responsive layouts", {
41
+ test("AssistantConversationElement keeps long conversations scrollable across responsive layouts", {
105
42
  skip: RUN_BROWSER_TEST
106
43
  ? false
107
44
  : "set JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION=1 to run assistant-core browser integration",
@@ -138,6 +75,45 @@ test("AssistantClientElement keeps long conversations scrollable across responsi
138
75
  }
139
76
  });
140
77
 
78
+ test("conversation scrolling preserves history anchors and resets expansion on selection", {
79
+ skip: !RUN_BROWSER_TEST,
80
+ timeout: 60_000
81
+ }, async () => {
82
+ const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
83
+ const browser = await chromium.launch(createChromiumLaunchOptions());
84
+ try {
85
+ const page = await browser.newPage();
86
+ await page.goto(`${vite.baseURL}/?controls=1`);
87
+ const body = page.locator(".assistant-transcript__body");
88
+ await expect(page.getByText("Conversation line 70:", { exact: false })).toBeVisible();
89
+ await body.hover();
90
+ await page.mouse.wheel(0, -100_000);
91
+ await expect.poll(() => body.evaluate((element) => element.scrollTop)).toBe(0);
92
+ await page.getByRole("button", { name: "Read more", exact: true }).click();
93
+ await expect(page.getByRole("button", { name: "Show less", exact: true })).toBeVisible();
94
+ const original = page.getByText("Conversation line 1:", { exact: false }).first();
95
+ const originalTop = (await original.boundingBox()).y;
96
+ await page.getByRole("button", { name: "Load older messages", exact: true }).click();
97
+ await expect.poll(async () => Math.abs((await original.boundingBox()).y - originalTop)).toBeLessThan(2);
98
+ const before = await body.evaluate((element) => element.scrollTop);
99
+ await page.getByRole("button", { name: "Append reply", exact: true }).click();
100
+ await expect(page.getByText("New reply.", { exact: false })).toBeAttached();
101
+ assert.equal(await body.evaluate((element) => element.scrollTop), before);
102
+ await body.hover();
103
+ await page.mouse.wheel(0, 100_000);
104
+ await expect.poll(() => body.evaluate((element) => element.scrollHeight - element.scrollTop - element.clientHeight)).toBeLessThan(2);
105
+ await page.getByRole("button", { name: "Append reply", exact: true }).click();
106
+ await expect(page.getByText("New reply.", { exact: false })).toHaveCount(2);
107
+ await expect.poll(() => body.evaluate((element) => element.scrollHeight - element.scrollTop - element.clientHeight)).toBeLessThan(2);
108
+ await page.getByRole("button", { name: "Change conversation", exact: true }).click();
109
+ await expect(page.getByRole("button", { name: "Show less", exact: true })).toHaveCount(0);
110
+ await expect(page.getByRole("button", { name: "Read more", exact: true })).toBeAttached();
111
+ } finally {
112
+ await browser.close();
113
+ await stopProcess(vite);
114
+ }
115
+ });
116
+
141
117
  test("conversation composer preserves typing and reacts to external state and pane resizing", {
142
118
  skip: !RUN_BROWSER_TEST,
143
119
  timeout: 60_000
@@ -168,6 +144,13 @@ test("conversation composer preserves typing and reacts to external state and pa
168
144
  const before = await input.evaluate((element) => element.clientHeight);
169
145
  await page.getByRole("button", { name: "Resize pane" }).evaluate((element) => element.click());
170
146
  await expect.poll(() => input.evaluate((element) => element.clientHeight)).toBeGreaterThan(before);
147
+ await page.getByRole("button", { name: "Toggle action feedback" }).evaluate((element) => element.click());
148
+ const feedback = page.getByText("Describe what you want to change.", { exact: true });
149
+ await expect(feedback).toBeVisible();
150
+ const sendBounds = await page.getByRole("button", { name: "Send", exact: true }).boundingBox();
151
+ assert.ok(sendBounds.height >= 48, "Compact panes retain a 48px action target on desktop");
152
+ assert.ok((await feedback.boundingBox()).y >= sendBounds.y + sendBounds.height, "Narrow action feedback follows the button");
153
+ await expect(input).toBeFocused();
171
154
  await input.dispatchEvent("keydown", { key: "Enter", isComposing: true });
172
155
  await expect(input).toHaveValue(draft);
173
156
  await input.press("Enter");
@@ -1,26 +1,13 @@
1
1
  import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
- import { readFile } from "node:fs/promises";
4
- import path from "node:path";
5
-
6
- const packageRoot = new URL("../", import.meta.url);
7
-
8
- async function readPackageFile(relativePath) {
9
- return readFile(path.join(packageRoot.pathname, relativePath), "utf8");
10
- }
11
-
12
- test("AssistantClientElement unwraps viewer refs before reading avatar fields", async () => {
13
- const source = await readPackageFile("src/client/components/AssistantClientElement.vue");
14
- assert.match(source, /normalizeObject\(unref\(props\.viewer\)\)/);
15
- });
16
-
17
- test("AssistantClientElement keeps its responsive scroll ancestors height-bounded", async () => {
18
- const source = await readPackageFile("src/client/components/AssistantClientElement.vue");
19
-
20
- assert.match(source, /<v-row class="assistant-layout h-100 flex-grow-1 flex-nowrap my-0">/u);
21
- assert.match(source, /<v-col cols="12" md="8" class="assistant-main-col/u);
22
- assert.match(source, /<v-col cols="12" md="4" class="assistant-side-col d-none d-md-flex/u);
23
- assert.match(source, /class="d-md-none"/u);
24
- assert.match(source, /\.assistant-main-col,\s*\.assistant-side-col,[\s\S]*height: 100%;[\s\S]*max-height: 100%;/u);
25
- assert.match(source, /\.messages-panel \{[\s\S]*flex: 1 1 auto;[\s\S]*min-height: 0;[\s\S]*overflow: auto;/u);
3
+ import { readFile, access } from "node:fs/promises";
4
+
5
+ test("client entry points expose one conversation implementation and retain settings/API helpers", async () => {
6
+ const source = await readFile(new URL("../src/client/index.js", import.meta.url), "utf8");
7
+ assert.doesNotMatch(source, /AssistantClientElement/);
8
+ assert.match(source, /AssistantSettingsFormCard/);
9
+ assert.match(source, /createAssistantApi/);
10
+ await assert.rejects(access(new URL("../src/client/components/AssistantClientElement.vue", import.meta.url)));
11
+ const conversation = await readFile(new URL("../src/client/conversation/index.js", import.meta.url), "utf8");
12
+ assert.match(conversation, /AssistantConversationElement/);
26
13
  });