canopy-ui 0.7.0 → 0.9.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 +1 -1
- package/src/chat/agui.fixture.json +580 -0
- package/src/chat/agui.test.ts +302 -0
- package/src/chat/agui.ts +295 -0
- package/src/chat/index.ts +7 -0
- package/src/chat/restMessage.test.ts +56 -0
- package/src/chat/restMessage.ts +54 -0
- package/src/chat/useSessionSocket.protocol.test.tsx +124 -0
- package/src/chat/useSessionSocket.ts +113 -3
- package/src/shell/WorkbenchNavItem.tsx +3 -1
- package/src/ui/button.tsx +5 -0
- package/src/ui/input.tsx +3 -1
- package/src/ui/tabs.tsx +11 -2
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, renderHook } from '@testing-library/react'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { useSessionSocket, withAguiProtocol } from './useSessionSocket'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* What the socket REALLY opens, and what it does with what comes back.
|
|
9
|
+
*
|
|
10
|
+
* This replaced an assertion that the hook's source contained `protocol=ag-ui`.
|
|
11
|
+
* That was true the whole time the flag was being dropped: the hook put it on
|
|
12
|
+
* the PATH it handed the caller's URL builder, and two of the first three
|
|
13
|
+
* builders ignore their path. Only opening a socket and reading its URL can see
|
|
14
|
+
* that — so that is what these do.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
class FakeSocket {
|
|
18
|
+
static opened: FakeSocket[] = []
|
|
19
|
+
onopen: (() => void) | null = null
|
|
20
|
+
onmessage: ((e: { data: string }) => void) | null = null
|
|
21
|
+
onclose: (() => void) | null = null
|
|
22
|
+
onerror: (() => void) | null = null
|
|
23
|
+
readyState = 0
|
|
24
|
+
constructor(public url: string) {
|
|
25
|
+
FakeSocket.opened.push(this)
|
|
26
|
+
}
|
|
27
|
+
send() {}
|
|
28
|
+
close() {}
|
|
29
|
+
receive(frame: unknown) {
|
|
30
|
+
this.onmessage?.({ data: JSON.stringify(frame) })
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
FakeSocket.opened = []
|
|
36
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
37
|
+
})
|
|
38
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
39
|
+
|
|
40
|
+
function lastUrl(): string {
|
|
41
|
+
return FakeSocket.opened.at(-1)!.url
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('the flag survives whatever URL the caller builds', () => {
|
|
45
|
+
it('with a builder that uses the path it is given', () => {
|
|
46
|
+
renderHook(() =>
|
|
47
|
+
useSessionSocket({ sessionId: 's1', wsUrl: (p) => `wss://h/${p}`, protocol: 'ag-ui' }),
|
|
48
|
+
)
|
|
49
|
+
expect(lastUrl()).toBe('wss://h/ws/canopy-sessions/s1/?protocol=ag-ui')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('with a builder that IGNORES its path — canopy-web’s widget', () => {
|
|
53
|
+
// `() => client.sessionSocketUrl(sessionId)`: the widget's real shape.
|
|
54
|
+
renderHook(() =>
|
|
55
|
+
useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/ws/canopy-sessions/s1/', protocol: 'ag-ui' }),
|
|
56
|
+
)
|
|
57
|
+
expect(lastUrl()).toContain('protocol=ag-ui')
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('with a builder that carries its own token — ace-web', () => {
|
|
61
|
+
// `buildCanopyWsUrl(base, id)` puts a token in the query, so the flag has
|
|
62
|
+
// to JOIN the query rather than start a second one.
|
|
63
|
+
renderHook(() =>
|
|
64
|
+
useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/ws/canopy-sessions/s1/?token=abc', protocol: 'ag-ui' }),
|
|
65
|
+
)
|
|
66
|
+
expect(lastUrl()).toBe('wss://h/ws/canopy-sessions/s1/?token=abc&protocol=ag-ui')
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('asks for nothing when the caller asks for nothing', () => {
|
|
70
|
+
// The default is canopy's own frames, which is what an un-upgraded consumer
|
|
71
|
+
// must keep getting.
|
|
72
|
+
renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x/?token=abc' }))
|
|
73
|
+
expect(lastUrl()).toBe('wss://h/x/?token=abc')
|
|
74
|
+
})
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
describe('withAguiProtocol', () => {
|
|
78
|
+
it('does not add the flag twice', () => {
|
|
79
|
+
expect(withAguiProtocol('wss://h/x/?protocol=ag-ui')).toBe('wss://h/x/?protocol=ag-ui')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('leaves an empty URL empty — the caller’s "not yet"', () => {
|
|
83
|
+
expect(withAguiProtocol('')).toBe('')
|
|
84
|
+
})
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
describe('a server that answers in canopy frames anyway', () => {
|
|
88
|
+
it('is understood, not decoded to nothing', () => {
|
|
89
|
+
// Asked for AG-UI, got native: an old server, or a flag lost on the way.
|
|
90
|
+
// Decoding these as AG-UI yields zero frames — a blank chat with no error.
|
|
91
|
+
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
92
|
+
const onTitleUpdated = vi.fn()
|
|
93
|
+
renderHook(() =>
|
|
94
|
+
useSessionSocket({ sessionId: 's1', wsUrl: (p) => `wss://h/${p}`, protocol: 'ag-ui', onTitleUpdated }),
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
act(() => {
|
|
98
|
+
FakeSocket.opened.at(-1)!.receive({
|
|
99
|
+
event: 'session.title_updated',
|
|
100
|
+
data: { title: 'Still readable' },
|
|
101
|
+
})
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
expect(onTitleUpdated).toHaveBeenCalledTimes(1)
|
|
105
|
+
expect(warn).toHaveBeenCalledTimes(1)
|
|
106
|
+
warn.mockRestore()
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
it('still decodes real AG-UI events as AG-UI', () => {
|
|
110
|
+
const onTitleUpdated = vi.fn()
|
|
111
|
+
renderHook(() =>
|
|
112
|
+
useSessionSocket({ sessionId: 's1', wsUrl: (p) => `wss://h/${p}`, protocol: 'ag-ui', onTitleUpdated }),
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
act(() => {
|
|
116
|
+
FakeSocket.opened.at(-1)!.receive({
|
|
117
|
+
type: 'STATE_DELTA',
|
|
118
|
+
delta: [{ op: 'replace', path: '/title', value: 'Via AG-UI' }],
|
|
119
|
+
})
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
expect(onTitleUpdated).toHaveBeenCalledTimes(1)
|
|
123
|
+
})
|
|
124
|
+
})
|
|
@@ -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,50 @@ 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";
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ask for AG-UI on the URL the caller built — whatever it looks like.
|
|
63
|
+
*
|
|
64
|
+
* The hook OWNS this flag rather than handing the caller a path with it already
|
|
65
|
+
* attached, because that contract was invisible and two of the first three
|
|
66
|
+
* callers broke it: canopy-web's widget builds `client.sessionSocketUrl(id)` and
|
|
67
|
+
* ace-web builds `buildCanopyWsUrl(base, id)`, and both ignore the path they are
|
|
68
|
+
* given (they need their own token in the query). The flag was dropped, the
|
|
69
|
+
* server answered in native frames, and every one of them decoded as AG-UI to
|
|
70
|
+
* nothing — a blank chat with no error, caught only in review before it shipped.
|
|
71
|
+
*
|
|
72
|
+
* Exported for the test, which asserts on the URL a socket really opens.
|
|
73
|
+
*/
|
|
74
|
+
export function withAguiProtocol(url: string): string {
|
|
75
|
+
// Empty is a caller saying "no URL yet" (the widget before it has a
|
|
76
|
+
// session); decorating it would turn a deliberate no-op into a bad request.
|
|
77
|
+
if (!url) return url;
|
|
78
|
+
if (/[?&]protocol=/.test(url)) return url;
|
|
79
|
+
return `${url}${url.includes("?") ? "&" : "?"}protocol=ag-ui`;
|
|
35
80
|
}
|
|
36
81
|
|
|
37
82
|
export interface UseSessionSocketResult {
|
|
@@ -51,10 +96,27 @@ export interface UseSessionSocketResult {
|
|
|
51
96
|
lastError: string | null;
|
|
52
97
|
}
|
|
53
98
|
|
|
99
|
+
/**
|
|
100
|
+
* Frames `sessionReducer` understands. Anything else is handed to
|
|
101
|
+
* `onUnknownEvent` rather than dropped — the reducer ignores what it does not
|
|
102
|
+
* recognise, which silently swallows an app-specific frame and leaves the
|
|
103
|
+
* container wondering why its feature never fires.
|
|
104
|
+
*
|
|
105
|
+
* Keep in step with sessionReducer's own switch.
|
|
106
|
+
*/
|
|
107
|
+
const KNOWN_EVENTS = new Set([
|
|
108
|
+
"chat.delta", "chat.stream_cancelled", "chat.stream_complete",
|
|
109
|
+
"chat.stream_error", "chat.stream_start", "chat.tool_result",
|
|
110
|
+
"chat.tool_use", "chat.user_message", "session.activity", "session.error",
|
|
111
|
+
"session.menu", "session.state", "session.stop", "session.title_updated",
|
|
112
|
+
]);
|
|
113
|
+
|
|
54
114
|
export function useSessionSocket({
|
|
55
115
|
sessionId,
|
|
56
116
|
wsUrl,
|
|
57
117
|
onTitleUpdated,
|
|
118
|
+
onUnknownEvent,
|
|
119
|
+
protocol = "canopy",
|
|
58
120
|
}: UseSessionSocketOptions): UseSessionSocketResult {
|
|
59
121
|
const [state, setState] = useState<SessionState>(INITIAL_STATE);
|
|
60
122
|
const [connected, setConnected] = useState(false);
|
|
@@ -73,6 +135,14 @@ export function useSessionSocket({
|
|
|
73
135
|
const pendingDraftBodyRef = useRef<string | null>(null);
|
|
74
136
|
const closedByUserRef = useRef(false);
|
|
75
137
|
const onTitleUpdatedRef = useRef(onTitleUpdated);
|
|
138
|
+
const onUnknownEventRef = useRef(onUnknownEvent);
|
|
139
|
+
// A ref, like the callbacks above: `connect` is a stable callback with empty
|
|
140
|
+
// deps, so reading the prop directly would pin whatever it was on first
|
|
141
|
+
// render — and a socket that reconnects would silently drop back to the other
|
|
142
|
+
// vocabulary mid-session.
|
|
143
|
+
const protocolRef = useRef(protocol);
|
|
144
|
+
protocolRef.current = protocol;
|
|
145
|
+
const warnedNativeRef = useRef(false);
|
|
76
146
|
// Control frames that must not be lost across a reconnect (currently
|
|
77
147
|
// only chat.stop). The WS-world analogue of an abortable chat transport.
|
|
78
148
|
const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
|
|
@@ -85,6 +155,10 @@ export function useSessionSocket({
|
|
|
85
155
|
onTitleUpdatedRef.current = onTitleUpdated;
|
|
86
156
|
}, [onTitleUpdated]);
|
|
87
157
|
|
|
158
|
+
useEffect(() => {
|
|
159
|
+
onUnknownEventRef.current = onUnknownEvent;
|
|
160
|
+
}, [onUnknownEvent]);
|
|
161
|
+
|
|
88
162
|
const send = useCallback((frame: { action: string; data: unknown }) => {
|
|
89
163
|
const ws = socketRef.current;
|
|
90
164
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
|
@@ -134,12 +208,26 @@ export function useSessionSocket({
|
|
|
134
208
|
}
|
|
135
209
|
}
|
|
136
210
|
}
|
|
211
|
+
// The reducer ignores anything it does not know, which silently drops an
|
|
212
|
+
// app-specific frame. Hand it over instead, so the container can act on
|
|
213
|
+
// vocabulary the shared kit deliberately does not carry.
|
|
214
|
+
if (!KNOWN_EVENTS.has(frame.event)) {
|
|
215
|
+
onUnknownEventRef.current?.(frame);
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
137
218
|
setState((prev) => sessionReducer(prev, frame));
|
|
138
219
|
}, []);
|
|
139
220
|
|
|
140
221
|
const connect = useCallback(() => {
|
|
141
222
|
if (closedByUserRef.current) return;
|
|
142
|
-
|
|
223
|
+
// A half-read tool call from the previous connection must not be completed
|
|
224
|
+
// by an ARGS event from this one — the ids are per-stream.
|
|
225
|
+
resetAguiState();
|
|
226
|
+
const path = `ws/canopy-sessions/${sessionId}/`;
|
|
227
|
+
const built = wsUrl(path);
|
|
228
|
+
const ws = new WebSocket(
|
|
229
|
+
protocolRef.current === "ag-ui" ? withAguiProtocol(built) : built,
|
|
230
|
+
);
|
|
143
231
|
socketRef.current = ws;
|
|
144
232
|
|
|
145
233
|
ws.onopen = () => {
|
|
@@ -162,8 +250,30 @@ export function useSessionSocket({
|
|
|
162
250
|
|
|
163
251
|
ws.onmessage = (e) => {
|
|
164
252
|
try {
|
|
165
|
-
const
|
|
166
|
-
|
|
253
|
+
const raw = JSON.parse(e.data);
|
|
254
|
+
if (protocolRef.current === "ag-ui") {
|
|
255
|
+
// We asked for AG-UI and the server answered in canopy's own frames:
|
|
256
|
+
// a server that predates the negotiation, or a URL that lost the flag
|
|
257
|
+
// on the way (see `withAguiProtocol`). The two are unambiguous — every
|
|
258
|
+
// AG-UI event has a `type`, and no canopy frame does — so apply it as
|
|
259
|
+
// what it is. Decoding it as AG-UI yields nothing, which is a blank
|
|
260
|
+
// chat with no error: the worst possible way to find out.
|
|
261
|
+
if (typeof raw?.type !== "string" && typeof raw?.event === "string") {
|
|
262
|
+
if (!warnedNativeRef.current) {
|
|
263
|
+
warnedNativeRef.current = true;
|
|
264
|
+
console.warn(
|
|
265
|
+
"canopy-ui: asked for protocol=ag-ui but the server sent canopy frames; handling them as canopy frames.",
|
|
266
|
+
);
|
|
267
|
+
}
|
|
268
|
+
applyEvent(raw as WsEvent);
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
// One AG-UI event can be several canopy frames (a tool call is three
|
|
272
|
+
// events) or none, so this is a fan-out rather than a rename.
|
|
273
|
+
for (const frame of fromAgui(raw)) applyEvent(frame);
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
applyEvent(raw as WsEvent);
|
|
167
277
|
} catch {
|
|
168
278
|
// ignore malformed frames
|
|
169
279
|
}
|
|
@@ -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
|
-
|
|
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
|
|
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-
|
|
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",
|