@yadsh/dsh-qa-surface 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +10 -0
  2. package/LICENSE +21 -0
  3. package/README.md +135 -0
  4. package/SPEC-dsh-qa-surface.md +1921 -0
  5. package/compatibility.json +15 -0
  6. package/cordis.patch.yml +9 -0
  7. package/docs/ARCHITECTURE.md +24 -0
  8. package/docs/COMPATIBILITY.md +13 -0
  9. package/docs/CONFIGURATION.md +18 -0
  10. package/docs/EMBEDDING.md +13 -0
  11. package/lib/client/QaConfigController.js +65 -0
  12. package/lib/client/QaConfigController.js.map +1 -0
  13. package/lib/client/QaRouteController.js +109 -0
  14. package/lib/client/QaRouteController.js.map +1 -0
  15. package/lib/client/QaSessionController.js +366 -0
  16. package/lib/client/QaSessionController.js.map +1 -0
  17. package/lib/client/QaSurface.js +109 -0
  18. package/lib/client/QaSurface.js.map +1 -0
  19. package/lib/client/QaTranscriptAdapter.js +92 -0
  20. package/lib/client/QaTranscriptAdapter.js.map +1 -0
  21. package/lib/client/components/Markdown.js +102 -0
  22. package/lib/client/components/Markdown.js.map +1 -0
  23. package/lib/client/components/QaComposer.js +33 -0
  24. package/lib/client/components/QaComposer.js.map +1 -0
  25. package/lib/client/components/QaMessage.js +14 -0
  26. package/lib/client/components/QaMessage.js.map +1 -0
  27. package/lib/client/index.js +46 -0
  28. package/lib/client/index.js.map +1 -0
  29. package/lib/client/styles.js +58 -0
  30. package/lib/client/styles.js.map +1 -0
  31. package/lib/client.js +1131 -0
  32. package/lib/client.js.map +1 -0
  33. package/lib/config.js +83 -0
  34. package/lib/config.js.map +1 -0
  35. package/lib/host-route.js +27 -0
  36. package/lib/host-route.js.map +1 -0
  37. package/lib/index.js +83 -0
  38. package/lib/index.js.map +1 -0
  39. package/lib/navigation-marker.js +3 -0
  40. package/lib/navigation-marker.js.map +1 -0
  41. package/lib/resolve-config.js +143 -0
  42. package/lib/resolve-config.js.map +1 -0
  43. package/lib/types/client/QaConfigController.d.ts +21 -0
  44. package/lib/types/client/QaRouteController.d.ts +43 -0
  45. package/lib/types/client/QaSessionController.d.ts +59 -0
  46. package/lib/types/client/QaSurface.d.ts +16 -0
  47. package/lib/types/client/QaTranscriptAdapter.d.ts +7 -0
  48. package/lib/types/client/components/Markdown.d.ts +5 -0
  49. package/lib/types/client/components/QaComposer.d.ts +11 -0
  50. package/lib/types/client/components/QaMessage.d.ts +8 -0
  51. package/lib/types/client/index.d.ts +19 -0
  52. package/lib/types/client/styles.d.ts +2 -0
  53. package/lib/types/config.d.ts +5 -0
  54. package/lib/types/host-route.d.ts +9 -0
  55. package/lib/types/index.d.ts +29 -0
  56. package/lib/types/navigation-marker.d.ts +3 -0
  57. package/lib/types/resolve-config.d.ts +6 -0
  58. package/lib/types/types.d.ts +114 -0
  59. package/lib/types.js +2 -0
  60. package/lib/types.js.map +1 -0
  61. package/package.json +137 -0
