@classic-homes/agent-chat 0.11.0 → 0.12.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.
- package/README.md +57 -2
- package/dist/components/AgentChat.svelte +58 -22
- package/dist/components/AgentChat.svelte.d.ts +11 -1
- package/dist/components/AgentChat.svelte.d.ts.map +1 -1
- package/dist/components/ArtifactPanel.svelte +4 -2
- package/dist/components/AssistantMessage.svelte +14 -5
- package/dist/components/AssistantMessage.svelte.d.ts +2 -0
- package/dist/components/AssistantMessage.svelte.d.ts.map +1 -1
- package/dist/components/ChatHeader.svelte +4 -1
- package/dist/components/ChatHeader.svelte.d.ts +2 -0
- package/dist/components/ChatHeader.svelte.d.ts.map +1 -1
- package/dist/components/ChatLauncher.svelte +157 -0
- package/dist/components/ChatLauncher.svelte.d.ts +23 -0
- package/dist/components/ChatLauncher.svelte.d.ts.map +1 -0
- package/dist/components/Composer.svelte +7 -3
- package/dist/components/Composer.svelte.d.ts.map +1 -1
- package/dist/components/FloatingWindow.svelte +239 -0
- package/dist/components/FloatingWindow.svelte.d.ts +26 -0
- package/dist/components/FloatingWindow.svelte.d.ts.map +1 -0
- package/dist/components/Markdown.svelte +4 -0
- package/dist/components/Markdown.svelte.d.ts.map +1 -1
- package/dist/components/MessageFeedback.svelte +11 -4
- package/dist/components/MessageFeedback.svelte.d.ts.map +1 -1
- package/dist/components/MessageFiles.svelte +133 -40
- package/dist/components/MessageFiles.svelte.d.ts +6 -0
- package/dist/components/MessageFiles.svelte.d.ts.map +1 -1
- package/dist/components/MessageItem.svelte +2 -0
- package/dist/components/MessageItem.svelte.d.ts +2 -0
- package/dist/components/MessageItem.svelte.d.ts.map +1 -1
- package/dist/components/MessageList.svelte +40 -7
- package/dist/components/MessageList.svelte.d.ts +3 -1
- package/dist/components/MessageList.svelte.d.ts.map +1 -1
- package/dist/components/ShortcutsDialog.svelte +10 -3
- package/dist/components/ShortcutsDialog.svelte.d.ts +2 -0
- package/dist/components/ShortcutsDialog.svelte.d.ts.map +1 -1
- package/dist/components/UiFrame.svelte +12 -4
- package/dist/components/UiFrame.svelte.d.ts.map +1 -1
- package/dist/components/UserMessage.svelte +10 -1
- package/dist/components/UserMessage.svelte.d.ts.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -0
- package/dist/markdown.d.ts.map +1 -1
- package/dist/markdown.js +94 -61
- package/dist/reducer.d.ts +11 -0
- package/dist/reducer.d.ts.map +1 -1
- package/dist/reducer.js +18 -2
- package/dist/session.svelte.d.ts +38 -0
- package/dist/session.svelte.d.ts.map +1 -1
- package/dist/session.svelte.js +176 -23
- package/dist/shortcuts.d.ts +7 -0
- package/dist/shortcuts.d.ts.map +1 -1
- package/dist/shortcuts.js +9 -0
- package/dist/surface-geometry.d.ts +56 -0
- package/dist/surface-geometry.d.ts.map +1 -0
- package/dist/surface-geometry.js +99 -0
- package/dist/surface.svelte.d.ts +69 -0
- package/dist/surface.svelte.d.ts.map +1 -0
- package/dist/surface.svelte.js +156 -0
- package/dist/ui-frame-protocol.d.ts +6 -0
- package/dist/ui-frame-protocol.d.ts.map +1 -1
- package/dist/ui-frame-protocol.js +11 -0
- package/package.json +8 -7
- package/dist/artifact-pages.test.d.ts +0 -2
- package/dist/artifact-pages.test.d.ts.map +0 -1
- package/dist/artifact-pages.test.js +0 -102
- package/dist/components/a11y.svelte.test.d.ts +0 -2
- package/dist/components/a11y.svelte.test.d.ts.map +0 -1
- package/dist/components/a11y.svelte.test.js +0 -183
- package/dist/components/agent-picker.svelte.test.d.ts +0 -2
- package/dist/components/agent-picker.svelte.test.d.ts.map +0 -1
- package/dist/components/agent-picker.svelte.test.js +0 -84
- package/dist/components/composer.svelte.test.d.ts +0 -2
- package/dist/components/composer.svelte.test.d.ts.map +0 -1
- package/dist/components/composer.svelte.test.js +0 -162
- package/dist/components/continue.svelte.test.d.ts +0 -2
- package/dist/components/continue.svelte.test.d.ts.map +0 -1
- package/dist/components/continue.svelte.test.js +0 -109
- package/dist/components/conversation-chrome.svelte.test.d.ts +0 -2
- package/dist/components/conversation-chrome.svelte.test.d.ts.map +0 -1
- package/dist/components/conversation-chrome.svelte.test.js +0 -147
- package/dist/components/feedback.svelte.test.d.ts +0 -2
- package/dist/components/feedback.svelte.test.d.ts.map +0 -1
- package/dist/components/feedback.svelte.test.js +0 -99
- package/dist/components/live-fixes.svelte.test.d.ts +0 -2
- package/dist/components/live-fixes.svelte.test.d.ts.map +0 -1
- package/dist/components/live-fixes.svelte.test.js +0 -127
- package/dist/components/markdown-prose.svelte.test.d.ts +0 -2
- package/dist/components/markdown-prose.svelte.test.d.ts.map +0 -1
- package/dist/components/markdown-prose.svelte.test.js +0 -26
- package/dist/components/messages.svelte.test.d.ts +0 -2
- package/dist/components/messages.svelte.test.d.ts.map +0 -1
- package/dist/components/messages.svelte.test.js +0 -215
- package/dist/components/regenerate.svelte.test.d.ts +0 -2
- package/dist/components/regenerate.svelte.test.d.ts.map +0 -1
- package/dist/components/regenerate.svelte.test.js +0 -108
- package/dist/components/welcome.svelte.test.d.ts +0 -2
- package/dist/components/welcome.svelte.test.d.ts.map +0 -1
- package/dist/components/welcome.svelte.test.js +0 -119
- package/dist/composer-keys.test.d.ts +0 -2
- package/dist/composer-keys.test.d.ts.map +0 -1
- package/dist/composer-keys.test.js +0 -16
- package/dist/markdown.test.d.ts +0 -2
- package/dist/markdown.test.d.ts.map +0 -1
- package/dist/markdown.test.js +0 -35
- package/dist/reducer.test.d.ts +0 -2
- package/dist/reducer.test.d.ts.map +0 -1
- package/dist/reducer.test.js +0 -153
- package/dist/ui-frame-protocol.test.d.ts +0 -2
- package/dist/ui-frame-protocol.test.d.ts.map +0 -1
- package/dist/ui-frame-protocol.test.js +0 -48
- package/dist/ui-frame-relay.test.d.ts +0 -2
- package/dist/ui-frame-relay.test.d.ts.map +0 -1
- package/dist/ui-frame-relay.test.js +0 -36
package/dist/reducer.js
CHANGED
|
@@ -35,8 +35,12 @@ export function describeContext(context) {
|
|
|
35
35
|
/** Fold one stream event into the assistant message it belongs to. Returns a new object. */
|
|
36
36
|
export function applyEvent(message, event) {
|
|
37
37
|
switch (event.type) {
|
|
38
|
-
case 'start':
|
|
39
|
-
|
|
38
|
+
case 'start': {
|
|
39
|
+
const next = { ...message, mode: event.mode };
|
|
40
|
+
// Accepted: the runtime has removed the replaced turn and stored this one.
|
|
41
|
+
delete next.replaces;
|
|
42
|
+
return next;
|
|
43
|
+
}
|
|
40
44
|
case 'delta':
|
|
41
45
|
return { ...message, text: message.text + event.text };
|
|
42
46
|
case 'proposal':
|
|
@@ -145,6 +149,9 @@ export function applyEvent(message, event) {
|
|
|
145
149
|
calls: message.usage.calls + 1,
|
|
146
150
|
},
|
|
147
151
|
};
|
|
152
|
+
case 'client_call':
|
|
153
|
+
// Answered by the session against the live page; nothing to show in the transcript.
|
|
154
|
+
return message;
|
|
148
155
|
case 'error':
|
|
149
156
|
return {
|
|
150
157
|
...message,
|
|
@@ -165,6 +172,15 @@ export function applyEvent(message, event) {
|
|
|
165
172
|
};
|
|
166
173
|
}
|
|
167
174
|
}
|
|
175
|
+
/**
|
|
176
|
+
* Whether Continue can carry on this answer: it was cut short (output limit) or stopped, and has
|
|
177
|
+
* text. A dropped connection or an error is retried instead.
|
|
178
|
+
*/
|
|
179
|
+
export function isContinuable(message) {
|
|
180
|
+
return ((message.status === 'incomplete' || message.status === 'cancelled') &&
|
|
181
|
+
(message.finishReason === 'length' || message.finishReason === 'cancelled') &&
|
|
182
|
+
!!message.text);
|
|
183
|
+
}
|
|
168
184
|
/**
|
|
169
185
|
* Prepare a retry of the turn `requestId`: returns the user message to resend and the transcript
|
|
170
186
|
* without that turn (its user message and the failed assistant reply), so the retried turn
|
package/dist/session.svelte.d.ts
CHANGED
|
@@ -22,6 +22,11 @@ export interface ChatSessionOptions {
|
|
|
22
22
|
conversationId: string | null;
|
|
23
23
|
status: string;
|
|
24
24
|
}) => void;
|
|
25
|
+
/**
|
|
26
|
+
* After Stop, how long to keep reading the stream for the runtime's `done` (so text the agent
|
|
27
|
+
* produced before the cancel landed still shows) before giving up locally. Default 3000 ms.
|
|
28
|
+
*/
|
|
29
|
+
cancelGraceMs?: number;
|
|
25
30
|
}
|
|
26
31
|
export type SessionStatus = 'idle' | 'loading' | 'streaming';
|
|
27
32
|
/**
|
|
@@ -48,10 +53,30 @@ export declare class ChatSession {
|
|
|
48
53
|
private controller;
|
|
49
54
|
/** The request being streamed; differs from the shown answer's id while continuing it. */
|
|
50
55
|
private streamRequestId;
|
|
56
|
+
/** Set once Stop was pressed for the running stream (the server cancel is on its way). */
|
|
57
|
+
private cancelling;
|
|
58
|
+
private cancelTimer;
|
|
59
|
+
/** Bumped by each load and reset, so a superseded load drops its result. */
|
|
60
|
+
private loadToken;
|
|
51
61
|
private readonly options;
|
|
62
|
+
private readonly listeners;
|
|
52
63
|
constructor(options: ChatSessionOptions);
|
|
64
|
+
/**
|
|
65
|
+
* Observe this session's stream events for as long as a view needs them. A session can
|
|
66
|
+
* outlive the component showing it (a host moving the chat between surfaces), so views
|
|
67
|
+
* subscribe instead of owning the `onEvent` option. Returns the unsubscribe.
|
|
68
|
+
*/
|
|
69
|
+
subscribe(listener: (event: ChatEvent) => void): () => void;
|
|
53
70
|
get streaming(): boolean;
|
|
54
71
|
get current(): AssistantMessage | undefined;
|
|
72
|
+
/**
|
|
73
|
+
* Forget the current conversation (the host started a new chat): stops reading a running
|
|
74
|
+
* stream without cancelling it on the server (the turn finishes there and stays in its
|
|
75
|
+
* conversation) and clears the transcript.
|
|
76
|
+
*/
|
|
77
|
+
reset(): void;
|
|
78
|
+
/** Stop reading the running stream, if any; its `run` then leaves the session alone. */
|
|
79
|
+
private detach;
|
|
55
80
|
/** Load an existing conversation's transcript and re-attach to a running turn if any. */
|
|
56
81
|
load(conversationId: string): Promise<void>;
|
|
57
82
|
/**
|
|
@@ -67,6 +92,11 @@ export declare class ChatSession {
|
|
|
67
92
|
* in the view and in the runtime's transcript, rather than duplicated.
|
|
68
93
|
*/
|
|
69
94
|
retry(requestId: string): Promise<void>;
|
|
95
|
+
/**
|
|
96
|
+
* The stored turn a resend of `requestId` replaces: the request itself once the runtime
|
|
97
|
+
* accepted it, else the turn that request was meant to replace (the runtime never stored it).
|
|
98
|
+
*/
|
|
99
|
+
private replacesFor;
|
|
70
100
|
/** Ask again for a new answer to the latest question, replacing the current one. */
|
|
71
101
|
regenerate(requestId: string): Promise<void>;
|
|
72
102
|
/** Change the latest question and answer it again, replacing that turn. */
|
|
@@ -84,8 +114,16 @@ export declare class ChatSession {
|
|
|
84
114
|
* runtime refuses it.
|
|
85
115
|
*/
|
|
86
116
|
rate(requestId: string, rating: TurnRating | null, comment?: string): Promise<void>;
|
|
117
|
+
/**
|
|
118
|
+
* Stop the running answer. The runtime is asked to cancel and the stream is read on until its
|
|
119
|
+
* `done`, so text produced before the cancel landed still shows (and a later Continue appends
|
|
120
|
+
* after it); after `cancelGraceMs` the stream is dropped locally. The answer shows as stopped
|
|
121
|
+
* at once.
|
|
122
|
+
*/
|
|
87
123
|
cancel(): void;
|
|
88
124
|
private run;
|
|
125
|
+
/** The turn's stream is over: restore a failed continuation, go idle, tell the host. */
|
|
126
|
+
private finish;
|
|
89
127
|
private toolName;
|
|
90
128
|
private update;
|
|
91
129
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"session.svelte.d.ts","sourceRoot":"","sources":["../src/lib/session.svelte.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,6BAA6B,CAAC;AAElE,OAAO,KAAK,EACV,QAAQ,EACR,WAAW,EACX,SAAS,EAET,UAAU,EACV,eAAe,EACf,OAAO,EACP,UAAU,EACX,MAAM,gCAAgC,CAAC;AACxC,OAAO,
|
|
1
|
+
{"version":3,"file":"session.svelte.d.ts","sourceRoot":"","sources":["../src/lib/session.svelte.ts"],"names":[],"mappings":"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"}
|
package/dist/session.svelte.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import { ApiRequestError } from '@classic-homes/agent-client';
|
|
2
|
-
import { applyEvent, newAssistantMessage, newUserMessage, latestTurn, turnForRetry, } from './reducer.js';
|
|
2
|
+
import { applyEvent, isContinuable, newAssistantMessage, newUserMessage, latestTurn, turnForRetry, } from './reducer.js';
|
|
3
|
+
/** Re-attach attempts after a stream ends without `done` (a dropped connection). */
|
|
4
|
+
const RESUME_ATTEMPTS = 2;
|
|
5
|
+
const DEFAULT_CANCEL_GRACE_MS = 3000;
|
|
3
6
|
/**
|
|
4
7
|
* Reactive conversation state (Svelte 5 runes class). Owns the stream lifecycle: one turn at a
|
|
5
8
|
* time, cancel, resume after reload, history load. Rendering is left to the components.
|
|
@@ -18,13 +21,30 @@ export class ChatSession {
|
|
|
18
21
|
controller;
|
|
19
22
|
/** The request being streamed; differs from the shown answer's id while continuing it. */
|
|
20
23
|
streamRequestId;
|
|
24
|
+
/** Set once Stop was pressed for the running stream (the server cancel is on its way). */
|
|
25
|
+
cancelling = false;
|
|
26
|
+
cancelTimer;
|
|
27
|
+
/** Bumped by each load and reset, so a superseded load drops its result. */
|
|
28
|
+
loadToken = 0;
|
|
21
29
|
options;
|
|
30
|
+
listeners = new Set();
|
|
22
31
|
constructor(options) {
|
|
23
32
|
this.options = options;
|
|
24
33
|
this.conversationId = options.conversationId ?? null;
|
|
25
34
|
this.mode = options.mode;
|
|
26
35
|
this.agent = options.agent ?? 'classic-agent';
|
|
27
36
|
}
|
|
37
|
+
/**
|
|
38
|
+
* Observe this session's stream events for as long as a view needs them. A session can
|
|
39
|
+
* outlive the component showing it (a host moving the chat between surfaces), so views
|
|
40
|
+
* subscribe instead of owning the `onEvent` option. Returns the unsubscribe.
|
|
41
|
+
*/
|
|
42
|
+
subscribe(listener) {
|
|
43
|
+
this.listeners.add(listener);
|
|
44
|
+
return () => {
|
|
45
|
+
this.listeners.delete(listener);
|
|
46
|
+
};
|
|
47
|
+
}
|
|
28
48
|
get streaming() {
|
|
29
49
|
return this.status === 'streaming';
|
|
30
50
|
}
|
|
@@ -32,12 +52,43 @@ export class ChatSession {
|
|
|
32
52
|
const last = this.messages[this.messages.length - 1];
|
|
33
53
|
return last?.role === 'assistant' ? last : undefined;
|
|
34
54
|
}
|
|
55
|
+
/**
|
|
56
|
+
* Forget the current conversation (the host started a new chat): stops reading a running
|
|
57
|
+
* stream without cancelling it on the server (the turn finishes there and stays in its
|
|
58
|
+
* conversation) and clears the transcript.
|
|
59
|
+
*/
|
|
60
|
+
reset() {
|
|
61
|
+
this.loadToken += 1;
|
|
62
|
+
this.detach();
|
|
63
|
+
this.status = 'idle';
|
|
64
|
+
this.messages = [];
|
|
65
|
+
this.conversationId = null;
|
|
66
|
+
this.loadedConversationId = null;
|
|
67
|
+
this.loadError = undefined;
|
|
68
|
+
this.pendingFiles = [];
|
|
69
|
+
}
|
|
70
|
+
/** Stop reading the running stream, if any; its `run` then leaves the session alone. */
|
|
71
|
+
detach() {
|
|
72
|
+
const controller = this.controller;
|
|
73
|
+
this.controller = undefined;
|
|
74
|
+
this.streamRequestId = undefined;
|
|
75
|
+
this.cancelling = false;
|
|
76
|
+
clearTimeout(this.cancelTimer);
|
|
77
|
+
this.cancelTimer = undefined;
|
|
78
|
+
controller?.abort();
|
|
79
|
+
}
|
|
35
80
|
/** Load an existing conversation's transcript and re-attach to a running turn if any. */
|
|
36
81
|
async load(conversationId) {
|
|
82
|
+
// Another conversation's stream must not keep writing into (or finishing) this one.
|
|
83
|
+
this.detach();
|
|
84
|
+
const token = ++this.loadToken;
|
|
37
85
|
this.status = 'loading';
|
|
38
86
|
this.loadError = undefined;
|
|
39
87
|
try {
|
|
40
88
|
const detail = await this.options.client.getConversation(conversationId);
|
|
89
|
+
// A newer load or a reset took over while this one waited.
|
|
90
|
+
if (token !== this.loadToken)
|
|
91
|
+
return;
|
|
41
92
|
this.conversationId = conversationId;
|
|
42
93
|
this.loadedConversationId = conversationId;
|
|
43
94
|
this.messages = detail.messages.map((m) => m.role === 'user'
|
|
@@ -51,13 +102,9 @@ export class ChatSession {
|
|
|
51
102
|
: {
|
|
52
103
|
...newAssistantMessage(m.id.replace(/^a-/, ''), m.createdAt),
|
|
53
104
|
text: m.content.map((c) => c.text).join('\n'),
|
|
54
|
-
// An answer that ended early keeps its Continue after
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
: m.finish === 'cancelled'
|
|
58
|
-
? 'cancelled'
|
|
59
|
-
: 'done',
|
|
60
|
-
...(m.finish ? { finishReason: m.finish } : {}),
|
|
105
|
+
// An answer that ended early keeps its Continue (or, after an error, its Try
|
|
106
|
+
// again) after a reload. A finish this client does not know reads as done.
|
|
107
|
+
...loadedFinish(m.finish),
|
|
61
108
|
...(detail.feedback?.[m.id.replace(/^a-/, '')]
|
|
62
109
|
? { feedback: detail.feedback[m.id.replace(/^a-/, '')] }
|
|
63
110
|
: {}),
|
|
@@ -69,6 +116,8 @@ export class ChatSession {
|
|
|
69
116
|
await this.resume(active);
|
|
70
117
|
}
|
|
71
118
|
catch (err) {
|
|
119
|
+
if (token !== this.loadToken)
|
|
120
|
+
return;
|
|
72
121
|
this.status = 'idle';
|
|
73
122
|
this.loadError = err instanceof Error ? err.message : 'Could not load the conversation';
|
|
74
123
|
}
|
|
@@ -95,10 +144,15 @@ export class ChatSession {
|
|
|
95
144
|
...(this.options.clientKind ? { client: this.options.clientKind } : {}),
|
|
96
145
|
...(options.replaces ? { replaces: options.replaces } : {}),
|
|
97
146
|
};
|
|
147
|
+
const answer = newAssistantMessage(requestId);
|
|
148
|
+
// Until the runtime accepts the turn (`start`), remember which stored turn it replaces, so
|
|
149
|
+
// retrying a turn that never started still replaces the original (see `replacesFor`).
|
|
150
|
+
if (options.replaces)
|
|
151
|
+
answer.replaces = options.replaces;
|
|
98
152
|
this.messages = [
|
|
99
153
|
...this.messages,
|
|
100
154
|
newUserMessage(requestId, message, { attachments: files, context }),
|
|
101
|
-
|
|
155
|
+
answer,
|
|
102
156
|
];
|
|
103
157
|
await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }));
|
|
104
158
|
}
|
|
@@ -112,8 +166,17 @@ export class ChatSession {
|
|
|
112
166
|
const turn = turnForRetry(this.messages, requestId);
|
|
113
167
|
if (!turn)
|
|
114
168
|
return;
|
|
169
|
+
const replaces = this.replacesFor(requestId);
|
|
115
170
|
this.messages = turn.rest;
|
|
116
|
-
await this.send(turn.user.text, { files: turn.user.attachments, replaces
|
|
171
|
+
await this.send(turn.user.text, { files: turn.user.attachments, replaces });
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The stored turn a resend of `requestId` replaces: the request itself once the runtime
|
|
175
|
+
* accepted it, else the turn that request was meant to replace (the runtime never stored it).
|
|
176
|
+
*/
|
|
177
|
+
replacesFor(requestId) {
|
|
178
|
+
const answer = this.messages.find((m) => m.role === 'assistant' && m.requestId === requestId);
|
|
179
|
+
return answer?.replaces ?? requestId;
|
|
117
180
|
}
|
|
118
181
|
/** Ask again for a new answer to the latest question, replacing the current one. */
|
|
119
182
|
async regenerate(requestId) {
|
|
@@ -122,8 +185,9 @@ export class ChatSession {
|
|
|
122
185
|
const turn = latestTurn(this.messages, requestId);
|
|
123
186
|
if (!turn)
|
|
124
187
|
return;
|
|
188
|
+
const replaces = this.replacesFor(requestId);
|
|
125
189
|
this.messages = turn.rest;
|
|
126
|
-
await this.send(turn.user.text, { files: turn.user.attachments, replaces
|
|
190
|
+
await this.send(turn.user.text, { files: turn.user.attachments, replaces });
|
|
127
191
|
}
|
|
128
192
|
/** Change the latest question and answer it again, replacing that turn. */
|
|
129
193
|
async edit(requestId, text) {
|
|
@@ -132,8 +196,9 @@ export class ChatSession {
|
|
|
132
196
|
const turn = latestTurn(this.messages, requestId);
|
|
133
197
|
if (!turn)
|
|
134
198
|
return;
|
|
199
|
+
const replaces = this.replacesFor(requestId);
|
|
135
200
|
this.messages = turn.rest;
|
|
136
|
-
await this.send(text, { files: turn.user.attachments, replaces
|
|
201
|
+
await this.send(text, { files: turn.user.attachments, replaces });
|
|
137
202
|
}
|
|
138
203
|
/**
|
|
139
204
|
* Carry on the latest answer where it was cut short or stopped. The runtime asks the agent to
|
|
@@ -145,10 +210,9 @@ export class ChatSession {
|
|
|
145
210
|
if (!conversationId || this.status !== 'idle')
|
|
146
211
|
return;
|
|
147
212
|
const last = this.current;
|
|
148
|
-
if (last?.requestId !== requestId ||
|
|
149
|
-
(last.status !== 'incomplete' && last.status !== 'cancelled') ||
|
|
150
|
-
!last.text)
|
|
213
|
+
if (last?.requestId !== requestId || !isContinuable(last))
|
|
151
214
|
return;
|
|
215
|
+
const previous = { status: last.status, finishReason: last.finishReason };
|
|
152
216
|
const request = {
|
|
153
217
|
requestId: crypto.randomUUID(),
|
|
154
218
|
conversationId,
|
|
@@ -163,7 +227,7 @@ export class ChatSession {
|
|
|
163
227
|
delete next.error;
|
|
164
228
|
return next;
|
|
165
229
|
});
|
|
166
|
-
await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }), request.requestId);
|
|
230
|
+
await this.run(requestId, (signal, onEvent) => this.options.client.streamChat(request, { signal, onEvent }), request.requestId, previous);
|
|
167
231
|
}
|
|
168
232
|
/** Re-attach to a turn after reload: replays events after the last seq we hold (none here). */
|
|
169
233
|
async resume(requestId) {
|
|
@@ -207,20 +271,40 @@ export class ChatSession {
|
|
|
207
271
|
throw err;
|
|
208
272
|
}
|
|
209
273
|
}
|
|
274
|
+
/**
|
|
275
|
+
* Stop the running answer. The runtime is asked to cancel and the stream is read on until its
|
|
276
|
+
* `done`, so text produced before the cancel landed still shows (and a later Continue appends
|
|
277
|
+
* after it); after `cancelGraceMs` the stream is dropped locally. The answer shows as stopped
|
|
278
|
+
* at once.
|
|
279
|
+
*/
|
|
210
280
|
cancel() {
|
|
211
281
|
const current = this.current;
|
|
212
|
-
|
|
282
|
+
const controller = this.controller;
|
|
283
|
+
if (!controller || !current || this.cancelling)
|
|
213
284
|
return;
|
|
214
|
-
this.
|
|
215
|
-
|
|
285
|
+
this.cancelling = true;
|
|
286
|
+
const answerId = current.requestId;
|
|
287
|
+
this.update(answerId, (m) => m.status === 'streaming' ? { ...m, status: 'cancelled', finishReason: 'cancelled' } : m);
|
|
288
|
+
void this.options.client.cancel(this.streamRequestId ?? answerId);
|
|
289
|
+
this.cancelTimer = setTimeout(() => controller.abort(), this.options.cancelGraceMs ?? DEFAULT_CANCEL_GRACE_MS);
|
|
216
290
|
}
|
|
217
291
|
async run(requestId, start,
|
|
218
292
|
/** The request streamed, when it is not `requestId` (a continuation of that answer). */
|
|
219
|
-
streamRequestId = requestId
|
|
293
|
+
streamRequestId = requestId,
|
|
294
|
+
/** A continuation: the answer's state before it, restored when the continuation fails. */
|
|
295
|
+
previous) {
|
|
296
|
+
this.detach();
|
|
220
297
|
this.status = 'streaming';
|
|
221
|
-
|
|
298
|
+
const controller = new AbortController();
|
|
299
|
+
this.controller = controller;
|
|
222
300
|
this.streamRequestId = streamRequestId;
|
|
301
|
+
// A reset, a load or a newer turn replaced this stream: leave the session alone.
|
|
302
|
+
const stale = () => this.controller !== controller;
|
|
303
|
+
let lastSeq = -1;
|
|
223
304
|
const onEvent = (event) => {
|
|
305
|
+
if (stale())
|
|
306
|
+
return;
|
|
307
|
+
lastSeq = event.seq;
|
|
224
308
|
if (event.type === 'start') {
|
|
225
309
|
this.mode = event.mode;
|
|
226
310
|
if (this.conversationId !== event.conversationId) {
|
|
@@ -233,15 +317,48 @@ export class ChatSession {
|
|
|
233
317
|
this.options.onToolResult?.(this.toolName(requestId, event.callId), event.status);
|
|
234
318
|
this.update(requestId, (m) => applyEvent(m, event));
|
|
235
319
|
this.options.onEvent?.(event);
|
|
320
|
+
for (const listener of [...this.listeners])
|
|
321
|
+
listener(event);
|
|
236
322
|
};
|
|
237
323
|
try {
|
|
238
|
-
|
|
324
|
+
let outcome = await start(controller.signal, onEvent);
|
|
325
|
+
// The connection dropped before `done`: the turn may still be running (or have just
|
|
326
|
+
// finished) on the server, so re-attach after the last event seen. Continue is not the
|
|
327
|
+
// answer here; the runtime refuses it while the turn runs.
|
|
328
|
+
for (let attempt = 0; outcome.kind === 'ended' && attempt < RESUME_ATTEMPTS && !stale() && !this.cancelling; attempt += 1) {
|
|
329
|
+
const conversationId = this.conversationId;
|
|
330
|
+
if (!conversationId)
|
|
331
|
+
break;
|
|
332
|
+
try {
|
|
333
|
+
outcome = await this.options.client.resumeTurn(conversationId, streamRequestId, lastSeq, {
|
|
334
|
+
signal: controller.signal,
|
|
335
|
+
onEvent,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// Counts as an attempt; the outcome stays `ended`.
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
if (stale())
|
|
343
|
+
return;
|
|
239
344
|
if (outcome.kind === 'aborted')
|
|
240
345
|
this.update(requestId, (m) => m.status === 'streaming' ? { ...m, status: 'cancelled', finishReason: 'cancelled' } : m);
|
|
241
346
|
else if (outcome.kind === 'ended')
|
|
242
|
-
this.update(requestId, (m) => m.status === 'streaming'
|
|
347
|
+
this.update(requestId, (m) => m.status === 'streaming'
|
|
348
|
+
? {
|
|
349
|
+
...m,
|
|
350
|
+
status: 'error',
|
|
351
|
+
error: {
|
|
352
|
+
code: 'network',
|
|
353
|
+
message: 'The connection was lost before the answer finished.',
|
|
354
|
+
retryable: true,
|
|
355
|
+
},
|
|
356
|
+
}
|
|
357
|
+
: m);
|
|
243
358
|
}
|
|
244
359
|
catch (err) {
|
|
360
|
+
if (stale())
|
|
361
|
+
return;
|
|
245
362
|
const code = err instanceof ApiRequestError ? err.code : 'network';
|
|
246
363
|
const text = err instanceof Error ? err.message : 'The request failed';
|
|
247
364
|
this.update(requestId, (m) => ({
|
|
@@ -255,6 +372,28 @@ export class ChatSession {
|
|
|
255
372
|
}));
|
|
256
373
|
}
|
|
257
374
|
finally {
|
|
375
|
+
if (!stale())
|
|
376
|
+
this.finish(requestId, previous);
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
/** The turn's stream is over: restore a failed continuation, go idle, tell the host. */
|
|
380
|
+
finish(requestId, previous) {
|
|
381
|
+
// A failed continuation keeps the text so far continuable; the error shows inline.
|
|
382
|
+
if (previous)
|
|
383
|
+
this.update(requestId, (m) => {
|
|
384
|
+
if (m.status !== 'error')
|
|
385
|
+
return m;
|
|
386
|
+
const next = { ...m, status: previous.status };
|
|
387
|
+
if (previous.finishReason)
|
|
388
|
+
next.finishReason = previous.finishReason;
|
|
389
|
+
else
|
|
390
|
+
delete next.finishReason;
|
|
391
|
+
return next;
|
|
392
|
+
});
|
|
393
|
+
{
|
|
394
|
+
clearTimeout(this.cancelTimer);
|
|
395
|
+
this.cancelTimer = undefined;
|
|
396
|
+
this.cancelling = false;
|
|
258
397
|
this.controller = undefined;
|
|
259
398
|
this.streamRequestId = undefined;
|
|
260
399
|
this.status = 'idle';
|
|
@@ -274,6 +413,20 @@ export class ChatSession {
|
|
|
274
413
|
this.messages = this.messages.map((m) => m.role === 'assistant' && m.requestId === requestId ? fn(m) : m);
|
|
275
414
|
}
|
|
276
415
|
}
|
|
416
|
+
/** Status for a stored answer's `finish` (absent: it finished normally). */
|
|
417
|
+
function loadedFinish(finish) {
|
|
418
|
+
if (finish === 'length')
|
|
419
|
+
return { status: 'incomplete', finishReason: 'length' };
|
|
420
|
+
if (finish === 'cancelled')
|
|
421
|
+
return { status: 'cancelled', finishReason: 'cancelled' };
|
|
422
|
+
if (finish === 'error')
|
|
423
|
+
return {
|
|
424
|
+
status: 'error',
|
|
425
|
+
finishReason: 'error',
|
|
426
|
+
error: { code: 'upstream_error', message: 'The answer did not finish.', retryable: true },
|
|
427
|
+
};
|
|
428
|
+
return { status: 'done' };
|
|
429
|
+
}
|
|
277
430
|
export function createChatSession(options) {
|
|
278
431
|
return new ChatSession(options);
|
|
279
432
|
}
|
package/dist/shortcuts.d.ts
CHANGED
|
@@ -8,4 +8,11 @@ export interface ChatShortcut {
|
|
|
8
8
|
}
|
|
9
9
|
/** What the chat itself handles, in the composer. Hosts add their own (e.g. a panel toggle). */
|
|
10
10
|
export declare const CHAT_SHORTCUTS: readonly ChatShortcut[];
|
|
11
|
+
/**
|
|
12
|
+
* The chat's shortcuts for the dialog: without saved prompts (`AgentChat`'s `prompts`) the `/`
|
|
13
|
+
* menu is off, so its entry is left out.
|
|
14
|
+
*/
|
|
15
|
+
export declare function chatShortcuts(options?: {
|
|
16
|
+
savedPrompts?: boolean;
|
|
17
|
+
}): ChatShortcut[];
|
|
11
18
|
//# sourceMappingURL=shortcuts.d.ts.map
|
package/dist/shortcuts.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"shortcuts.d.ts","sourceRoot":"","sources":["../src/lib/shortcuts.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gGAAgG;AAChG,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EAMjD,CAAC"}
|
|
1
|
+
{"version":3,"file":"shortcuts.d.ts","sourceRoot":"","sources":["../src/lib/shortcuts.ts"],"names":[],"mappings":"AAAA;;;GAGG;AACH,MAAM,WAAW,YAAY;IAC3B,IAAI,EAAE,MAAM,EAAE,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;CACf;AAED,gGAAgG;AAChG,eAAO,MAAM,cAAc,EAAE,SAAS,YAAY,EAMjD,CAAC;AAEF;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,GAAE;IAAE,YAAY,CAAC,EAAE,OAAO,CAAA;CAAO,GAAG,YAAY,EAAE,CAItF"}
|
package/dist/shortcuts.js
CHANGED
|
@@ -6,3 +6,12 @@ export const CHAT_SHORTCUTS = [
|
|
|
6
6
|
{ keys: ['/'], label: 'Insert a saved prompt' },
|
|
7
7
|
{ keys: ['Esc'], label: 'Cancel editing a message' },
|
|
8
8
|
];
|
|
9
|
+
/**
|
|
10
|
+
* The chat's shortcuts for the dialog: without saved prompts (`AgentChat`'s `prompts`) the `/`
|
|
11
|
+
* menu is off, so its entry is left out.
|
|
12
|
+
*/
|
|
13
|
+
export function chatShortcuts(options = {}) {
|
|
14
|
+
return options.savedPrompts === false
|
|
15
|
+
? CHAT_SHORTCUTS.filter((s) => s.label !== 'Insert a saved prompt')
|
|
16
|
+
: [...CHAT_SHORTCUTS];
|
|
17
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Geometry for the detached chat: a floating launcher button that snaps to a viewport corner and
|
|
3
|
+
* a floating window the user can move and resize. Pure functions in viewport (CSS px,
|
|
4
|
+
* `position: fixed`) coordinates so they can be tested without layout.
|
|
5
|
+
*
|
|
6
|
+
* The drag threshold and the separate launcher/window positions follow the customer chat widget
|
|
7
|
+
* (Classic-Homes/chat, `packages/widget`), rewritten for this package.
|
|
8
|
+
*/
|
|
9
|
+
export interface Point {
|
|
10
|
+
x: number;
|
|
11
|
+
y: number;
|
|
12
|
+
}
|
|
13
|
+
export interface Size {
|
|
14
|
+
width: number;
|
|
15
|
+
height: number;
|
|
16
|
+
}
|
|
17
|
+
export interface Rect extends Point, Size {
|
|
18
|
+
}
|
|
19
|
+
export type Corner = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
|
|
20
|
+
export declare const CORNERS: readonly Corner[];
|
|
21
|
+
/** Compass edges a resize handle moves. */
|
|
22
|
+
export type ResizeDirection = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw';
|
|
23
|
+
export declare const RESIZE_DIRECTIONS: readonly ResizeDirection[];
|
|
24
|
+
export declare const FLOAT_LIMITS: {
|
|
25
|
+
readonly minWidth: 340;
|
|
26
|
+
readonly minHeight: 420;
|
|
27
|
+
readonly maxWidth: 900;
|
|
28
|
+
readonly maxHeight: 1000;
|
|
29
|
+
/** Gap kept between the window and the viewport edge. */
|
|
30
|
+
readonly margin: 12;
|
|
31
|
+
};
|
|
32
|
+
export declare const DEFAULT_FLOAT_SIZE: Size;
|
|
33
|
+
export declare const LAUNCHER_SIZE = 56;
|
|
34
|
+
export declare const LAUNCHER_MARGIN = 20;
|
|
35
|
+
/** Pointer travel (px) before a press on the launcher or the window header counts as a drag. */
|
|
36
|
+
export declare const DRAG_THRESHOLD = 3;
|
|
37
|
+
/** Releasing a dragged window with the pointer this close to the right edge docks it. */
|
|
38
|
+
export declare const DOCK_THRESHOLD = 24;
|
|
39
|
+
/** Arrow-key step for moving and resizing the window; Alt makes it fine. */
|
|
40
|
+
export declare const KEY_STEP = 24;
|
|
41
|
+
/** Size within the limits and never larger than the viewport allows. */
|
|
42
|
+
export declare function clampSize(size: Size, viewport: Size): Size;
|
|
43
|
+
/** The rect resized to the limits and moved fully inside the viewport. */
|
|
44
|
+
export declare function clampRect(rect: Rect, viewport: Size): Rect;
|
|
45
|
+
/** Where a new window opens: beside the launcher's corner, clear of the launcher itself. */
|
|
46
|
+
export declare function defaultRect(viewport: Size, corner: Corner, size?: Size): Rect;
|
|
47
|
+
/** The corner a dropped launcher snaps to. */
|
|
48
|
+
export declare function nearestCorner(point: Point, viewport: Size): Corner;
|
|
49
|
+
/**
|
|
50
|
+
* The rect after dragging a resize handle by `(dx, dy)`. The opposite edge stays put, also when
|
|
51
|
+
* a limit stops the dragged edge.
|
|
52
|
+
*/
|
|
53
|
+
export declare function resizeRect(rect: Rect, direction: ResizeDirection, dx: number, dy: number, viewport: Size): Rect;
|
|
54
|
+
/** Was a dragged window released against the right edge (the dock side)? */
|
|
55
|
+
export declare function isDockDrop(pointer: Point, viewport: Size): boolean;
|
|
56
|
+
//# sourceMappingURL=surface-geometry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"surface-geometry.d.ts","sourceRoot":"","sources":["../src/lib/surface-geometry.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,MAAM,WAAW,KAAK;IACpB,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;CACX;AACD,MAAM,WAAW,IAAI;IACnB,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;CAChB;AACD,MAAM,WAAW,IAAK,SAAQ,KAAK,EAAE,IAAI;CAAG;AAE5C,MAAM,MAAM,MAAM,GAAG,cAAc,GAAG,aAAa,GAAG,WAAW,GAAG,UAAU,CAAC;AAC/E,eAAO,MAAM,OAAO,EAAE,SAAS,MAAM,EAA6D,CAAC;AAEnG,2CAA2C;AAC3C,MAAM,MAAM,eAAe,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,GAAG,IAAI,CAAC;AAChF,eAAO,MAAM,iBAAiB,EAAE,SAAS,eAAe,EASvD,CAAC;AAEF,eAAO,MAAM,YAAY;;;;;IAKvB,yDAAyD;;CAEjD,CAAC;AACX,eAAO,MAAM,kBAAkB,EAAE,IAAkC,CAAC;AACpE,eAAO,MAAM,aAAa,KAAK,CAAC;AAChC,eAAO,MAAM,eAAe,KAAK,CAAC;AAClC,gGAAgG;AAChG,eAAO,MAAM,cAAc,IAAI,CAAC;AAChC,yFAAyF;AACzF,eAAO,MAAM,cAAc,KAAK,CAAC;AACjC,4EAA4E;AAC5E,eAAO,MAAM,QAAQ,KAAK,CAAC;AAK3B,wEAAwE;AACxE,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,GAAG,IAAI,CAS1D;AAED,0EAA0E;AAC1E,wBAAgB,SAAS,CAAC,IAAI,EAAE,IAAI,EAAE,QAAQ,EAAE,IAAI,GAAG,IAAI,CAQ1D;AAED,4FAA4F;AAC5F,wBAAgB,WAAW,CAAC,QAAQ,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,IAAI,GAAE,IAAyB,GAAG,IAAI,CAmBjG;AAED,8CAA8C;AAC9C,wBAAgB,aAAa,CAAC,KAAK,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,GAAG,MAAM,CAIlE;AAED;;;GAGG;AACH,wBAAgB,UAAU,CACxB,IAAI,EAAE,IAAI,EACV,SAAS,EAAE,eAAe,EAC1B,EAAE,EAAE,MAAM,EACV,EAAE,EAAE,MAAM,EACV,QAAQ,EAAE,IAAI,GACb,IAAI,CAaN;AAED,4EAA4E;AAC5E,wBAAgB,UAAU,CAAC,OAAO,EAAE,KAAK,EAAE,QAAQ,EAAE,IAAI,GAAG,OAAO,CAElE"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
export const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
|
|
2
|
+
export const RESIZE_DIRECTIONS = [
|
|
3
|
+
'n',
|
|
4
|
+
's',
|
|
5
|
+
'e',
|
|
6
|
+
'w',
|
|
7
|
+
'ne',
|
|
8
|
+
'nw',
|
|
9
|
+
'se',
|
|
10
|
+
'sw',
|
|
11
|
+
];
|
|
12
|
+
export const FLOAT_LIMITS = {
|
|
13
|
+
minWidth: 340,
|
|
14
|
+
minHeight: 420,
|
|
15
|
+
maxWidth: 900,
|
|
16
|
+
maxHeight: 1000,
|
|
17
|
+
/** Gap kept between the window and the viewport edge. */
|
|
18
|
+
margin: 12,
|
|
19
|
+
};
|
|
20
|
+
export const DEFAULT_FLOAT_SIZE = { width: 420, height: 640 };
|
|
21
|
+
export const LAUNCHER_SIZE = 56;
|
|
22
|
+
export const LAUNCHER_MARGIN = 20;
|
|
23
|
+
/** Pointer travel (px) before a press on the launcher or the window header counts as a drag. */
|
|
24
|
+
export const DRAG_THRESHOLD = 3;
|
|
25
|
+
/** Releasing a dragged window with the pointer this close to the right edge docks it. */
|
|
26
|
+
export const DOCK_THRESHOLD = 24;
|
|
27
|
+
/** Arrow-key step for moving and resizing the window; Alt makes it fine. */
|
|
28
|
+
export const KEY_STEP = 24;
|
|
29
|
+
const between = (value, min, max) => Math.min(Math.max(value, min), Math.max(min, max));
|
|
30
|
+
/** Size within the limits and never larger than the viewport allows. */
|
|
31
|
+
export function clampSize(size, viewport) {
|
|
32
|
+
const { minWidth, minHeight, maxWidth, maxHeight, margin } = FLOAT_LIMITS;
|
|
33
|
+
const roomX = viewport.width - margin * 2;
|
|
34
|
+
const roomY = viewport.height - margin * 2;
|
|
35
|
+
return {
|
|
36
|
+
// A viewport smaller than the minimum wins: the window must stay fully visible.
|
|
37
|
+
width: Math.round(Math.min(between(size.width, minWidth, maxWidth), Math.max(roomX, 0))),
|
|
38
|
+
height: Math.round(Math.min(between(size.height, minHeight, maxHeight), Math.max(roomY, 0))),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** The rect resized to the limits and moved fully inside the viewport. */
|
|
42
|
+
export function clampRect(rect, viewport) {
|
|
43
|
+
const size = clampSize(rect, viewport);
|
|
44
|
+
const { margin } = FLOAT_LIMITS;
|
|
45
|
+
return {
|
|
46
|
+
...size,
|
|
47
|
+
x: Math.round(between(rect.x, margin, viewport.width - size.width - margin)),
|
|
48
|
+
y: Math.round(between(rect.y, margin, viewport.height - size.height - margin)),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
/** Where a new window opens: beside the launcher's corner, clear of the launcher itself. */
|
|
52
|
+
export function defaultRect(viewport, corner, size = DEFAULT_FLOAT_SIZE) {
|
|
53
|
+
const clear = LAUNCHER_MARGIN + LAUNCHER_SIZE + FLOAT_LIMITS.margin;
|
|
54
|
+
const fitted = clampSize({
|
|
55
|
+
width: size.width,
|
|
56
|
+
height: Math.min(size.height, viewport.height - clear - FLOAT_LIMITS.margin),
|
|
57
|
+
}, viewport);
|
|
58
|
+
return clampRect({
|
|
59
|
+
...fitted,
|
|
60
|
+
x: corner.endsWith('left')
|
|
61
|
+
? LAUNCHER_MARGIN
|
|
62
|
+
: viewport.width - fitted.width - LAUNCHER_MARGIN,
|
|
63
|
+
y: corner.startsWith('top') ? clear : viewport.height - fitted.height - clear,
|
|
64
|
+
}, viewport);
|
|
65
|
+
}
|
|
66
|
+
/** The corner a dropped launcher snaps to. */
|
|
67
|
+
export function nearestCorner(point, viewport) {
|
|
68
|
+
const vertical = point.y < viewport.height / 2 ? 'top' : 'bottom';
|
|
69
|
+
const horizontal = point.x < viewport.width / 2 ? 'left' : 'right';
|
|
70
|
+
return `${vertical}-${horizontal}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* The rect after dragging a resize handle by `(dx, dy)`. The opposite edge stays put, also when
|
|
74
|
+
* a limit stops the dragged edge.
|
|
75
|
+
*/
|
|
76
|
+
export function resizeRect(rect, direction, dx, dy, viewport) {
|
|
77
|
+
const { margin } = FLOAT_LIMITS;
|
|
78
|
+
let { x, y, width, height } = rect;
|
|
79
|
+
const right = x + width;
|
|
80
|
+
const bottom = y + height;
|
|
81
|
+
if (direction.includes('e'))
|
|
82
|
+
width = Math.min(width + dx, viewport.width - margin - x);
|
|
83
|
+
if (direction.includes('s'))
|
|
84
|
+
height = Math.min(height + dy, viewport.height - margin - y);
|
|
85
|
+
if (direction.includes('w'))
|
|
86
|
+
width = Math.min(width - dx, right - margin);
|
|
87
|
+
if (direction.includes('n'))
|
|
88
|
+
height = Math.min(height - dy, bottom - margin);
|
|
89
|
+
({ width, height } = clampSize({ width, height }, viewport));
|
|
90
|
+
if (direction.includes('w'))
|
|
91
|
+
x = right - width;
|
|
92
|
+
if (direction.includes('n'))
|
|
93
|
+
y = bottom - height;
|
|
94
|
+
return clampRect({ x, y, width, height }, viewport);
|
|
95
|
+
}
|
|
96
|
+
/** Was a dragged window released against the right edge (the dock side)? */
|
|
97
|
+
export function isDockDrop(pointer, viewport) {
|
|
98
|
+
return pointer.x >= viewport.width - DOCK_THRESHOLD;
|
|
99
|
+
}
|