@grackle-ai/web-components 0.111.0 → 0.112.1
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/.rush/temp/{406cafe37bd01cafa4c4b9e60e3abee9180ce991.untar.log → 30718c57404891eb5c6c96c935c4a6879170c963.untar.log} +2 -2
- package/.rush/temp/{9432bf197de04657ccc35f53841e118300f161e9.untar.log → 6ab1fbb7acd3281d7612ffe24dcc9982dd8c9061.untar.log} +2 -2
- package/.rush/temp/operation/_phase_build/all.log +6 -6
- package/.rush/temp/operation/_phase_build/log-chunks.jsonl +6 -6
- package/.rush/temp/operation/_phase_build/state.json +1 -1
- package/.rush/temp/operation/_phase_test/all.log +24 -23
- package/.rush/temp/operation/_phase_test/log-chunks.jsonl +24 -23
- package/.rush/temp/operation/_phase_test/state.json +1 -1
- package/dist/index.css +1 -1
- package/dist/index.js +6646 -6593
- package/package.json +2 -2
- package/rush-logs/web-components._phase_build.cache.log +1 -1
- package/rush-logs/web-components._phase_test.cache.log +1 -1
- package/src/components/chat/ChatInput.module.scss +10 -2
- package/src/components/chat/ChatInput.stories.tsx +35 -0
- package/src/components/chat/ChatInput.tsx +88 -6
- package/src/components/display/EventRenderer.module.scss +18 -85
- package/src/components/display/EventRenderer.stories.tsx +16 -0
- package/src/components/display/EventRenderer.tsx +19 -5
- package/src/components/display/EventStream.stories.tsx +3 -1
- package/src/components/display/SplashScreen.tsx +2 -1
- package/src/components/layout/AppNav.stories.tsx +5 -5
- package/src/components/layout/AppNav.tsx +23 -14
- package/src/components/layout/StatusBar.tsx +2 -1
- package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +2 -2
- package/src/components/panels/KeyboardShortcutsPanel.tsx +3 -2
- package/src/index.ts +2 -0
- package/src/mocks/mockData.ts +12 -0
- package/src/styles/mixins.scss +93 -0
- package/src/utils/assetUrl.test.ts +23 -0
- package/src/utils/assetUrl.ts +33 -0
- package/.rush/temp/406cafe37bd01cafa4c4b9e60e3abee9180ce991.tar.log +0 -235
- package/.rush/temp/9432bf197de04657ccc35f53841e118300f161e9.tar.log +0 -12
- package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +0 -18
- package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +0 -124
- package/rush-logs/web-components._phase_build.log +0 -18
- package/rush-logs/web-components._phase_test.log +0 -124
- package/temp/build/lint/_eslint-5eVG3S6w.json +0 -830
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@grackle-ai/web-components",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.112.1",
|
|
4
4
|
"description": "Presentational React component library for the Grackle web UI",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"sideEffects": [
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"remark-gfm": "^4.0.0",
|
|
47
47
|
"lucide-react": "~0.474.0",
|
|
48
48
|
"react-router": "^7.0.0",
|
|
49
|
-
"@grackle-ai/common": "0.
|
|
49
|
+
"@grackle-ai/common": "0.112.1"
|
|
50
50
|
},
|
|
51
51
|
"devDependencies": {
|
|
52
52
|
"@rushstack/heft": "1.2.7",
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
.bar {
|
|
8
8
|
@include surface-panel;
|
|
9
9
|
display: flex;
|
|
10
|
-
align-items:
|
|
10
|
+
align-items: flex-end;
|
|
11
11
|
gap: var(--space-sm);
|
|
12
12
|
padding: var(--space-sm) var(--space-md);
|
|
13
13
|
border-top: 1px solid var(--border-subtle);
|
|
@@ -22,9 +22,17 @@
|
|
|
22
22
|
// Form controls
|
|
23
23
|
// ---------------------------------------------------------------------------
|
|
24
24
|
|
|
25
|
-
.
|
|
25
|
+
.textarea {
|
|
26
26
|
@include input-field;
|
|
27
27
|
flex: 1;
|
|
28
|
+
// Auto-resizing multiline composer: starts at one row and grows (JS sets the
|
|
29
|
+
// inline height); max-height must match MAX_COMPOSER_HEIGHT_PX in ChatInput.tsx.
|
|
30
|
+
box-sizing: border-box;
|
|
31
|
+
resize: none;
|
|
32
|
+
max-height: 200px;
|
|
33
|
+
overflow-y: auto;
|
|
34
|
+
line-height: var(--line-height);
|
|
35
|
+
font-family: var(--font-ui);
|
|
28
36
|
|
|
29
37
|
@include mobile {
|
|
30
38
|
min-width: 0;
|
|
@@ -134,3 +134,38 @@ export const SendModeActive: Story = {
|
|
|
134
134
|
await expect(canvas.getByRole("button", { name: "Send" })).toBeDisabled();
|
|
135
135
|
},
|
|
136
136
|
};
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The composer is multiline: a plain Enter inserts a newline and does NOT submit.
|
|
140
|
+
*/
|
|
141
|
+
export const ComposerEnterInsertsNewline: Story = {
|
|
142
|
+
args: {
|
|
143
|
+
mode: "send",
|
|
144
|
+
sessionId: "sess-1",
|
|
145
|
+
environmentId: "local",
|
|
146
|
+
},
|
|
147
|
+
play: async ({ canvas, args }) => {
|
|
148
|
+
const input = canvas.getByPlaceholderText("Type a message...");
|
|
149
|
+
await userEvent.type(input, "line one{Enter}line two");
|
|
150
|
+
await expect(input).toHaveValue("line one\nline two");
|
|
151
|
+
await expect(args.onSendInput).not.toHaveBeenCalled();
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Ctrl/Cmd+Enter submits the composer (mirrors clicking Send) and clears the box.
|
|
157
|
+
*/
|
|
158
|
+
export const ComposerCtrlEnterSubmits: Story = {
|
|
159
|
+
args: {
|
|
160
|
+
mode: "send",
|
|
161
|
+
sessionId: "sess-1",
|
|
162
|
+
environmentId: "local",
|
|
163
|
+
},
|
|
164
|
+
play: async ({ canvas, args }) => {
|
|
165
|
+
const input = canvas.getByPlaceholderText("Type a message...");
|
|
166
|
+
await userEvent.type(input, "hello there");
|
|
167
|
+
await userEvent.keyboard("{Control>}{Enter}{/Control}");
|
|
168
|
+
await expect(args.onSendInput).toHaveBeenCalledWith("sess-1", "hello there");
|
|
169
|
+
await expect(input).toHaveValue("");
|
|
170
|
+
},
|
|
171
|
+
};
|
|
@@ -1,8 +1,14 @@
|
|
|
1
|
-
import { useState, type FormEvent, type JSX } from "react";
|
|
1
|
+
import { useState, useLayoutEffect, useRef, type FormEvent, type KeyboardEvent, type JSX } from "react";
|
|
2
2
|
import type { ToastVariant } from "../../context/ToastContext.js";
|
|
3
3
|
import type { Environment, PersonaData } from "../../hooks/types.js";
|
|
4
4
|
import styles from "./ChatInput.module.scss";
|
|
5
5
|
|
|
6
|
+
/**
|
|
7
|
+
* Maximum height (px) the composer grows to before scrolling internally.
|
|
8
|
+
* Must stay in sync with `max-height` on `.textarea` in ChatInput.module.scss.
|
|
9
|
+
*/
|
|
10
|
+
const MAX_COMPOSER_HEIGHT_PX: number = 200;
|
|
11
|
+
|
|
6
12
|
// --- Helpers ---
|
|
7
13
|
|
|
8
14
|
/** Returns true when the environment with the given ID is disconnected or in error. */
|
|
@@ -41,6 +47,77 @@ function DisconnectedBanner({ environmentId, onReconnect }: DisconnectedBannerPr
|
|
|
41
47
|
);
|
|
42
48
|
}
|
|
43
49
|
|
|
50
|
+
/** Props for the auto-resizing chat composer textarea. */
|
|
51
|
+
interface ComposerTextAreaProps {
|
|
52
|
+
/** Current text value. */
|
|
53
|
+
value: string;
|
|
54
|
+
/** Called on every keystroke with the new value. */
|
|
55
|
+
onChange: (value: string) => void;
|
|
56
|
+
/** Called when the user submits via Ctrl/Cmd+Enter. */
|
|
57
|
+
onSubmit: () => void;
|
|
58
|
+
/** Placeholder text shown when empty. */
|
|
59
|
+
placeholder: string;
|
|
60
|
+
/** Whether the textarea is disabled. */
|
|
61
|
+
disabled?: boolean;
|
|
62
|
+
/** Whether to auto-focus the textarea on mount. */
|
|
63
|
+
autoFocus?: boolean;
|
|
64
|
+
/** Accessible label for the textarea. */
|
|
65
|
+
ariaLabel: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Auto-resizing multiline chat composer.
|
|
70
|
+
*
|
|
71
|
+
* Enter inserts a newline; Ctrl/Cmd+Enter submits (the Send button submits too).
|
|
72
|
+
* The textarea grows with its content up to {@link MAX_COMPOSER_HEIGHT_PX}, then
|
|
73
|
+
* scrolls internally.
|
|
74
|
+
*/
|
|
75
|
+
function ComposerTextArea({
|
|
76
|
+
value,
|
|
77
|
+
onChange,
|
|
78
|
+
onSubmit,
|
|
79
|
+
placeholder,
|
|
80
|
+
disabled,
|
|
81
|
+
autoFocus,
|
|
82
|
+
ariaLabel,
|
|
83
|
+
}: ComposerTextAreaProps): JSX.Element {
|
|
84
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
85
|
+
|
|
86
|
+
// Auto-resize: collapse to measure natural height, then grow to fit (capped).
|
|
87
|
+
useLayoutEffect(() => {
|
|
88
|
+
const el = ref.current;
|
|
89
|
+
if (!el) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
el.style.height = "auto";
|
|
93
|
+
el.style.height = `${Math.min(el.scrollHeight, MAX_COMPOSER_HEIGHT_PX)}px`;
|
|
94
|
+
}, [value]);
|
|
95
|
+
|
|
96
|
+
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>): void => {
|
|
97
|
+
// Ctrl/Cmd+Enter submits; plain Enter falls through to insert a newline.
|
|
98
|
+
// The isComposing guard avoids submitting mid-IME composition (e.g. CJK input).
|
|
99
|
+
if (e.key === "Enter" && (e.metaKey || e.ctrlKey) && !e.nativeEvent.isComposing) {
|
|
100
|
+
e.preventDefault();
|
|
101
|
+
onSubmit();
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
return (
|
|
106
|
+
<textarea
|
|
107
|
+
ref={ref}
|
|
108
|
+
rows={1}
|
|
109
|
+
value={value}
|
|
110
|
+
onChange={(e) => onChange(e.target.value)}
|
|
111
|
+
onKeyDown={handleKeyDown}
|
|
112
|
+
placeholder={placeholder}
|
|
113
|
+
disabled={disabled}
|
|
114
|
+
autoFocus={autoFocus}
|
|
115
|
+
className={styles.textarea}
|
|
116
|
+
aria-label={ariaLabel}
|
|
117
|
+
/>
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
44
121
|
// --- Main component ---
|
|
45
122
|
|
|
46
123
|
/** Chat input mode determines the action performed on submit. */
|
|
@@ -92,8 +169,8 @@ export function ChatInput({
|
|
|
92
169
|
|
|
93
170
|
const envDisconnected = isEnvDisconnected(environmentId, environments);
|
|
94
171
|
|
|
95
|
-
|
|
96
|
-
|
|
172
|
+
/** Performs the mode-specific submit action. Called by the form and Ctrl/Cmd+Enter. */
|
|
173
|
+
const submit = (): void => {
|
|
97
174
|
if (!text.trim()) {
|
|
98
175
|
return;
|
|
99
176
|
}
|
|
@@ -122,6 +199,11 @@ export function ChatInput({
|
|
|
122
199
|
}
|
|
123
200
|
};
|
|
124
201
|
|
|
202
|
+
const handleSubmit = (e: FormEvent): void => {
|
|
203
|
+
e.preventDefault();
|
|
204
|
+
submit();
|
|
205
|
+
};
|
|
206
|
+
|
|
125
207
|
// --- spawn mode ---
|
|
126
208
|
if (mode === "spawn") {
|
|
127
209
|
return (
|
|
@@ -129,7 +211,7 @@ export function ChatInput({
|
|
|
129
211
|
<span className={styles.badge}>
|
|
130
212
|
new chat
|
|
131
213
|
</span>
|
|
132
|
-
<
|
|
214
|
+
<ComposerTextArea value={text} onChange={setText} onSubmit={submit} placeholder="Enter prompt..." autoFocus ariaLabel="Enter prompt" />
|
|
133
215
|
{showPersonaSelect && (
|
|
134
216
|
<select value={spawnPersonaId} onChange={(e) => setSpawnPersonaId(e.target.value)} className={styles.select} aria-label="Select persona">
|
|
135
217
|
<option value="">(Default)</option>
|
|
@@ -147,7 +229,7 @@ export function ChatInput({
|
|
|
147
229
|
if (mode === "start") {
|
|
148
230
|
return (
|
|
149
231
|
<form onSubmit={handleSubmit} className={styles.bar}>
|
|
150
|
-
<
|
|
232
|
+
<ComposerTextArea value={text} onChange={setText} onSubmit={submit} placeholder="Type a message..." autoFocus ariaLabel="Type a message" />
|
|
151
233
|
<button type="submit" disabled={!text.trim()} className={styles.btnPrimary}>Send</button>
|
|
152
234
|
</form>
|
|
153
235
|
);
|
|
@@ -159,7 +241,7 @@ export function ChatInput({
|
|
|
159
241
|
{envDisconnected && environmentId && (
|
|
160
242
|
<DisconnectedBanner environmentId={environmentId} onReconnect={onProvisionEnvironment} />
|
|
161
243
|
)}
|
|
162
|
-
<
|
|
244
|
+
<ComposerTextArea value={text} onChange={setText} onSubmit={submit} placeholder="Type a message..." autoFocus={!envDisconnected} disabled={envDisconnected} ariaLabel="Type a message" />
|
|
163
245
|
<span title={envDisconnected ? "Environment is unavailable — reconnect first" : undefined}>
|
|
164
246
|
<button type="submit" disabled={!text.trim() || envDisconnected} className={styles.btnPrimary}>Send</button>
|
|
165
247
|
</span>
|
|
@@ -81,91 +81,7 @@
|
|
|
81
81
|
padding-left: var(--space-xs);
|
|
82
82
|
line-height: var(--line-height);
|
|
83
83
|
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
h1, h2, h3, h4, h5, h6 {
|
|
87
|
-
margin: var(--space-sm) 0 var(--space-xs);
|
|
88
|
-
font-weight: var(--font-weight-bold);
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
h1 { font-size: 1.4em; }
|
|
92
|
-
h2 { font-size: 1.2em; }
|
|
93
|
-
h3 { font-size: 1.1em; }
|
|
94
|
-
|
|
95
|
-
p {
|
|
96
|
-
margin: var(--space-xs) 0;
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
pre {
|
|
100
|
-
@include surface-inset;
|
|
101
|
-
padding: var(--space-sm);
|
|
102
|
-
margin: var(--space-xs) 0;
|
|
103
|
-
overflow: auto;
|
|
104
|
-
font-size: 11px;
|
|
105
|
-
white-space: pre-wrap;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
code {
|
|
109
|
-
font-family: var(--font-mono);
|
|
110
|
-
font-size: 0.9em;
|
|
111
|
-
background: var(--bg-overlay);
|
|
112
|
-
padding: 1px 4px;
|
|
113
|
-
border-radius: var(--radius-sm);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
pre code {
|
|
117
|
-
background: none;
|
|
118
|
-
padding: 0;
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
table {
|
|
122
|
-
border-collapse: collapse;
|
|
123
|
-
margin: var(--space-xs) 0;
|
|
124
|
-
font-size: var(--font-size-sm);
|
|
125
|
-
width: 100%;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
th, td {
|
|
129
|
-
border: 1px solid var(--border-subtle);
|
|
130
|
-
padding: var(--space-xs) var(--space-sm);
|
|
131
|
-
text-align: left;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
th {
|
|
135
|
-
background: var(--bg-overlay);
|
|
136
|
-
font-weight: var(--font-weight-bold);
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
ul, ol {
|
|
140
|
-
padding-left: var(--space-lg);
|
|
141
|
-
margin: var(--space-xs) 0;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
li {
|
|
145
|
-
margin: 2px 0;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
strong {
|
|
149
|
-
font-weight: var(--font-weight-bold);
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
a {
|
|
153
|
-
color: var(--accent-blue);
|
|
154
|
-
text-decoration: underline;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
hr {
|
|
158
|
-
border: none;
|
|
159
|
-
border-top: 1px solid var(--border-subtle);
|
|
160
|
-
margin: var(--space-sm) 0;
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
blockquote {
|
|
164
|
-
border-left: 3px solid var(--border-subtle);
|
|
165
|
-
padding-left: var(--space-sm);
|
|
166
|
-
margin: var(--space-xs) 0;
|
|
167
|
-
color: var(--text-secondary);
|
|
168
|
-
}
|
|
84
|
+
@include markdown-content;
|
|
169
85
|
}
|
|
170
86
|
|
|
171
87
|
// --- Copy button positioning ---
|
|
@@ -220,6 +136,23 @@
|
|
|
220
136
|
font-size: var(--font-size-sm);
|
|
221
137
|
line-height: var(--line-height);
|
|
222
138
|
|
|
139
|
+
@include markdown-content;
|
|
140
|
+
|
|
141
|
+
// Collapse outer block margins so the bubble hugs its markdown content.
|
|
142
|
+
& > :first-child {
|
|
143
|
+
margin-top: 0;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
& > :last-child {
|
|
147
|
+
margin-bottom: 0;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// Keep wide block content (code, tables) within the bubble bounds.
|
|
151
|
+
pre,
|
|
152
|
+
table {
|
|
153
|
+
max-width: 100%;
|
|
154
|
+
}
|
|
155
|
+
|
|
223
156
|
@include mobile {
|
|
224
157
|
max-width: 90%;
|
|
225
158
|
}
|
|
@@ -170,6 +170,22 @@ export const MarkdownParagraphWrapping: Story = {
|
|
|
170
170
|
},
|
|
171
171
|
};
|
|
172
172
|
|
|
173
|
+
/** User input events render as markdown (bold, lists, inline code) inside the bubble. */
|
|
174
|
+
export const UserMessageMarkdown: Story = {
|
|
175
|
+
args: {
|
|
176
|
+
event: makeEvent({
|
|
177
|
+
eventType: "user_input",
|
|
178
|
+
content: "Please fix **the bug** in:\n\n- `index.ts`\n- `app.ts`",
|
|
179
|
+
}),
|
|
180
|
+
},
|
|
181
|
+
play: async ({ canvas }) => {
|
|
182
|
+
await expect(canvas.getByTestId("user-input-content")).toBeInTheDocument();
|
|
183
|
+
await expect(canvas.getByText("the bug").tagName).toBe("STRONG");
|
|
184
|
+
await expect(canvas.getAllByRole("listitem")).toHaveLength(2);
|
|
185
|
+
await expect(canvas.getByText("index.ts").tagName).toBe("CODE");
|
|
186
|
+
},
|
|
187
|
+
};
|
|
188
|
+
|
|
173
189
|
/** System context event renders as collapsible prompt. */
|
|
174
190
|
export const SystemContext: Story = {
|
|
175
191
|
args: {
|
|
@@ -107,13 +107,25 @@ const markdownComponents: Record<string, typeof CodeBlockWrapper> = {
|
|
|
107
107
|
pre: CodeBlockWrapper,
|
|
108
108
|
};
|
|
109
109
|
|
|
110
|
+
/**
|
|
111
|
+
* Renders a markdown string with GFM support and syntax-highlighted code blocks.
|
|
112
|
+
*
|
|
113
|
+
* Shared by both assistant text events and user input events so the two render
|
|
114
|
+
* through an identical pipeline.
|
|
115
|
+
*/
|
|
116
|
+
function MarkdownContent({ content }: { content: string }): JSX.Element {
|
|
117
|
+
return (
|
|
118
|
+
<Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypePrismPlus]} components={markdownComponents}>
|
|
119
|
+
{content}
|
|
120
|
+
</Markdown>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
110
124
|
/** Renders an assistant text output event with markdown formatting. */
|
|
111
125
|
function TextEvent({ content }: { content: string }): JSX.Element {
|
|
112
126
|
return (
|
|
113
127
|
<div className={styles.textEvent}>
|
|
114
|
-
<
|
|
115
|
-
{content}
|
|
116
|
-
</Markdown>
|
|
128
|
+
<MarkdownContent content={content} />
|
|
117
129
|
</div>
|
|
118
130
|
);
|
|
119
131
|
}
|
|
@@ -140,11 +152,13 @@ function StatusEvent({ content }: { content: string }): JSX.Element {
|
|
|
140
152
|
);
|
|
141
153
|
}
|
|
142
154
|
|
|
143
|
-
/** Renders a user input event, right-aligned to distinguish it from agent output. */
|
|
155
|
+
/** Renders a user input event as markdown, right-aligned to distinguish it from agent output. */
|
|
144
156
|
function UserInputEvent({ content }: { content: string }): JSX.Element {
|
|
145
157
|
return (
|
|
146
158
|
<div className={styles.userInputEvent}>
|
|
147
|
-
<
|
|
159
|
+
<div className={styles.userInputContent} data-testid="user-input-content">
|
|
160
|
+
<MarkdownContent content={content} />
|
|
161
|
+
</div>
|
|
148
162
|
</div>
|
|
149
163
|
);
|
|
150
164
|
}
|
|
@@ -5,6 +5,8 @@ import type { DisplayEvent } from "../../utils/sessionEvents.js";
|
|
|
5
5
|
import { makeEvent, makeSession, makeEnvironment } from "../../test-utils/storybook-helpers.js";
|
|
6
6
|
|
|
7
7
|
const sampleEvents: DisplayEvent[] = [
|
|
8
|
+
// User messages render as markdown, same as agent output.
|
|
9
|
+
makeEvent({ eventType: "user_input", content: "Can you check **`auth.ts`**? The `refreshToken` flow looks broken:\n\n1. token isn't refreshed before expiry\n2. the retry has an off-by-one", timestamp: "2026-01-01T00:00:00Z" }),
|
|
8
10
|
makeEvent({ eventType: "text", content: "First message", timestamp: "2026-01-01T00:00:01Z" }),
|
|
9
11
|
makeEvent({ eventType: "text", content: "Second message", timestamp: "2026-01-01T00:00:02Z" }),
|
|
10
12
|
makeEvent({ eventType: "text", content: "Third message", timestamp: "2026-01-01T00:00:03Z" }),
|
|
@@ -12,7 +14,7 @@ const sampleEvents: DisplayEvent[] = [
|
|
|
12
14
|
|
|
13
15
|
/** A richer set of events including non-content types for selection mode tests. */
|
|
14
16
|
const mixedEvents: DisplayEvent[] = [
|
|
15
|
-
makeEvent({ eventType: "user_input", content: "Fix the bug", timestamp: "2026-01-01T00:00:01Z" }),
|
|
17
|
+
makeEvent({ eventType: "user_input", content: "Fix the **login bug** in `auth.ts`:\n\n- token isn't refreshed\n- expiry check is off-by-one", timestamp: "2026-01-01T00:00:01Z" }),
|
|
16
18
|
makeEvent({ eventType: "text", content: "Looking into it.", timestamp: "2026-01-01T00:00:02Z" }),
|
|
17
19
|
makeEvent({ eventType: "status", content: "running", timestamp: "2026-01-01T00:00:03Z" }),
|
|
18
20
|
makeEvent({ eventType: "text", content: "Found the issue in auth.ts", timestamp: "2026-01-01T00:00:04Z" }),
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { JSX } from "react";
|
|
2
2
|
import { Spinner } from "./Spinner.js";
|
|
3
|
+
import { assetUrl } from "../../utils/assetUrl.js";
|
|
3
4
|
import styles from "./SplashScreen.module.scss";
|
|
4
5
|
|
|
5
6
|
/**
|
|
@@ -9,7 +10,7 @@ import styles from "./SplashScreen.module.scss";
|
|
|
9
10
|
export function SplashScreen(): JSX.Element {
|
|
10
11
|
return (
|
|
11
12
|
<div className={styles.splash} data-testid="splash-screen">
|
|
12
|
-
<img src="
|
|
13
|
+
<img src={assetUrl("grackle-logo.png")} alt="Grackle" className={styles.logo} />
|
|
13
14
|
<Spinner size="xl" label="Loading Grackle" liveRegion />
|
|
14
15
|
</div>
|
|
15
16
|
);
|
|
@@ -17,7 +17,7 @@ type Story = StoryObj<typeof meta>;
|
|
|
17
17
|
export const AllTabsRendered: Story = {
|
|
18
18
|
play: async ({ canvas }) => {
|
|
19
19
|
await expect(canvas.getByRole("tab", { name: /Dashboard/ })).toBeInTheDocument();
|
|
20
|
-
await expect(canvas.getByRole("tab", { name: /
|
|
20
|
+
await expect(canvas.getByRole("tab", { name: /Sessions/ })).toBeInTheDocument();
|
|
21
21
|
await expect(canvas.getByRole("tab", { name: /Tasks/ })).toBeInTheDocument();
|
|
22
22
|
await expect(canvas.getByRole("tab", { name: /Environments/ })).toBeInTheDocument();
|
|
23
23
|
await expect(canvas.getByRole("tab", { name: /Knowledge/ })).toBeInTheDocument();
|
|
@@ -30,14 +30,14 @@ export const CoreOnlyTabs: Story = {
|
|
|
30
30
|
args: {
|
|
31
31
|
tabs: [
|
|
32
32
|
{ view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard" },
|
|
33
|
-
{ view: "chat", label: "
|
|
33
|
+
{ view: "chat", label: "Sessions", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
|
|
34
34
|
{ view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments" },
|
|
35
35
|
{ view: "settings", label: "Settings", icon: <Settings size={ICON_LG} />, route: SETTINGS_CREDENTIALS_URL, testId: "sidebar-tab-settings" },
|
|
36
36
|
],
|
|
37
37
|
},
|
|
38
38
|
play: async ({ canvas }) => {
|
|
39
39
|
await expect(canvas.getByRole("tab", { name: /Dashboard/ })).toBeInTheDocument();
|
|
40
|
-
await expect(canvas.getByRole("tab", { name: /
|
|
40
|
+
await expect(canvas.getByRole("tab", { name: /Sessions/ })).toBeInTheDocument();
|
|
41
41
|
await expect(canvas.getByRole("tab", { name: /Environments/ })).toBeInTheDocument();
|
|
42
42
|
await expect(canvas.getByRole("tab", { name: /Settings/ })).toBeInTheDocument();
|
|
43
43
|
await expect(canvas.queryByRole("tab", { name: /Tasks/ })).not.toBeInTheDocument();
|
|
@@ -51,7 +51,7 @@ export const AllTabsExplicit: Story = {
|
|
|
51
51
|
args: {
|
|
52
52
|
tabs: [
|
|
53
53
|
{ view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard" },
|
|
54
|
-
{ view: "chat", label: "
|
|
54
|
+
{ view: "chat", label: "Sessions", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
|
|
55
55
|
{ view: "tasks", label: "Tasks", icon: <ClipboardList size={ICON_LG} />, route: TASKS_URL, testId: "sidebar-tab-tasks" },
|
|
56
56
|
{ view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments" },
|
|
57
57
|
{ view: "knowledge", label: "Knowledge", icon: <Brain size={ICON_LG} />, route: KNOWLEDGE_URL, testId: "sidebar-tab-knowledge" },
|
|
@@ -78,7 +78,7 @@ export const SettingsPinnedRight: Story = {
|
|
|
78
78
|
args: {
|
|
79
79
|
tabs: [
|
|
80
80
|
{ view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard" },
|
|
81
|
-
{ view: "chat", label: "
|
|
81
|
+
{ view: "chat", label: "Sessions", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
|
|
82
82
|
{ view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments" },
|
|
83
83
|
{ view: "settings", label: "Settings", icon: <Settings size={ICON_LG} />, route: SETTINGS_CREDENTIALS_URL, testId: "sidebar-tab-settings", align: "end" },
|
|
84
84
|
{ view: "tasks", label: "Tasks", icon: <ClipboardList size={ICON_LG} />, route: TASKS_URL, testId: "sidebar-tab-tasks" },
|
|
@@ -21,18 +21,24 @@ export interface AppTab {
|
|
|
21
21
|
route: string;
|
|
22
22
|
/** data-testid suffix. */
|
|
23
23
|
testId: string;
|
|
24
|
+
/**
|
|
25
|
+
* Display order within the nav bar (lower numbers appear first). Applied
|
|
26
|
+
* across all plugins so tab order is explicit rather than dependent on plugin
|
|
27
|
+
* load order. End-aligned tabs ignore this and are always pinned right.
|
|
28
|
+
*/
|
|
29
|
+
order?: number;
|
|
24
30
|
/** Horizontal alignment within the nav bar. `"end"` pins the tab to the right edge. */
|
|
25
31
|
align?: "end";
|
|
26
32
|
}
|
|
27
33
|
|
|
28
34
|
/** Ordered list of all app navigation tabs. Exported for plugin registry use. */
|
|
29
35
|
export const TABS: AppTab[] = [
|
|
30
|
-
{ view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard" },
|
|
31
|
-
{ view: "
|
|
32
|
-
{ view: "
|
|
33
|
-
{ view: "
|
|
34
|
-
{ view: "
|
|
35
|
-
{ view: "
|
|
36
|
+
{ view: "dashboard", label: "Dashboard", icon: <Home size={ICON_LG} />, route: HOME_URL, testId: "sidebar-tab-dashboard", order: 0 },
|
|
37
|
+
{ view: "tasks", label: "Tasks", icon: <ClipboardList size={ICON_LG} />, route: TASKS_URL, testId: "sidebar-tab-tasks", order: 1 },
|
|
38
|
+
{ view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments", order: 2 },
|
|
39
|
+
{ view: "chat", label: "Sessions", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat", order: 3 },
|
|
40
|
+
{ view: "findings", label: "Findings", icon: <Search size={ICON_LG} />, route: FINDINGS_URL, testId: "sidebar-tab-findings", order: 4 },
|
|
41
|
+
{ view: "knowledge", label: "Knowledge", icon: <Brain size={ICON_LG} />, route: KNOWLEDGE_URL, testId: "sidebar-tab-knowledge", order: 5 },
|
|
36
42
|
{ view: "settings", label: "Settings", icon: <Settings size={ICON_LG} />, route: SETTINGS_CREDENTIALS_URL, testId: "sidebar-tab-settings", align: "end" },
|
|
37
43
|
];
|
|
38
44
|
|
|
@@ -67,15 +73,18 @@ export function AppNav({ tabs = TABS }: { tabs?: AppTab[] }): JSX.Element {
|
|
|
67
73
|
|
|
68
74
|
const activeView = getActiveView(location.pathname);
|
|
69
75
|
|
|
70
|
-
//
|
|
71
|
-
// so they stay pinned to the right edge no matter which
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
// Sort by explicit `order`, then render end-aligned tabs (e.g. Settings) last
|
|
77
|
+
// regardless of order, so they stay pinned to the right edge no matter which
|
|
78
|
+
// plugins contribute tabs. Tabs without an `order` keep their incoming order
|
|
79
|
+
// (stable sort) and fall after explicitly-ordered ones.
|
|
80
|
+
const orderedTabs = useMemo(() => {
|
|
81
|
+
const byOrder = (a: AppTab, b: AppTab): number =>
|
|
82
|
+
(a.order ?? Number.MAX_SAFE_INTEGER) - (b.order ?? Number.MAX_SAFE_INTEGER);
|
|
83
|
+
return [
|
|
84
|
+
...tabs.filter((t) => t.align !== "end").sort(byOrder),
|
|
75
85
|
...tabs.filter((t) => t.align === "end"),
|
|
76
|
-
]
|
|
77
|
-
|
|
78
|
-
);
|
|
86
|
+
];
|
|
87
|
+
}, [tabs]);
|
|
79
88
|
const firstEndAlignedView = orderedTabs.find((t) => t.align === "end")?.view;
|
|
80
89
|
|
|
81
90
|
const handleClick = useCallback((tab: AppTab) => {
|
|
@@ -3,6 +3,7 @@ import { Circle, Menu } from "lucide-react";
|
|
|
3
3
|
import type { ConnectionStatus, Environment, Session } from "../../hooks/types.js";
|
|
4
4
|
import { ICON_LG, ICON_XS } from "../../utils/iconSize.js";
|
|
5
5
|
import { HOME_URL, useAppNavigate } from "../../utils/navigation.js";
|
|
6
|
+
import { assetUrl } from "../../utils/assetUrl.js";
|
|
6
7
|
import { Tooltip } from "../display/Tooltip.js";
|
|
7
8
|
import styles from "./StatusBar.module.scss";
|
|
8
9
|
|
|
@@ -51,7 +52,7 @@ export function StatusBar({ connectionStatus, environments, sessions, onToggleSi
|
|
|
51
52
|
)}
|
|
52
53
|
<Tooltip text="Home" placement="bottom">
|
|
53
54
|
<button type="button" className={styles.brand} onClick={() => navigate(HOME_URL)} data-testid="statusbar-brand">
|
|
54
|
-
<img src="
|
|
55
|
+
<img src={assetUrl("icon-192x192.png")} alt="" className={styles.brandLogo} aria-hidden="true" data-testid="statusbar-logo" />
|
|
55
56
|
Grackle
|
|
56
57
|
</button>
|
|
57
58
|
</Tooltip>
|
|
@@ -25,7 +25,7 @@ export const AllCategoriesRendered: Story = {
|
|
|
25
25
|
await expect(canvas.getByText("Workspace Page")).toBeInTheDocument();
|
|
26
26
|
await expect(canvas.getByText("Navigation Lists")).toBeInTheDocument();
|
|
27
27
|
await expect(canvas.getByText("Editing")).toBeInTheDocument();
|
|
28
|
-
await expect(canvas.getByText("
|
|
28
|
+
await expect(canvas.getByText("Sessions")).toBeInTheDocument();
|
|
29
29
|
},
|
|
30
30
|
};
|
|
31
31
|
|
|
@@ -35,6 +35,6 @@ export const ShortcutDescriptions: Story = {
|
|
|
35
35
|
await expect(canvas.getByText("Open keyboard shortcuts reference")).toBeInTheDocument();
|
|
36
36
|
await expect(canvas.getByText("Create a new task")).toBeInTheDocument();
|
|
37
37
|
await expect(canvas.getByText("Switch to Overview tab")).toBeInTheDocument();
|
|
38
|
-
await expect(canvas.getByText("Send message (when
|
|
38
|
+
await expect(canvas.getByText("Send message (when the composer is focused)")).toBeInTheDocument();
|
|
39
39
|
},
|
|
40
40
|
};
|
|
@@ -66,9 +66,10 @@ const SHORTCUT_GROUPS: ShortcutGroup[] = [
|
|
|
66
66
|
],
|
|
67
67
|
},
|
|
68
68
|
{
|
|
69
|
-
title: "
|
|
69
|
+
title: "Sessions",
|
|
70
70
|
shortcuts: [
|
|
71
|
-
{ keys: ["Enter"], description: "Send message (when
|
|
71
|
+
{ keys: ["Ctrl/Cmd", "Enter"], description: "Send message (when the composer is focused)" },
|
|
72
|
+
{ keys: ["Enter"], description: "Insert a new line in the message composer" },
|
|
72
73
|
],
|
|
73
74
|
},
|
|
74
75
|
];
|
package/src/index.ts
CHANGED
|
@@ -202,6 +202,8 @@ export { computeKpis, getAttentionTasks, getActiveSessions, getWorkspaceSnapshot
|
|
|
202
202
|
|
|
203
203
|
export { isNearAnchor, computeScrollCompensation, SCROLL_ANCHOR_THRESHOLD_PX } from "./utils/scrollUtils.js";
|
|
204
204
|
|
|
205
|
+
export { assetUrl } from "./utils/assetUrl.js";
|
|
206
|
+
|
|
205
207
|
// ─── Themes ──────────────────────────────────────────────────────────────────
|
|
206
208
|
|
|
207
209
|
export { THEMES, THEME_IDS, DEFAULT_THEME_ID, getThemeById } from "./themes.js";
|
package/src/mocks/mockData.ts
CHANGED
|
@@ -265,6 +265,12 @@ export const MOCK_EVENTS: SessionEvent[] = [
|
|
|
265
265
|
content: "You are a senior backend engineer. Focus on security best practices and thorough testing.\nProject: acme/alpha — Node.js + Express REST API\nTask: Implement auth middleware using JWT tokens\n\nNote: A previous session attempted this task but was interrupted. Pick up where it left off.",
|
|
266
266
|
raw: JSON.stringify({ systemContext: true }),
|
|
267
267
|
},
|
|
268
|
+
{
|
|
269
|
+
sessionId: "sess-001",
|
|
270
|
+
eventType: "user_input",
|
|
271
|
+
timestamp: "2026-02-27T08:15:03Z",
|
|
272
|
+
content: "Pick up the **JWT auth migration** from the previous session. A few requirements:\n\n- use `jsonwebtoken`, not `jose`\n- access tokens expire in `24h`\n- store refresh tokens in the DB\n\nMake sure the existing tests still pass.",
|
|
273
|
+
},
|
|
268
274
|
{
|
|
269
275
|
sessionId: "sess-001",
|
|
270
276
|
eventType: "text",
|
|
@@ -492,6 +498,12 @@ export const MOCK_EVENTS: SessionEvent[] = [
|
|
|
492
498
|
timestamp: "2026-02-27T08:15:42Z",
|
|
493
499
|
content: "All 14 tests pass. Here's a summary of the changes:\n\n### Changes made\n\n| File | Action |\n|------|--------|\n| `src/middleware/auth.ts` | Rewrote to verify JWT Bearer tokens |\n| `src/routes/login.ts` | Now issues JWT access + refresh tokens |\n| `src/routes/protected.ts` | Updated to read `req.user` from JWT payload |\n| `src/middleware/__tests__/auth.test.ts` | Updated tests for JWT verification |\n\n### Key decisions\n- **Access token expiry**: 24 hours (configurable via `JWT_SECRET` env var)\n- **Refresh tokens**: 48-byte random hex, stored in DB\n- **Error handling**: Distinguishes expired tokens (401) from invalid tokens (403)",
|
|
494
500
|
},
|
|
501
|
+
{
|
|
502
|
+
sessionId: "sess-001",
|
|
503
|
+
eventType: "user_input",
|
|
504
|
+
timestamp: "2026-02-27T08:15:50Z",
|
|
505
|
+
content: "Looks great! Two follow-ups:\n\n1. add a `/refresh` endpoint that swaps a refresh token for a new access token\n2. note the token flow in `README.md`",
|
|
506
|
+
},
|
|
495
507
|
|
|
496
508
|
// ── sess-002: completed unit test session ──
|
|
497
509
|
|