@@ -0,0 +1,15 @@
1
+ {
2
+ "deepseekHarness": {
3
+ "channel": "next",
4
+ "range": ">=0.1.1-rc.2 <0.2.0",
5
+ "testedReleases": ["0.1.1-rc.2"],
6
+ "requiredClientFeatures": [
7
+ "shell.overlay",
8
+ "connection.api.sessions",
9
+ "sessions.binding",
10
+ "settingsScope",
11
+ "webServer.register"
12
+ ]
13
+ },
14
+ "node": "^22.19.0 || >=24.0.0"
15
+ }
@@ -0,0 +1,9 @@
1
+ # The DSH plugin manager discovers the browser half through package.json.
2
+ - insert:
3
+ - id: dsh-qa-surface
4
+ name: "@yadsh/dsh-qa-surface"
5
+ config:
6
+ enabled: true
7
+ route:
8
+ path: /qa
9
+ matchChildren: true
@@ -0,0 +1,24 @@
1
+ # Architecture
2
+
3
+ The Host half validates the Loader configuration, registers the `qa-surface`
4
+ settings namespace and owns one narrow GET/HEAD route for the configured QA
5
+ path. Published DSH `0.1.1-rc.2` returns 404 for unknown frontend paths, so the
6
+ route redirects navigation through the canonical `/` index with an encoded
7
+ same-origin path marker. It does not serve HTML, create another server, replace
8
+ the frontend fallback, or touch API/plugin routes. The browser removes the
9
+ marker and restores the requested path with `history.replaceState` before
10
+ route matching. The Host creates no sessions.
11
+
12
+ The browser half registers `QaSurface` in the root-scoped `shell.overlay` list.
13
+ `QaRouteController` keeps that entry dormant outside the configured path and
14
+ reversibly observes `pushState`, `replaceState` and `popstate`.
15
+
16
+ `QaSessionController` is the only DSH session-coupled module. It uses
17
+ `connection.api.sessions.create/selectModel` for creation policy and the public
18
+ `SessionFace` for prompt, cancel and snapshots. `QaTranscriptAdapter` projects
19
+ those snapshots to plugin-local messages, excluding reasoning, tool arguments,
20
+ tool results, system context and raw failures.
21
+
22
+ The overlay locks document scrolling and contains keyboard focus while active.
23
+ It does not locate AppFrame through generated CSS classes and does not dispose
24
+ global DSH runtime services when the route changes.
@@ -0,0 +1,13 @@
1
+ # Compatibility
2
+
3
+ | Plugin version | Tested DSH release | Required public contracts |
4
+ | -------------- | ------------------ | ----------------------------------------------------------------------------------------------------- |
5
+ | 0.1.x | 0.1.1-rc.2 | `shell.overlay`, `settingsScope`, `connection.api.sessions`, `sessions.binding`, `webServer.register` |
6
+
7
+ DSH is developer-preview software. Compatibility-sensitive calls are isolated
8
+ in `QaSessionController`, `QaTranscriptAdapter`, `QaConfigController` and
9
+ `QaRouteController`. The Host navigation adapter also works around this tested
10
+ release's exact-index static serving: `/qa` is redirected through `/`, then the
11
+ client restores the original pathname. Missing settings fall back to default
12
+ browser config; missing required client services prevent the browser plugin
13
+ from being injected rather than patching DSH internals.
@@ -0,0 +1,18 @@
1
+ # Configuration reference
2
+
3
+ Defaults and constraints are documented in the root README. Important runtime
4
+ rules are:
5
+
6
+ - `route.path` starts with `/`, has trailing slashes removed, and cannot claim
7
+ `/`, `/api` or `/plugins`;
8
+ - `fixed` requires `fixedSessionId`;
9
+ - `provider` and `model` are either both absent or both present;
10
+ - `maxContentWidth` is an integer from 480 through 1600;
11
+ - duplicate/blank suggested questions are removed;
12
+ - approval and question policies are fixed to safe blocking behavior;
13
+ - reasoning display cannot be enabled in the 0.1.x MVP.
14
+
15
+ Browser persistence stores only the DSH session id under
16
+ `<storageKey>:v1:<route>:session`. Transcript content, credentials and tool
17
+ results remain in the Host-owned DSH Session and are never copied to browser
18
+ storage.
@@ -0,0 +1,13 @@
1
+ # Embedding and reverse proxies
2
+
3
+ Serve `/qa` through the normal DSH origin so it shares the established browser
4
+ connection, authentication and trust policy. The plugin's narrow navigation
5
+ route redirects GET/HEAD requests through DSH's canonical `/` document; the
6
+ client immediately restores `/qa` before mounting. Reverse proxies must pass
7
+ the configured QA path to DSH instead of replacing it with a separately hosted
8
+ page.
9
+
10
+ Iframe embedding is disabled or allowed by deployment headers, not by this
11
+ plugin. Configure an explicit CSP `frame-ancestors` allowlist at a trusted
12
+ reverse proxy and keep the DSH API same-origin. Do not enable wildcard CORS or
13
+ expose settings/session APIs without the deployment's authentication layer.
@@ -0,0 +1,65 @@
1
+ import { DEFAULT_QA_SURFACE_CONFIG, resolveConfig } from "../resolve-config.js";
2
+ /** Browser projection of the Host-owned qa-surface settings namespace. */
3
+ export class QaConfigController {
4
+ scope;
5
+ listeners = new Set();
6
+ snapshot;
7
+ unsubscribe;
8
+ constructor(scope) {
9
+ this.scope = scope;
10
+ this.snapshot = this.project();
11
+ this.unsubscribe = scope.subscribe(() => this.refresh());
12
+ }
13
+ getSnapshot = () => this.snapshot;
14
+ subscribe = (listener) => {
15
+ this.listeners.add(listener);
16
+ return () => this.listeners.delete(listener);
17
+ };
18
+ dispose() {
19
+ this.unsubscribe();
20
+ this.listeners.clear();
21
+ }
22
+ project() {
23
+ const settings = this.scope.getSnapshot();
24
+ if (settings.status === "loading") {
25
+ return {
26
+ status: "loading",
27
+ config: DEFAULT_QA_SURFACE_CONFIG,
28
+ error: null,
29
+ };
30
+ }
31
+ if (settings.status === "unavailable") {
32
+ return {
33
+ status: "unavailable",
34
+ config: DEFAULT_QA_SURFACE_CONFIG,
35
+ error: null,
36
+ };
37
+ }
38
+ try {
39
+ return {
40
+ status: "ready",
41
+ config: resolveConfig(settings.value ?? {}),
42
+ error: null,
43
+ };
44
+ }
45
+ catch {
46
+ return {
47
+ status: "error",
48
+ config: DEFAULT_QA_SURFACE_CONFIG,
49
+ error: "The assistant configuration is unavailable.",
50
+ };
51
+ }
52
+ }
53
+ refresh() {
54
+ const next = this.project();
55
+ if (next.status === this.snapshot.status &&
56
+ next.config === this.snapshot.config &&
57
+ next.error === this.snapshot.error) {
58
+ return;
59
+ }
60
+ this.snapshot = next;
61
+ for (const listener of this.listeners)
62
+ listener();
63
+ }
64
+ }
65
+ //# sourceMappingURL=QaConfigController.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"QaConfigController.js","sourceRoot":"","sources":["../../src/client/QaConfigController.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,yBAAyB,EAAE,aAAa,EAAE,MAAM,sBAAsB,CAAC;AAShF,0EAA0E;AAC1E,MAAM,OAAO,kBAAkB;IAKA,KAAK;IAJjB,SAAS,GAAG,IAAI,GAAG,EAAc,CAAC;IAC3C,QAAQ,CAAmB;IAClB,WAAW,CAAa;IAEzC,YAA6B,KAAqC;qBAArC,KAAK;QAChC,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAC/B,IAAI,CAAC,WAAW,GAAG,KAAK,CAAC,SAAS,CAAC,GAAG,EAAE,CAAC,IAAI,CAAC,OAAO,EAAE,CAAC,CAAC;IAC3D,CAAC;IAED,WAAW,GAAG,GAAqB,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC;IAEpD,SAAS,GAAG,CAAC,QAAoB,EAAgB,EAAE;QACjD,IAAI,CAAC,SAAS,CAAC,GAAG,CAAC,QAAQ,CAAC,CAAC;QAC7B,OAAO,GAAG,EAAE,CAAC,IAAI,CAAC,SAAS,CAAC,MAAM,CAAC,QAAQ,CAAC,CAAC;IAC/C,CAAC,CAAC;IAEF,OAAO;QACL,IAAI,CAAC,WAAW,EAAE,CAAC;QACnB,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,CAAC;IACzB,CAAC;IAEO,OAAO;QACb,MAAM,QAAQ,GAAG,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,CAAC;QAC1C,IAAI,QAAQ,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;YAClC,OAAO;gBACL,MAAM,EAAE,SAAS;gBACjB,MAAM,EAAE,yBAAyB;gBACjC,KAAK,EAAE,IAAI;aACZ,CAAC;QACJ,CAAC;QACD,IAAI,QAAQ,CAAC,MAAM,KAAK,aAAa,EAAE,CAAC;YACtC,OAAO;gBACL,MAAM,EAAE,aAAa;gBACrB,MAAM,EAAE,yBAAyB;gBACjC,KAAK,EAAE,IAAI;aACZ,CAAC;QACJ,CAAC;QACD,IAAI,CAAC;YACH,OAAO;gBACL,MAAM,EAAE,OAAO;gBACf,MAAM,EAAE,aAAa,CAAC,QAAQ,CAAC,KAAK,IAAI,EAAE,CAAC;gBAC3C,KAAK,EAAE,IAAI;aACZ,CAAC;QACJ,CAAC;QAAC,MAAM,CAAC;YACP,OAAO;gBACL,MAAM,EAAE,OAAO;gBACf,MAAM,EAAE,yBAAyB;gBACjC,KAAK,EAAE,6CAA6C;aACrD,CAAC;QACJ,CAAC;IACH,CAAC;IAEO,OAAO;QACb,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QAC5B,IACE,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,QAAQ,CAAC,MAAM;YACpC,IAAI,CAAC,MAAM,KAAK,IAAI,CAAC,QAAQ,CAAC,MAAM;YACpC,IAAI,CAAC,KAAK,KAAK,IAAI,CAAC,QAAQ,CAAC,KAAK,EAClC,CAAC;YACD,OAAO;QACT,CAAC;QACD,IAAI,CAAC,QAAQ,GAAG,IAAI,CAAC;QACrB,KAAK,MAAM,QAAQ,IAAI,IAAI,CAAC,SAAS;YAAE,QAAQ,EAAE,CAAC;IACpD,CAAC;CACF"}
@@ -0,0 +1,109 @@
1
+ import { QA_NAVIGATION_MARKER } from "../navigation-marker.js";
2
+ export function matchesQaRoute(pathname, route) {
3
+ if (pathname === route.path || pathname === `${route.path}/`)
4
+ return true;
5
+ if (route.path === "/")
6
+ return route.matchChildren;
7
+ if (!route.matchChildren)
8
+ return false;
9
+ return pathname.startsWith(`${route.path}/`);
10
+ }
11
+ /** Observable route matcher with reversible History API instrumentation. */
12
+ export class QaRouteController {
13
+ target;
14
+ listeners = new Set();
15
+ route = {
16
+ path: "/qa",
17
+ matchChildren: true,
18
+ };
19
+ enabled = false;
20
+ snapshot;
21
+ originalPushState;
22
+ originalReplaceState;
23
+ wrappedPushState;
24
+ wrappedReplaceState;
25
+ disposed = false;
26
+ constructor(target = window) {
27
+ this.target = target;
28
+ this.originalPushState = target.history.pushState;
29
+ this.originalReplaceState = target.history.replaceState;
30
+ this.restoreRedirectedPath();
31
+ this.snapshot = this.project();
32
+ const refresh = () => this.refresh();
33
+ const history = target.history;
34
+ const originalPushState = this.originalPushState;
35
+ const originalReplaceState = this.originalReplaceState;
36
+ this.wrappedPushState = function (...args) {
37
+ Reflect.apply(originalPushState, history, args);
38
+ refresh();
39
+ };
40
+ this.wrappedReplaceState = function (...args) {
41
+ Reflect.apply(originalReplaceState, history, args);
42
+ refresh();
43
+ };
44
+ target.history.pushState = this.wrappedPushState;
45
+ target.history.replaceState = this.wrappedReplaceState;
46
+ target.addEventListener("popstate", refresh);
47
+ this.removePopstate = () => target.removeEventListener("popstate", refresh);
48
+ }
49
+ removePopstate;
50
+ getSnapshot = () => this.snapshot;
51
+ subscribe = (listener) => {
52
+ this.listeners.add(listener);
53
+ return () => this.listeners.delete(listener);
54
+ };
55
+ configure(config, ready = true) {
56
+ this.route = config.route;
57
+ this.enabled = ready && config.enabled;
58
+ this.refresh();
59
+ }
60
+ dispose() {
61
+ if (this.disposed)
62
+ return;
63
+ this.disposed = true;
64
+ this.removePopstate();
65
+ if (this.target.history.pushState === this.wrappedPushState) {
66
+ this.target.history.pushState = this.originalPushState;
67
+ }
68
+ if (this.target.history.replaceState === this.wrappedReplaceState) {
69
+ this.target.history.replaceState = this.originalReplaceState;
70
+ }
71
+ this.listeners.clear();
72
+ }
73
+ project() {
74
+ const pathname = this.target.location.pathname;
75
+ return {
76
+ pathname,
77
+ active: this.enabled && matchesQaRoute(pathname, this.route),
78
+ };
79
+ }
80
+ restoreRedirectedPath() {
81
+ const search = this.target.location.search;
82
+ if (search === undefined || search === "")
83
+ return;
84
+ const requested = new URLSearchParams(search).get(QA_NAVIGATION_MARKER);
85
+ if (requested === null ||
86
+ !requested.startsWith("/") ||
87
+ requested.startsWith("//")) {
88
+ return;
89
+ }
90
+ Reflect.apply(this.originalReplaceState, this.target.history, [
91
+ null,
92
+ "",
93
+ requested,
94
+ ]);
95
+ }
96
+ refresh() {
97
+ if (this.disposed)
98
+ return;
99
+ const next = this.project();
100
+ if (next.pathname === this.snapshot.pathname &&
101
+ next.active === this.snapshot.active) {
102
+ return;
103
+ }
104
+ this.snapshot = next;
105
+ for (const listener of this.listeners)
106
+ listener();
107
+ }
108
+ }
109
+ //# sourceMappingURL=QaRouteController.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"QaRouteController.js","sourceRoot":"","sources":["../../src/client/QaRouteController.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,366 @@
1
+ import { projectTranscript } from "./QaTranscriptAdapter.js";
2
+ const EMPTY_STATE = Object.freeze({
3
+ phase: "idle",
4
+ sessionId: null,
5
+ messages: Object.freeze([]),
6
+ error: null,
7
+ canSend: false,
8
+ canStop: false,
9
+ });
10
+ function waitFor(source, predicate, timeoutMs) {
11
+ const current = source.getSnapshot();
12
+ if (predicate(current))
13
+ return Promise.resolve(current);
14
+ return new Promise((resolve, reject) => {
15
+ let settled = false;
16
+ const timeout = {};
17
+ let unsubscribe = () => undefined;
18
+ const finish = (value) => {
19
+ if (settled)
20
+ return;
21
+ settled = true;
22
+ if (timeout.id !== undefined)
23
+ clearTimeout(timeout.id);
24
+ unsubscribe();
25
+ resolve(value);
26
+ };
27
+ unsubscribe = source.subscribe(() => {
28
+ const next = source.getSnapshot();
29
+ if (predicate(next))
30
+ finish(next);
31
+ });
32
+ // Tolerate observable implementations that notify synchronously while a
33
+ // subscriber is being installed, and close that subscription afterward.
34
+ if (settled) {
35
+ unsubscribe();
36
+ return;
37
+ }
38
+ const afterSubscribe = source.getSnapshot();
39
+ if (predicate(afterSubscribe)) {
40
+ finish(afterSubscribe);
41
+ return;
42
+ }
43
+ timeout.id = setTimeout(() => {
44
+ if (settled)
45
+ return;
46
+ settled = true;
47
+ unsubscribe();
48
+ reject(new Error("Timed out waiting for the DSH runtime."));
49
+ }, timeoutMs);
50
+ });
51
+ }
52
+ /** The only module that couples QA behavior to DSH Session/client APIs. */
53
+ export class QaSessionController {
54
+ listeners = new Set();
55
+ sessions;
56
+ api;
57
+ connection;
58
+ config;
59
+ storage;
60
+ timeoutMs;
61
+ state = EMPTY_STATE;
62
+ session;
63
+ unsubscribeSession;
64
+ unsubscribeConnection;
65
+ ensuring;
66
+ operationError = null;
67
+ admissionPending = false;
68
+ connectedOnce;
69
+ disposed = false;
70
+ generation = 0;
71
+ constructor(options) {
72
+ this.sessions = options.sessions;
73
+ this.api = options.api;
74
+ this.connection = options.connection;
75
+ this.config = options.config;
76
+ this.storage = options.storage;
77
+ this.timeoutMs = options.timeoutMs ?? 15_000;
78
+ this.connectedOnce = this.connection.getSnapshot() !== undefined;
79
+ this.unsubscribeConnection = this.connection.subscribe(() => {
80
+ if (this.connection.getSnapshot() !== undefined)
81
+ this.connectedOnce = true;
82
+ this.publish();
83
+ });
84
+ }
85
+ getSnapshot = () => this.state;
86
+ subscribe = (listener) => {
87
+ this.listeners.add(listener);
88
+ return () => this.listeners.delete(listener);
89
+ };
90
+ ensureSession() {
91
+ if (this.ensuring !== undefined)
92
+ return this.ensuring;
93
+ const operation = this.ensureSessionNow().finally(() => {
94
+ if (this.ensuring === operation)
95
+ this.ensuring = undefined;
96
+ });
97
+ this.ensuring = operation;
98
+ return operation;
99
+ }
100
+ async send(text) {
101
+ const prompt = text.trim();
102
+ if (prompt === "" || this.session === undefined || !this.state.canSend) {
103
+ return false;
104
+ }
105
+ if (prompt.startsWith("/")) {
106
+ this.operationError =
107
+ "Slash commands are not available in this assistant view.";
108
+ this.publish();
109
+ return false;
110
+ }
111
+ this.operationError = null;
112
+ this.admissionPending = true;
113
+ this.publish();
114
+ try {
115
+ const result = await this.session.prompt([{ type: "text", text: prompt }], "queue");
116
+ if (!result.ok) {
117
+ this.admissionPending = false;
118
+ this.operationError = "Your message could not be sent.";
119
+ this.publish();
120
+ return false;
121
+ }
122
+ this.publish();
123
+ return true;
124
+ }
125
+ catch (error) {
126
+ this.admissionPending = false;
127
+ console.error("dsh-qa-surface: prompt failed", error);
128
+ this.operationError = "Your message could not be sent.";
129
+ this.publish();
130
+ return false;
131
+ }
132
+ }
133
+ async stop() {
134
+ if (this.session === undefined || !this.state.canStop)
135
+ return;
136
+ try {
137
+ const result = await this.session.cancel();
138
+ if (!result.ok)
139
+ this.operationError = "The response could not be stopped.";
140
+ }
141
+ catch (error) {
142
+ console.error("dsh-qa-surface: stop failed", error);
143
+ this.operationError = "The response could not be stopped.";
144
+ }
145
+ this.publish();
146
+ }
147
+ async reset() {
148
+ if (this.config.session.policy === "fixed")
149
+ return;
150
+ const previous = this.session;
151
+ if (previous?.getSnapshot().running === true) {
152
+ try {
153
+ await previous.cancel();
154
+ }
155
+ catch (error) {
156
+ console.error("dsh-qa-surface: stop before reset failed", error);
157
+ }
158
+ }
159
+ const operation = ++this.generation;
160
+ this.unbind();
161
+ this.operationError = null;
162
+ this.admissionPending = false;
163
+ this.state = { ...EMPTY_STATE, phase: "creating" };
164
+ this.emit();
165
+ try {
166
+ await this.waitForConnection();
167
+ const id = await this.createSession();
168
+ if (this.disposed || operation !== this.generation)
169
+ return;
170
+ this.persist(id);
171
+ await this.bind(id);
172
+ }
173
+ catch (error) {
174
+ this.fail("Unable to start a new chat.", error);
175
+ }
176
+ }
177
+ dispose() {
178
+ if (this.disposed)
179
+ return;
180
+ this.disposed = true;
181
+ this.generation += 1;
182
+ this.unsubscribeConnection();
183
+ this.unbind();
184
+ this.listeners.clear();
185
+ }
186
+ async ensureSessionNow() {
187
+ const operation = ++this.generation;
188
+ this.state = { ...EMPTY_STATE, phase: "creating" };
189
+ this.emit();
190
+ try {
191
+ await this.waitForConnection();
192
+ const list = await waitFor(this.sessions.list, (snapshot) => snapshot.phase === "ready", this.timeoutMs);
193
+ let id = null;
194
+ if (this.config.session.policy === "fixed") {
195
+ id = this.config.session.fixedSessionId;
196
+ if (id === null || !Object.hasOwn(list.byId, id)) {
197
+ throw new Error("Configured fixed session is unavailable.");
198
+ }
199
+ }
200
+ else if (this.config.session.policy === "browser-persistent") {
201
+ const stored = this.readPersisted();
202
+ if (stored !== null && Object.hasOwn(list.byId, stored)) {
203
+ id = stored;
204
+ }
205
+ else if (stored !== null) {
206
+ this.clearPersisted();
207
+ }
208
+ }
209
+ if (id === null) {
210
+ id = await this.createSession();
211
+ if (this.config.session.policy === "browser-persistent")
212
+ this.persist(id);
213
+ }
214
+ if (this.disposed || operation !== this.generation)
215
+ return;
216
+ await this.bind(id);
217
+ }
218
+ catch (error) {
219
+ this.fail("Unable to start a chat.", error);
220
+ }
221
+ }
222
+ async createSession() {
223
+ const response = await this.api.create({
224
+ ...(this.config.session.workspaceId === null
225
+ ? {}
226
+ : { workspaceId: this.config.session.workspaceId }),
227
+ ...(this.config.session.agentPreset === null
228
+ ? {}
229
+ : { agentPreset: this.config.session.agentPreset }),
230
+ });
231
+ if (!response.result.ok)
232
+ throw new Error(response.result.error.code);
233
+ const id = String(response.result.value.sessionId);
234
+ await waitFor(this.sessions.list, (snapshot) => Object.hasOwn(snapshot.byId, id), this.timeoutMs);
235
+ if (this.config.session.provider !== null &&
236
+ this.config.session.model !== null) {
237
+ const selected = await this.api.selectModel({
238
+ sessionId: id,
239
+ provider: this.config.session.provider,
240
+ model: this.config.session.model,
241
+ ...(this.config.session.reasoningEffort === null
242
+ ? {}
243
+ : { reasoningEffort: this.config.session.reasoningEffort }),
244
+ });
245
+ if (!selected.result.ok)
246
+ throw new Error(selected.result.error.code);
247
+ }
248
+ return id;
249
+ }
250
+ async bind(id) {
251
+ this.sessions.open(id);
252
+ let binding = this.sessions.binding(id);
253
+ if (binding === undefined) {
254
+ await waitFor(this.sessions.list, (snapshot) => Object.hasOwn(snapshot.byId, id), this.timeoutMs);
255
+ binding = this.sessions.binding(id);
256
+ }
257
+ if (binding === undefined)
258
+ throw new Error("Session binding is unavailable.");
259
+ this.unbind();
260
+ this.session = binding.session;
261
+ this.unsubscribeSession = binding.session.subscribe(() => {
262
+ this.admissionPending = false;
263
+ this.operationError = null;
264
+ this.publish();
265
+ });
266
+ this.publish();
267
+ }
268
+ unbind() {
269
+ this.unsubscribeSession?.();
270
+ this.unsubscribeSession = undefined;
271
+ this.session = undefined;
272
+ this.admissionPending = false;
273
+ }
274
+ waitForConnection() {
275
+ return waitFor(this.connection, (description) => description !== undefined, this.timeoutMs);
276
+ }
277
+ publish() {
278
+ if (this.disposed)
279
+ return;
280
+ const connected = this.connection.getSnapshot() !== undefined;
281
+ if (this.session === undefined) {
282
+ this.state = {
283
+ ...this.state,
284
+ phase: !connected && this.connectedOnce ? "reconnecting" : this.state.phase,
285
+ error: this.operationError ?? this.state.error,
286
+ };
287
+ this.emit();
288
+ return;
289
+ }
290
+ const snapshot = this.session.getSnapshot();
291
+ const blocked = snapshot.pending.length > 0;
292
+ const error = this.operationError ??
293
+ (blocked
294
+ ? "This request requires an interaction that is not available in this assistant view."
295
+ : snapshot.removed || snapshot.openState === "error"
296
+ ? "This chat is no longer available."
297
+ : null);
298
+ const phase = !connected
299
+ ? "reconnecting"
300
+ : blocked
301
+ ? "blocked"
302
+ : snapshot.removed || snapshot.openState === "error"
303
+ ? "error"
304
+ : snapshot.openState !== "open"
305
+ ? "creating"
306
+ : snapshot.running || this.admissionPending
307
+ ? "running"
308
+ : "ready";
309
+ this.state = {
310
+ phase,
311
+ sessionId: String(this.session.sessionId),
312
+ messages: projectTranscript(snapshot, {
313
+ showToolActivity: this.config.ui.showToolActivity,
314
+ }),
315
+ error,
316
+ canSend: connected && phase === "ready",
317
+ canStop: connected && snapshot.running && this.config.ui.showStop && !blocked,
318
+ };
319
+ this.emit();
320
+ }
321
+ fail(message, error) {
322
+ console.error("dsh-qa-surface: session operation failed", error);
323
+ if (this.disposed)
324
+ return;
325
+ this.operationError = message;
326
+ this.state = {
327
+ ...EMPTY_STATE,
328
+ phase: "error",
329
+ error: message,
330
+ };
331
+ this.emit();
332
+ }
333
+ storageKey() {
334
+ return `${this.config.session.storageKey}:v1:${this.config.route.path}:session`;
335
+ }
336
+ readPersisted() {
337
+ try {
338
+ const value = this.storage?.getItem(this.storageKey())?.trim() ?? "";
339
+ return value === "" ? null : value;
340
+ }
341
+ catch {
342
+ return null;
343
+ }
344
+ }
345
+ persist(id) {
346
+ try {
347
+ this.storage?.setItem(this.storageKey(), id);
348
+ }
349
+ catch {
350
+ // A denied localStorage write must not prevent a real DSH session.
351
+ }
352
+ }
353
+ clearPersisted() {
354
+ try {
355
+ this.storage?.removeItem(this.storageKey());
356
+ }
357
+ catch {
358
+ // A denied localStorage removal is harmless; the id is revalidated later.
359
+ }
360
+ }
361
+ emit() {
362
+ for (const listener of this.listeners)
363
+ listener();
364
+ }
365
+ }
366
+ //# sourceMappingURL=QaSessionController.js.map