@grackle-ai/web-components 0.110.3 → 0.112.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/.rush/temp/{de557b666a8980e74bc5c775dc53097f779f8d53.tar.log → c8b4fba3a6cfb704f3582f1197cac0fc11abbc50.tar.log} +18 -18
  2. package/.rush/temp/{6bc9700f6b9938b330a84bd0f8c7ef5a7e3dbfb6.untar.log → c8b4fba3a6cfb704f3582f1197cac0fc11abbc50.untar.log} +2 -2
  3. package/.rush/temp/chunked-rush-logs/web-components._phase_build.chunks.jsonl +5 -5
  4. package/.rush/temp/chunked-rush-logs/web-components._phase_test.chunks.jsonl +25 -23
  5. package/.rush/temp/{6bc9700f6b9938b330a84bd0f8c7ef5a7e3dbfb6.tar.log → e2794f6b3dd02e58a0ca34e8c438c22d25296c3e.tar.log} +2 -2
  6. package/.rush/temp/{de557b666a8980e74bc5c775dc53097f779f8d53.untar.log → e2794f6b3dd02e58a0ca34e8c438c22d25296c3e.untar.log} +2 -2
  7. package/.rush/temp/operation/_phase_build/all.log +5 -5
  8. package/.rush/temp/operation/_phase_build/log-chunks.jsonl +5 -5
  9. package/.rush/temp/operation/_phase_build/state.json +1 -1
  10. package/.rush/temp/operation/_phase_test/all.log +25 -23
  11. package/.rush/temp/operation/_phase_test/log-chunks.jsonl +25 -23
  12. package/.rush/temp/operation/_phase_test/state.json +1 -1
  13. package/.rush/temp/shrinkwrap-deps.json +3 -3
  14. package/dist/index.css +1 -1
  15. package/dist/index.js +6629 -6581
  16. package/package.json +2 -2
  17. package/rush-logs/web-components._phase_build.cache.log +1 -1
  18. package/rush-logs/web-components._phase_build.log +5 -5
  19. package/rush-logs/web-components._phase_test.cache.log +1 -1
  20. package/rush-logs/web-components._phase_test.log +25 -23
  21. package/src/components/chat/ChatInput.module.scss +10 -2
  22. package/src/components/chat/ChatInput.stories.tsx +35 -0
  23. package/src/components/chat/ChatInput.tsx +88 -6
  24. package/src/components/display/EventRenderer.module.scss +18 -85
  25. package/src/components/display/EventRenderer.stories.tsx +16 -0
  26. package/src/components/display/EventRenderer.tsx +19 -5
  27. package/src/components/display/EventStream.stories.tsx +3 -1
  28. package/src/mocks/mockData.ts +12 -0
  29. package/src/styles/mixins.scss +93 -0
  30. package/temp/build/lint/_eslint-5eVG3S6w.json +6 -6
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grackle-ai/web-components",
3
- "version": "0.110.3",
3
+ "version": "0.112.0",
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.110.3"
49
+ "@grackle-ai/common": "0.112.0"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@rushstack/heft": "1.2.7",
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: de557b666a8980e74bc5c775dc53097f779f8d53
2
+ Cache key: c8b4fba3a6cfb704f3582f1197cac0fc11abbc50
3
3
  Clearing cached folders: dist, storybook-static, .rush/temp/operation/_phase_build
4
4
  Successfully restored output from the build cache.
@@ -10,9 +10,9 @@ transforming...
10
10
  ✓ 2715 modules transformed.
11
11
  rendering chunks...
12
12
  computing gzip size...
13
- dist/index.css 156.09 kB │ gzip: 20.37 kB
14
- dist/index.js 1,589.58 kB │ gzip: 401.03 kB
15
- ✓ built in 5.83s
13
+ dist/index.css 158.47 kB │ gzip: 20.60 kB
14
+ dist/index.js 1,590.94 kB │ gzip: 401.57 kB
15
+ ✓ built in 5.74s
16
16
  [build:vite-build] Vite build completed.
17
- ---- build finished (39.698s) ----
18
- -------------------- Finished (39.701s) --------------------
17
+ ---- build finished (74.958s) ----
18
+ -------------------- Finished (74.961s) --------------------
@@ -1,4 +1,4 @@
1
1
  Build cache hit.
2
- Cache key: 6bc9700f6b9938b330a84bd0f8c7ef5a7e3dbfb6
2
+ Cache key: e2794f6b3dd02e58a0ca34e8c438c22d25296c3e
3
3
  Clearing cached folders: .rush/temp/operation/_phase_test
