@jskit-ai/assistant-runtime 0.1.162 → 0.1.163

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,47 @@
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">
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="surface = surface === 'admin' ? 'console' : 'admin'">Switch surface</button>
26
+ <button @click="mounted = !mounted">Toggle view</button>
27
+ <button @click="second = !second">Second instance</button>
28
+ <button @click="compact = !compact">Resize pane</button>
29
+ <button @click="theme.change(theme.global.name.value === 'dark' ? 'light' : 'dark')">Toggle theme</button>
30
+ </nav>
31
+ <div class="fixture__panes" :class="{ 'fixture__panes--compact': compact }">
32
+ <AssistantSurfaceClientElement v-if="mounted" :surface-id="surface" :layout="compact ? 'compact' : 'page'" assistant-label="Fixture assistant" />
33
+ <AssistantSurfaceClientElement v-if="second" surface-id="console" assistant-label="Second assistant" />
34
+ </div>
35
+ </div>
36
+ </v-main>
37
+ </v-app>
38
+ </template>
39
+ <style>
40
+ html, body, #app { height: 100%; margin: 0; }
41
+ .fixture { height: 100dvh; display: flex; flex-direction: column; min-height: 0; }
42
+ .fixture nav { display: flex; flex-wrap: wrap; gap: .5rem; flex: 0 0 auto; }
43
+ .fixture nav button { padding: .25rem; }
44
+ .fixture__panes { display: flex; flex: 1 1 auto; min-height: 0; min-width: 0; }
45
+ .fixture__panes > * { flex: 1 1 0; min-width: 0; }
46
+ .fixture__panes--compact { width: min(360px, 100%); }
47
+ </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.163",
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.170",
15
+ "@jskit-ai/database-runtime": "0.1.192",
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.190",
20
+ "@jskit-ai/http-web": "0.1.37",
21
+ "@jskit-ai/kernel": "0.1.192",
22
+ "@jskit-ai/shell-web": "0.1.196",
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,137 @@
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, reactive, ref, toRef } 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
+ const runtime = useAssistantRuntime({ surfaceId: toRef(props, "surfaceId") });
16
+ const state = reactive(runtime.state);
17
+ const conversation = ref(null);
18
+ const historyOpen = ref(false);
19
+ const selectedConversation = computed(() => state.conversationHistory.find((entry) => String(entry.id) === state.activeConversationId));
20
+ const turns = computed(() => mapAssistantConversationTurns(state.messages, state.pendingToolEvents, props));
21
+ const adapter = computed(() => ({
22
+ conversation: {
23
+ turns: turns.value,
24
+ scrollKey: `${state.scopeKey}:${state.activeConversationId || "new"}`,
25
+ loading: state.isRestoringConversation,
26
+ assistantLabel: props.assistantLabel,
27
+ welcomeMessage: state.messages.length || state.isRestoringConversation ? "" : props.welcomeMessage
28
+ },
29
+ composer: {
30
+ draft: state.input,
31
+ canSend: state.canSend,
32
+ disabled: !state.hasRuntimeScope,
33
+ canStop: state.isStreaming,
34
+ stopPending: state.isCanceling,
35
+ placeholder: props.placeholder,
36
+ rows: 2,
37
+ density: props.layout === "compact" ? "compact" : "default",
38
+ submitOnEnter: true,
39
+ submitOnModifierEnter: true
40
+ },
41
+ actions: {
42
+ setDraft(value) { state.input = value; },
43
+ submit: runtime.actions.sendMessage,
44
+ stop: runtime.actions.cancelStream
45
+ }
46
+ }));
15
47
 
16
- const runtime = useAssistantRuntime({
17
- surfaceId: props.surfaceId
18
- });
48
+ function conversationSubtitle(entry) {
49
+ const details = [runtime.meta.normalizeConversationStatus(entry.status), runtime.meta.formatConversationStartedAt(entry.startedAt), `${Number(entry.messageCount || 0)} messages`];
50
+ if (state.isAdminSurface) {
51
+ details.push(entry.createdByUserDisplayName || entry.createdByUserEmail || (entry.createdByUserId ? `User #${entry.createdByUserId}` : "Unknown user"));
52
+ }
53
+ return details.join(" · ");
54
+ }
55
+
56
+ async function selectConversation(entry) {
57
+ historyOpen.value = false;
58
+ await runtime.actions.selectConversation(entry);
59
+ }
60
+
61
+ function startNewConversation() {
62
+ runtime.actions.startNewConversation();
63
+ historyOpen.value = false;
64
+ }
65
+
66
+ defineExpose({ focus: () => conversation.value?.focus() });
19
67
  </script>
