@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.
Files changed (38) hide show
  1. package/.rush/temp/{406cafe37bd01cafa4c4b9e60e3abee9180ce991.untar.log → 30718c57404891eb5c6c96c935c4a6879170c963.untar.log} +2 -2
  2. package/.rush/temp/{9432bf197de04657ccc35f53841e118300f161e9.untar.log → 6ab1fbb7acd3281d7612ffe24dcc9982dd8c9061.untar.log} +2 -2
  3. package/.rush/temp/operation/_phase_build/all.log +6 -6
  4. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +6 -6
  5. package/.rush/temp/operation/_phase_build/state.json +1 -1
  6. package/.rush/temp/operation/_phase_test/all.log +24 -23
  7. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +24 -23
  8. package/.rush/temp/operation/_phase_test/state.json +1 -1
  9. package/dist/index.css +1 -1
  10. package/dist/index.js +6646 -6593
  11. package/package.json +2 -2
  12. package/rush-logs/web-components._phase_build.cache.log +1 -1
  13. package/rush-logs/web-components._phase_test.cache.log +1 -1
  14. package/src/components/chat/ChatInput.module.scss +10 -2
  15. package/src/components/chat/ChatInput.stories.tsx +35 -0
  16. package/src/components/chat/ChatInput.tsx +88 -6
  17. package/src/components/display/EventRenderer.module.scss +18 -85
  18. package/src/components/display/EventRenderer.stories.tsx +16 -0
  19. package/src/components/display/EventRenderer.tsx +19 -5
  20. package/src/components/display/EventStream.stories.tsx +3 -1
  21. package/src/components/display/SplashScreen.tsx +2 -1
  22. package/src/components/layout/AppNav.stories.tsx +5 -5
  23. package/src/components/layout/AppNav.tsx +23 -14
  24. package/src/components/layout/StatusBar.tsx +2 -1
  25. package/src/components/panels/KeyboardShortcutsPanel.stories.tsx +2 -2
  26. package/src/components/panels/KeyboardShortcutsPanel.tsx +3 -2
  27. package/src/index.ts +2 -0
  28. package/src/mocks/mockData.ts +12 -0
  29. package/src/styles/mixins.scss +93 -0
  30. package/src/utils/assetUrl.test.ts +23 -0
  31. package/src/utils/assetUrl.ts +33 -0
  32. package/.rush/temp/406cafe37bd01cafa4c4b9e60e3abee9180ce991.tar.log +0 -235
  33. package/.rush/temp/9432bf197de04657ccc35f53841e118300f161e9.tar.log +0 -12
  34. package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +0 -18
  35. package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +0 -124
  36. package/rush-logs/web-components._phase_build.log +0 -18
  37. package/rush-logs/web-components._phase_test.log +0 -124
  38. 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.111.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.111.0"
49
+ "@grackle-ai/common": "0.112.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@rushstack/heft": "1.2.7",
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: 406cafe37bd01cafa4c4b9e60e3abee9180ce991
2
+ Cache key: 30718c57404891eb5c6c96c935c4a6879170c963
3
3
  Clearing cached folders: dist, storybook-static, .rush/temp/operation/_phase_build
4
4
  Successfully restored output from the build cache.
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: 9432bf197de04657ccc35f53841e118300f161e9
2
+ Cache key: 6ab1fbb7acd3281d7612ffe24dcc9982dd8c9061
3
3
  Clearing cached folders: .rush/temp/operation/_phase_test
4
4
  Successfully restored output from the build cache.
@@ -7,7 +7,7 @@
7
7
  .bar {
8
8
  @include surface-panel;
9
9
  display: flex;
10
- align-items: center;
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
- .input {
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
- const handleSubmit = (e: FormEvent): void => {
96
- e.preventDefault();
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
- <input type="text" value={text} onChange={(e) => setText(e.target.value)} placeholder="Enter prompt..." autoFocus className={styles.input} aria-label="Enter prompt" />
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
- <input type="text" value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a message..." autoFocus className={styles.input} aria-label="Type a message" />
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
- <input type="text" value={text} onChange={(e) => setText(e.target.value)} placeholder="Type a message..." autoFocus={!envDisconnected} disabled={envDisconnected} className={styles.input} aria-label="Type a message" />
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
- // --- Markdown element styles ---
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
- <Markdown remarkPlugins={[remarkGfm]} rehypePlugins={[rehypePrismPlus]} components={markdownComponents}>
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
- <span className={styles.userInputContent}>{content}</span>
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="/grackle-logo.png" alt="Grackle" className={styles.logo} />
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: /Chat/ })).toBeInTheDocument();
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: "Chat", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
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: /Chat/ })).toBeInTheDocument();
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: "Chat", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
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: "Chat", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
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: "chat", label: "Chat", icon: <MessageSquare size={ICON_LG} />, route: CHAT_URL, testId: "sidebar-tab-chat" },
32
- { view: "tasks", label: "Tasks", icon: <ClipboardList size={ICON_LG} />, route: TASKS_URL, testId: "sidebar-tab-tasks" },
33
- { view: "environments", label: "Environments", icon: <Monitor size={ICON_LG} />, route: ENVIRONMENTS_URL, testId: "sidebar-tab-environments" },
34
- { view: "knowledge", label: "Knowledge", icon: <Brain size={ICON_LG} />, route: KNOWLEDGE_URL, testId: "sidebar-tab-knowledge" },
35
- { view: "findings", label: "Findings", icon: <Search size={ICON_LG} />, route: FINDINGS_URL, testId: "sidebar-tab-findings" },
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
- // Render end-aligned tabs (e.g. Settings) last regardless of the incoming order,
71
- // so they stay pinned to the right edge no matter which plugins contribute tabs.
72
- const orderedTabs = useMemo(
73
- () => [
74
- ...tabs.filter((t) => t.align !== "end"),
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
- [tabs],
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="/icon-192x192.png" alt="" className={styles.brandLogo} aria-hidden="true" data-testid="statusbar-logo" />
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("Chat")).toBeInTheDocument();
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 input is focused)")).toBeInTheDocument();
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: "Chat",
69
+ title: "Sessions",
70
70
  shortcuts: [
71
- { keys: ["Enter"], description: "Send message (when input is focused)" },
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";
@@ -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