canopy-ui 0.7.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,235 @@
1
+ [
2
+ {
3
+ "canopy": {
4
+ "event": "chat.stream_start",
5
+ "data": {
6
+ "message_id": "m1",
7
+ "turn_index": 4
8
+ }
9
+ },
10
+ "agui": [
11
+ {
12
+ "metadata": {
13
+ "canopy": {
14
+ "turn_index": 4
15
+ }
16
+ },
17
+ "type": "TEXT_MESSAGE_START",
18
+ "messageId": "m1",
19
+ "role": "assistant"
20
+ }
21
+ ]
22
+ },
23
+ {
24
+ "canopy": {
25
+ "event": "chat.delta",
26
+ "data": {
27
+ "message_id": "m1",
28
+ "text": "hello"
29
+ }
30
+ },
31
+ "agui": [
32
+ {
33
+ "type": "TEXT_MESSAGE_CONTENT",
34
+ "messageId": "m1",
35
+ "delta": "hello"
36
+ }
37
+ ]
38
+ },
39
+ {
40
+ "canopy": {
41
+ "event": "chat.stream_complete",
42
+ "data": {
43
+ "message_id": "m1",
44
+ "plaintext": "hello"
45
+ }
46
+ },
47
+ "agui": [
48
+ {
49
+ "metadata": {
50
+ "canopy": {
51
+ "plaintext": "hello"
52
+ }
53
+ },
54
+ "type": "TEXT_MESSAGE_END",
55
+ "messageId": "m1"
56
+ }
57
+ ]
58
+ },
59
+ {
60
+ "canopy": {
61
+ "event": "chat.user_message",
62
+ "data": {
63
+ "message_id": "u1",
64
+ "turn_index": 3,
65
+ "plaintext": "hi there"
66
+ }
67
+ },
68
+ "agui": [
69
+ {
70
+ "metadata": {
71
+ "canopy": {
72
+ "turn_index": 3
73
+ }
74
+ },
75
+ "type": "TEXT_MESSAGE_CHUNK",
76
+ "messageId": "u1",
77
+ "role": "user",
78
+ "delta": "hi there"
79
+ }
80
+ ]
81
+ },
82
+ {
83
+ "canopy": {
84
+ "event": "chat.tool_use",
85
+ "data": {
86
+ "tool_message_id": "t9",
87
+ "parent_message_id": "m1",
88
+ "turn_index": 5,
89
+ "block": {
90
+ "name": "list_insights",
91
+ "input": {
92
+ "limit": 5
93
+ }
94
+ }
95
+ }
96
+ },
97
+ "agui": [
98
+ {
99
+ "metadata": {
100
+ "canopy": {
101
+ "turn_index": 5,
102
+ "block": {
103
+ "name": "list_insights",
104
+ "input": {
105
+ "limit": 5
106
+ }
107
+ }
108
+ }
109
+ },
110
+ "type": "TOOL_CALL_START",
111
+ "toolCallId": "t9",
112
+ "toolCallName": "list_insights",
113
+ "parentMessageId": "m1"
114
+ },
115
+ {
116
+ "type": "TOOL_CALL_ARGS",
117
+ "toolCallId": "t9",
118
+ "delta": "{\"limit\": 5}"
119
+ },
120
+ {
121
+ "type": "TOOL_CALL_END",
122
+ "toolCallId": "t9"
123
+ }
124
+ ]
125
+ },
126
+ {
127
+ "canopy": {
128
+ "event": "chat.tool_result",
129
+ "data": {
130
+ "tool_message_id": "t9",
131
+ "parent_message_id": "m1",
132
+ "turn_index": 6,
133
+ "block": {
134
+ "type": "tool_result",
135
+ "tool_use_id": "toolu_01ABC",
136
+ "content": "[]"
137
+ }
138
+ }
139
+ },
140
+ "agui": [
141
+ {
142
+ "metadata": {
143
+ "canopy": {
144
+ "turn_index": 6,
145
+ "parent_message_id": "m1",
146
+ "block": {
147
+ "type": "tool_result",
148
+ "tool_use_id": "toolu_01ABC",
149
+ "content": "[]"
150
+ }
151
+ }
152
+ },
153
+ "type": "TOOL_CALL_RESULT",
154
+ "messageId": "t9",
155
+ "toolCallId": "t9",
156
+ "content": "[]"
157
+ }
158
+ ]
159
+ },
160
+ {
161
+ "canopy": {
162
+ "event": "session.title_updated",
163
+ "data": {
164
+ "title": "Insights triage"
165
+ }
166
+ },
167
+ "agui": [
168
+ {
169
+ "type": "STATE_DELTA",
170
+ "delta": [
171
+ {
172
+ "op": "replace",
173
+ "path": "/title",
174
+ "value": "Insights triage"
175
+ }
176
+ ]
177
+ }
178
+ ]
179
+ },
180
+ {
181
+ "canopy": {
182
+ "event": "draft.updated",
183
+ "data": {
184
+ "id": "d1",
185
+ "body": "x",
186
+ "version": 2
187
+ }
188
+ },
189
+ "agui": [
190
+ {
191
+ "type": "CUSTOM",
192
+ "name": "canopy.draft.updated",
193
+ "value": {
194
+ "id": "d1",
195
+ "body": "x",
196
+ "version": 2
197
+ }
198
+ }
199
+ ]
200
+ },
201
+ {
202
+ "canopy": {
203
+ "event": "presence.joined",
204
+ "data": {
205
+ "user_id": 7
206
+ }
207
+ },
208
+ "agui": [
209
+ {
210
+ "type": "CUSTOM",
211
+ "name": "canopy.presence.joined",
212
+ "value": {
213
+ "user_id": 7
214
+ }
215
+ }
216
+ ]
217
+ },
218
+ {
219
+ "canopy": {
220
+ "event": "presence.left",
221
+ "data": {
222
+ "user_id": 7
223
+ }
224
+ },
225
+ "agui": [
226
+ {
227
+ "type": "CUSTOM",
228
+ "name": "canopy.presence.left",
229
+ "value": {
230
+ "user_id": 7
231
+ }
232
+ }
233
+ ]
234
+ }
235
+ ]
@@ -0,0 +1,298 @@
1
+ import { readFileSync } from 'node:fs'
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import fixture from './agui.fixture.json'
6
+ import { fromAgui, resetAguiState } from './agui'
7
+ import { sessionReducer } from './sessionReducer'
8
+ import type { SessionState, WsEvent } from './protocol'
9
+
10
+ /** The hook's own source. Read from disk rather than imported, because what is
11
+ * being checked is that the WIRING exists — which no behavioural test of a
12
+ * pure module can see. */
13
+ function hookSource(): string {
14
+ return readFileSync(new URL('./useSessionSocket.ts', import.meta.url), 'utf8')
15
+ }
16
+
17
+ /**
18
+ * The inverse projection, checked against the forward one.
19
+ *
20
+ * `agui.fixture.json` is GENERATED by `apps/canopy_sessions/agui.py` and a
21
+ * Python test asserts it is current. So the round trip is proved across two
22
+ * languages with a shared artifact — which is the only thing that can catch the
23
+ * projection and its inverse drifting apart, since neither codebase can see the
24
+ * other.
25
+ */
26
+
27
+ beforeEach(() => resetAguiState())
28
+
29
+ type FixtureEntry = { canopy: WsEvent; agui: Array<Record<string, unknown>> }
30
+ const entries = fixture as unknown as FixtureEntry[]
31
+
32
+ /** Replay a fixture entry's AG-UI events through the inverse. */
33
+ function roundTrip(entry: FixtureEntry): WsEvent[] {
34
+ return entry.agui.flatMap((event) => fromAgui(event))
35
+ }
36
+
37
+ describe('the round trip, against the server’s own output', () => {
38
+ it('covers a real conversation, not just the easy frames', () => {
39
+ // A fixture of two trivial events would pass forever while proving nothing
40
+ // about a streamed reply or a tool call.
41
+ const covered = new Set(entries.map((e) => e.canopy.event))
42
+ expect(covered).toContain('chat.stream_start')
43
+ expect(covered).toContain('chat.tool_use')
44
+ expect(covered).toContain('chat.tool_result')
45
+ expect(entries.length).toBeGreaterThanOrEqual(10)
46
+ })
47
+
48
+ it.each(entries.map((e) => [e.canopy.event, e] as const))(
49
+ 'recovers the original canopy frame for %s',
50
+ (_name, entry) => {
51
+ expect(roundTrip(entry)).toEqual([entry.canopy])
52
+ },
53
+ )
54
+ })
55
+
56
+ describe('the reducer cannot tell which protocol it was fed', () => {
57
+ /** The property that actually matters: a client reading AG-UI must end up in
58
+ * exactly the state a client reading canopy's own frames ends up in. Field
59
+ * equality per frame is necessary but not sufficient — ordering and the
60
+ * reducer's own dedupe are where a subtle difference would show. */
61
+ function reduceAll(frames: WsEvent[]): SessionState {
62
+ const initial: SessionState = {
63
+ messages: [],
64
+ active_draft: null,
65
+ participants: [],
66
+ presence_user_ids: [],
67
+ current_user_id: 0,
68
+ }
69
+ return frames.reduce(sessionReducer, initial)
70
+ }
71
+
72
+ it('reaches identical state via canopy frames and via AG-UI', () => {
73
+ // Time is frozen because the reducer stamps `created_at`/`started_at` with
74
+ // the wall clock as it runs, so two reductions a millisecond apart differ
75
+ // in a field that has nothing to do with the protocol. Without this the
76
+ // test fails intermittently for a reason that would send the next reader
77
+ // hunting a mapping bug that is not there.
78
+ vi.useFakeTimers()
79
+ vi.setSystemTime(new Date('2026-09-15T12:00:00Z'))
80
+ try {
81
+ const direct = entries.map((e) => e.canopy)
82
+ const viaAgui = entries.flatMap(roundTrip)
83
+
84
+ expect(reduceAll(viaAgui)).toEqual(reduceAll(direct))
85
+ } finally {
86
+ vi.useRealTimers()
87
+ }
88
+ })
89
+
90
+ it('and that state is not vacuously empty', () => {
91
+ // Guard: if the fixture stopped producing messages this test would pass
92
+ // while comparing nothing to nothing.
93
+ expect(reduceAll(entries.map((e) => e.canopy)).messages.length).toBeGreaterThan(0)
94
+ })
95
+ })
96
+
97
+ describe('a tool call spans three events, so it is assembled not truncated', () => {
98
+ it('emits nothing until the arguments arrive', () => {
99
+ // canopy carries a tool's input on one frame; AG-UI streams it on the ARGS
100
+ // event that follows. Emitting on START would render a tool row with no
101
+ // input — the half-empty tool call the ACP notes warn about.
102
+ const start = fromAgui({
103
+ type: 'TOOL_CALL_START',
104
+ toolCallId: 't1',
105
+ toolCallName: 'list_insights',
106
+ })
107
+
108
+ expect(start).toEqual([])
109
+ })
110
+
111
+ it('restores the runner’s own block verbatim', () => {
112
+ // canopy passes the block straight through from the transcript and never
113
+ // interprets it, so its shape belongs to the PRODUCER — Anthropic's on the
114
+ // laptop, the cloud runner's over ACP. Rebuilding one from the AG-UI fields
115
+ // would invent an `id` and a `type` that the real payload may not have had,
116
+ // and hand the renderer something no runner ever sent.
117
+ const block = { type: 'tool_use', id: 'toolu_01ABC', name: 'list_insights', input: { limit: 5 } }
118
+ fromAgui({
119
+ type: 'TOOL_CALL_START',
120
+ toolCallId: 't1',
121
+ toolCallName: 'list_insights',
122
+ parentMessageId: 'm1',
123
+ metadata: { canopy: { turn_index: 5, block } },
124
+ })
125
+
126
+ const [frame] = fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"limit":5}' })
127
+
128
+ expect(frame).toEqual({
129
+ event: 'chat.tool_use',
130
+ data: {
131
+ parent_message_id: 'm1',
132
+ tool_message_id: 't1',
133
+ turn_index: 5,
134
+ block,
135
+ },
136
+ })
137
+ })
138
+
139
+ it('falls back to the protocol’s own fields for a non-canopy producer', () => {
140
+ // An AG-UI stream from something that is not canopy carries no block. The
141
+ // row is still renderable from name + arguments, which is the minimum the
142
+ // protocol guarantees — better than dropping the call entirely.
143
+ fromAgui({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'search' })
144
+ const [frame] = fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{"q":"x"}' })
145
+
146
+ expect((frame.data as unknown as { block: unknown }).block).toEqual({ name: 'search', input: { q: 'x' } })
147
+ })
148
+
149
+ it('ignores arguments for a call it never saw start', () => {
150
+ // A reconnect mid-call, or a stream that began before this client attached.
151
+ expect(fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 'unknown', delta: '{}' })).toEqual([])
152
+ })
153
+
154
+ it('does not carry a half-read call across a reconnect', () => {
155
+ fromAgui({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'x' })
156
+ resetAguiState()
157
+
158
+ expect(fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: '{}' })).toEqual([])
159
+ })
160
+
161
+ it('survives malformed arguments with an empty input rather than no row', () => {
162
+ fromAgui({ type: 'TOOL_CALL_START', toolCallId: 't1', toolCallName: 'x' })
163
+ const [frame] = fromAgui({ type: 'TOOL_CALL_ARGS', toolCallId: 't1', delta: 'not json' })
164
+
165
+ expect((frame.data as unknown as { block: { input: unknown } }).block.input).toEqual({})
166
+ })
167
+ })
168
+
169
+ describe('the blocked agent survives the trip', () => {
170
+ const INTERRUPT = {
171
+ type: 'RUN_FINISHED',
172
+ threadId: 't1',
173
+ runId: 'r1',
174
+ outcome: {
175
+ type: 'interrupt',
176
+ interrupts: [
177
+ {
178
+ id: 'i1',
179
+ reason: 'transcript',
180
+ message: 'Phase gate',
181
+ expiresAt: '2026-09-15T10:00:00+00:00',
182
+ metadata: {
183
+ canopy: {
184
+ body: 'Phase 4 is test-gated.',
185
+ questions: [
186
+ {
187
+ index: 0,
188
+ question: 'Proceed?',
189
+ multi_select: false,
190
+ options: [{ number: 1, label: 'Yes' }],
191
+ },
192
+ ],
193
+ },
194
+ },
195
+ },
196
+ ],
197
+ },
198
+ }
199
+
200
+ it('becomes a canopy menu a client can render', () => {
201
+ const [frame] = fromAgui(INTERRUPT)
202
+ const menu = (frame.data as unknown as { menu: Record<string, unknown> }).menu
203
+
204
+ expect(frame.event).toBe('session.menu')
205
+ expect(menu.question).toBe('Proceed?')
206
+ // The body is often the only thing that makes a dialog answerable away from
207
+ // the keyboard: "Do you want to proceed?" says nothing without it.
208
+ expect(menu.body).toBe('Phase 4 is test-gated.')
209
+ })
210
+
211
+ it('keeps every question, not only the first', () => {
212
+ // The TUI draws them as tabs and will not submit until each has an answer,
213
+ // so a client showing one cannot complete the ask however it is pressed.
214
+ const [frame] = fromAgui(INTERRUPT)
215
+ const menu = (frame.data as unknown as { menu: { questions: unknown[] } }).menu
216
+
217
+ expect(menu.questions).toHaveLength(1)
218
+ })
219
+
220
+ it('carries the staleness back as observed_at', () => {
221
+ const [frame] = fromAgui(INTERRUPT)
222
+ const menu = (frame.data as unknown as { menu: { observed_at: number } }).menu
223
+
224
+ expect(menu.observed_at).toBe(Date.parse('2026-09-15T10:00:00+00:00') / 1000)
225
+ })
226
+
227
+ it('retracts a menu when someone answered at the keyboard', () => {
228
+ const [frame] = fromAgui({ type: 'CUSTOM', name: 'canopy.menu.retracted', value: {} })
229
+
230
+ expect(frame).toEqual({ event: 'session.menu', data: { menu: null } })
231
+ })
232
+ })
233
+
234
+ describe('stopping is not erroring', () => {
235
+ it('reads a cancelled run as a cancelled stream', () => {
236
+ const [frame] = fromAgui({
237
+ type: 'RUN_FINISHED',
238
+ threadId: 't1',
239
+ runId: 'r1',
240
+ result: { cancelled: true, partial_len: 12 },
241
+ })
242
+
243
+ expect(frame.event).toBe('chat.stream_cancelled')
244
+ })
245
+
246
+ it('reads a plain finish as nothing at all', () => {
247
+ // canopy has no frame for "the run ended cleanly" — the stream_complete
248
+ // already said so. Inventing one would double-count the turn.
249
+ expect(fromAgui({ type: 'RUN_FINISHED', threadId: 't1', runId: 'r1' })).toEqual([])
250
+ })
251
+ })
252
+
253
+ describe('an unknown event cannot break a client', () => {
254
+ it('ignores an event type this version has never heard of', () => {
255
+ // AG-UI is 0.x and gains event types. A client that throws on one it does
256
+ // not know is a client that breaks on the protocol improving.
257
+ expect(fromAgui({ type: 'SOMETHING_NEW_IN_1_0', foo: 1 })).toEqual([])
258
+ })
259
+
260
+ it('ignores a CUSTOM event from someone else’s namespace', () => {
261
+ expect(fromAgui({ type: 'CUSTOM', name: 'vendor.thing', value: {} })).toEqual([])
262
+ })
263
+
264
+ it.each([{}, { type: null }, { type: 'TEXT_MESSAGE_CONTENT' }])(
265
+ 'does not throw on malformed input %#',
266
+ (frame) => {
267
+ expect(() => fromAgui(frame as Record<string, unknown>)).not.toThrow()
268
+ },
269
+ )
270
+ })
271
+
272
+ describe('the socket actually asks for it', () => {
273
+ /** A pure module nothing calls is a design document with a test suite — the
274
+ * mistake `apps/mcp/page_tools.py` made expensive once. These assert the
275
+ * option reaches the URL and the message handler, not just that the mapping
276
+ * is correct in isolation. */
277
+ it('is wired to the hook’s public option', () => {
278
+ const source = hookSource()
279
+
280
+ expect(source).toContain('protocol=ag-ui')
281
+ expect(source).toContain('fromAgui')
282
+ // And the default stays canopy's own vocabulary, which is what lets an
283
+ // existing consumer — ace-web installs this package from npm — notice
284
+ // nothing at all.
285
+ expect(source).toContain('protocol = "canopy"')
286
+ })
287
+
288
+ it('clears half-read state when it opens a connection', () => {
289
+ // Tool-call ids are per-stream, so an ARGS event from a new connection must
290
+ // not complete a call the previous one left open. The BEHAVIOUR is tested
291
+ // directly above ("does not carry a half-read call across a reconnect");
292
+ // what is checked here is that `connect` actually calls it, which no
293
+ // behavioural test of the pure module can see.
294
+ const connectBody = hookSource().slice(hookSource().indexOf('const connect ='))
295
+
296
+ expect(connectBody).toContain('resetAguiState()')
297
+ })
298
+ })
@@ -0,0 +1,282 @@
1
+ /**
2
+ * AG-UI events, read back into canopy's own session frames.
3
+ *
4
+ * The inverse of `apps/canopy_sessions/agui.py`. The server projects canopy →
5
+ * AG-UI; this reads AG-UI → canopy, so `sessionReducer` never learns a second
6
+ * vocabulary and every existing surface keeps working unchanged.
7
+ *
8
+ * **Why an inverse rather than a second reducer.** Teaching the reducer AG-UI
9
+ * directly would mean two code paths producing the same `SessionState`, and
10
+ * they would diverge — the multiplayer cases especially, which AG-UI does not
11
+ * model and which only canopy's path exercises. One reducer with a translator
12
+ * in front of it has a single behaviour to test.
13
+ *
14
+ * **The round trip is proved, not assumed.** `agui.fixture.json` is generated
15
+ * by the Python projection; a Python test asserts it is current and the test
16
+ * beside this one asserts this module recovers the original canopy frame from
17
+ * it. The projection and its inverse live in different languages, so a shared
18
+ * artifact is the only thing that can catch them drifting apart.
19
+ *
20
+ * **Lossless via `metadata`.** AG-UI has no slot for canopy's transcript
21
+ * ordinal (`turn_index`) or for the settled text of a whole message
22
+ * (`plaintext`), and the reducer needs both — one to sort a live row into the
23
+ * position it will occupy after a reload, the other to tell a web send from the
24
+ * echo of the same text arriving from the runner. They ride `metadata.canopy`,
25
+ * which is the extension point AG-UI reserves ("Every other key is user
26
+ * space"). Without them the projection would be a downgrade dressed as a
27
+ * standard.
28
+ */
29
+
30
+ import type { SessionMenu, WsEvent } from "./protocol";
31
+
32
+ /** Where canopy's own fields ride. Mirrors `agui.METADATA_KEY`. */
33
+ export const METADATA_KEY = "canopy";
34
+
35
+ /** Prefix on canopy's `CUSTOM` events. Mirrors `agui.CUSTOM_PREFIX`. */
36
+ export const CUSTOM_PREFIX = "canopy.";
37
+
38
+ /** An AG-UI event as it arrives on the wire: camelCase, unknown shape. */
39
+ type AguiFrame = Record<string, unknown>;
40
+
41
+ function meta(frame: AguiFrame): Record<string, unknown> {
42
+ const m = frame.metadata as Record<string, unknown> | undefined;
43
+ const mine = m?.[METADATA_KEY] as Record<string, unknown> | undefined;
44
+ return mine ?? {};
45
+ }
46
+
47
+ function str(value: unknown): string {
48
+ return typeof value === "string" ? value : "";
49
+ }
50
+
51
+ function num(value: unknown, fallback = 0): number {
52
+ return typeof value === "number" && Number.isFinite(value) ? value : fallback;
53
+ }
54
+
55
+ /**
56
+ * One AG-UI event → zero or more canopy frames.
57
+ *
58
+ * Zero is a real answer, twice over. Some AG-UI events have no canopy meaning
59
+ * (`RUN_STARTED` duplicates an activity frame canopy already sent); and an
60
+ * unknown event returns zero rather than throwing, because a protocol that
61
+ * gains an event type must not be able to break a client that has not been
62
+ * updated yet — which, for a 0.x protocol, is a matter of when rather than if.
63
+ */
64
+ export function fromAgui(frame: AguiFrame): WsEvent[] {
65
+ const type = typeof frame.type === "string" ? frame.type : "";
66
+ const m = meta(frame);
67
+
68
+ switch (type) {
69
+ case "TEXT_MESSAGE_START":
70
+ return [
71
+ {
72
+ event: "chat.stream_start",
73
+ data: { message_id: str(frame.messageId), turn_index: num(m.turn_index) },
74
+ },
75
+ ];
76
+
77
+ case "TEXT_MESSAGE_CONTENT":
78
+ return [
79
+ {
80
+ event: "chat.delta",
81
+ data: { message_id: str(frame.messageId), text: str(frame.delta) },
82
+ },
83
+ ];
84
+
85
+ case "TEXT_MESSAGE_END":
86
+ return [
87
+ {
88
+ event: "chat.stream_complete",
89
+ data: { message_id: str(frame.messageId), plaintext: str(m.plaintext) },
90
+ },
91
+ ];
92
+
93
+ case "TEXT_MESSAGE_CHUNK":
94
+ // Only the USER variant is canopy's `chat.user_message` — a human typing
95
+ // into emdash rather than into this page. An assistant chunk would be a
96
+ // whole reply arriving at once, which canopy's runner does not produce;
97
+ // mapping it to `stream_complete` with no preceding `stream_start` would
98
+ // hand the reducer a completion for a message it never opened.
99
+ if (str(frame.role) !== "user") return [];
100
+ return [
101
+ {
102
+ event: "chat.user_message",
103
+ data: {
104
+ message_id: str(frame.messageId),
105
+ turn_index: num(m.turn_index),
106
+ plaintext: str(frame.delta),
107
+ },
108
+ },
109
+ ];
110
+
111
+ case "TOOL_CALL_START":
112
+ // canopy carries the tool's payload on one frame; AG-UI streams the
113
+ // arguments on the ARGS event that follows. So nothing is emitted until
114
+ // ARGS arrives — a row rendered on START would have no input, which is
115
+ // the half-empty-tool-call bug the ACP notes warn about.
116
+ pendingCalls.set(str(frame.toolCallId), {
117
+ name: str(frame.toolCallName),
118
+ parentMessageId: str(frame.parentMessageId),
119
+ turnIndex: num(m.turn_index),
120
+ block: m.block as Record<string, unknown> | undefined,
121
+ });
122
+ return [];
123
+
124
+ case "TOOL_CALL_ARGS": {
125
+ const id = str(frame.toolCallId);
126
+ const pending = pendingCalls.get(id);
127
+ if (!pending) return [];
128
+ let input: unknown = {};
129
+ try {
130
+ input = JSON.parse(str(frame.delta) || "{}");
131
+ } catch {
132
+ // A fragment that is not valid JSON on its own is legal in AG-UI. The
133
+ // server sends arguments whole, so this means genuinely malformed
134
+ // input, and an empty object is a better row than a dropped one.
135
+ input = {};
136
+ }
137
+ // The runner's own payload where we have it, verbatim. canopy passes the
138
+ // block straight through from the transcript and never interprets it, so
139
+ // its shape belongs to the PRODUCER — reconstructing one from the AG-UI
140
+ // fields would invent a `type` and an `id` the real payload may not have
141
+ // carried, and hand the renderer something no runner ever sent.
142
+ const block = pending.block ?? { name: pending.name, input };
143
+ return [
144
+ {
145
+ event: "chat.tool_use",
146
+ data: {
147
+ parent_message_id: pending.parentMessageId || null,
148
+ tool_message_id: id,
149
+ turn_index: pending.turnIndex,
150
+ block,
151
+ },
152
+ },
153
+ ];
154
+ }
155
+
156
+ case "TOOL_CALL_END":
157
+ pendingCalls.delete(str(frame.toolCallId));
158
+ return [];
159
+
160
+ case "TOOL_CALL_RESULT":
161
+ return [
162
+ {
163
+ event: "chat.tool_result",
164
+ data: {
165
+ parent_message_id: (m.parent_message_id as string | undefined) ?? null,
166
+ tool_message_id: str(frame.toolCallId),
167
+ turn_index: num(m.turn_index),
168
+ block: (m.block as Record<string, unknown> | undefined) ?? { content: frame.content },
169
+ },
170
+ },
171
+ ];
172
+
173
+ case "ACTIVITY_SNAPSHOT": {
174
+ const content = (frame.content as Record<string, unknown>) ?? {};
175
+ const state = str(content.state);
176
+ if (!state) return [];
177
+ return [{ event: "session.activity", data: { state: state as "working" | "idle" | "blocked" } }];
178
+ }
179
+
180
+ case "RUN_FINISHED": {
181
+ const outcome = frame.outcome as Record<string, unknown> | undefined;
182
+ if (outcome?.type === "interrupt") {
183
+ const interrupts = (outcome.interrupts as Array<Record<string, unknown>>) ?? [];
184
+ return interrupts.map((i) => ({
185
+ event: "session.menu" as const,
186
+ data: { menu: interruptToMenu(i) },
187
+ }));
188
+ }
189
+ const result = frame.result as Record<string, unknown> | undefined;
190
+ if (result?.cancelled) {
191
+ return [
192
+ {
193
+ event: "chat.stream_cancelled",
194
+ data: { message_id: null, partial_len: num(result.partial_len) },
195
+ },
196
+ ];
197
+ }
198
+ return [];
199
+ }
200
+
201
+ case "RUN_ERROR":
202
+ return [
203
+ {
204
+ event: "session.error",
205
+ data: { code: str(frame.code) || "run_error", message: str(frame.message) },
206
+ },
207
+ ];
208
+
209
+ case "STATE_DELTA": {
210
+ // Only the one patch canopy sends. A general JSON-Patch applier would be
211
+ // a second source of truth for session state, which is the reducer's job.
212
+ const ops = (frame.delta as Array<Record<string, unknown>>) ?? [];
213
+ const title = ops.find((op) => op.path === "/title");
214
+ if (!title) return [];
215
+ return [{ event: "session.title_updated", data: { title: str(title.value) } }];
216
+ }
217
+
218
+ case "CUSTOM": {
219
+ // canopy's own vocabulary, coming home. Drafts, presence and placement
220
+ // have no AG-UI spelling because the protocol models one user and one
221
+ // agent; they were namespaced on the way out and are unwrapped here.
222
+ const name = str(frame.name);
223
+ if (!name.startsWith(CUSTOM_PREFIX)) return [];
224
+ const event = name.slice(CUSTOM_PREFIX.length);
225
+ if (event === "menu.retracted") return [{ event: "session.menu", data: { menu: null } }];
226
+ if (event === "menu") {
227
+ return [{ event: "session.menu", data: { menu: frame.value as never } }];
228
+ }
229
+ return [{ event, data: frame.value } as unknown as WsEvent];
230
+ }
231
+
232
+ default:
233
+ return [];
234
+ }
235
+ }
236
+
237
+ /** Arguments arrive on a later event than the tool's name, so the head of a
238
+ * call is held until they do. Module-level because a socket is a stream and
239
+ * the pairing spans events; `resetAguiState` exists so a test — and a
240
+ * reconnect — can start from nothing rather than inheriting a half-read call
241
+ * from the connection before. */
242
+ const pendingCalls = new Map<
243
+ string,
244
+ {
245
+ name: string;
246
+ parentMessageId: string;
247
+ turnIndex: number;
248
+ block?: Record<string, unknown>;
249
+ }
250
+ >();
251
+
252
+ export function resetAguiState(): void {
253
+ pendingCalls.clear();
254
+ }
255
+
256
+ /** An AG-UI interrupt back into canopy's menu shape.
257
+ *
258
+ * The fields line up because the protocol independently arrived at the same
259
+ * ones: `expiresAt` is canopy's `observed_at` (a dialog lives on a terminal
260
+ * and this is a copy, so it has to carry its own age), and the option list
261
+ * lives in `metadata.canopy.questions` because a response schema can say a
262
+ * field takes a list but not that the TUI draws it as checkboxes a number key
263
+ * TOGGLES rather than answers. */
264
+ function interruptToMenu(interrupt: Record<string, unknown>): SessionMenu {
265
+ const m = (interrupt.metadata as Record<string, unknown> | undefined)?.[METADATA_KEY] as
266
+ | Record<string, unknown>
267
+ | undefined;
268
+ const questions = (m?.questions as Array<Record<string, unknown>>) ?? [];
269
+ const first = questions[0] ?? {};
270
+ return {
271
+ question: str(first.question) || str(interrupt.message),
272
+ title: str(interrupt.message),
273
+ body: str(m?.body),
274
+ source: str(interrupt.reason),
275
+ questions: questions as unknown as SessionMenu["questions"],
276
+ options: (first.options as unknown as SessionMenu["options"]) ?? [],
277
+ answer_error: str(m?.answer_error) || undefined,
278
+ answer_note: str(m?.answer_note) || undefined,
279
+ restored: Boolean(m?.restored),
280
+ observed_at: interrupt.expiresAt ? Date.parse(str(interrupt.expiresAt)) / 1000 : undefined,
281
+ };
282
+ }
package/src/chat/index.ts CHANGED
@@ -17,6 +17,10 @@ export type {
17
17
  WsEvent,
18
18
  } from "./protocol";