68
+
69
+ <template>
70
+ <section class="assistant-surface" :class="`assistant-surface--${layout}`" :aria-label="assistantLabel">
71
+ <div class="assistant-surface__toolbar">
72
+ <span class="assistant-surface__title">{{ selectedConversation?.title || 'New conversation' }}</span>
73
+ <v-btn variant="text" @click="historyOpen = true">Conversations</v-btn>
74
+ <v-menu v-if="showToolActivity && state.pendingToolEvents.length" location="bottom end">
75
+ <template #activator="{ props: menuProps }">
76
+ <v-btn v-bind="menuProps" variant="text">Activity</v-btn>
77
+ </template>
78
+ <v-list aria-label="Tool activity" max-height="320" max-width="360" class="assistant-surface__activity">
79
+ <v-list-item v-for="event in state.pendingToolEvents" :key="event.id" :title="event.name" :subtitle="event.status" />
80
+ </v-list>
81
+ </v-menu>
82
+ </div>
83
+ <AssistantConversationElement ref="conversation" :adapter="adapter" label="Conversation" class="assistant-surface__conversation">
84
+ <template #message-actions="{ message }">
85
+ <p v-if="message.status === 'failed' && message.error" class="assistant-surface__message-error" role="status">{{ message.error }}</p>
86
+ <p v-if="message.status === 'interrupted'" class="assistant-surface__notice" role="status">Response canceled. A tool already running may still finish.</p>
87
+ </template>
88
+ <template #hints>
89
+ <p v-if="state.error && !state.messages.some((message) => message.error === state.error)" class="assistant-surface__message-error" role="status">{{ state.error }}</p>
90
+ </template>
91
+ <template #composer-tools><slot name="composer-tools" :runtime="runtime" /></template>
92
+ </AssistantConversationElement>
93
+
94
+ <v-dialog v-model="historyOpen" max-width="640" scrollable aria-label="Conversations">
95
+ <v-card class="assistant-surface__history">
96
+ <v-card-title>Conversations</v-card-title>
97
+ <v-card-actions class="assistant-surface__history-actions">
98
+ <v-btn color="primary" :disabled="!state.canStartNewConversation" @click="startNewConversation">Start new conversation</v-btn>
99
+ <v-btn :disabled="state.conversationHistoryLoading" @click="runtime.actions.refreshConversationHistory">Refresh</v-btn>
100
+ <v-btn @click="historyOpen = false">Close</v-btn>
101
+ </v-card-actions>
102
+ <v-card-text>
103
+ <p v-if="state.conversationHistoryError" role="alert">{{ state.conversationHistoryError }}</p>
104
+ <v-skeleton-loader v-if="state.conversationHistoryLoading && !state.conversationHistory.length" type="list-item-two-line@3" aria-label="Loading conversations" />
105
+ <v-list v-else aria-label="Saved conversations">
106
+ <v-list-item v-if="!state.conversationHistory.length" title="No saved conversations" />
107
+ <v-list-item
108
+ v-for="entry in state.conversationHistory" :key="entry.id"
109
+ :active="String(entry.id) === state.activeConversationId"
110
+ :title="entry.title || 'Untitled conversation'" :subtitle="conversationSubtitle(entry)"
111
+ :disabled="state.isStreaming || state.isRestoringConversation" @click="selectConversation(entry)"
112
+ />
113
+ </v-list>
114
+ <v-btn v-if="state.conversationHistoryHasMore" :disabled="state.conversationHistoryLoadingMore" variant="text" @click="runtime.actions.loadMoreConversationHistory">
115
+ {{ state.conversationHistoryLoadingMore ? 'Loading conversations…' : 'Load older conversations' }}
116
+ </v-btn>
117
+ </v-card-text>
118
+ </v-card>
119
+ </v-dialog>
120
+ </section>
121
+ </template>
122
+
123
+ <style scoped>
124
+ .assistant-surface { display: flex; flex-direction: column; height: 100%; max-height: 100%; min-height: 0; min-width: 0; overflow: hidden; gap: .5rem; }
125
+ .assistant-surface--page { padding: .5rem; }
126
+ .assistant-surface__toolbar { display: flex; align-items: center; flex-wrap: wrap; flex: 0 0 auto; gap: .25rem; }
127
+ .assistant-surface__title { flex: 1 1 8rem; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: .875rem; }
128
+ .assistant-surface__conversation { flex: 1 1 auto; }
129
+ .assistant-surface__activity { overflow: auto; }
130
+ .assistant-surface__message-error, .assistant-surface__notice { font-size: .85rem; margin: .25rem 0; overflow-wrap: anywhere; }
131
+ .assistant-surface__message-error { color: rgb(var(--v-theme-error)); }
132
+ .assistant-surface__notice { color: rgba(var(--v-theme-on-surface), .75); }
133
+ .assistant-surface__history-actions { flex-wrap: wrap; }
134
+ @media (max-width: 600px), (pointer: coarse) {
135
+ .assistant-surface__toolbar .v-btn, .assistant-surface__history .v-btn { min-height: 48px; }
136
+ }
137
+ </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,6 +167,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
165
167
 
