@jskit-ai/assistant-runtime 0.1.162 → 0.1.164

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.
@@ -0,0 +1,50 @@
1
+ <script setup>
2
+ import { computed, ref } from "vue";
3
+ import { useRoute, useRouter } from "vue-router";
4
+ import { useTheme } from "vuetify";
5
+ import { useWebPlacementContext } from "@jskit-ai/shell-web/client/placement";
6
+ import AssistantSurfaceClientElement from "../../src/client/components/AssistantSurfaceClientElement.vue";
7
+ const route = useRoute();
8
+ const router = useRouter();
9
+ const theme = useTheme();
10
+ const { context, mergeContext } = useWebPlacementContext();
11
+ const mounted = ref(true);
12
+ const second = ref(false);
13
+ const compact = ref(false);
14
+ const surface = ref("admin");
15
+ const workspace = computed(() => route.query.workspace || "alpha");
16
+ function switchWorkspace() { router.push({ query: { workspace: workspace.value === "alpha" ? "beta" : "alpha" } }); }
17
+ </script>
18
+ <template>
19
+ <v-app>
20
+ <v-main>
21
+ <div class="fixture" :class="{ 'fixture--page': route.query.page === '1' }">
22
+ <nav aria-label="Fixture controls">
23
+ <button @click="switchWorkspace">Switch workspace</button>
24
+ <button @click="mergeContext({ user: { id: context.user.id === '1' ? '2' : '1' } })">Switch user</button>
25
+ <button @click="mergeContext({ workspace: { id: '42', slug: workspace, name: 'Workspace metadata loaded' } })">Load workspace metadata</button>
26
+ <button @click="surface = surface === 'admin' ? 'console' : 'admin'">Switch surface</button>
27
+ <button @click="mounted = !mounted">Toggle view</button>
28
+ <button @click="second = !second">Second instance</button>
29
+ <button @click="compact = !compact">Resize pane</button>
30
+ <button @click="theme.change(theme.global.name.value === 'dark' ? 'light' : 'dark')">Toggle theme</button>
31
+ </nav>
32
+ <div class="fixture__panes" :class="{ 'fixture__panes--compact': compact }">
33
+ <AssistantSurfaceClientElement v-if="mounted" :surface-id="surface" :layout="compact ? 'compact' : 'page'" assistant-label="Fixture assistant" />
34
+ <AssistantSurfaceClientElement v-if="second" surface-id="console" assistant-label="Second assistant" />
35
+ </div>
36
+ </div>
37
+ </v-main>
38
+ </v-app>
39
+ </template>
40
+ <style>
41
+ html, body, #app { height: 100%; margin: 0; }
42
+ .fixture { height: 100dvh; display: flex; flex-direction: column; min-height: 0; }
43
+ .fixture nav { display: flex; flex-wrap: wrap; gap: .5rem; flex: 0 0 auto; }
44
+ .fixture nav button { padding: .25rem; }
45
+ .fixture__panes { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; }
46
+ .fixture__panes > * { flex: 1 1 0; min-width: 0; }
47
+ .fixture__panes--compact { width: min(360px, 100%); }
48
+ .fixture--page { height: auto; }
49
+ .fixture--page .fixture__panes { display: block; }
50
+ </style>
@@ -0,0 +1 @@
1
+ <!doctype html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body><div id="app"></div><script type="module" src="/main.js"></script></body></html>
@@ -0,0 +1,31 @@
1
+ import { createApp } from "vue";
2
+ import { createRouter, createWebHistory } from "vue-router";
3
+ import { VueQueryPlugin } from "@tanstack/vue-query";
4
+ import { createVuetify } from "vuetify";
5
+ import * as components from "vuetify/components";
6
+ import * as directives from "vuetify/directives";
7
+ import { aliases, mdi } from "vuetify/iconsets/mdi-svg";
8
+ import "vuetify/styles";
9
+ import App from "./App.vue";
10
+ import { createWebPlacementRuntime } from "../../../shell-web/src/client/placement/runtime.js";
11
+
12
+ globalThis.__JSKIT_CLIENT_APP_CONFIG__ = {
13
+ surfaceDefinitions: { admin: { requiresWorkspace: true }, console: { requiresWorkspace: false } },
14
+ assistantSurfaces: {
15
+ admin: { settingsSurfaceId: "admin", configScope: "workspace" },
16
+ console: { settingsSurfaceId: "console", configScope: "global" }
17
+ }
18
+ };
19
+ const router = createRouter({ history: createWebHistory(), routes: [{ path: "/:pathMatch(.*)*", component: App }] });
20
+ const app = createApp(App);
21
+ const placement = createWebPlacementRuntime({ components: new Map() });
22
+ placement.setContext({ user: { id: "1" }, surfaceConfig: {
23
+ defaultSurfaceId: "admin",
24
+ surfacesById: { admin: { id: "admin", routeBase: "/", requiresWorkspace: false } }
25
+ } });
26
+ app.provide("jskit.shell-web.runtime.web-placement.client", placement);
27
+ app.provide("jskit.workspaces.web.scope-support", { readRouteScope: ({ route }) => ({ workspaceSlug: route.query.workspace || "alpha" }) });
28
+ app.use(router).use(VueQueryPlugin, { queryClientConfig: { defaultOptions: { queries: { retry: false } } } });
29
+ app.use(createVuetify({ theme: { defaultTheme: "light" }, components, directives, icons: { defaultSet: "mdi", aliases, sets: { mdi } } }));
30
+ await router.isReady();
31
+ app.mount("#app");
@@ -0,0 +1,69 @@
1
+ import { defineConfig } from "vite";
2
+ import vue from "@vitejs/plugin-vue";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ export default defineConfig({
6
+ root: fileURLToPath(new URL("./", import.meta.url)),
7
+ server: { host: "127.0.0.1", strictPort: true },
8
+ plugins: [vue(), {
9
+ name: "controlled-assistant-api",
10
+ configureServer(server) {
11
+ const streams = [];
12
+ const requests = [];
13
+ server.middlewares.use(async (req, res, next) => {
14
+ const url = new URL(req.url, "http://fixture");
15
+ if (!url.pathname.startsWith("/api/") && !url.pathname.startsWith("/fixture/")) return next();
16
+ const json = (value) => { res.setHeader("Content-Type", "application/json"); res.end(JSON.stringify(value)); };
17
+ const send = (stream, event) => stream.res.write(`${JSON.stringify(event)}\n`);
18
+ if (url.pathname === "/api/session") return json({ csrfToken: "fixture-only" });
19
+ if (url.pathname === "/fixture/state") return json({ requests, streams: streams.map(({ closed }) => ({ closed })) });
20
+ if (url.pathname === "/fixture/next") {
21
+ for (const stream of streams.filter((entry) => !entry.closed)) {
22
+ if (url.searchParams.has("fail")) {
23
+ send(stream, { type: "error", message: "Fixture provider failed." });
24
+ send(stream, { type: "done", status: "failed" });
25
+ stream.res.end();
26
+ } else if (url.searchParams.has("finish")) {
27
+ send(stream, { type: "tool_result", toolCallId: "lookup", ok: true, result: { private: "never-render-this-payload" } });
28
+ send(stream, { type: "assistant_delta", delta: " completed." });
29
+ send(stream, { type: "done", status: "completed" });
30
+ stream.res.end();
31
+ } else {
32
+ send(stream, { type: "assistant_delta", delta: "Partial answer" });
33
+ }
34
+ }
35
+ return json({ ok: true });
36
+ }
37
+ if (url.pathname.endsWith("/chat/stream")) {
38
+ let body = "";
39
+ for await (const part of req) body += part;
40
+ const payload = JSON.parse(body);
41
+ requests.push({ path: url.pathname, surface: req.headers['x-jskit-surface'], ...payload });
42
+ res.setHeader("Content-Type", "application/x-ndjson");
43
+ const stream = { res, closed: false };
44
+ streams.push(stream);
45
+ res.on("close", () => { stream.closed = true; });
46
+ send(stream, { type: "meta", conversationId: payload.conversationId || "100" });
47
+ send(stream, { type: "tool_call", toolCallId: "lookup", name: "Look up records", arguments: "never-render-this-payload" });
48
+ return;
49
+ }
50
+ if (url.pathname.endsWith("/messages")) {
51
+ const id = url.pathname.split("/").at(-2);
52
+ const entries = Array.from({ length: 70 }, (_, index) => ({
53
+ id: String(index + 1), kind: "chat", role: index % 2 ? "assistant" : "user",
54
+ contentText: index < 2 ? "Identical text" : `Saved ${id} message ${index + 1}.\n\n**Formatted** answer with a [link](https://example.com). Long_text_keeps_wrapping_in_a_narrow_drawer_even_while_other_content_is_streaming.`
55
+ }));
56
+ return json({ data: { type: "assistant-conversation-messages", id, attributes: { entries, totalPages: 1 } } });
57
+ }
58
+ if (url.pathname.endsWith("/conversations")) {
59
+ const older = url.searchParams.has("page[cursor]");
60
+ return json({ data: [{ type: "assistant-conversations", id: older ? "2" : "1", attributes: {
61
+ title: older ? "Older conversation" : "Saved conversation", status: "completed", startedAt: "2026-09-01T00:00:00Z"
62
+ } }], meta: { page: { nextCursor: older ? null : "older" } } });
63
+ }
64
+ res.statusCode = 404;
65
+ json({ error: "Unknown fixture request" });
66
+ });
67
+ }
68
+ }]
69
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-runtime",
3
- "version": "0.1.162",
3
+ "version": "0.1.164",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -11,15 +11,15 @@
11
11
  "./server/actionIds": "./src/server/actionIds.js"
12
12
  },
13
13
  "dependencies": {
14
- "@jskit-ai/assistant-core": "0.1.167",
15
- "@jskit-ai/database-runtime": "0.1.191",
14
+ "@jskit-ai/assistant-core": "0.1.171",
15
+ "@jskit-ai/database-runtime": "0.1.193",
16
16
  "json-rest-schema": "^1.0.17"
17
17
  },
18
18
  "peerDependencies": {
19
- "@jskit-ai/http-runtime": "0.1.189",
20
- "@jskit-ai/http-web": "0.1.36",
21
- "@jskit-ai/kernel": "0.1.191",
22
- "@jskit-ai/shell-web": "0.1.195",
19
+ "@jskit-ai/http-runtime": "0.1.191",
20
+ "@jskit-ai/http-web": "0.1.38",
21
+ "@jskit-ai/kernel": "0.1.193",
22
+ "@jskit-ai/shell-web": "0.1.197",
23
23
  "@tanstack/vue-query": "^5.90.5",
24
24
  "vue": "^3.5.13",
25
25
  "vuetify": "^4.0.0"
@@ -75,3 +75,20 @@ public workspace support.
75
75
  - workspace scope on a non-workspace surface
76
76
  - copied runtime repositories, actions, or routes
77
77
  - generator commands, question metadata, receipts, or provenance
78
+
79
+ ## Presentation and view lifetime
80
+
81
+ The surface uses the same `AssistantConversationElement` as applications with
82
+ their own runtime. Use `layout="compact"` for a bounded drawer and the
83
+ `assistantLabel`, `welcomeMessage`, `placeholder`, and `showToolActivity` props
84
+ for presentation. Conversations remains available at all widths. Keep settings
85
+ on the configured settings route. Use the `composer-tools` slot for extra
86
+ application controls and `focus()` when opening a pane; do not target internal
87
+ DOM classes.
88
+
89
+ The runtime owns one draft and maps its stream statuses to the shared contract.
90
+ Typing remains possible during a response; Send waits. Unmounting or switching
91
+ workspace/surface aborts the local response and discards that view's state.
92
+ Reopening restores saved history, not a live stream. Stop cannot guarantee that
93
+ a server-side tool already running has stopped. Custom editor conversations
94
+ should use their own adapter and keep their existing operation/storage owner.
@@ -1,19 +1,152 @@
1
- <template>
2
- <AssistantClientElement v-bind="runtime" />
3
- </template>
4
-
5
1
  <script setup>
6
- import { AssistantClientElement } from "@jskit-ai/assistant-core/client";
2
+ import { computed, onMounted, onScopeDispose, reactive, ref, toRef, watchPostEffect } from "vue";
3
+ import { AssistantConversationElement } from "@jskit-ai/assistant-core/client/conversation";
7
4
  import { useAssistantRuntime } from "../composables/useAssistantRuntime.js";
5
+ import { mapAssistantConversationTurns } from "../support/assistantRuntimeState.js";
8
6
 
9
7
  const props = defineProps({
10
- surfaceId: {
11
- type: String,
12
- required: true
13
- }
8
+ surfaceId: { type: String, required: true },
9
+ layout: { type: String, default: "page", validator: (value) => ["page", "compact"].includes(value) },
10
+ assistantLabel: { type: String, default: "Assistant" },
11
+ welcomeMessage: { type: String, default: "What would you like to do?" },
12
+ placeholder: { type: String, default: "Message the assistant…" },
13
+ showToolActivity: { type: Boolean, default: true }
14
14
  });
15
-
16
- const runtime = useAssistantRuntime({
17
- surfaceId: props.surfaceId
15
+ const runtime = useAssistantRuntime({ surfaceId: toRef(props, "surfaceId") });
16
+ const state = reactive(runtime.state);
17
+ const conversation = ref(null);
18
+ const root = ref(null);
19
+ const historyOpen = ref(false);
20
+ // Page mode owns viewport sizing; compact mode fills the host's bounded pane.
21
+ onMounted(() => {
22
+ const syncPageOffset = () => {
23
+ if (props.layout === "page") root.value.style.setProperty("--assistant-page-top", `${Math.max(0, root.value.getBoundingClientRect().top)}px`);
24
+ };
25
+ const observer = new ResizeObserver(syncPageOffset);
26
+ observer.observe(root.value.parentElement);
27
+ window.addEventListener("resize", syncPageOffset);
28
+ watchPostEffect(syncPageOffset);
29
+ onScopeDispose(() => {
30
+ observer.disconnect();
31
+ window.removeEventListener("resize", syncPageOffset);
32
+ });
18
33
  });
34
+ const selectedConversation = computed(() => state.conversationHistory.find((entry) => String(entry.id) === state.activeConversationId));
35
+ const turns = computed(() => mapAssistantConversationTurns(state.messages, state.pendingToolEvents, props));
36
+ const adapter = computed(() => ({
37
+ conversation: {
38
+ turns: turns.value,
39
+ scrollKey: `${state.scopeKey}:${state.activeConversationId || "new"}`,
40
+ loading: state.isRestoringConversation,
41
+ assistantLabel: props.assistantLabel,
42
+ welcomeMessage: state.messages.length || state.isRestoringConversation ? "" : props.welcomeMessage
43
+ },
44
+ composer: {
45
+ draft: state.input,
46
+ canSend: state.canSend,
47
+ disabled: !state.hasRuntimeScope,
48
+ canStop: state.isStreaming,
49
+ stopPending: state.isCanceling,
50
+ placeholder: props.placeholder,
51
+ rows: 2,
52
+ density: props.layout === "compact" ? "compact" : "default",
53
+ submitOnEnter: true,
54
+ submitOnModifierEnter: true
55
+ },
56
+ actions: {
57
+ setDraft(value) { state.input = value; },
58
+ submit: runtime.actions.sendMessage,
59
+ stop: runtime.actions.cancelStream
60
+ }
61
+ }));
62
+
63
+ function conversationSubtitle(entry) {
64
+ const details = [runtime.meta.normalizeConversationStatus(entry.status), runtime.meta.formatConversationStartedAt(entry.startedAt), `${Number(entry.messageCount || 0)} messages`];
65
+ if (state.isAdminSurface) {
66
+ details.push(entry.createdByUserDisplayName || entry.createdByUserEmail || (entry.createdByUserId ? `User #${entry.createdByUserId}` : "Unknown user"));
67
+ }
68
+ return details.join(" · ");
69
+ }
70
+
71
+ async function selectConversation(entry) {
72
+ historyOpen.value = false;
73
+ await runtime.actions.selectConversation(entry);
74
+ }
75
+
76
+ function startNewConversation() {
77
+ runtime.actions.startNewConversation();
78
+ historyOpen.value = false;
79
+ }
80
+
81
+ defineExpose({ focus: () => conversation.value?.focus() });
19
82
  </script>
83
+
84
+ <template>
85
+ <section ref="root" class="assistant-surface" :class="`assistant-surface--${layout}`" :aria-label="assistantLabel">
86
+ <div class="assistant-surface__toolbar">
87
+ <span class="assistant-surface__title">{{ selectedConversation?.title || 'New conversation' }}</span>
88
+ <v-btn variant="text" @click="historyOpen = true">Conversations</v-btn>
89
+ <v-menu v-if="showToolActivity && state.pendingToolEvents.length" location="bottom end">
90
+ <template #activator="{ props: menuProps }">
91
+ <v-btn v-bind="menuProps" variant="text">Activity</v-btn>
92
+ </template>
93
+ <v-list aria-label="Tool activity" max-height="320" max-width="360" class="assistant-surface__activity">
94
+ <v-list-item v-for="event in state.pendingToolEvents" :key="event.id" :title="event.name" :subtitle="event.status" />
95
+ </v-list>
96
+ </v-menu>
97
+ </div>
98
+ <AssistantConversationElement ref="conversation" :adapter="adapter" label="Conversation" class="assistant-surface__conversation">
99
+ <template #message-actions="{ message }">
100
+ <p v-if="message.status === 'failed' && message.error" class="assistant-surface__message-error" role="status">{{ message.error }}</p>
101
+ <p v-if="message.status === 'interrupted'" class="assistant-surface__notice" role="status">Response canceled. A tool already running may still finish.</p>
102
+ </template>
103
+ <template #hints>
104
+ <p v-if="state.error && !state.messages.some((message) => message.error === state.error)" class="assistant-surface__message-error" role="status">{{ state.error }}</p>
105
+ </template>
106
+ <template #composer-tools><slot name="composer-tools" :runtime="runtime" /></template>
107
+ </AssistantConversationElement>
108
+
109
+ <v-dialog v-model="historyOpen" max-width="640" scrollable aria-label="Conversations">
110
+ <v-card class="assistant-surface__history">
111
+ <v-card-title>Conversations</v-card-title>
112
+ <v-card-actions class="assistant-surface__history-actions">
113
+ <v-btn color="primary" :disabled="!state.canStartNewConversation" @click="startNewConversation">Start new conversation</v-btn>
114
+ <v-btn :disabled="state.conversationHistoryLoading" @click="runtime.actions.refreshConversationHistory">Refresh</v-btn>
115
+ <v-btn @click="historyOpen = false">Close</v-btn>
116
+ </v-card-actions>
117
+ <v-card-text>
118
+ <p v-if="state.conversationHistoryError" role="alert">{{ state.conversationHistoryError }}</p>
119
+ <v-skeleton-loader v-if="state.conversationHistoryLoading && !state.conversationHistory.length" type="list-item-two-line@3" aria-label="Loading conversations" />
120
+ <v-list v-else aria-label="Saved conversations">
121
+ <v-list-item v-if="!state.conversationHistory.length" title="No saved conversations" />
122
+ <v-list-item
123
+ v-for="entry in state.conversationHistory" :key="entry.id"
124
+ :active="String(entry.id) === state.activeConversationId"
125
+ :title="entry.title || 'Untitled conversation'" :subtitle="conversationSubtitle(entry)"
126
+ :disabled="state.isStreaming || state.isRestoringConversation" @click="selectConversation(entry)"
127
+ />
128
+ </v-list>
129
+ <v-btn v-if="state.conversationHistoryHasMore" :disabled="state.conversationHistoryLoadingMore" variant="text" @click="runtime.actions.loadMoreConversationHistory">
130
+ {{ state.conversationHistoryLoadingMore ? 'Loading conversations…' : 'Load older conversations' }}
131
+ </v-btn>
132
+ </v-card-text>
133
+ </v-card>
134
+ </v-dialog>
135
+ </section>
136
+ </template>
137
+
138
+ <style scoped>
139
+ .assistant-surface { display: flex; flex-direction: column; height: 100%; max-height: 100%; min-height: 0; min-width: 0; overflow: hidden; gap: .5rem; }
140
+ .assistant-surface--page { padding: .5rem; height: calc(100dvh - var(--assistant-page-top, 0px) - var(--v-layout-bottom, 0px) - 1rem - env(safe-area-inset-bottom, 0px)); }
141
+ .assistant-surface__toolbar { display: flex; align-items: center; flex-wrap: wrap; flex: 0 0 auto; gap: .25rem; }
142
+ .assistant-surface__title { flex: 1 1 8rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .875rem; }
143
+ .assistant-surface__conversation { flex: 1 1 auto; }
144
+ .assistant-surface__activity { overflow: auto; }
145
+ .assistant-surface__message-error, .assistant-surface__notice { font-size: .85rem; margin: .25rem 0; overflow-wrap: anywhere; }
146
+ .assistant-surface__message-error { color: rgb(var(--v-theme-error)); }
147
+ .assistant-surface__notice { color: rgba(var(--v-theme-on-surface), .75); }
148
+ .assistant-surface__history-actions { flex-wrap: wrap; }
149
+ @media (max-width: 600px), (pointer: coarse) {
150
+ .assistant-surface__toolbar .v-btn, .assistant-surface__history .v-btn { min-height: 48px; }
151
+ }
152
+ </style>
@@ -1,4 +1,4 @@
1
- import { computed, nextTick, ref, watch } from "vue";
1
+ import { computed, onScopeDispose, ref, shallowRef, toValue, watch } from "vue";
2
2
  import { useQueryClient } from "@tanstack/vue-query";
3
3
  import { getClientAppConfig } from "@jskit-ai/kernel/client";
4
4
  import { normalizeObject, normalizeRecordId, normalizeText } from "@jskit-ai/kernel/shared/support/normalize";
@@ -28,7 +28,6 @@ import {
28
28
  mapTranscriptEntriesToAssistantState,
29
29
  normalizeToolName
30
30
  } from "../support/assistantRuntimeState.js";
31
- import { insertTextAtSelection } from "../support/composerInputSupport.js";
32
31
  import {
33
32
  loadConversationTranscript,
34
33
  resolveConversationRestorePolicy
@@ -51,7 +50,8 @@ function toNonNegativeInteger(value, fallback = 0) {
51
50
  function buildScopeStorageKey(scope = {}) {
52
51
  const runtimeSurfaceId = normalizeText(scope?.targetSurfaceId).toLowerCase() || "assistant";
53
52
  const workspaceSlug = normalizeText(scope?.workspaceSlug).toLowerCase() || "global";
54
- return `assistant.activeConversationId:${runtimeSurfaceId}:${workspaceSlug}`;
53
+ const userId = normalizeRecordId(scope?.userId, { fallback: "anonymous" });
54
+ return `assistant.activeConversationId:${runtimeSurfaceId}:${workspaceSlug}:${userId}`;
55
55
  }
56
56
 
57
57
  function readStoredActiveConversationId(scope = {}) {
@@ -151,11 +151,13 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
151
151
  const error = ref("");
152
152
  const pendingToolEvents = ref([]);
153
153
  const conversationId = ref(null);
154
- const abortController = ref(null);
154
+ const abortController = shallowRef(null);
155
+ const isCanceling = ref(false);
156
+ let restoreVersion = 0;
155
157
 
156
158
  const placementSnapshot = computed(() => normalizeObject(placementContext.value));
157
159
  const assistantSurface = computed(() =>
158
- resolveAssistantSurfaceConfig(appConfig, surfaceId)
160
+ resolveAssistantSurfaceConfig(appConfig, toValue(surfaceId))
159
161
  );
160
162
  const routeScope = computed(() => workspaceScopeSupport.readRouteScope(routeContext));
161
163
  const runtimeScope = computed(() => {
@@ -165,14 +167,12 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
165
167
 
166
168
  return {
167
169
  targetSurfaceId: normalizeText(assistantSurface.value?.targetSurfaceId).toLowerCase(),
168
- workspaceSlug,
169
- workspaceId: assistantSurface.value?.runtimeSurfaceRequiresWorkspace
170
- ? normalizeRecordId(placementSnapshot.value?.workspace?.id, { fallback: null })
171
- : null
170
+ userId: normalizeRecordId(placementSnapshot.value?.user?.id, { fallback: null }),
171
+ workspaceSlug
172
172
  };
173
173
  });
174
174
  const hasRuntimeScope = computed(() =>
175
- Boolean(assistantSurface.value) &&
175
+ Boolean(assistantSurface.value && runtimeScope.value.userId) &&
176
176
  (assistantSurface.value?.runtimeSurfaceRequiresWorkspace ? Boolean(runtimeScope.value.workspaceSlug) : true)
177
177
  );
178
178
 
@@ -197,7 +197,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
197
197
  normalizeText(input.value)
198
198
  );
199
199
  });
200
- const canStartNewConversation = computed(() => Boolean(hasRuntimeScope.value && !isStreaming.value));
200
+ const canStartNewConversation = computed(() => Boolean(hasRuntimeScope.value && !isStreaming.value && !isRestoringConversation.value));
201
+ const scopeKey = computed(() => JSON.stringify(runtimeScope.value));
201
202
 
202
203
  function setRuntimeError(message, dedupeKey = "") {
203
204
  const normalizedMessage = normalizeText(message);
@@ -218,9 +219,9 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
218
219
 
219
220
  const conversationHistoryCollection = usePagedCollection({
220
221
  queryKey: computed(() =>
221
- assistantConversationsListQueryKey(runtimeScope.value, {
222
+ [...assistantConversationsListQueryKey(runtimeScope.value, {
222
223
  limit: runtimePolicy.historyPageSize
223
- })
224
+ }), runtimeScope.value.userId]
224
225
  ),
225
226
  queryFn: ({ pageParam = null }) =>
226
227
  runtimeApi.listConversations({
@@ -248,23 +249,6 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
248
249
  const conversationHistoryHasMore = conversationHistoryCollection.hasMore;
249
250
  const conversationHistoryError = conversationHistoryCollection.loadError;
250
251
 
251
- watch(conversationId, (nextConversationId, previousConversationId) => {
252
- if (!hasRuntimeScope.value) {
253
- return;
254
- }
255
-
256
- const nextConversationIdKey = normalizeRecordId(nextConversationId, { fallback: null });
257
- if (nextConversationIdKey) {
258
- writeStoredActiveConversationId(runtimeScope.value, nextConversationIdKey);
259
- return;
260
- }
261
-
262
- const previousConversationIdKey = normalizeRecordId(previousConversationId, { fallback: null });
263
- if (previousConversationIdKey) {
264
- writeStoredActiveConversationId(runtimeScope.value, "");
265
- }
266
- });
267
-
268
252
  watch(
269
253
  [
270
254
  hasRuntimeScope,
@@ -282,7 +266,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
282
266
  nextConversationHistory,
283
267
  nextIsRestoringConversation
284
268
  ]) => {
285
- if (!nextHasRuntimeScope || nextConversationHistoryLoading || nextIsRestoringConversation) {
269
+ if (!nextHasRuntimeScope || nextConversationHistoryLoading || nextIsRestoringConversation || isStreaming.value) {
286
270
  return;
287
271
  }
288
272
 
@@ -309,7 +293,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
309
293
  return;
310
294
  }
311
295
 
312
- await selectConversationById(storedConversationId);
296
+ await selectConversationById(storedConversationId, { preserveDraft: true });
313
297
  },
314
298
  {
315
299
  immediate: true
@@ -360,19 +344,16 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
360
344
  await conversationHistoryCollection.loadMore();
361
345
  }
362
346
 
363
- async function selectConversationById(nextConversationId) {
364
- const normalizedConversationId = normalizeText(nextConversationId);
347
+ async function selectConversationById(nextConversationId, { preserveDraft = false } = {}) {
348
+ const normalizedConversationId = normalizeRecordId(nextConversationId, { fallback: "" });
365
349
  if (!normalizedConversationId || isStreaming.value || isRestoringConversation.value || !hasRuntimeScope.value) {
366
350
  return;
367
351
  }
368
352
 
369
- const parsedConversationId = normalizeRecordId(normalizedConversationId, { fallback: null });
370
- if (!parsedConversationId) {
371
- return;
372
- }
373
-
374
- const previousConversationId = conversationId.value;
375
- conversationId.value = parsedConversationId;
353
+ const version = ++restoreVersion;
354
+ const scope = runtimeScope.value;
355
+ const previousDraft = input.value;
356
+ if (!preserveDraft) input.value = "";
376
357
  isRestoringConversation.value = true;
377
358
  setRuntimeError("");
378
359
 
@@ -381,27 +362,33 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
381
362
  pageSize: runtimePolicy.restoreMessagesPageSize,
382
363
  maxEntries: runtimePolicy.restoreMessagesMaxEntries,
383
364
  fetchPage: (page, pageSize) => queryClient.fetchQuery({
384
- queryKey: assistantConversationMessagesQueryKey(runtimeScope.value, parsedConversationId, {
365
+ queryKey: [...assistantConversationMessagesQueryKey(scope, normalizedConversationId, {
385
366
  page,
386
367
  pageSize
387
- }),
388
- queryFn: () => runtimeApi.getConversationMessages(parsedConversationId, {
389
- page,
390
- pageSize
391
- }),
368
+ }), scope.userId],
369
+ queryFn: () => {
370
+ if (version !== restoreVersion) throw new DOMException("Conversation changed.", "AbortError");
371
+ return runtimeApi.getConversationMessages(normalizedConversationId, { page, pageSize });
372
+ },
392
373
  staleTime: runtimePolicy.historyStaleTimeMs
393
374
  })
394
375
  });
395
376
 
377
+ if (version !== restoreVersion) return;
396
378
  const restored = mapTranscriptEntriesToAssistantState(transcript.entries);
379
+ conversationId.value = normalizedConversationId;
380
+ writeStoredActiveConversationId(scope, normalizedConversationId);
397
381
  messages.value = restored.messages;
398
382
  pendingToolEvents.value = restored.pendingToolEvents;
399
- input.value = "";
400
383
  } catch (loadError) {
401
- conversationId.value = previousConversationId;
384
+ if (version !== restoreVersion) return;
385
+ if (readStoredActiveConversationId(scope) === normalizedConversationId) {
386
+ writeStoredActiveConversationId(scope, "");
387
+ }
388
+ if (!input.value) input.value = previousDraft;
402
389
  setRuntimeError(normalizeText(loadError?.message) || "Unable to load conversation.");
403
390
  } finally {
404
- isRestoringConversation.value = false;
391
+ if (version === restoreVersion) isRestoringConversation.value = false;
405
392
  }
406
393
  }
407
394
 
@@ -409,61 +396,33 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
409
396
  await selectConversationById(conversation?.id);
410
397
  }
411
398
 
412
- function startNewConversation() {
413
- if (abortController.value) {
414
- abortController.value.abort();
415
- }
416
-
399
+ function clearView() {
400
+ restoreVersion += 1;
401
+ abortController.value?.abort();
402
+ abortController.value = null;
417
403
  messages.value = [];
418
404
  pendingToolEvents.value = [];
419
405
  input.value = "";
420
- setRuntimeError("");
406
+ error.value = "";
421
407
  conversationId.value = null;
422
- writeStoredActiveConversationId(runtimeScope.value, "");
423
408
  isStreaming.value = false;
409
+ isCanceling.value = false;
424
410
  isRestoringConversation.value = false;
425
- abortController.value = null;
426
411
  }
427
412
 
428
- function handleInputKeydown(event) {
429
- if (event?.key === "Enter" && isStreaming.value) {
430
- event.preventDefault();
431
- return;
432
- }
433
-
434
- if (event?.key === "Enter" && event?.altKey === true && event?.ctrlKey !== true && event?.metaKey !== true) {
435
- event.preventDefault();
436
-
437
- const target = event?.target;
438
- const nextValue = insertTextAtSelection(input.value, target?.selectionStart, target?.selectionEnd, "\n");
439
- input.value = nextValue.value;
440
-
441
- void nextTick(() => {
442
- if (!target || typeof target.setSelectionRange !== "function") {
443
- return;
444
- }
445
-
446
- target.setSelectionRange(nextValue.selectionStart, nextValue.selectionEnd);
447
- });
448
- return;
449
- }
450
-
451
- if (
452
- event?.key === "Enter" &&
453
- event?.shiftKey !== true &&
454
- event?.ctrlKey !== true &&
455
- event?.metaKey !== true &&
456
- event?.altKey !== true
457
- ) {
458
- event.preventDefault();
459
- void sendMessage();
460
- }
413
+ function startNewConversation() {
414
+ if (!canStartNewConversation.value) return;
415
+ writeStoredActiveConversationId(runtimeScope.value, "");
416
+ clearView();
461
417
  }
462
418
 
419
+ watch(scopeKey, clearView, { flush: "sync" });
420
+ onScopeDispose(clearView);
421
+
463
422
  function cancelStream() {
464
- if (abortController.value) {
465
- abortController.value.abort();
466
- }
423
+ if (!abortController.value || isCanceling.value) return;
424
+ isCanceling.value = true;
425
+ abortController.value.abort();
467
426
  }
468
427
 
469
428
  async function sendMessage() {
@@ -505,6 +464,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
505
464
  }, runtimePolicy.timeoutMs);
506
465
 
507
466
  let streamDoneStatus = "";
467
+ const ownsStream = () => abortController.value === streamAbortController;
508
468
 
509
469
  try {
510
470
  await runtimeApi.streamChat(
@@ -517,10 +477,12 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
517
477
  {
518
478
  signal: streamAbortController.signal,
519
479
  onEvent(event) {
480
+ if (!ownsStream() || streamAbortController.signal.aborted) return;
520
481
  const eventType = normalizeAssistantStreamEventType(event?.type, "");
521
482
 
522
483
  if (eventType === ASSISTANT_STREAM_EVENT_TYPES.META && Object.hasOwn(event || {}, "conversationId")) {
523
484
  conversationId.value = normalizeRecordId(event?.conversationId, { fallback: null });
485
+ writeStoredActiveConversationId(runtimeScope.value, conversationId.value);
524
486
  return;
525
487
  }
526
488
 
@@ -552,6 +514,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
552
514
  ...pendingToolEvents.value,
553
515
  {
554
516
  id: toolCallId,
517
+ messageId: assistantMessageId,
555
518
  name: normalizeToolName(event?.name),
556
519
  arguments: String(event?.arguments || ""),
557
520
  status: "pending",
@@ -600,6 +563,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
600
563
  }
601
564
  );
602
565
 
566
+ if (!ownsStream()) return;
567
+ if (streamAbortController.signal.aborted) streamDoneStatus = "aborted";
603
568
  const assistantMessage = findMessage(assistantMessageId);
604
569
  const assistantMessageText = normalizeText(assistantMessage?.text);
605
570
  if (!assistantMessageText && streamDoneStatus !== "aborted") {
@@ -619,6 +584,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
619
584
  }));
620
585
  }
621
586
  } catch (streamError) {
587
+ if (!ownsStream()) return;
622
588
  if (String(streamError?.name || "") === "AbortError") {
623
589
  updateMessage(assistantMessageId, {
624
590
  status: "canceled"
@@ -631,11 +597,19 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
631
597
  }
632
598
  } finally {
633
599
  clearTimeout(streamTimeout);
634
- pendingToolEvents.value = interruptPendingToolEvents(pendingToolEvents.value);
635
- abortController.value = null;
636
- isStreaming.value = false;
637
- await invalidateConversationScope();
638
- await refreshConversationHistory();
600
+ if (ownsStream()) {
601
+ const answer = findMessage(assistantMessageId);
602
+ if (answer?.status === "error") {
603
+ updateMessage(assistantMessageId, { error: error.value });
604
+ if (!answer.text && !input.value) input.value = normalizedInput;
605
+ }
606
+ pendingToolEvents.value = interruptPendingToolEvents(pendingToolEvents.value);
607
+ abortController.value = null;
608
+ isStreaming.value = false;
609
+ isCanceling.value = false;
610
+ await invalidateConversationScope();
611
+ await refreshConversationHistory();
612
+ }
639
613
  }
640
614
  }
641
615
 
@@ -657,6 +631,9 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
657
631
  messages,
658
632
  input,
659
633
  isStreaming,
634
+ isCanceling,
635
+ hasRuntimeScope,
636
+ scopeKey,
660
637
  isRestoringConversation,
661
638
  error,
662
639
  pendingToolEvents,
@@ -673,7 +650,6 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
673
650
  },
674
651
  actions: {
675
652
  sendMessage,
676
- handleInputKeydown,
677
653
  cancelStream,
678
654
  startNewConversation,
679
655
  clearConversation: startNewConversation,
@@ -5,6 +5,25 @@ import {
5
5
  parseJsonObject
6
6
  } from "@jskit-ai/assistant-core/shared";
7
7
  import { isAssistantProgressOnlyText } from "../../shared/assistantResponseText.js";
8
+ import { conversationTurnsFromMessages } from "@jskit-ai/assistant-core/shared/conversation";
9
+
10
+ const CONVERSATION_MESSAGE_STATUS = Object.freeze({
11
+ streaming: "inProgress",
12
+ done: "completed",
13
+ error: "failed",
14
+ canceled: "interrupted"
15
+ });
16
+
17
+ function mapAssistantConversationTurns(messages, toolEvents = [], { showToolActivity = true } = {}) {
18
+ return conversationTurnsFromMessages(messages.map((message) => ({
19
+ ...message,
20
+ messageId: String(message.id),
21
+ status: CONVERSATION_MESSAGE_STATUS[message.status],
22
+ progressUpdates: showToolActivity ? toolEvents
23
+ .filter((event) => event.messageId === message.id)
24
+ .map((event) => ({ id: event.id, text: `${event.name} — ${event.status}` })) : []
25
+ })));
26
+ }
8
27
 
9
28
  function buildId(prefix = "id") {
10
29
  if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
@@ -64,6 +83,9 @@ function mapTranscriptEntriesToAssistantState(entries) {
64
83
  const sourceEntries = Array.isArray(entries) ? entries : [];
65
84
  const messages = [];
66
85
  const toolEventsById = new Map();
86
+ let latestUserId = null;
87
+ let currentAssistantId = null;
88
+ const pendingAssociation = [];
67
89
 
68
90
  function ensureToolEvent(toolCallId, toolName) {
69
91
  const key = normalizeText(toolCallId) || buildId("tool_call");
@@ -103,6 +125,15 @@ function mapTranscriptEntriesToAssistantState(entries) {
103
125
  text,
104
126
  status: "done"
105
127
  });
128
+ if (role === "user") {
129
+ latestUserId = messageId;
130
+ currentAssistantId = null;
131
+ pendingAssociation.length = 0;
132
+ } else {
133
+ currentAssistantId = messageId;
134
+ for (const event of pendingAssociation) event.messageId = messageId;
135
+ pendingAssociation.length = 0;
136
+ }
106
137
  continue;
107
138
  }
108
139
 
@@ -111,6 +142,8 @@ function mapTranscriptEntriesToAssistantState(entries) {
111
142
  const toolEvent = ensureToolEvent(toolCallId, metadata.tool);
112
143
  toolEvent.arguments = String(entry?.contentText || "");
113
144
  toolEvent.status = "pending";
145
+ toolEvent.messageId = currentAssistantId || latestUserId;
146
+ pendingAssociation.push(toolEvent);
114
147
  continue;
115
148
  }
116
149
 
@@ -136,5 +169,6 @@ export {
136
169
  buildId,
137
170
  interruptPendingToolEvents,
138
171
  mapTranscriptEntriesToAssistantState,
172
+ mapAssistantConversationTurns,
139
173
  normalizeToolName
140
174
  };
@@ -132,3 +132,50 @@ test("live stream cleanup interrupts only tool events that remain pending", () =
132
132
  ]
133
133
  );
134
134
  });
135
+
136
+ test("runtime statuses and tool activity map explicitly without changing message identity or exposing payloads", async () => {
137
+ const { mapAssistantConversationTurns } = await import("../src/client/support/assistantRuntimeState.js");
138
+ const messages = [
139
+ { id: "user-1", role: "user", status: "done", text: "Question" },
140
+ { id: "answer-1", role: "assistant", status: "streaming", text: "" }
141
+ ];
142
+ const tools = [{ id: "tool-1", messageId: "answer-1", name: "Search records", status: "pending", arguments: "secret", result: { private: true } }];
143
+ let turns = mapAssistantConversationTurns(messages, tools);
144
+ assert.equal(turns[0].turnId, "user-1");
145
+ assert.equal(turns[0].pending, true);
146
+ assert.equal(turns[0].messages.at(-1).text, "Search records — pending");
147
+ assert.doesNotMatch(JSON.stringify(turns), /secret|private/);
148
+ for (const [status, expected] of [["done", "completed"], ["error", "failed"], ["canceled", "interrupted"]]) {
149
+ messages[1] = { ...messages[1], text: "Partial answer", status };
150
+ turns = mapAssistantConversationTurns(messages, tools);
151
+ assert.equal(turns[0].assistant.messageId, "answer-1");
152
+ assert.equal(turns[0].assistant.status, expected);
153
+ assert.equal(turns[0].assistant.text, "Partial answer");
154
+ assert.equal(turns[0].pending, false);
155
+ }
156
+ assert.equal(mapAssistantConversationTurns(messages, tools, { showToolActivity: false })[0].messages.length, 2);
157
+ });
158
+
159
+ test("numeric persisted identities and distinct equal-text replies remain distinct", async () => {
160
+ const { mapAssistantConversationTurns } = await import("../src/client/support/assistantRuntimeState.js");
161
+ const restored = mapTranscriptEntriesToAssistantState([
162
+ { id: 1, role: "user", kind: "chat", contentText: "Question" },
163
+ { id: 2, role: "assistant", kind: "chat", contentText: "Same answer" },
164
+ { id: "3", role: "assistant", kind: "chat", contentText: "Same answer" }
165
+ ]);
166
+ const turns = mapAssistantConversationTurns(restored.messages);
167
+ assert.deepEqual(turns.map((turn) => turn.assistant.messageId), ["transcript_2", "transcript_3"]);
168
+ });
169
+
170
+ test("restored tool calls retain their association with the reply they preceded", async () => {
171
+ const { mapAssistantConversationTurns } = await import("../src/client/support/assistantRuntimeState.js");
172
+ const restored = mapTranscriptEntriesToAssistantState([
173
+ { id: 1, role: "user", kind: "chat", contentText: "Question" },
174
+ { id: 2, role: "assistant", kind: "tool_call", contentText: "secret arguments", metadata: { toolCallId: "t", tool: "Search" } },
175
+ { id: 3, role: "assistant", kind: "tool_result", contentText: '{"ok":true,"result":{"private":true}}', metadata: { toolCallId: "t" } },
176
+ { id: 4, role: "assistant", kind: "chat", contentText: "Found it" }
177
+ ]);
178
+ const [turn] = mapAssistantConversationTurns(restored.messages, restored.pendingToolEvents);
179
+ assert.equal(turn.messages[1].text, "Search — done");
180
+ assert.doesNotMatch(JSON.stringify(turn), /secret|private/);
181
+ });
@@ -0,0 +1,260 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdir } from "node:fs/promises";
3
+ import { fileURLToPath } from "node:url";
4
+ import test from "node:test";
5
+ import { chromium, expect } from "@playwright/test";
6
+ import { createChromiumLaunchOptions, startViteFixture, stopProcess } from "../../../tooling/testUtils/browserFixture.mjs";
7
+
8
+ const fixtureRoot = fileURLToPath(new URL("../fixtures/conversation/", import.meta.url));
9
+
10
+ test("ready-made assistant preserves streaming, drafts, scope and compact conversation access", {
11
+ skip: process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION !== "1",
12
+ timeout: 180_000
13
+ }, async () => {
14
+ const vite = await startViteFixture({ fixtureRoot });
15
+ const browser = await chromium.launch(createChromiumLaunchOptions());
16
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
17
+ const errors = [];
18
+ page.on("pageerror", (error) => errors.push(error.message));
19
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
20
+ const send = page.getByRole("button", { name: "Send message", exact: true });
21
+ const advance = (query = "") => page.request.get(`${vite.baseURL}/fixture/next${query}`);
22
+ const requestState = async () => (await page.request.get(`${vite.baseURL}/fixture/state`)).json();
23
+ try {
24
+ await page.goto(vite.baseURL);
25
+ await expect(input).toBeEnabled();
26
+ await input.fill("First request");
27
+ await input.dispatchEvent("keydown", { key: "Enter", isComposing: true });
28
+ assert.equal((await requestState()).requests.length, 0);
29
+ await input.press("Enter");
30
+ await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
31
+ await expect(input).toHaveValue("");
32
+ await expect(page.getByLabel("Fixture assistant progress")).toBeVisible();
33
+ await expect(send).toBeDisabled();
34
+ await input.fill("Draft typed while streaming. Keep my selection.");
35
+ await input.evaluate((element) => { element.focus(); element.setSelectionRange(6, 11); window.fixtureInput = element; });
36
+ await advance();
37
+ await expect(page.getByText("Partial answer", { exact: true })).toBeVisible();
38
+ await expect(input).toBeFocused();
39
+ assert.deepEqual(await input.evaluate((element) => [element === window.fixtureInput, element.selectionStart, element.selectionEnd]), [true, 6, 11]);
40
+ await advance("?finish=1");
41
+ await expect(page.getByRole("button", { name: "Stop", exact: true })).toHaveCount(0);
42
+ await expect(input).toHaveValue("Draft typed while streaming. Keep my selection.");
43
+ await expect(page.getByText("Partial answer completed.", { exact: true })).toHaveCount(1);
44
+ await expect(page.getByText("never-render-this-payload", { exact: false })).toHaveCount(0);
45
+ assert.equal((await requestState()).requests.length, 1);
46
+ assert.equal((await requestState()).requests[0].surface, "admin");
47
+
48
+ await input.fill("Partial failure");
49
+ await send.click();
50
+ await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
51
+ await advance();
52
+ await advance("?fail=1");
53
+ await expect(page.getByText("Fixture provider failed.", { exact: true })).toHaveCount(1);
54
+ await expect(page.getByText("Partial answer", { exact: true })).toBeVisible();
55
+ await input.fill("Failure before answer");
56
+ await send.click();
57
+ await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
58
+ await advance("?fail=1");
59
+ await expect(input).toHaveValue("Failure before answer");
60
+ await expect(send).toBeEnabled();
61
+
62
+ await send.click();
63
+ await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
64
+ await input.fill("Preserve this after cancellation");
65
+ await page.getByRole("button", { name: "Stop", exact: true }).click();
66
+ await expect(page.getByText("Response canceled. A tool already running may still finish.")).toBeVisible();
67
+ await expect(input).toHaveValue("Preserve this after cancellation");
68
+ await expect.poll(async () => (await requestState()).streams.at(-1).closed).toBe(true);
69
+
70
+ await input.fill("Old workspace request");
71
+ await send.click();
72
+ await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
73
+ await input.fill("Old workspace draft");
74
+ await page.getByRole("button", { name: "Switch workspace", exact: true }).click();
75
+ await expect(input).toHaveValue("");
76
+ await expect(page.getByText("Old workspace request", { exact: true })).toHaveCount(0);
77
+ await expect.poll(async () => (await requestState()).streams.at(-1).closed).toBe(true);
78
+ await advance("?finish=1");
79
+ await expect(page.getByText("Partial answer completed.", { exact: true })).toHaveCount(0);
80
+ await input.fill("New workspace request");
81
+ await send.click();
82
+ await expect.poll(async () => (await requestState()).requests.at(-1).path).toContain("/beta/");
83
+ await page.getByRole("button", { name: "Toggle view", exact: true }).click();
84
+ await expect(input).toHaveCount(0);
85
+ await expect.poll(async () => (await requestState()).streams.at(-1).closed).toBe(true);
86
+ await page.getByRole("button", { name: "Toggle view", exact: true }).click();
87
+ await expect(input).toBeVisible();
88
+ await page.getByRole("button", { name: "Conversations", exact: true }).click();
89
+ await expect(page.getByText("Saved conversation", { exact: true })).toBeVisible();
90
+ await page.getByRole("button", { name: "Load older conversations" }).click();
91
+ await expect(page.getByText("Older conversation", { exact: true })).toBeVisible();
92
+ await page.getByText("Saved conversation", { exact: true }).click();
93
+ await expect(page.getByRole("dialog", { name: "Conversations", exact: true })).toHaveCount(0);
94
+ await expect(page.getByText("Saved 1 message 70.", { exact: false })).toBeVisible();
95
+ await expect(page.getByText("Identical text", { exact: true })).toHaveCount(2);
96
+
97
+ const screenshots = process.env.JSKIT_ASSISTANT_SCREENSHOTS;
98
+ if (screenshots) await mkdir(screenshots, { recursive: true });
99
+ for (const viewport of [{ width: 390, height: 844 }, { width: 800, height: 800 }, { width: 1280, height: 900 }, { width: 1280, height: 600 }, { width: 360, height: 600 }]) {
100
+ await page.setViewportSize(viewport);
101
+ await expect(input).toBeVisible();
102
+ const bounds = await input.boundingBox();
103
+ assert.ok(bounds.y + bounds.height <= viewport.height, JSON.stringify({ viewport, bounds }));
104
+ assert.ok(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth));
105
+ const sendBounds = await send.boundingBox();
106
+ assert.ok(sendBounds.y + sendBounds.height <= viewport.height);
107
+ if (viewport.width <= 600) assert.ok(sendBounds.height >= 48);
108
+ await expect(page.getByRole("button", { name: "Conversations", exact: true })).toBeVisible();
109
+ if (screenshots) await page.screenshot({ path: `${screenshots}/runtime-${viewport.width}-${viewport.height}.png` });
110
+ }
111
+ await page.setViewportSize({ width: 1280, height: 800 });
112
+ const draft = "A long draft that remains focused while the pane narrows. ".repeat(4);
113
+ await input.fill(draft);
114
+ await input.evaluate((element) => { element.focus(); element.setSelectionRange(7, 7); });
115
+ const before = await input.evaluate((element) => element.clientHeight);
116
+ await page.getByRole("button", { name: "Resize pane" }).evaluate((element) => element.click());
117
+ await expect.poll(() => input.evaluate((element) => element.clientHeight)).toBeGreaterThan(before);
118
+ await expect(input).toBeFocused();
119
+ await page.getByRole("button", { name: "Toggle theme" }).click();
120
+ if (screenshots) await page.screenshot({ path: `${screenshots}/runtime-dark-drawer.png` });
121
+ await page.getByRole("button", { name: "Second instance" }).click();
122
+ await expect(input).toHaveCount(2);
123
+ await expect(input.first()).toHaveValue(draft);
124
+ await expect(input.last()).toHaveValue("");
125
+ await input.last().fill("Second instance draft");
126
+ await expect(input.first()).toHaveValue(draft);
127
+ await page.getByRole("button", { name: "Second instance" }).click();
128
+ await page.getByRole("button", { name: "Switch surface", exact: true }).click();
129
+ await expect(input).toHaveValue("");
130
+ await expect(page.getByText("Saved 1 message 70.", { exact: false })).toHaveCount(0);
131
+ await input.fill("Private draft before switching user");
132
+ await send.click();
133
+ await expect(page.getByRole("button", { name: "Stop", exact: true })).toBeVisible();
134
+ await page.getByRole("button", { name: "Switch user", exact: true }).click();
135
+ await expect(input).toHaveValue("");
136
+ await expect(page.getByText("Private draft before switching user", { exact: true })).toHaveCount(0);
137
+ await expect.poll(async () => (await requestState()).streams.at(-1).closed).toBe(true);
138
+ assert.deepEqual(errors, []);
139
+ } catch (error) {
140
+ throw new Error(`${error.message}\nPage errors: ${JSON.stringify(errors)}\n${vite.readOutput()}`, { cause: error });
141
+ } finally {
142
+ await browser.close();
143
+ await stopProcess(vite);
144
+ }
145
+ });
146
+
147
+ test("history restores preserve new typing and reject obsolete pages and failed automatic restores", {
148
+ skip: process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION !== "1",
149
+ timeout: 60_000
150
+ }, async () => {
151
+ const vite = await startViteFixture({ fixtureRoot });
152
+ const browser = await chromium.launch(createChromiumLaunchOptions());
153
+ const page = await browser.newPage();
154
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
155
+ const send = page.getByRole("button", { name: "Send message", exact: true });
156
+ const selectSaved = async () => {
157
+ await page.getByRole("button", { name: "Conversations", exact: true }).click();
158
+ await page.getByText("Saved conversation", { exact: true }).click();
159
+ await expect(page.getByRole("dialog", { name: "Conversations", exact: true })).toHaveCount(0);
160
+ };
161
+ let release;
162
+ let restoreRequests = 0;
163
+ try {
164
+ const held = new Promise((resolve) => { release = resolve; });
165
+ await page.route("**/conversations/1/messages?**", async (route) => {
166
+ restoreRequests += 1;
167
+ await held;
168
+ await route.fulfill({ json: { data: { type: "assistant-conversation-messages", id: "1", attributes: {
169
+ entries: [{ id: "delayed", kind: "chat", role: "assistant", contentText: "Obsolete restored answer" }], totalPages: 2
170
+ } } } });
171
+ });
172
+ await page.goto(vite.baseURL);
173
+ await selectSaved();
174
+ await expect.poll(() => restoreRequests).toBe(1);
175
+ await input.fill("Written during history restore");
176
+ await expect(send).toBeDisabled();
177
+ await page.getByRole("button", { name: "Switch workspace", exact: true }).click();
178
+ await expect(page).toHaveURL(/workspace=beta/);
179
+ await expect(input).toHaveValue("");
180
+ await input.fill("New workspace draft");
181
+ release();
182
+ await page.unrouteAll({ behavior: "wait" });
183
+ await expect(input).toHaveValue("New workspace draft");
184
+ await expect(send).toBeEnabled();
185
+ assert.equal(restoreRequests, 1, "An obsolete restore must not fetch its second page in the new scope");
186
+ await expect(page.getByText("Obsolete restored answer", { exact: true })).toHaveCount(0);
187
+
188
+ let finishRestore;
189
+ const pending = new Promise((resolve) => { finishRestore = resolve; });
190
+ await page.route("**/conversations/1/messages?**", async (route) => {
191
+ await pending;
192
+ await route.continue();
193
+ });
194
+ await selectSaved();
195
+ await input.fill("Keep typing while selected history loads");
196
+ await expect(send).toBeDisabled();
197
+ finishRestore();
198
+ await expect(page.getByText("Saved 1 message 70.", { exact: false })).toBeVisible();
199
+ await expect(input).toHaveValue("Keep typing while selected history loads");
200
+ await expect(send).toBeEnabled();
201
+ await page.unrouteAll({ behavior: "wait" });
202
+
203
+ let failRestore;
204
+ const failure = new Promise((resolve) => { failRestore = resolve; });
205
+ let failedRequests = 0;
206
+ await page.route("**/conversations/1/messages?**", async (route) => {
207
+ failedRequests += 1;
208
+ await failure;
209
+ await route.fulfill({ status: 403, json: { error: "Conversation unavailable." } });
210
+ });
211
+ await page.reload();
212
+ await expect.poll(() => failedRequests).toBe(1);
213
+ await input.fill("Typed during automatic restore");
214
+ failRestore();
215
+ await expect(send).toBeEnabled();
216
+ await expect(input).toHaveValue("Typed during automatic restore");
217
+ await page.getByRole("button", { name: "Conversations", exact: true }).click();
218
+ await page.getByRole("button", { name: "Refresh", exact: true }).click();
219
+ await expect(page.getByText("Saved conversation", { exact: true })).toBeVisible();
220
+ assert.equal(failedRequests, 1, "A failed automatic restore must require explicit selection to retry");
221
+ } finally {
222
+ release?.();
223
+ await browser.close();
224
+ await stopProcess(vite);
225
+ }
226
+ });
227
+
228
+ test("page assistant bounds long history in a document layout and preserves drafts when workspace metadata arrives", {
229
+ skip: process.env.JSKIT_ASSISTANT_RUNTIME_BROWSER_INTEGRATION !== "1",
230
+ timeout: 60_000
231
+ }, async () => {
232
+ const vite = await startViteFixture({ fixtureRoot });
233
+ const browser = await chromium.launch(createChromiumLaunchOptions());
234
+ const page = await browser.newPage();
235
+ try {
236
+ await page.goto(`${vite.baseURL}/?page=1`);
237
+ await page.getByRole("button", { name: "Conversations", exact: true }).click();
238
+ await page.getByRole("dialog", { name: "Conversations", exact: true }).getByText("Saved conversation", { exact: true }).click();
239
+ await expect(page.getByText("Saved 1 message 70.", { exact: false })).toBeVisible();
240
+ const input = page.getByRole("textbox", { name: "Message AI assistant" });
241
+ for (const viewport of [{ width: 390, height: 844 }, { width: 800, height: 800 }, { width: 1280, height: 600 }]) {
242
+ await page.setViewportSize(viewport);
243
+ await expect.poll(async () => {
244
+ const bounds = await input.boundingBox();
245
+ return bounds.y + bounds.height;
246
+ }).toBeLessThanOrEqual(viewport.height);
247
+ assert.ok(await page.getByLabel("Conversation messages", { exact: true }).evaluate(element => element.scrollHeight > element.clientHeight));
248
+ }
249
+ await input.fill("Keep this draft while workspace details load.");
250
+ await input.evaluate(element => { element.focus(); element.setSelectionRange(5, 9); });
251
+ await page.getByRole("button", { name: "Load workspace metadata" }).evaluate(element => element.click());
252
+ await expect(input).toHaveValue("Keep this draft while workspace details load.");
253
+ await expect(input).toBeFocused();
254
+ assert.deepEqual(await input.evaluate(element => [element.selectionStart, element.selectionEnd]), [5, 9]);
255
+ await expect(page.getByText("Saved 1 message 70.", { exact: false })).toBeVisible();
256
+ } finally {
257
+ await browser.close();
258
+ await stopProcess(vite);
259
+ }
260
+ });
@@ -1,29 +0,0 @@
1
- function normalizeSelectionBoundary(value, fallback, max) {
2
- const parsed = Number(value);
3
- if (!Number.isInteger(parsed) || parsed < 0) {
4
- return fallback;
5
- }
6
-
7
- if (parsed > max) {
8
- return max;
9
- }
10
-
11
- return parsed;
12
- }
13
-
14
- function insertTextAtSelection(source = "", selectionStart, selectionEnd, text = "") {
15
- const value = String(source || "");
16
- const maxBoundary = value.length;
17
- const normalizedStart = normalizeSelectionBoundary(selectionStart, maxBoundary, maxBoundary);
18
- const normalizedEnd = normalizeSelectionBoundary(selectionEnd, normalizedStart, maxBoundary);
19
- const insertedText = String(text ?? "");
20
- const nextBoundary = normalizedStart + insertedText.length;
21
-
22
- return {
23
- value: `${value.slice(0, normalizedStart)}${insertedText}${value.slice(normalizedEnd)}`,
24
- selectionStart: nextBoundary,
25
- selectionEnd: nextBoundary
26
- };
27
- }
28
-
29
- export { insertTextAtSelection };
@@ -1,23 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { insertTextAtSelection } from "../src/client/support/composerInputSupport.js";
4
-
5
- test("insertTextAtSelection inserts a line break at the caret", () => {
6
- const result = insertTextAtSelection("hello world", 5, 5, "\n");
7
-
8
- assert.deepEqual(result, {
9
- value: "hello\n world",
10
- selectionStart: 6,
11
- selectionEnd: 6
12
- });
13
- });
14
-
15
- test("insertTextAtSelection replaces the active selection with a line break", () => {
16
- const result = insertTextAtSelection("hello world", 5, 11, "\n");
17
-
18
- assert.deepEqual(result, {
19
- value: "hello\n",
20
- selectionStart: 6,
21
- selectionEnd: 6
22
- });
23
- });