19
19
 
20
+ // REST <-> kit conversion. Shared because both hosts that read a transcript over
21
+ // REST were writing this out by hand, against this kit's own Message type.
22
+ export { restToKitMessage, type RestMessage } from "./restMessage";
23
+
20
24
  // Reducer (pure)
21
25
  export { sessionReducer } from "./sessionReducer";
22
26
  export { prependHistory } from "./history";
@@ -67,3 +71,6 @@ export {
67
71
  type PlacementBannerProps,
68
72
  type PlacementRunner,
69
73
  } from "./PlacementBanner";
74
+ // The AG-UI projection's inverse. Exported so a consumer can translate a stream
75
+ // it obtained some other way — and so the round-trip test can reach it.
76
+ export { fromAgui, resetAguiState, CUSTOM_PREFIX, METADATA_KEY } from "./agui";
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import { prependHistory } from "./history";
4
+ import { restToKitMessage, type RestMessage } from "./restMessage";
5
+ import type { Message } from "./protocol";
6
+
7
+ const row: RestMessage = {
8
+ turn_index: 7,
9
+ role: "assistant",
10
+ content: { text: "hello" },
11
+ plaintext: "hello",
12
+ created_at: "2026-09-16T10:00:00Z",
13
+ };
14
+
15
+ describe("restToKitMessage", () => {
16
+ it("maps a REST row onto the kit's Message shape", () => {
17
+ expect(restToKitMessage(row)).toEqual({
18
+ id: "t7",
19
+ turn_index: 7,
20
+ role: "assistant",
21
+ content: { text: "hello" },
22
+ plaintext: "hello",
23
+ status: "complete",
24
+ error_detail: null,
25
+ started_at: null,
26
+ completed_at: "2026-09-16T10:00:00Z",
27
+ created_at: "2026-09-16T10:00:00Z",
28
+ });
29
+ });
30
+
31
+ it("gives a row read back from REST no streaming history", () => {
32
+ // A REST row was never watched arriving, so claiming a `started_at` would
33
+ // invent a fact. `completed_at` is what the server does know.
34
+ const m = restToKitMessage(row);
35
+ expect(m.started_at).toBeNull();
36
+ expect(m.completed_at).toBe(row.created_at);
37
+ });
38
+
39
+ it("accepts a readonly generated row (what both hosts actually pass)", () => {
40
+ // openapi-typescript emits every field `readonly`. Property readonly-ness
41
+ // does not affect assignability, and this asserts that stays true — it is
42
+ // the reason `RestMessage` can be declared structurally instead of as
43
+ // either host's generated type.
44
+ const generated: { readonly [K in keyof RestMessage]: RestMessage[K] } = row;
45
+ expect(restToKitMessage(generated).id).toBe("t7");
46
+ });
47
+
48
+ it("does not collide with the live WS row for the same turn", () => {
49
+ // The synthetic `t<turn_index>` id is only safe because prependHistory
50
+ // dedupes on turn_index. If that ever changed to dedupe on id, this fails.
51
+ const live: Message = { ...restToKitMessage(row), id: "1234", status: "streaming" };
52
+ const merged = prependHistory([live], [restToKitMessage(row)]);
53
+ expect(merged).toHaveLength(1);
54
+ expect(merged[0].id).toBe("1234");
55
+ });
56
+ });
@@ -0,0 +1,54 @@
1
+ /**
2
+ * One REST transcript row -> the kit's `Message`.
3
+ *
4
+ * This lived twice, written out by hand in both hosts that read a transcript
5
+ * over REST and then hand it to this kit: canopy-web's own
6
+ * `pages/chatPageLogic.ts` and ace-web's `canopy/CanopyChatPanel.tsx`, whose
7
+ * copy carried the comment "mirrors canopy-web's own
8
+ * `chatPageLogic.ts::restToKitMessage`" — an accurate description of a bug
9
+ * waiting to happen. It is a pure function OF this kit's own `Message` type
10
+ * against canopy's own `MessageOut` wire schema, so neither host was ever the
11
+ * right owner of it; both were translating between two shapes they had each
12
+ * imported from somewhere else.
13
+ *
14
+ * The input is declared structurally rather than as either host's generated
15
+ * type, because the hosts generate their own: canopy-web has
16
+ * `components["schemas"]["MessageOut"]`, ace-web casts an `unknown` row. Both
17
+ * are assignable to `RestMessage`, and a generated type's `readonly` property
18
+ * modifiers do not affect that.
19
+ */
20
+
21
+ import type { Message } from "./protocol";
22
+
23
+ /** canopy's `MessageOut` (apps/canopy_sessions/schemas.py), structurally. */
24
+ export interface RestMessage {
25
+ turn_index: number;
26
+ role: string;
27
+ content: Record<string, unknown>;
28
+ plaintext: string;
29
+ created_at: string;
30
+ }
31
+
32
+ /**
33
+ * Synthetic id (`t<turn_index>`) + `status: "complete"`.
34
+ *
35
+ * `prependHistory` dedupes on `turn_index`, not on `id`, so a synthetic id can
36
+ * never collide with the live WS row for the same turn — which is the whole
37
+ * reason it is safe to invent one here. `started_at` is null because a row read
38
+ * back from REST has no streaming history to report; `completed_at` takes
39
+ * `created_at`, which is when the turn finished as far as the server records it.
40
+ */
41
+ export function restToKitMessage(m: RestMessage): Message {
42
+ return {
43
+ id: `t${m.turn_index}`,
44
+ turn_index: m.turn_index,
45
+ role: m.role as Message["role"],
46
+ content: m.content,
47
+ plaintext: m.plaintext,
48
+ status: "complete",
49
+ error_detail: null,
50
+ started_at: null,
51
+ completed_at: m.created_at,
52
+ created_at: m.created_at,
53
+ };
54
+ }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
2
 
