@jxsuite/studio 0.34.0 → 0.36.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/dist/iframe-entry.js +9 -1
- package/dist/iframe-entry.js.map +3 -3
- package/dist/studio.js +18382 -3686
- package/dist/studio.js.map +265 -26
- package/package.json +9 -6
- package/src/files/files.ts +3 -0
- package/src/new-project/design-fields.ts +260 -0
- package/src/new-project/import-tab.ts +322 -0
- package/src/new-project/new-project-modal.ts +472 -89
- package/src/new-project/templates.ts +61 -0
- package/src/packages/ensure-deps.ts +6 -0
- package/src/packages/pull-package-sync.ts +339 -0
- package/src/panels/ai-chat/attached-context.ts +49 -0
- package/src/panels/ai-chat/chat-markdown.ts +38 -0
- package/src/panels/ai-chat/chat-view.ts +250 -0
- package/src/panels/ai-chat/composer.ts +315 -0
- package/src/panels/ai-chat/sessions-view.ts +98 -0
- package/src/panels/ai-panel.ts +214 -458
- package/src/panels/drag-ghost.ts +1 -1
- package/src/panels/git-panel.ts +16 -1
- package/src/panels/right-panel.ts +21 -5
- package/src/panels/toolbar.ts +2 -0
- package/src/panels/welcome-screen.ts +26 -0
- package/src/platforms/cloud.ts +774 -0
- package/src/platforms/devserver.ts +100 -1
- package/src/project-list.ts +38 -0
- package/src/publish/pages-service.ts +186 -0
- package/src/publish/publish-panel.ts +360 -0
- package/src/services/agent-seed.ts +91 -0
- package/src/services/ai-models.ts +93 -0
- package/src/services/ai-session-store.ts +250 -0
- package/src/services/ai-settings.ts +5 -0
- package/src/services/automation.ts +140 -0
- package/src/services/cf-settings.ts +58 -0
- package/src/services/document-assistant.ts +98 -38
- package/src/services/import-client.ts +134 -0
- package/src/services/settings-store.ts +99 -0
- package/src/studio.ts +33 -0
- package/src/types.ts +130 -133
- package/src/ui/ai-credentials-form.ts +205 -0
- package/src/ui/spectrum.ts +8 -0
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Chat-view.js — the active-conversation templates: header and message list.
|
|
4
|
+
*
|
|
5
|
+
* Message-row anatomy: user messages render as right-aligned bubbles (attached-context
|
|
6
|
+
* blocks become chips), assistant messages render sanitized markdown plus tool-call
|
|
7
|
+
* chips, tool messages surface failures only (ADR §11.3), the streaming tail renders
|
|
8
|
+
* as plain text with a cursor (markdown parses once on finalize), and chat errors get
|
|
9
|
+
* a danger row with recovery advice. Pure templates — state lives in ai-panel.
|
|
10
|
+
*
|
|
11
|
+
* @license MIT
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { html, nothing } from "lit-html";
|
|
15
|
+
import type { TemplateResult } from "lit-html";
|
|
16
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
17
|
+
import type { Message, ToolCallRecord } from "@jxsuite/ai/chat-state";
|
|
18
|
+
import { splitAttachedContext } from "./attached-context";
|
|
19
|
+
import { renderMarkdown } from "./chat-markdown";
|
|
20
|
+
|
|
21
|
+
// ─── Helpers (moved from ai-panel.ts) ────────────────────────────────────────
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Parse a tool result message content (JSON string) into its success/error shape. Returns null if
|
|
25
|
+
* the content isn't a valid tool result.
|
|
26
|
+
*
|
|
27
|
+
* @param {string} content
|
|
28
|
+
* @returns {{ success: boolean; error?: string; summary?: string } | null}
|
|
29
|
+
*/
|
|
30
|
+
export function tryParseToolResult(
|
|
31
|
+
content: string,
|
|
32
|
+
): { success: boolean; error?: string; summary?: string } | null {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(content) as { success?: unknown; error?: string; summary?: string };
|
|
35
|
+
if (parsed && typeof parsed.success === "boolean") {
|
|
36
|
+
return parsed as { success: boolean; error?: string; summary?: string };
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
/* Not JSON — not a tool result */
|
|
40
|
+
}
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Label for an assistant tool call: the tool name plus the target path when present.
|
|
46
|
+
*
|
|
47
|
+
* @param {{ name: string; arguments: string }} tc
|
|
48
|
+
* @returns {string}
|
|
49
|
+
*/
|
|
50
|
+
export function formatToolLabel(tc: { name: string; arguments: string }): string {
|
|
51
|
+
let detail = "";
|
|
52
|
+
try {
|
|
53
|
+
const args = (tc.arguments ? JSON.parse(tc.arguments) : {}) as {
|
|
54
|
+
path?: unknown;
|
|
55
|
+
parentPath?: unknown;
|
|
56
|
+
};
|
|
57
|
+
if (Array.isArray(args.path)) {
|
|
58
|
+
detail = `: ${JSON.stringify(args.path)}`;
|
|
59
|
+
} else if (Array.isArray(args.parentPath)) {
|
|
60
|
+
detail = `: ${JSON.stringify(args.parentPath)}`;
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
/* Partial/unparsed args — show name only */
|
|
64
|
+
}
|
|
65
|
+
return `${tc.name}${detail}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Return actionable advice for common AI assistant errors so the user knows how to recover instead
|
|
70
|
+
* of just seeing a raw error message.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} error
|
|
73
|
+
* @returns {string}
|
|
74
|
+
*/
|
|
75
|
+
export function formatErrorAdvice(error: string): string {
|
|
76
|
+
const lower = error.toLowerCase();
|
|
77
|
+
if (lower.includes("no api key") || lower.includes("401")) {
|
|
78
|
+
return "Use the settings button below the message box to add an OpenAI-compatible API key.";
|
|
79
|
+
}
|
|
80
|
+
if (lower.includes("network error") || lower.includes("fetch")) {
|
|
81
|
+
return "Check that the dev server is running and reachable.";
|
|
82
|
+
}
|
|
83
|
+
if (lower.includes("429") || lower.includes("rate limit")) {
|
|
84
|
+
return "The API rate limit was hit. Wait a moment and try again.";
|
|
85
|
+
}
|
|
86
|
+
if (lower.includes("500") || lower.includes("internal")) {
|
|
87
|
+
return "The upstream API returned a server error. Try again in a moment.";
|
|
88
|
+
}
|
|
89
|
+
return "";
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Header ─────────────────────────────────────────────────────────────────
|
|
93
|
+
|
|
94
|
+
export interface ChatHeaderOptions {
|
|
95
|
+
/** The open session's title, or null for a fresh unsaved chat. */
|
|
96
|
+
title: string | null;
|
|
97
|
+
streaming: boolean;
|
|
98
|
+
onShowSessions: () => void;
|
|
99
|
+
onNewChat: () => void;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** The chat header: history button, session title, streaming spinner, New Chat. */
|
|
103
|
+
export function renderChatHeader(opts: ChatHeaderOptions): TemplateResult {
|
|
104
|
+
return html`
|
|
105
|
+
<div class="ai-chat-header">
|
|
106
|
+
<sp-action-button size="s" quiet title="Chat history" @click=${opts.onShowSessions}>
|
|
107
|
+
<sp-icon-history slot="icon"></sp-icon-history>
|
|
108
|
+
</sp-action-button>
|
|
109
|
+
<span class="ai-chat-title">${opts.title ?? "New chat"}</span>
|
|
110
|
+
<span class="ai-header-spacer"></span>
|
|
111
|
+
${opts.streaming
|
|
112
|
+
? html`<sp-progress-circle size="s" indeterminate></sp-progress-circle>`
|
|
113
|
+
: nothing}
|
|
114
|
+
<sp-action-button size="s" quiet title="New chat" @click=${opts.onNewChat}>
|
|
115
|
+
<sp-icon-add slot="icon"></sp-icon-add>
|
|
116
|
+
</sp-action-button>
|
|
117
|
+
</div>
|
|
118
|
+
`;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ─── Message rows ───────────────────────────────────────────────────────────
|
|
122
|
+
|
|
123
|
+
function renderUserMessage(msg: Message): TemplateResult {
|
|
124
|
+
const { body, contextLines } = splitAttachedContext(msg.content);
|
|
125
|
+
return html`
|
|
126
|
+
<div class="ai-msg-user">
|
|
127
|
+
<div class="ai-msg-user-body">${body}</div>
|
|
128
|
+
${contextLines.length > 0
|
|
129
|
+
? html`
|
|
130
|
+
<div class="ai-msg-context-chips">
|
|
131
|
+
${contextLines.map((line) => html`<span class="ai-context-chip">${line}</span>`)}
|
|
132
|
+
</div>
|
|
133
|
+
`
|
|
134
|
+
: nothing}
|
|
135
|
+
</div>
|
|
136
|
+
`;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function renderToolChips(toolCalls: ToolCallRecord[]): TemplateResult | typeof nothing {
|
|
140
|
+
if (toolCalls.length === 0) {
|
|
141
|
+
return nothing;
|
|
142
|
+
}
|
|
143
|
+
return html`
|
|
144
|
+
<div class="ai-msg-tools">
|
|
145
|
+
${toolCalls.map(
|
|
146
|
+
(tc) => html`
|
|
147
|
+
<span class="ai-tool-chip">
|
|
148
|
+
<sp-icon-gears size="xs"></sp-icon-gears>
|
|
149
|
+
${formatToolLabel(tc)}
|
|
150
|
+
</span>
|
|
151
|
+
`,
|
|
152
|
+
)}
|
|
153
|
+
</div>
|
|
154
|
+
`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function renderAssistantMessage(msg: Message): TemplateResult | typeof nothing {
|
|
158
|
+
const toolCalls = msg.toolCalls ?? [];
|
|
159
|
+
if (!msg.content && toolCalls.length === 0) {
|
|
160
|
+
return nothing;
|
|
161
|
+
}
|
|
162
|
+
return html`
|
|
163
|
+
<div class="ai-msg-assistant">
|
|
164
|
+
${msg.content ? renderMarkdown(msg.id, msg.content) : nothing} ${renderToolChips(toolCalls)}
|
|
165
|
+
</div>
|
|
166
|
+
`;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderToolMessage(msg: Message): TemplateResult | typeof nothing {
|
|
170
|
+
// Show only failed tool results so the user knows why an edit didn't land
|
|
171
|
+
// (ADR §11.3). Successful tool results stay hidden to reduce noise.
|
|
172
|
+
const parsed = tryParseToolResult(msg.content);
|
|
173
|
+
if (!parsed || parsed.success) {
|
|
174
|
+
return nothing;
|
|
175
|
+
}
|
|
176
|
+
return html`<div class="ai-msg-tool-error">⚠️ ${parsed.error || "Tool call failed"}</div>`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function renderStreamingTail(msg: Message): TemplateResult {
|
|
180
|
+
if (!msg.content) {
|
|
181
|
+
return html`
|
|
182
|
+
<div class="ai-msg-typing">
|
|
183
|
+
<span></span>
|
|
184
|
+
<span></span>
|
|
185
|
+
<span></span>
|
|
186
|
+
</div>
|
|
187
|
+
`;
|
|
188
|
+
}
|
|
189
|
+
return html`
|
|
190
|
+
<div class="ai-msg-assistant">
|
|
191
|
+
<span class="ai-msg-streaming">${msg.content}</span>
|
|
192
|
+
${renderToolChips(msg.toolCalls ?? [])}
|
|
193
|
+
</div>
|
|
194
|
+
`;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ─── Message list ───────────────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
export interface MessageListOptions {
|
|
200
|
+
messages: Message[];
|
|
201
|
+
/** ChatState status — "streaming" renders the tail live. */
|
|
202
|
+
status: string;
|
|
203
|
+
error: string | null;
|
|
204
|
+
onScroll: (e: Event) => void;
|
|
205
|
+
/** Ref to the scrolling element, for stick-to-bottom maintenance. */
|
|
206
|
+
listRef: (el: Element | undefined) => void;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** The scrollable message list — THE scroller of the chat view. */
|
|
210
|
+
export function renderMessageList(opts: MessageListOptions): TemplateResult {
|
|
211
|
+
const { messages, status } = opts;
|
|
212
|
+
const lastIdx = messages.length - 1;
|
|
213
|
+
return html`
|
|
214
|
+
<div class="ai-chat-messages" ${ref(opts.listRef)} @scroll=${opts.onScroll}>
|
|
215
|
+
${messages.length === 0 && status !== "streaming"
|
|
216
|
+
? html`
|
|
217
|
+
<div class="ai-chat-empty">
|
|
218
|
+
Ask the assistant to build or edit this page — it can add sections, restyle elements,
|
|
219
|
+
and wire up components.
|
|
220
|
+
</div>
|
|
221
|
+
`
|
|
222
|
+
: nothing}
|
|
223
|
+
${messages.map((msg, i) => {
|
|
224
|
+
if (msg.role === "user") {
|
|
225
|
+
return renderUserMessage(msg);
|
|
226
|
+
}
|
|
227
|
+
if (msg.role === "tool") {
|
|
228
|
+
return renderToolMessage(msg);
|
|
229
|
+
}
|
|
230
|
+
if (msg.role === "assistant") {
|
|
231
|
+
if (status === "streaming" && i === lastIdx) {
|
|
232
|
+
return renderStreamingTail(msg);
|
|
233
|
+
}
|
|
234
|
+
return renderAssistantMessage(msg);
|
|
235
|
+
}
|
|
236
|
+
return nothing;
|
|
237
|
+
})}
|
|
238
|
+
${status !== "streaming" && opts.error
|
|
239
|
+
? html`
|
|
240
|
+
<div class="ai-msg-error">
|
|
241
|
+
<div>${opts.error}</div>
|
|
242
|
+
${formatErrorAdvice(opts.error)
|
|
243
|
+
? html`<div class="ai-msg-error-advice">${formatErrorAdvice(opts.error)}</div>`
|
|
244
|
+
: nothing}
|
|
245
|
+
</div>
|
|
246
|
+
`
|
|
247
|
+
: nothing}
|
|
248
|
+
</div>
|
|
249
|
+
`;
|
|
250
|
+
}
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Composer.js — the sticky bottom chat input (VSCode-Copilot style).
|
|
4
|
+
*
|
|
5
|
+
* Auto-growing textarea (Enter sends, Shift+Enter newline) above a control row with a
|
|
6
|
+
* context-attach menu, model picker, settings button, and a Send button that morphs
|
|
7
|
+
* into Stop while streaming. Closure factory (precedent: createAiCredentialsForm) so
|
|
8
|
+
* draft state never leaks between hosts.
|
|
9
|
+
*
|
|
10
|
+
* The textarea is intentionally uncontrolled (no .value binding): lit re-renders reuse
|
|
11
|
+
* the DOM node, so streaming updates never clobber the draft, focus, or caret — the
|
|
12
|
+
* reason this panel can re-render without the panel-scheduler's focus guard.
|
|
13
|
+
*
|
|
14
|
+
* @license MIT
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { html, nothing } from "lit-html";
|
|
18
|
+
import type { TemplateResult } from "lit-html";
|
|
19
|
+
import { live } from "lit-html/directives/live.js";
|
|
20
|
+
import { ref } from "lit-html/directives/ref.js";
|
|
21
|
+
import { getNodeAtPath } from "../../state";
|
|
22
|
+
import { fetchAvailableModels } from "../../services/ai-models";
|
|
23
|
+
import { getModel, setModel } from "../../services/ai-settings";
|
|
24
|
+
import { activeTab } from "../../workspace/workspace";
|
|
25
|
+
import { buildMessageWithContext } from "./attached-context";
|
|
26
|
+
import type { ContextChip } from "./attached-context";
|
|
27
|
+
import type { AiModel } from "../../services/ai-models";
|
|
28
|
+
import type { JxMutableNode, JxPath } from "@jxsuite/schema/types";
|
|
29
|
+
|
|
30
|
+
/** Tallest the textarea auto-grows before it scrolls internally. */
|
|
31
|
+
const MAX_INPUT_HEIGHT = 120;
|
|
32
|
+
const RETRY_MODELS = "__retry_models__";
|
|
33
|
+
|
|
34
|
+
export interface ComposerOptions {
|
|
35
|
+
/** Receives the full message content (typed text + serialized context). */
|
|
36
|
+
onSend: (text: string) => void;
|
|
37
|
+
onStop: () => void;
|
|
38
|
+
/** Opens the credentials form. */
|
|
39
|
+
onOpenSettings: () => void;
|
|
40
|
+
isStreaming: () => boolean;
|
|
41
|
+
/** Host re-render scheduler — called whenever composer state changes. */
|
|
42
|
+
requestRender: () => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Composer {
|
|
46
|
+
render: () => TemplateResult;
|
|
47
|
+
focus: () => void;
|
|
48
|
+
clear: () => void;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Create a composer instance bound to a host's render scheduler.
|
|
53
|
+
*
|
|
54
|
+
* @param {ComposerOptions} opts
|
|
55
|
+
* @returns {Composer}
|
|
56
|
+
*/
|
|
57
|
+
export function createComposer(opts: ComposerOptions): Composer {
|
|
58
|
+
let textareaEl: HTMLTextAreaElement | null = null;
|
|
59
|
+
let hasText = false;
|
|
60
|
+
let chips: ContextChip[] = [];
|
|
61
|
+
|
|
62
|
+
/** Null until the first fetch settles; kept on error so the picker can offer Retry. */
|
|
63
|
+
let models: AiModel[] | null = null;
|
|
64
|
+
let modelsLoading = false;
|
|
65
|
+
let modelsError = "";
|
|
66
|
+
|
|
67
|
+
// ── Model picker ──────────────────────────────────────────────────────
|
|
68
|
+
|
|
69
|
+
function ensureModels(force = false) {
|
|
70
|
+
if (modelsLoading || (models !== null && !force)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
modelsLoading = true;
|
|
74
|
+
modelsError = "";
|
|
75
|
+
fetchAvailableModels({ force })
|
|
76
|
+
.then((list) => {
|
|
77
|
+
models = list;
|
|
78
|
+
})
|
|
79
|
+
.catch((error: unknown) => {
|
|
80
|
+
models = [];
|
|
81
|
+
modelsError = (error as Error).message || "Failed to fetch models";
|
|
82
|
+
})
|
|
83
|
+
.finally(() => {
|
|
84
|
+
modelsLoading = false;
|
|
85
|
+
opts.requestRender();
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function onModelChange(e: Event) {
|
|
90
|
+
const { value } = e.target as HTMLInputElement;
|
|
91
|
+
if (value === RETRY_MODELS) {
|
|
92
|
+
models = null;
|
|
93
|
+
ensureModels(true);
|
|
94
|
+
// Re-render so the picker snaps back to the stored model instead of "Retry".
|
|
95
|
+
opts.requestRender();
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (value) {
|
|
99
|
+
setModel(value);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderModelPicker(): TemplateResult {
|
|
104
|
+
ensureModels();
|
|
105
|
+
const current = getModel();
|
|
106
|
+
const listed = models ?? [];
|
|
107
|
+
const items = listed.some((m) => m.id === current)
|
|
108
|
+
? listed
|
|
109
|
+
: [{ id: current, name: current }, ...listed];
|
|
110
|
+
return html`
|
|
111
|
+
<sp-picker
|
|
112
|
+
size="s"
|
|
113
|
+
quiet
|
|
114
|
+
class="ai-model-picker"
|
|
115
|
+
title=${modelsError ? `Couldn't load models: ${modelsError}` : "Model"}
|
|
116
|
+
.value=${live(current)}
|
|
117
|
+
@change=${onModelChange}
|
|
118
|
+
>
|
|
119
|
+
${items.map((m) => html`<sp-menu-item value=${m.id}>${m.name}</sp-menu-item>`)}
|
|
120
|
+
${modelsLoading
|
|
121
|
+
? html`<sp-menu-item disabled value="__loading__">Loading models…</sp-menu-item>`
|
|
122
|
+
: nothing}
|
|
123
|
+
${modelsError
|
|
124
|
+
? html`<sp-menu-item value=${RETRY_MODELS}>Retry loading models</sp-menu-item>`
|
|
125
|
+
: nothing}
|
|
126
|
+
</sp-picker>
|
|
127
|
+
`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// ── Context attach ────────────────────────────────────────────────────
|
|
131
|
+
|
|
132
|
+
/** Snapshot the current page / selected element into chip candidates. */
|
|
133
|
+
function contextCandidates() {
|
|
134
|
+
const tab = activeTab.value;
|
|
135
|
+
const documentPath = tab?.documentPath || null;
|
|
136
|
+
const selection = (tab?.session.selection as JxPath | null) || null;
|
|
137
|
+
let selectionChip: ContextChip | null = null;
|
|
138
|
+
if (tab && selection) {
|
|
139
|
+
const node = getNodeAtPath(tab.doc.document as JxMutableNode, selection) as
|
|
140
|
+
| (JxMutableNode & { tagName?: string; textContent?: string })
|
|
141
|
+
| undefined;
|
|
142
|
+
const tag = node?.tagName || "element";
|
|
143
|
+
const text = typeof node?.textContent === "string" ? node.textContent.slice(0, 40) : "";
|
|
144
|
+
selectionChip = {
|
|
145
|
+
detail: `Selected element at ${JSON.stringify(selection)}: <${tag}>${text ? ` "${text}"` : ""}`,
|
|
146
|
+
kind: "selection",
|
|
147
|
+
label: `<${tag}>`,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
pageChip: documentPath
|
|
152
|
+
? ({ detail: `Page: ${documentPath}`, kind: "page", label: documentPath } as ContextChip)
|
|
153
|
+
: null,
|
|
154
|
+
selectionChip,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Add (or refresh) a chip of the given kind — one chip per kind. */
|
|
159
|
+
function addChip(chip: ContextChip) {
|
|
160
|
+
chips = [...chips.filter((c) => c.kind !== chip.kind), chip];
|
|
161
|
+
opts.requestRender();
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function removeChip(kind: ContextChip["kind"]) {
|
|
165
|
+
chips = chips.filter((c) => c.kind !== kind);
|
|
166
|
+
opts.requestRender();
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function renderAttachMenu(): TemplateResult {
|
|
170
|
+
const { pageChip, selectionChip } = contextCandidates();
|
|
171
|
+
return html`
|
|
172
|
+
<overlay-trigger placement="top-start" triggered-by="click">
|
|
173
|
+
<sp-action-button size="s" quiet slot="trigger" title="Attach context">
|
|
174
|
+
<sp-icon-attach slot="icon"></sp-icon-attach>
|
|
175
|
+
</sp-action-button>
|
|
176
|
+
<sp-popover slot="click-content" tip>
|
|
177
|
+
<sp-menu
|
|
178
|
+
@change=${(e: Event) => {
|
|
179
|
+
const { value } = e.target as unknown as HTMLInputElement;
|
|
180
|
+
if (value === "page" && pageChip) {
|
|
181
|
+
addChip(pageChip);
|
|
182
|
+
} else if (value === "selection" && selectionChip) {
|
|
183
|
+
addChip(selectionChip);
|
|
184
|
+
}
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
<sp-menu-item value="page" ?disabled=${!pageChip}>
|
|
188
|
+
Current page${pageChip ? ` — ${pageChip.label}` : ""}
|
|
189
|
+
</sp-menu-item>
|
|
190
|
+
<sp-menu-item value="selection" ?disabled=${!selectionChip}>
|
|
191
|
+
Selected element${selectionChip ? ` — ${selectionChip.label}` : ""}
|
|
192
|
+
</sp-menu-item>
|
|
193
|
+
</sp-menu>
|
|
194
|
+
</sp-popover>
|
|
195
|
+
</overlay-trigger>
|
|
196
|
+
`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function renderChips(): TemplateResult | typeof nothing {
|
|
200
|
+
if (chips.length === 0) {
|
|
201
|
+
return nothing;
|
|
202
|
+
}
|
|
203
|
+
return html`
|
|
204
|
+
<div class="ai-composer-chips">
|
|
205
|
+
${chips.map(
|
|
206
|
+
(c) => html`
|
|
207
|
+
<span class="ai-context-chip" title=${c.detail}>
|
|
208
|
+
${c.label}
|
|
209
|
+
<sp-action-button quiet size="s" title="Remove" @click=${() => removeChip(c.kind)}>
|
|
210
|
+
<sp-icon-close slot="icon"></sp-icon-close>
|
|
211
|
+
</sp-action-button>
|
|
212
|
+
</span>
|
|
213
|
+
`,
|
|
214
|
+
)}
|
|
215
|
+
</div>
|
|
216
|
+
`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Input ─────────────────────────────────────────────────────────────
|
|
220
|
+
|
|
221
|
+
function autoGrow() {
|
|
222
|
+
if (!textareaEl) {
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
textareaEl.style.height = "auto";
|
|
226
|
+
textareaEl.style.height = `${Math.min(textareaEl.scrollHeight, MAX_INPUT_HEIGHT)}px`;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function onInput() {
|
|
230
|
+
autoGrow();
|
|
231
|
+
const nowHasText = Boolean(textareaEl?.value.trim());
|
|
232
|
+
if (nowHasText !== hasText) {
|
|
233
|
+
hasText = nowHasText;
|
|
234
|
+
// Only re-render on the empty↔non-empty flip so typing stays cheap.
|
|
235
|
+
opts.requestRender();
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function onKeydown(e: KeyboardEvent) {
|
|
240
|
+
if (e.key === "Enter" && !e.shiftKey && !e.isComposing) {
|
|
241
|
+
e.preventDefault();
|
|
242
|
+
trySend();
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function trySend() {
|
|
247
|
+
const text = textareaEl?.value ?? "";
|
|
248
|
+
if (!text.trim() || opts.isStreaming()) {
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
opts.onSend(buildMessageWithContext(text, chips));
|
|
252
|
+
clear();
|
|
253
|
+
opts.requestRender();
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function clear() {
|
|
257
|
+
if (textareaEl) {
|
|
258
|
+
textareaEl.value = "";
|
|
259
|
+
textareaEl.style.height = "auto";
|
|
260
|
+
}
|
|
261
|
+
hasText = false;
|
|
262
|
+
chips = [];
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function focus() {
|
|
266
|
+
textareaEl?.focus();
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// ── Template ──────────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
function render(): TemplateResult {
|
|
272
|
+
const streaming = opts.isStreaming();
|
|
273
|
+
return html`
|
|
274
|
+
<div class="ai-composer">
|
|
275
|
+
${renderChips()}
|
|
276
|
+
<textarea
|
|
277
|
+
class="ai-composer-input"
|
|
278
|
+
rows="1"
|
|
279
|
+
placeholder="Ask the assistant… (Enter to send)"
|
|
280
|
+
${ref((el) => {
|
|
281
|
+
textareaEl = (el as HTMLTextAreaElement | null) || null;
|
|
282
|
+
})}
|
|
283
|
+
@input=${onInput}
|
|
284
|
+
@keydown=${onKeydown}
|
|
285
|
+
></textarea>
|
|
286
|
+
<div class="ai-composer-row">
|
|
287
|
+
${renderAttachMenu()} ${renderModelPicker()}
|
|
288
|
+
<span class="ai-header-spacer"></span>
|
|
289
|
+
<sp-action-button size="s" quiet title="API key & endpoint" @click=${opts.onOpenSettings}>
|
|
290
|
+
<sp-icon-settings slot="icon"></sp-icon-settings>
|
|
291
|
+
</sp-action-button>
|
|
292
|
+
${streaming
|
|
293
|
+
? html`
|
|
294
|
+
<sp-action-button size="s" class="ai-send-btn" title="Stop" @click=${opts.onStop}>
|
|
295
|
+
<sp-icon-stop slot="icon"></sp-icon-stop>
|
|
296
|
+
</sp-action-button>
|
|
297
|
+
`
|
|
298
|
+
: html`
|
|
299
|
+
<sp-action-button
|
|
300
|
+
size="s"
|
|
301
|
+
class="ai-send-btn"
|
|
302
|
+
title="Send"
|
|
303
|
+
?disabled=${!hasText}
|
|
304
|
+
@click=${trySend}
|
|
305
|
+
>
|
|
306
|
+
<sp-icon-send slot="icon"></sp-icon-send>
|
|
307
|
+
</sp-action-button>
|
|
308
|
+
`}
|
|
309
|
+
</div>
|
|
310
|
+
</div>
|
|
311
|
+
`;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
return { clear, focus, render };
|
|
315
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
/**
|
|
3
|
+
* Sessions-view.js — the full-pane chat history list (VSCode-Copilot style).
|
|
4
|
+
*
|
|
5
|
+
* Renders the session rows (title, relative time, message count) with per-row delete,
|
|
6
|
+
* a header with a New Chat action, and an empty state. Pure template functions — all
|
|
7
|
+
* state and storage live in ai-panel / document-assistant.
|
|
8
|
+
*
|
|
9
|
+
* @license MIT
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { html } from "lit-html";
|
|
13
|
+
import type { TemplateResult } from "lit-html";
|
|
14
|
+
import type { SessionMeta } from "../../services/ai-session-store";
|
|
15
|
+
|
|
16
|
+
const MINUTE = 60_000;
|
|
17
|
+
const HOUR = 60 * MINUTE;
|
|
18
|
+
const DAY = 24 * HOUR;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Human relative timestamp: "just now", "5m ago", "3h ago", "yesterday", "4d ago", then a locale
|
|
22
|
+
* date.
|
|
23
|
+
*
|
|
24
|
+
* @param {number} ts
|
|
25
|
+
* @param {number} [now]
|
|
26
|
+
* @returns {string}
|
|
27
|
+
*/
|
|
28
|
+
export function relativeTime(ts: number, now: number = Date.now()): string {
|
|
29
|
+
const delta = Math.max(0, now - ts);
|
|
30
|
+
if (delta < MINUTE) {
|
|
31
|
+
return "just now";
|
|
32
|
+
}
|
|
33
|
+
if (delta < HOUR) {
|
|
34
|
+
return `${Math.floor(delta / MINUTE)}m ago`;
|
|
35
|
+
}
|
|
36
|
+
if (delta < DAY) {
|
|
37
|
+
return `${Math.floor(delta / HOUR)}h ago`;
|
|
38
|
+
}
|
|
39
|
+
if (delta < 2 * DAY) {
|
|
40
|
+
return "yesterday";
|
|
41
|
+
}
|
|
42
|
+
if (delta < 7 * DAY) {
|
|
43
|
+
return `${Math.floor(delta / DAY)}d ago`;
|
|
44
|
+
}
|
|
45
|
+
return new Date(ts).toLocaleDateString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface SessionsListOptions {
|
|
49
|
+
sessions: SessionMeta[];
|
|
50
|
+
onOpen: (id: string) => void;
|
|
51
|
+
onDelete: (id: string) => void;
|
|
52
|
+
onNew: () => void;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function renderRow(s: SessionMeta, opts: SessionsListOptions): TemplateResult {
|
|
56
|
+
return html`
|
|
57
|
+
<div class="ai-session-row" @click=${() => opts.onOpen(s.id)}>
|
|
58
|
+
<div class="ai-session-text">
|
|
59
|
+
<div class="ai-session-title">${s.title}</div>
|
|
60
|
+
<div class="ai-session-meta">
|
|
61
|
+
${relativeTime(s.updatedAt)} · ${s.messageCount}
|
|
62
|
+
${s.messageCount === 1 ? "message" : "messages"}
|
|
63
|
+
</div>
|
|
64
|
+
</div>
|
|
65
|
+
<sp-action-button
|
|
66
|
+
quiet
|
|
67
|
+
size="s"
|
|
68
|
+
class="ai-session-delete"
|
|
69
|
+
title="Delete chat"
|
|
70
|
+
@click=${(e: Event) => {
|
|
71
|
+
e.stopPropagation();
|
|
72
|
+
opts.onDelete(s.id);
|
|
73
|
+
}}
|
|
74
|
+
>
|
|
75
|
+
<sp-icon-delete slot="icon"></sp-icon-delete>
|
|
76
|
+
</sp-action-button>
|
|
77
|
+
</div>
|
|
78
|
+
`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** The sessions pane: header ("Chats" + New Chat) above the scrollable session rows. */
|
|
82
|
+
export function renderSessionsList(opts: SessionsListOptions): TemplateResult {
|
|
83
|
+
return html`
|
|
84
|
+
<div class="ai-chat-header">
|
|
85
|
+
<span class="ai-chat-title">Chats</span>
|
|
86
|
+
<span class="ai-header-spacer"></span>
|
|
87
|
+
<sp-action-button size="s" quiet title="New chat" @click=${opts.onNew}>
|
|
88
|
+
<sp-icon-add slot="icon"></sp-icon-add>
|
|
89
|
+
New Chat
|
|
90
|
+
</sp-action-button>
|
|
91
|
+
</div>
|
|
92
|
+
<div class="ai-sessions">
|
|
93
|
+
${opts.sessions.length === 0
|
|
94
|
+
? html`<div class="ai-sessions-empty">No previous chats</div>`
|
|
95
|
+
: opts.sessions.map((s) => renderRow(s, opts))}
|
|
96
|
+
</div>
|
|
97
|
+
`;
|
|
98
|
+
}
|