4
4
  Successfully restored output from the build cache.
@@ -5,28 +5,30 @@ The provided list of phases does not contain all phase dependencies. You may nee
5
5
 
6
6
  RUN v3.2.4 /home/runner/work/grackle/grackle/packages/web-components
7
7
 
8
- ✓ src/utils/eventContent.test.ts (38 tests) 103ms
9
8
  ✓ src/utils/sessionEvents.test.ts (14 tests) 54ms
10
- ✓ src/utils/dashboard.test.ts (4 tests) 28ms
11
- ✓ src/utils/route-config.test.ts (23 tests) 45ms
12
- ✓ src/utils/scrollUtils.test.ts (11 tests) 19ms
13
- ✓ src/utils/breadcrumbs.test.ts (18 tests) 108ms
14
- ✓ src/components/tools/classifyTool.test.ts (6 tests) 17ms
15
- ✓ src/components/tools/toolCardHelpers.test.ts (10 tests) 21ms
16
- ✓ src/components/display/extractText.test.tsx (8 tests) 6ms
17
- ✓ src/components/editable/useEditableField.test.tsx (17 tests) 154ms
18
- ✓ src/hooks/useEventSelection.test.ts (13 tests) 114ms
19
- ✓ src/components/notifications/UpdateBanner.test.tsx (4 tests) 73ms
20
- ✓ src/components/display/McpAppWidget.test.tsx (3 tests) 251ms
21
- ✓ src/utils/grackleHostStyleVariables.test.ts (2 tests) 229ms
9
+ ✓ src/utils/eventContent.test.ts (38 tests) 139ms
10
+ ✓ src/utils/dashboard.test.ts (4 tests) 7ms
11
+ ✓ src/utils/route-config.test.ts (23 tests) 30ms
12
+ ✓ src/utils/scrollUtils.test.ts (11 tests) 24ms
13
+ ✓ src/utils/breadcrumbs.test.ts (18 tests) 55ms
14
+ ✓ src/components/tools/toolCardHelpers.test.ts (10 tests) 27ms
15
+ ✓ src/components/tools/classifyTool.test.ts (6 tests) 28ms
16
+ ✓ src/components/display/extractText.test.tsx (8 tests) 32ms
17
+ ✓ src/hooks/useEventSelection.test.ts (13 tests) 131ms
18
+ ✓ src/components/editable/useEditableField.test.tsx (17 tests) 200ms
19
+ ✓ src/components/notifications/UpdateBanner.test.tsx (4 tests) 264ms
20
+ ✓ src/utils/grackleHostStyleVariables.test.ts (2 tests) 540ms
21
+ ✓ grackleHostStyleVariables > always returns the MCP-standard fallback variables 516ms
22
+ ✓ src/components/display/McpAppWidget.test.tsx (3 tests) 501ms
23
+ ✓ McpAppWidget > mounts an iframe host for the widget 321ms
22
24
 
23
25
  Test Files 14 passed (14)
24
26
  Tests 171 passed (171)
25
- Start at 14:51:21
26
- Duration 13.26s (transform 2.17s, setup 0ms, collect 16.02s, tests 1.22s, environment 8.39s, prepare 5.24s)
27
+ Start at 22:02:20
28
+ Duration 16.20s (transform 2.92s, setup 0ms, collect 17.59s, tests 2.03s, environment 14.57s, prepare 5.64s)
27
29
 
28
30
  [test:vitest] Vitest completed.
29
- [test:storybook-test] Starting Storybook static server on port 45689...
31
+ [test:storybook-test] Starting Storybook static server on port 38719...
30
32
  [test:storybook-test] Storybook server ready. Running interaction tests...
31
33
  jest-haste-map: duplicate manual mock found: adapter-manager
32
34
  The following files share their name; please delete one of them:
@@ -103,21 +105,21 @@ jest-haste-map: duplicate manual mock found: token-push
103
105
  * <rootDir>/packages/server/dist/__mocks__/token-push.js
104
106
  * <rootDir>/packages/server/src/__mocks__/token-push.ts
105
107
 
106
- jest-haste-map: duplicate manual mock found: utils/format-gh-error
107
- The following files share their name; please delete one of them:
108
- * <rootDir>/packages/server/dist/__mocks__/utils/format-gh-error.js
109
- * <rootDir>/packages/server/src/__mocks__/utils/format-gh-error.ts
110
-
111
108
  jest-haste-map: duplicate manual mock found: utils/exec