3
+ import { fromAgui, resetAguiState } from "./agui";
3
4
  import type { Message, SessionState, WsEvent } from "./protocol";
4
5
  import { shouldSyncDraftLive } from "./drafts";
5
6
  import { prependHistory } from "./history";
@@ -32,6 +33,29 @@ export interface UseSessionSocketOptions {
32
33
  * has no opinion on what to do with it.
33
34
  */
34
35
  onTitleUpdated?: () => void;
36
+ /**
37
+ * A frame the kit does not understand.
38
+ *
39
+ * The kit stays agnostic: canopy grew `session.page_action` (an agent asking
40
+ * the embedded page to do something) and ace-web will grow its own. Teaching
41
+ * the reducer about each would make a shared kit carry one app's vocabulary.
42
+ * Same shape as `onTitleUpdated` — handed over, no opinion taken.
43
+ */
44
+ onUnknownEvent?: (frame: WsEvent) => void;
45
+ /**
46
+ * Which vocabulary to ask the server for.
47
+ *
48
+ * `"canopy"` (the default) is this kit's own frames, unchanged — the reason
49
+ * an existing consumer, including ace-web installing `canopy-ui` from npm,
50
+ * notices nothing. `"ag-ui"` asks the server to project the same conversation
51
+ * into AG-UI and translates it back here, so the reducer never learns a
52
+ * second vocabulary and there is one behaviour to test rather than two.
53
+ *
54
+ * Opting in buys interoperability, not features: a canopy frame and its
55
+ * AG-UI projection reduce to identical state (`agui.test.ts` asserts exactly
56
+ * that against a fixture the server generates).
57
+ */
58
+ protocol?: "canopy" | "ag-ui";
35
59
  }