166
168
  return {
167
169
  targetSurfaceId: normalizeText(assistantSurface.value?.targetSurfaceId).toLowerCase(),
170
+ userId: normalizeRecordId(placementSnapshot.value?.user?.id, { fallback: null }),
168
171
  workspaceSlug,
169
172
  workspaceId: assistantSurface.value?.runtimeSurfaceRequiresWorkspace
170
173
  ? normalizeRecordId(placementSnapshot.value?.workspace?.id, { fallback: null })
@@ -197,7 +200,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
197
200
  normalizeText(input.value)
198
201
  );
199
202
  });
200
- const canStartNewConversation = computed(() => Boolean(hasRuntimeScope.value && !isStreaming.value));
203
+ const canStartNewConversation = computed(() => Boolean(hasRuntimeScope.value && !isStreaming.value && !isRestoringConversation.value));
204
+ const scopeKey = computed(() => JSON.stringify(runtimeScope.value));
201
205
 
202
206
  function setRuntimeError(message, dedupeKey = "") {
203
207
  const normalizedMessage = normalizeText(message);
@@ -218,9 +222,9 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
218
222
 
219
223
  const conversationHistoryCollection = usePagedCollection({
220
224
  queryKey: computed(() =>
221
- assistantConversationsListQueryKey(runtimeScope.value, {
225
+ [...assistantConversationsListQueryKey(runtimeScope.value, {
222
226
  limit: runtimePolicy.historyPageSize
223
- })
227
+ }), runtimeScope.value.userId]
224
228
  ),
225
229
  queryFn: ({ pageParam = null }) =>
226
230
  runtimeApi.listConversations({
@@ -248,23 +252,6 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
248
252
  const conversationHistoryHasMore = conversationHistoryCollection.hasMore;
249
253
  const conversationHistoryError = conversationHistoryCollection.loadError;
250
254
 
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
255
  watch(
269
256
  [
270
257
  hasRuntimeScope,
@@ -282,7 +269,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
282
269
  nextConversationHistory,
283
270
  nextIsRestoringConversation
284
271
  ]) => {
285
- if (!nextHasRuntimeScope || nextConversationHistoryLoading || nextIsRestoringConversation) {
272
+ if (!nextHasRuntimeScope || nextConversationHistoryLoading || nextIsRestoringConversation || isStreaming.value) {
286
273
  return;
287
274
  }
288
275
 
@@ -309,7 +296,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
309
296
  return;
310
297
  }
311
298
 
312
- await selectConversationById(storedConversationId);
299
+ await selectConversationById(storedConversationId, { preserveDraft: true });
313
300
  },
314
301
  {
315
302
  immediate: true
@@ -360,19 +347,16 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
360
347
  await conversationHistoryCollection.loadMore();
361
348
  }
362
349
 
363
- async function selectConversationById(nextConversationId) {
364
- const normalizedConversationId = normalizeText(nextConversationId);
350
+ async function selectConversationById(nextConversationId, { preserveDraft = false } = {}) {
351
+ const normalizedConversationId = normalizeRecordId(nextConversationId, { fallback: "" });
365
352
  if (!normalizedConversationId || isStreaming.value || isRestoringConversation.value || !hasRuntimeScope.value) {
366
353
  return;
367
354
  }
368
355
 
369
- const parsedConversationId = normalizeRecordId(normalizedConversationId, { fallback: null });
370
- if (!parsedConversationId) {
371
- return;
372
- }
373
-
374
- const previousConversationId = conversationId.value;
375
- conversationId.value = parsedConversationId;
356
+ const version = ++restoreVersion;
357
+ const scope = runtimeScope.value;
358
+ const previousDraft = input.value;
359
+ if (!preserveDraft) input.value = "";
376
360
  isRestoringConversation.value = true;
377
361
  setRuntimeError("");
378
362
 
@@ -381,27 +365,33 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
381
365
  pageSize: runtimePolicy.restoreMessagesPageSize,
382
366
  maxEntries: runtimePolicy.restoreMessagesMaxEntries,
383
367
  fetchPage: (page, pageSize) => queryClient.fetchQuery({
384
- queryKey: assistantConversationMessagesQueryKey(runtimeScope.value, parsedConversationId, {
368
+ queryKey: [...assistantConversationMessagesQueryKey(scope, normalizedConversationId, {
385
369
  page,
386
370
  pageSize
387
- }),
388
- queryFn: () => runtimeApi.getConversationMessages(parsedConversationId, {
389
- page,
390
- pageSize
391
- }),
371
+ }), scope.userId],
372
+ queryFn: () => {
373
+ if (version !== restoreVersion) throw new DOMException("Conversation changed.", "AbortError");
374
+ return runtimeApi.getConversationMessages(normalizedConversationId, { page, pageSize });
375
+ },
392
376
  staleTime: runtimePolicy.historyStaleTimeMs
393
377
  })
394
378
  });
395
379
 
380
+ if (version !== restoreVersion) return;
396
381
  const restored = mapTranscriptEntriesToAssistantState(transcript.entries);
382
+ conversationId.value = normalizedConversationId;
383
+ writeStoredActiveConversationId(scope, normalizedConversationId);
397
384
  messages.value = restored.messages;
398
385
  pendingToolEvents.value = restored.pendingToolEvents;
399
- input.value = "";
400
386
  } catch (loadError) {
401
- conversationId.value = previousConversationId;
387
+ if (version !== restoreVersion) return;
388
+ if (readStoredActiveConversationId(scope) === normalizedConversationId) {
389
+ writeStoredActiveConversationId(scope, "");
390
+ }
391
+ if (!input.value) input.value = previousDraft;
402
392
  setRuntimeError(normalizeText(loadError?.message) || "Unable to load conversation.");
403
393
  } finally {
404
- isRestoringConversation.value = false;
394
+ if (version === restoreVersion) isRestoringConversation.value = false;
405
395
  }
406
396
  }
407
397
 
@@ -409,61 +399,33 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
409
399
  await selectConversationById(conversation?.id);
410
400
  }
411
401
 
412
- function startNewConversation() {
413
- if (abortController.value) {
414
- abortController.value.abort();
415
- }
416
-
402
+ function clearView() {
403
+ restoreVersion += 1;
404
+ abortController.value?.abort();
405
+ abortController.value = null;
417
406
  messages.value = [];
418
407
  pendingToolEvents.value = [];
419
408
  input.value = "";
420
- setRuntimeError("");
409
+ error.value = "";
421
410
  conversationId.value = null;
422
- writeStoredActiveConversationId(runtimeScope.value, "");
423
411
  isStreaming.value = false;
412
+ isCanceling.value = false;
424
413
  isRestoringConversation.value = false;
425
- abortController.value = null;
426
414
  }
427
415
 
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
- }
416
+ function startNewConversation() {
417
+ if (!canStartNewConversation.value) return;
418
+ writeStoredActiveConversationId(runtimeScope.value, "");
419
+ clearView();
461
420
  }
462
421
 
422
+ watch(scopeKey, clearView, { flush: "sync" });
423
+ onScopeDispose(clearView);
424
+
463
425
  function cancelStream() {
464
- if (abortController.value) {
465
- abortController.value.abort();
466
- }
426
+ if (!abortController.value || isCanceling.value) return;
427
+ isCanceling.value = true;
428
+ abortController.value.abort();
467
429
  }
468
430
 
469
431
  async function sendMessage() {
@@ -505,6 +467,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
505
467
  }, runtimePolicy.timeoutMs);
506
468
 
507
469
  let streamDoneStatus = "";
470
+ const ownsStream = () => abortController.value === streamAbortController;
508
471
 
509
472
  try {
510
473
  await runtimeApi.streamChat(
@@ -517,10 +480,12 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
517
480
  {
518
481
  signal: streamAbortController.signal,
519
482
  onEvent(event) {
483
+ if (!ownsStream() || streamAbortController.signal.aborted) return;
520
484
  const eventType = normalizeAssistantStreamEventType(event?.type, "");
521
485
 
522
486
  if (eventType === ASSISTANT_STREAM_EVENT_TYPES.META && Object.hasOwn(event || {}, "conversationId")) {
523
487
  conversationId.value = normalizeRecordId(event?.conversationId, { fallback: null });
488
+ writeStoredActiveConversationId(runtimeScope.value, conversationId.value);
524
489
  return;
525
490
  }
526
491
 
@@ -552,6 +517,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
552
517
  ...pendingToolEvents.value,
553
518
  {
554
519
  id: toolCallId,
520
+ messageId: assistantMessageId,
555
521
  name: normalizeToolName(event?.name),
556
522
  arguments: String(event?.arguments || ""),
557
523
  status: "pending",
@@ -600,6 +566,8 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
600
566
  }
601
567
  );
602
568
 
569
+ if (!ownsStream()) return;
570
+ if (streamAbortController.signal.aborted) streamDoneStatus = "aborted";
603
571
  const assistantMessage = findMessage(assistantMessageId);
604
572
  const assistantMessageText = normalizeText(assistantMessage?.text);
605
573
  if (!assistantMessageText && streamDoneStatus !== "aborted") {
@@ -619,6 +587,7 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
619
587
  }));
620
588
  }
621
589
  } catch (streamError) {
590
+ if (!ownsStream()) return;
622
591
  if (String(streamError?.name || "") === "AbortError") {
623
592
  updateMessage(assistantMessageId, {
624
593
  status: "canceled"
@@ -631,11 +600,19 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
631
600
  }
632
601
  } finally {
633
602
  clearTimeout(streamTimeout);
634
- pendingToolEvents.value = interruptPendingToolEvents(pendingToolEvents.value);
635
- abortController.value = null;
636
- isStreaming.value = false;
637
- await invalidateConversationScope();
638
- await refreshConversationHistory();
603
+ if (ownsStream()) {
604
+ const answer = findMessage(assistantMessageId);
605
+ if (answer?.status === "error") {
606
+ updateMessage(assistantMessageId, { error: error.value });
607
+ if (!answer.text && !input.value) input.value = normalizedInput;
608
+ }
609
+ pendingToolEvents.value = interruptPendingToolEvents(pendingToolEvents.value);
610
+ abortController.value = null;
611
+ isStreaming.value = false;
612
+ isCanceling.value = false;
613
+ await invalidateConversationScope();
614
+ await refreshConversationHistory();
615
+ }
639
616
  }
640
617
  }
641
618
 
@@ -657,6 +634,9 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
657
634
  messages,
658
635
  input,
659
636
  isStreaming,
637
+ isCanceling,
638
+ hasRuntimeScope,
639
+ scopeKey,
660
640
  isRestoringConversation,
661
641
  error,
662
642
  pendingToolEvents,
@@ -673,7 +653,6 @@ function useAssistantRuntime({ api = null, surfaceId = "" } = {}) {
673
653
  },
674
654
  actions: {
675
655
  sendMessage,
676
- handleInputKeydown,
677
656
  cancelStream,
678
657
  startNewConversation,
679
658
  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,226 @@
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
+ });
@@ -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
- });