112
109
  The following files share their name; please delete one of them:
113
110
  * <rootDir>/packages/server/dist/__mocks__/utils/exec.js
114
111
  * <rootDir>/packages/server/src/__mocks__/utils/exec.ts
115
112
 
113
+ jest-haste-map: duplicate manual mock found: utils/format-gh-error
114
+ The following files share their name; please delete one of them:
115
+ * <rootDir>/packages/server/dist/__mocks__/utils/format-gh-error.js
116
+ * <rootDir>/packages/server/src/__mocks__/utils/format-gh-error.ts
117
+
116
118
  jest-haste-map: duplicate manual mock found: utils/network
117
119
  The following files share their name; please delete one of them:
118
120
  * <rootDir>/packages/server/dist/__mocks__/utils/network.js
119
121
  * <rootDir>/packages/server/src/__mocks__/utils/network.ts
120
122
 
121
123
  [test:storybook-test] Storybook interaction tests completed.
122
- ---- test finished (68.057s) ----
123
- -------------------- Finished (68.063s) --------------------
124
+ ---- test finished (115.550s) ----
125
+ -------------------- Finished (115.554s) --------------------
@@ -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" }),
@@ -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
 
@@ -264,6 +264,99 @@ $tablet: 1024px;
264
264
  font-weight: var(--font-weight-medium);
265
265
  }
266
266
 
267
+ // =============================================================================
268
+ // Markdown Content
269
+ // =============================================================================
270
+
271
+ /// Styles for a rendered react-markdown element tree (headings, code, tables,
272
+ /// lists, links, etc.). Applied to any container that renders markdown output,
273
+ /// e.g. agent text events and user message bubbles.
274
+ @mixin markdown-content {
275
+ h1, h2, h3, h4, h5, h6 {
276
+ margin: var(--space-sm) 0 var(--space-xs);
277
+ font-weight: var(--font-weight-bold);
278
+ }
279
+
280
+ h1 { font-size: 1.4em; }
281
+ h2 { font-size: 1.2em; }
282
+ h3 { font-size: 1.1em; }
283
+
284
+ p {
285
+ margin: var(--space-xs) 0;
286
+ }
287
+
288
+ pre {
289
+ @include surface-inset;
290
+ padding: var(--space-sm);
291
+ margin: var(--space-xs) 0;
292
+ overflow: auto;
293
+ font-size: 11px;
294
+ white-space: pre-wrap;
295
+ }
296
+
297
+ code {
298
+ font-family: var(--font-mono);
299
+ font-size: 0.9em;
300
+ background: var(--bg-overlay);
301
+ padding: 1px 4px;
302
+ border-radius: var(--radius-sm);
303
+ }
304
+
305
+ pre code {
306
+ background: none;
307
+ padding: 0;
308
+ }
309
+
310
+ table {
311
+ border-collapse: collapse;
312
+ margin: var(--space-xs) 0;
313
+ font-size: var(--font-size-sm);
314
+ width: 100%;
315
+ }
316
+
317
+ th, td {
318
+ border: 1px solid var(--border-subtle);
319
+ padding: var(--space-xs) var(--space-sm);
320
+ text-align: left;
321
+ }
322
+
323
+ th {
324
+ background: var(--bg-overlay);
325
+ font-weight: var(--font-weight-bold);
326
+ }
327
+
328
+ ul, ol {
329
+ padding-left: var(--space-lg);
330
+ margin: var(--space-xs) 0;
331
+ }
332
+
333
+ li {
334
+ margin: 2px 0;
335
+ }
336
+
337
+ strong {
338
+ font-weight: var(--font-weight-bold);
339
+ }
340
+
341
+ a {
342
+ color: var(--accent-blue);
343
+ text-decoration: underline;
344
+ }
345
+
346
+ hr {
347
+ border: none;
348
+ border-top: 1px solid var(--border-subtle);
349
+ margin: var(--space-sm) 0;
350
+ }
351
+
352
+ blockquote {
353
+ border-left: 3px solid var(--border-subtle);
354
+ padding-left: var(--space-sm);
355
+ margin: var(--space-xs) 0;
356
+ color: var(--text-secondary);
357
+ }
358
+ }
359
+
267
360
  // =============================================================================
268
361
  // Animation Helpers
269
362
  // =============================================================================