36
60
 
37
61
  export interface UseSessionSocketResult {
@@ -51,10 +75,27 @@ export interface UseSessionSocketResult {
51
75
  lastError: string | null;
52
76
  }
53
77
 
78
+ /**
79
+ * Frames `sessionReducer` understands. Anything else is handed to
80
+ * `onUnknownEvent` rather than dropped — the reducer ignores what it does not
81
+ * recognise, which silently swallows an app-specific frame and leaves the
82
+ * container wondering why its feature never fires.
83
+ *
84
+ * Keep in step with sessionReducer's own switch.
85
+ */
86
+ const KNOWN_EVENTS = new Set([
87
+ "chat.delta", "chat.stream_cancelled", "chat.stream_complete",
88
+ "chat.stream_error", "chat.stream_start", "chat.tool_result",
89
+ "chat.tool_use", "chat.user_message", "session.activity", "session.error",
90
+ "session.menu", "session.state", "session.stop", "session.title_updated",
91
+ ]);
92
+
54
93
  export function useSessionSocket({
55
94
  sessionId,
56
95
  wsUrl,
57
96
  onTitleUpdated,
97
+ onUnknownEvent,
98
+ protocol = "canopy",
58
99
  }: UseSessionSocketOptions): UseSessionSocketResult {
59
100
  const [state, setState] = useState<SessionState>(INITIAL_STATE);
60
101
  const [connected, setConnected] = useState(false);
@@ -73,6 +114,13 @@ export function useSessionSocket({
73
114
  const pendingDraftBodyRef = useRef<string | null>(null);
74
115
  const closedByUserRef = useRef(false);
75
116
  const onTitleUpdatedRef = useRef(onTitleUpdated);
117
+ const onUnknownEventRef = useRef(onUnknownEvent);
118
+ // A ref, like the callbacks above: `connect` is a stable callback with empty
119
+ // deps, so reading the prop directly would pin whatever it was on first
120
+ // render — and a socket that reconnects would silently drop back to the other
121
+ // vocabulary mid-session.
122
+ const protocolRef = useRef(protocol);
123
+ protocolRef.current = protocol;
76
124
  // Control frames that must not be lost across a reconnect (currently
77
125
  // only chat.stop). The WS-world analogue of an abortable chat transport.
78
126
  const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
@@ -85,6 +133,10 @@ export function useSessionSocket({
85
133
  onTitleUpdatedRef.current = onTitleUpdated;
86
134
  }, [onTitleUpdated]);
87
135
 
136
+ useEffect(() => {
137
+ onUnknownEventRef.current = onUnknownEvent;
138
+ }, [onUnknownEvent]);
139
+
88
140
  const send = useCallback((frame: { action: string; data: unknown }) => {
89
141
  const ws = socketRef.current;
90
142
  if (ws && ws.readyState === WebSocket.OPEN) {
@@ -134,12 +186,25 @@ export function useSessionSocket({
134
186
  }
135
187
  }
136
188
  }
189
+ // The reducer ignores anything it does not know, which silently drops an
190
+ // app-specific frame. Hand it over instead, so the container can act on
191
+ // vocabulary the shared kit deliberately does not carry.
192
+ if (!KNOWN_EVENTS.has(frame.event)) {
193
+ onUnknownEventRef.current?.(frame);
194
+ return;
195
+ }
137
196
  setState((prev) => sessionReducer(prev, frame));
138
197
  }, []);
139
198
 
140
199
  const connect = useCallback(() => {
141
200
  if (closedByUserRef.current) return;
142
- const ws = new WebSocket(wsUrl(`ws/canopy-sessions/${sessionId}/`));
201
+ // A half-read tool call from the previous connection must not be completed
202
+ // by an ARGS event from this one — the ids are per-stream.
203
+ resetAguiState();
204
+ const path = `ws/canopy-sessions/${sessionId}/`;
205
+ const ws = new WebSocket(
206
+ wsUrl(protocolRef.current === "ag-ui" ? `${path}?protocol=ag-ui` : path),
207
+ );
143
208
  socketRef.current = ws;
144
209
 
145
210
  ws.onopen = () => {
@@ -162,8 +227,14 @@ export function useSessionSocket({
162
227
 
163
228
  ws.onmessage = (e) => {
164
229
  try {
165
- const frame = JSON.parse(e.data) as WsEvent;
166
- applyEvent(frame);
230
+ const raw = JSON.parse(e.data);
231
+ if (protocolRef.current === "ag-ui") {
232
+ // One AG-UI event can be several canopy frames (a tool call is three
233
+ // events) or none, so this is a fan-out rather than a rename.
234
+ for (const frame of fromAgui(raw)) applyEvent(frame);
235
+ return;
236
+ }
237
+ applyEvent(raw as WsEvent);
167
238
  } catch {
168
239
  // ignore malformed frames
169
240
  }
@@ -12,8 +12,10 @@ export function workbenchNavItemClass({
12
12
  variant === 'neutral'
13
13
  ? 'bg-accent border-transparent text-foreground font-medium'
14
14
  : 'bg-primary/10 border-primary/30 text-primary font-medium'
15
+ // `min-h-11` below `sm` is the 44px touch minimum; from `sm` up the rail keeps
16
+ // its original density, where the pointer is precise and vertical space is dear.
15
17
  return cn(
16
- 'flex items-center justify-between gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors',
18
+ 'flex min-h-11 items-center justify-between gap-2 rounded-md border px-3 py-1.5 text-sm transition-colors sm:min-h-0',
17
19
  active
18
20
  ? activeClass
19
21
  : 'border-transparent text-muted-foreground hover:bg-accent hover:text-foreground',
package/src/ui/button.tsx CHANGED
@@ -4,6 +4,11 @@ import { cva, type VariantProps } from "class-variance-authority"
4
4
  import { cn } from "../lib/cn"
5
5
 
6
6
  const buttonVariants = cva(
7
+ // Every size variant sets a fixed height (default h-8, sm h-7, xs h-6), all of
8
+ // them below the 44px touch minimum. Rather than teach each variant about
9
+ // touch, the floor lives here once and lifts off at `sm`, where a pointer is
10
+ // precise and the density is the point.
11
+ "min-h-11 min-w-11 sm:min-h-0 sm:min-w-0 " +
7
12
  "group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
8
13
  {
9
14
  variants: {
package/src/ui/input.tsx CHANGED
@@ -9,7 +9,9 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
9
9
  type={type}
10
10
  data-slot="input"
11
11
  className={cn(
12
- "h-8 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
12
+ // Touch floor, matching Button: `h-8` is 32px, under the 44px minimum. Lifts
13
+ // off at `sm` so desktop density is unchanged.
14
+ "min-h-11 sm:min-h-0 sm:h-8 h-11 w-full min-w-0 rounded-lg border border-input bg-transparent px-2.5 py-1 text-base transition-colors outline-none file:inline-flex file:h-6 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none disabled:cursor-not-allowed disabled:bg-input/50 disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 md:text-sm dark:bg-input/30 dark:disabled:bg-input/80 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40",
13
15
  className
14
16
  )}
15
17
  {...props}
package/src/ui/tabs.tsx CHANGED
@@ -23,8 +23,17 @@ function Tabs({
23
23
  )
24
24
  }
25
25
 
26
+ // Touch first, then shrink to the desktop density at `sm`.
27
+ //
28
+ // `w-fit` + a fixed `h-8` was two bugs at once on a phone: the list sized itself
29
+ // to its content, so a fourth tab ran off the viewport with no scroll and no
30
+ // wrap (measured on /supervisor at 375px: scrollWidth 302 vs clientWidth 293,
31
+ // clipping "Runners"), and the height capped every trigger at 23px — roughly
32
+ // half the 44px touch minimum, on the surface that ships as the phone PWA.
33
+ // Below `sm` the list is now full-width and wraps; from `sm` up it is exactly
34
+ // what it was.
26
35
  const tabsListVariants = cva(
27
- "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
36
+ "group/tabs-list grid w-full max-w-full grid-cols-2 items-center justify-center rounded-lg p-[3px] text-muted-foreground sm:inline-flex sm:w-fit sm:grid-cols-none group-data-horizontal/tabs:h-auto sm:group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
28
37
  {
29
38
  variants: {
30
39
  variant: {
@@ -58,7 +67,7 @@ function TabsTrigger({ className, ...props }: TabsPrimitive.Tab.Props) {
58
67
  <TabsPrimitive.Tab
59
68
  data-slot="tabs-trigger"
60
69
  className={cn(
61
- "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2.5 py-0.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground-secondary focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:outline-1 focus-visible:outline-primary/40 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
70
+ "relative inline-flex min-h-11 flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-2 py-0.5 text-sm font-medium whitespace-nowrap text-muted-foreground transition-all sm:h-[calc(100%-1px)] sm:min-h-0 sm:px-2.5 group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground-secondary focus-visible:border-primary/50 focus-visible:ring-2 focus-visible:ring-primary/20 focus-visible:outline-1 focus-visible:outline-primary/40 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
62
71
  "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent group-data-[variant=line]/tabs-list:data-active:border-transparent",
63
72
  "data-active:bg-background data-active:text-foreground data-active:border-border",
64
73
  "after:absolute after:bg-primary after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",