@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.
- package/fixtures/conversation/App.vue +50 -0
- package/fixtures/conversation/index.html +1 -0
- package/fixtures/conversation/main.js +31 -0
- package/fixtures/conversation/vite.config.mjs +69 -0
- package/package.json +7 -7
- package/patterns/assistant-surface/PATTERN.md +17 -0
- package/src/client/components/AssistantSurfaceClientElement.vue +145 -12
- package/src/client/composables/useAssistantRuntime.js +76 -100
- package/src/client/support/assistantRuntimeState.js +34 -0
- package/test/assistantRuntimeState.test.js +47 -0
- package/test/conversation.browser.test.js +260 -0
- package/src/client/support/composerInputSupport.js +0 -29
- package/test/composerInputSupport.test.js +0 -23
|
@@ -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.
|
|
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.
|
|
15
|
-
"@jskit-ai/database-runtime": "0.1.
|
|
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.
|
|
20
|
-
"@jskit-ai/http-web": "0.1.
|
|
21
|
-
"@jskit-ai/kernel": "0.1.
|
|
22
|
-
"@jskit-ai/shell-web": "0.1.
|
|
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 {
|
|
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
|
-
|
|
12
|
-
|
|
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
|
|
17
|
-
|
|
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,
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
169
|
-
|
|
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 =
|
|
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
|
|
370
|
-
|
|
371
|
-
|
|
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(
|
|
365
|
+
queryKey: [...assistantConversationMessagesQueryKey(scope, normalizedConversationId, {
|
|
385
366
|
page,
|
|
386
367
|
pageSize
|
|
387
|
-
}),
|
|
388
|
-
queryFn: () =>
|
|
389
|
-
|
|
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
|
-
|
|
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
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
|
429
|
-
if (
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
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
|
-
});
|