@flamingo-stack/openframe-frontend-core 0.0.701 → 0.0.702-snapshot.20261005191338
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/dist/{chunk-DJLZXJYB.js → chunk-2NE3DNXY.js} +1 -1
- package/dist/{chunk-V5QNDOEL.js → chunk-53AC23ZN.js} +4 -4
- package/dist/{chunk-BQ34PIFC.cjs → chunk-5VPIC5MD.cjs} +3 -3
- package/dist/{chunk-BRQY2HKD.cjs → chunk-67CXGPTW.cjs} +24 -24
- package/dist/{chunk-BZYMGI4L.js → chunk-7632TNE7.js} +1 -1
- package/dist/{chunk-QPNR4N7N.cjs → chunk-BMJUKS6S.cjs} +20 -20
- package/dist/{chunk-GD4JALTR.js → chunk-BOUPNYUV.js} +2 -2
- package/dist/{chunk-BPS7JQCI.cjs → chunk-BSEIEWM5.cjs} +2 -2
- package/dist/{chunk-6JG75WVO.js → chunk-DAW6TDZB.js} +4 -4
- package/dist/{chunk-B7M6QEEM.js → chunk-ENGBNKUJ.js} +673 -432
- package/dist/{chunk-N7HMUQRV.js → chunk-G55W76S6.js} +1 -1
- package/dist/{chunk-65YVUYWJ.cjs → chunk-GX3OUTFO.cjs} +32 -32
- package/dist/{chunk-S4ADN25W.js → chunk-GZYLZ4MB.js} +2 -2
- package/dist/{chunk-A5ZGKBN2.js → chunk-HHFPQNP3.js} +1 -1
- package/dist/{chunk-RTGPIHVO.cjs → chunk-HY5YL3U5.cjs} +247 -173
- package/dist/{chunk-DG2I57UM.cjs → chunk-HYLIQMZH.cjs} +21 -21
- package/dist/{chunk-N7DPIH57.js → chunk-J4K5JOLI.js} +1 -1
- package/dist/{chunk-4H5Q6MQG.cjs → chunk-KFDP3DUL.cjs} +44 -44
- package/dist/{chunk-QDIX2ZRL.cjs → chunk-MXHJROXJ.cjs} +754 -513
- package/dist/{chunk-CUPJCGLB.js → chunk-NCTYD7HL.js} +2 -2
- package/dist/{chunk-MFORWVGD.js → chunk-NMGX2ZTU.js} +5 -5
- package/dist/{chunk-QZGFJBG5.cjs → chunk-NU724ZVN.cjs} +70 -70
- package/dist/{chunk-ZSPHOHOW.cjs → chunk-NYSYSKSH.cjs} +9 -9
- package/dist/{chunk-PNWIVND4.js → chunk-PJP5SMOU.js} +3 -3
- package/dist/{chunk-6JPQDYYY.js → chunk-QGTDWFT6.js} +419 -345
- package/dist/{chunk-GFXZMFDX.cjs → chunk-RLDAYA2A.cjs} +9 -9
- package/dist/{chunk-2CSYUMNM.cjs → chunk-RTXTGLGB.cjs} +2 -2
- package/dist/{chunk-YBC346FY.cjs → chunk-S6V7A5FS.cjs} +12 -12
- package/dist/{chunk-7ZDTAABP.cjs → chunk-SFGXIFYO.cjs} +7 -7
- package/dist/{chunk-JPVDJB27.js → chunk-SL2FOFT2.js} +4 -4
- package/dist/{chunk-EONNPRAW.js → chunk-UTW4SQDI.js} +2 -2
- package/dist/{chunk-NIT257DV.cjs → chunk-YBGEPKC2.cjs} +4 -4
- package/dist/components/case-studies/index.cjs +14 -14
- package/dist/components/case-studies/index.js +3 -3
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +2 -2
- package/dist/components/chat/index.js +1 -1
- package/dist/components/contact/index.cjs +4 -4
- package/dist/components/contact/index.js +3 -3
- package/dist/components/docs/index.cjs +7 -7
- package/dist/components/docs/index.js +6 -6
- package/dist/components/embeds/index.cjs +4 -4
- package/dist/components/embeds/index.js +3 -3
- package/dist/components/faq/index.cjs +4 -4
- package/dist/components/faq/index.js +3 -3
- package/dist/components/features/index.cjs +23 -3
- package/dist/components/features/index.js +22 -2
- package/dist/components/features/remote-session/index.d.ts +9 -1
- package/dist/components/features/remote-session/index.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-block.d.ts +4 -2
- package/dist/components/features/remote-session/remote-session-block.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-chat-panel.d.ts +4 -2
- package/dist/components/features/remote-session/remote-session-chat-panel.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-events.d.ts +24 -0
- package/dist/components/features/remote-session/remote-session-events.d.ts.map +1 -0
- package/dist/components/features/remote-session/remote-session-keep-modals.d.ts +41 -0
- package/dist/components/features/remote-session/remote-session-keep-modals.d.ts.map +1 -0
- package/dist/components/features/remote-session/remote-session-parts.d.ts +13 -2
- package/dist/components/features/remote-session/remote-session-parts.d.ts.map +1 -1
- package/dist/components/features/remote-session/remote-session-status.d.ts +34 -0
- package/dist/components/features/remote-session/remote-session-status.d.ts.map +1 -0
- package/dist/components/features/remote-session/remote-session-viewers.d.ts +15 -0
- package/dist/components/features/remote-session/remote-session-viewers.d.ts.map +1 -0
- package/dist/components/features/remote-session/types.d.ts +22 -0
- package/dist/components/features/remote-session/types.d.ts.map +1 -1
- package/dist/components/help-center-pages/index.cjs +71 -71
- package/dist/components/help-center-pages/index.js +14 -14
- package/dist/components/index.cjs +61 -41
- package/dist/components/index.js +33 -13
- package/dist/components/meeting-scheduler/index.cjs +25 -25
- package/dist/components/meeting-scheduler/index.js +2 -2
- package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -1
- package/dist/components/navigation/index.cjs +6 -6
- package/dist/components/navigation/index.js +5 -5
- package/dist/components/onboarding-guides/index.cjs +6 -6
- package/dist/components/onboarding-guides/index.js +5 -5
- package/dist/components/tickets/index.cjs +7 -7
- package/dist/components/tickets/index.js +6 -6
- package/dist/components/ui/drawer.d.ts +7 -3
- package/dist/components/ui/drawer.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +3 -3
- package/dist/components/ui/index.js +2 -2
- package/dist/components/ui/panel-resize-handle.d.ts +25 -0
- package/dist/components/ui/panel-resize-handle.d.ts.map +1 -0
- package/dist/hooks/ui/use-resizable-panel-size.d.ts +46 -0
- package/dist/hooks/ui/use-resizable-panel-size.d.ts.map +1 -0
- package/dist/index.cjs +27 -7
- package/dist/index.js +26 -6
- package/dist/tailwind.config.cjs +7 -1
- package/dist/tailwind.config.js +7 -1
- package/package.json +1 -1
- package/src/components/chat/embeddable-chat.tsx +11 -9
- package/src/components/features/remote-session/index.ts +28 -0
- package/src/components/features/remote-session/remote-session-block.tsx +6 -3
- package/src/components/features/remote-session/remote-session-chat-panel.tsx +11 -7
- package/src/components/features/remote-session/remote-session-events.tsx +93 -0
- package/src/components/features/remote-session/remote-session-keep-modals.tsx +208 -0
- package/src/components/features/remote-session/remote-session-parts.tsx +32 -3
- package/src/components/features/remote-session/remote-session-status.tsx +102 -0
- package/src/components/features/remote-session/remote-session-viewers.tsx +103 -0
- package/src/components/features/remote-session/types.ts +26 -0
- package/src/components/navigation/app-layout-drawer.tsx +14 -237
- package/src/components/ui/drawer.tsx +41 -188
- package/src/components/ui/panel-resize-handle.tsx +184 -0
- package/src/hooks/ui/use-resizable-panel-size.ts +114 -0
- package/tailwind.config.ts +6 -0
|
@@ -5,7 +5,8 @@ import { cn } from '../../../utils/cn';
|
|
|
5
5
|
import { DraggerIcon, RecordingIcon } from '../../icons-v2-generated';
|
|
6
6
|
import { OpenFrameLogo } from '../../icons/openframe-logo';
|
|
7
7
|
import { SquareAvatar } from '../../ui/square-avatar';
|
|
8
|
-
import
|
|
8
|
+
import { RemoteSessionViewers } from './remote-session-viewers';
|
|
9
|
+
import type { RemoteSessionDragHandlers, RemoteSessionViewer } from './types';
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* The remote session surfaces are fixed-size desktop windows (440 / 600 px
|
|
@@ -90,15 +91,43 @@ export function RemoteSessionOrgLogo({ name, logoUrl, sizePx, className }: Remot
|
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
/** The green SESSION RECORDING tag. */
|
|
93
|
-
export function RemoteSessionRecordingTag() {
|
|
94
|
+
export function RemoteSessionRecordingTag({ className }: { className?: string }) {
|
|
94
95
|
return (
|
|
95
|
-
<div
|
|
96
|
+
<div
|
|
97
|
+
className={cn(
|
|
98
|
+
'flex h-8 items-center gap-[var(--spacing-system-xs)] self-start rounded-md bg-ods-success-secondary p-[var(--spacing-system-xsf)]',
|
|
99
|
+
className,
|
|
100
|
+
)}
|
|
101
|
+
>
|
|
96
102
|
<RecordingIcon size={16} color="currentColor" className="text-ods-success" />
|
|
97
103
|
<span className="text-ods-success text-h5">Session Recording</span>
|
|
98
104
|
</div>
|
|
99
105
|
);
|
|
100
106
|
}
|
|
101
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The row under the block's header: the SESSION RECORDING tag and, while
|
|
110
|
+
* colleagues watch, who is in the session. With viewers the tag stretches to
|
|
111
|
+
* the left and the viewers sit on the right.
|
|
112
|
+
*/
|
|
113
|
+
export function RemoteSessionStatusRow({
|
|
114
|
+
showRecordingTag,
|
|
115
|
+
viewers,
|
|
116
|
+
}: {
|
|
117
|
+
showRecordingTag: boolean;
|
|
118
|
+
viewers?: RemoteSessionViewer[];
|
|
119
|
+
}) {
|
|
120
|
+
const hasViewers = !!viewers && viewers.length > 0;
|
|
121
|
+
if (!showRecordingTag && !hasViewers) return null;
|
|
122
|
+
if (!hasViewers) return <RemoteSessionRecordingTag />;
|
|
123
|
+
return (
|
|
124
|
+
<div className="flex w-full items-center gap-[var(--spacing-system-m)]">
|
|
125
|
+
{showRecordingTag && <RemoteSessionRecordingTag className="flex-1 justify-center self-auto" />}
|
|
126
|
+
<RemoteSessionViewers viewers={viewers} className="ml-auto" />
|
|
127
|
+
</div>
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
102
131
|
/** Grab cursor on the surfaces the window is moved by. */
|
|
103
132
|
export const REMOTE_SESSION_DRAG_HANDLE_CLASS = 'cursor-grab active:cursor-grabbing';
|
|
104
133
|
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { cn } from '../../../utils/cn';
|
|
4
|
+
import { AlertTriangleIcon, LockIcon } from '../../icons-v2-generated';
|
|
5
|
+
import { Tag } from '../../ui/tag';
|
|
6
|
+
import type { RemoteSessionStatus } from './types';
|
|
7
|
+
|
|
8
|
+
/** The pulsing dot of the LIVE tag; still when the user asks for reduced motion. */
|
|
9
|
+
function LiveDot() {
|
|
10
|
+
return <span aria-hidden className="size-2 animate-live-pulse rounded-full bg-current motion-reduce:animate-none" />;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const STATUS_TAG: Record<RemoteSessionStatus, { label: string; variant: 'critical' | 'warning' | 'error' }> = {
|
|
14
|
+
live: { label: 'Live', variant: 'critical' },
|
|
15
|
+
processing: { label: 'Processing', variant: 'warning' },
|
|
16
|
+
failed: { label: 'Failed', variant: 'error' },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export interface RemoteSessionStatusTagProps {
|
|
20
|
+
status: RemoteSessionStatus;
|
|
21
|
+
className?: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** LIVE / PROCESSING / FAILED beside a session's date in the session list and on its page. */
|
|
25
|
+
export function RemoteSessionStatusTag({ status, className }: RemoteSessionStatusTagProps) {
|
|
26
|
+
const { label, variant } = STATUS_TAG[status];
|
|
27
|
+
return (
|
|
28
|
+
<Tag
|
|
29
|
+
label={label}
|
|
30
|
+
variant={variant}
|
|
31
|
+
icon={status === 'live' ? <LiveDot /> : undefined}
|
|
32
|
+
className={cn('pointer-events-none shrink-0', className)}
|
|
33
|
+
/>
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type RemoteSessionExpiryProps = { className?: string } & (
|
|
38
|
+
| { state: 'kept'; keptBy: string; reason: string }
|
|
39
|
+
| { state: 'scheduled'; date: string; remaining?: string | null }
|
|
40
|
+
| { state: 'expired' }
|
|
41
|
+
| { state: 'none' }
|
|
42
|
+
);
|
|
43
|
+
|
|
44
|
+
/** The EXPIRES cell of a recording: its expiry date, the Keep holding it, or that it has expired. */
|
|
45
|
+
export function RemoteSessionExpiry(props: RemoteSessionExpiryProps) {
|
|
46
|
+
const { className } = props;
|
|
47
|
+
if (props.state === 'none') {
|
|
48
|
+
return <p className={cn('text-ods-text-primary text-h4', className)}>-</p>;
|
|
49
|
+
}
|
|
50
|
+
if (props.state === 'expired') {
|
|
51
|
+
return <p className={cn('text-ods-text-secondary text-h4', className)}>Expired</p>;
|
|
52
|
+
}
|
|
53
|
+
if (props.state === 'kept') {
|
|
54
|
+
return (
|
|
55
|
+
<div className={cn('flex min-w-0 flex-col', className)}>
|
|
56
|
+
<p className="flex items-center gap-[var(--spacing-system-xxs)] text-ods-text-primary text-h4">
|
|
57
|
+
<LockIcon size={24} color="currentColor" className="shrink-0" />
|
|
58
|
+
Kept
|
|
59
|
+
</p>
|
|
60
|
+
<p className="truncate text-ods-text-secondary text-h6">
|
|
61
|
+
Kept by {props.keptBy} · {props.reason}
|
|
62
|
+
</p>
|
|
63
|
+
</div>
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return (
|
|
67
|
+
<div className={cn('flex min-w-0 flex-col', className)}>
|
|
68
|
+
<p className="text-ods-text-primary text-h4">{props.date}</p>
|
|
69
|
+
{props.remaining && <p className="truncate text-ods-text-secondary text-h6">{props.remaining}</p>}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RemoteSessionStorageAlertProps {
|
|
75
|
+
/** `device` on the device page (new sessions there aren't recorded); `session` on a running session. */
|
|
76
|
+
scope: 'device' | 'session';
|
|
77
|
+
/** The plan's recording storage, e.g. "100 GB"; named on the device page. */
|
|
78
|
+
capacity?: string | null;
|
|
79
|
+
className?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The banner shown while the tenant's recording storage is full. */
|
|
83
|
+
export function RemoteSessionStorageAlert({ scope, capacity, className }: RemoteSessionStorageAlertProps) {
|
|
84
|
+
const message =
|
|
85
|
+
scope === 'session'
|
|
86
|
+
? "This session isn't being recorded. Recording storage is full. New sessions aren't recorded until space is freed."
|
|
87
|
+
: `Recording storage full. New sessions aren't recorded.${
|
|
88
|
+
capacity ? ` All ${capacity} of recording storage is used.` : ''
|
|
89
|
+
}`;
|
|
90
|
+
return (
|
|
91
|
+
<div
|
|
92
|
+
role="alert"
|
|
93
|
+
className={cn(
|
|
94
|
+
'flex w-full items-start gap-[var(--spacing-system-m)] rounded-md bg-ods-warning-secondary p-[var(--spacing-system-s)] text-ods-warning',
|
|
95
|
+
className,
|
|
96
|
+
)}
|
|
97
|
+
>
|
|
98
|
+
<AlertTriangleIcon size={24} color="currentColor" className="shrink-0" />
|
|
99
|
+
<p className="min-w-0 flex-1 text-h3">{message}</p>
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react';
|
|
4
|
+
import { cn } from '../../../utils/cn';
|
|
5
|
+
import { Chevron02DownIcon, Chevron02UpIcon } from '../../icons-v2-generated';
|
|
6
|
+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '../../ui/dropdown-menu';
|
|
7
|
+
import { SquareAvatar } from '../../ui/square-avatar';
|
|
8
|
+
import type { RemoteSessionViewer } from './types';
|
|
9
|
+
|
|
10
|
+
function viewerLabel(viewer: RemoteSessionViewer): string {
|
|
11
|
+
if (viewer.isHost) return `${viewer.name} (host)`;
|
|
12
|
+
if (viewer.isYou) return `${viewer.name} (you)`;
|
|
13
|
+
return viewer.name;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** The host first, everyone else in the order the backend reports them. */
|
|
17
|
+
function hostFirst(viewers: RemoteSessionViewer[]): RemoteSessionViewer[] {
|
|
18
|
+
return [...viewers.filter(viewer => viewer.isHost), ...viewers.filter(viewer => !viewer.isHost)];
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface RemoteSessionViewersProps {
|
|
22
|
+
/** Everyone in the session, the host included. */
|
|
23
|
+
viewers: RemoteSessionViewer[];
|
|
24
|
+
/** Faces shown before the rest collapse into "+N". */
|
|
25
|
+
max?: number;
|
|
26
|
+
className?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Who is in a live session: an avatar stack (the host ringed in the accent
|
|
31
|
+
* colour) that opens the full list. Shown to the host, to the watchers and to
|
|
32
|
+
* the end user.
|
|
33
|
+
*/
|
|
34
|
+
export function RemoteSessionViewers({ viewers, max = 4, className }: RemoteSessionViewersProps) {
|
|
35
|
+
const [open, setOpen] = useState(false);
|
|
36
|
+
if (viewers.length === 0) return null;
|
|
37
|
+
|
|
38
|
+
const ordered = hostFirst(viewers);
|
|
39
|
+
const faces = ordered.slice(0, max);
|
|
40
|
+
const overflow = ordered.length - faces.length;
|
|
41
|
+
const Chevron = open ? Chevron02UpIcon : Chevron02DownIcon;
|
|
42
|
+
|
|
43
|
+
return (
|
|
44
|
+
<DropdownMenu open={open} onOpenChange={setOpen}>
|
|
45
|
+
<DropdownMenuTrigger
|
|
46
|
+
aria-label={`In this session: ${ordered.map(viewerLabel).join(', ')}`}
|
|
47
|
+
className={cn(
|
|
48
|
+
'flex shrink-0 items-center gap-[var(--spacing-system-xxs)] rounded-md outline-none',
|
|
49
|
+
'focus-visible:ring-2 focus-visible:ring-ods-focus',
|
|
50
|
+
className,
|
|
51
|
+
)}
|
|
52
|
+
>
|
|
53
|
+
<span className="flex items-center">
|
|
54
|
+
{faces.map((viewer, index) => (
|
|
55
|
+
<SquareAvatar
|
|
56
|
+
key={viewer.id}
|
|
57
|
+
variant="round"
|
|
58
|
+
sizePx={32}
|
|
59
|
+
src={viewer.avatarUrl ?? undefined}
|
|
60
|
+
alt={viewer.name}
|
|
61
|
+
fallback={viewer.name}
|
|
62
|
+
className={cn(
|
|
63
|
+
'relative bg-ods-bg',
|
|
64
|
+
viewer.isHost ? 'ring-2 ring-ods-accent' : 'ring-1 ring-ods-border',
|
|
65
|
+
index > 0 && '-ml-2',
|
|
66
|
+
)}
|
|
67
|
+
style={{ zIndex: faces.length - index + 1 }}
|
|
68
|
+
/>
|
|
69
|
+
))}
|
|
70
|
+
{overflow > 0 && (
|
|
71
|
+
<span className="relative -ml-2 flex size-8 shrink-0 items-center justify-center rounded-full bg-ods-bg text-ods-text-secondary ring-1 ring-ods-border text-h6">
|
|
72
|
+
+{overflow}
|
|
73
|
+
</span>
|
|
74
|
+
)}
|
|
75
|
+
</span>
|
|
76
|
+
<span className="flex size-8 items-center justify-center rounded-md text-ods-text-primary">
|
|
77
|
+
<Chevron size={16} color="currentColor" />
|
|
78
|
+
</span>
|
|
79
|
+
</DropdownMenuTrigger>
|
|
80
|
+
<DropdownMenuContent align="end" className="w-[280px] p-0">
|
|
81
|
+
{ordered.map(viewer => (
|
|
82
|
+
<DropdownMenuItem
|
|
83
|
+
key={viewer.id}
|
|
84
|
+
className={cn(
|
|
85
|
+
'gap-[var(--spacing-system-xsf)] rounded-none border-b border-ods-border bg-ods-bg last:border-b-0',
|
|
86
|
+
'py-[var(--spacing-system-s)] pl-[var(--spacing-system-mf)] pr-[var(--spacing-system-xs)] text-h4',
|
|
87
|
+
)}
|
|
88
|
+
>
|
|
89
|
+
<SquareAvatar
|
|
90
|
+
variant="round"
|
|
91
|
+
sizePx={24}
|
|
92
|
+
src={viewer.avatarUrl ?? undefined}
|
|
93
|
+
alt=""
|
|
94
|
+
fallback={viewer.name}
|
|
95
|
+
className="shrink-0"
|
|
96
|
+
/>
|
|
97
|
+
<span className="min-w-0 flex-1 truncate">{viewerLabel(viewer)}</span>
|
|
98
|
+
</DropdownMenuItem>
|
|
99
|
+
))}
|
|
100
|
+
</DropdownMenuContent>
|
|
101
|
+
</DropdownMenu>
|
|
102
|
+
);
|
|
103
|
+
}
|
|
@@ -31,6 +31,32 @@ export interface RemoteSessionChatMessage {
|
|
|
31
31
|
/** The terminal dialogs of a session: the end confirm and the two "it is over" notices. */
|
|
32
32
|
export type RemoteSessionDialogVariant = 'end-confirm' | 'session-ended' | 'connection-lost';
|
|
33
33
|
|
|
34
|
+
/** Someone in a live session: the technician running it (the host) or a colleague watching. */
|
|
35
|
+
export interface RemoteSessionViewer {
|
|
36
|
+
id: string;
|
|
37
|
+
name: string;
|
|
38
|
+
avatarUrl?: string | null;
|
|
39
|
+
/** The technician running the session; listed first and ringed in the accent colour. */
|
|
40
|
+
isHost?: boolean;
|
|
41
|
+
/** The signed-in user, labelled "(you)" in the list. */
|
|
42
|
+
isYou?: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Where a remote session's recording is in its lifecycle, as the session list tags it. */
|
|
46
|
+
export type RemoteSessionStatus = 'live' | 'processing' | 'failed';
|
|
47
|
+
|
|
48
|
+
/** Why a recording is kept past its expiry date. */
|
|
49
|
+
export type RemoteSessionKeepReason = 'CLIENT_DISPUTE' | 'INTERNAL_REVIEW' | 'LEGAL_OR_COMPLIANCE' | 'OTHER';
|
|
50
|
+
|
|
51
|
+
/** A moment of the session, placed on the recording by its offset from the start. */
|
|
52
|
+
export interface RemoteSessionEvent {
|
|
53
|
+
id: string;
|
|
54
|
+
/** Milliseconds from the start of the recording. */
|
|
55
|
+
offsetMs: number;
|
|
56
|
+
title: string;
|
|
57
|
+
detail?: string | null;
|
|
58
|
+
}
|
|
59
|
+
|
|
34
60
|
/**
|
|
35
61
|
* Pointer handlers a host attaches to the surfaces the user moves the window
|
|
36
62
|
* by (the desktop chat starts a native window drag from them).
|
|
@@ -6,9 +6,6 @@ import {
|
|
|
6
6
|
type CSSProperties,
|
|
7
7
|
type ComponentPropsWithoutRef,
|
|
8
8
|
type ComponentRef,
|
|
9
|
-
type KeyboardEvent,
|
|
10
|
-
type PointerEvent,
|
|
11
|
-
type RefObject,
|
|
12
9
|
createContext,
|
|
13
10
|
forwardRef,
|
|
14
11
|
useCallback,
|
|
@@ -18,8 +15,8 @@ import {
|
|
|
18
15
|
useState,
|
|
19
16
|
} from 'react';
|
|
20
17
|
|
|
18
|
+
import { useResizablePanelSize } from '../../hooks/ui/use-resizable-panel-size';
|
|
21
19
|
import { cn } from '../../utils/cn';
|
|
22
|
-
import { clamp } from '../../utils/common';
|
|
23
20
|
import {
|
|
24
21
|
DrawerBody,
|
|
25
22
|
DrawerDescription,
|
|
@@ -29,6 +26,7 @@ import {
|
|
|
29
26
|
OVERLAY_BACKDROP_CLASS,
|
|
30
27
|
type DrawerSide,
|
|
31
28
|
} from '../ui/drawer';
|
|
29
|
+
import { PanelResizeHandle } from '../ui/panel-resize-handle';
|
|
32
30
|
import {
|
|
33
31
|
type AppLayoutDrawerHandle,
|
|
34
32
|
useAppLayoutDrawerContainer,
|
|
@@ -216,25 +214,8 @@ const HORIZONTAL_SIDES: ReadonlySet<DrawerSide> = new Set(['left', 'right']);
|
|
|
216
214
|
*/
|
|
217
215
|
const PERSIST_CLOSED_HOLD = 'data-[state=closed]:fill-mode-forwards';
|
|
218
216
|
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
isHorizontal: boolean;
|
|
222
|
-
minSize: number;
|
|
223
|
-
maxSize: number;
|
|
224
|
-
defaultSize: number;
|
|
225
|
-
storageKey?: string;
|
|
226
|
-
container: HTMLElement | null;
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
function useContainedResizableSize({
|
|
230
|
-
enabled,
|
|
231
|
-
isHorizontal,
|
|
232
|
-
minSize,
|
|
233
|
-
maxSize,
|
|
234
|
-
defaultSize,
|
|
235
|
-
storageKey,
|
|
236
|
-
container,
|
|
237
|
-
}: UseContainedResizableSizeArgs) {
|
|
217
|
+
/** Room the portal container gives the panel along its resize axis, in px. */
|
|
218
|
+
function useContainerExtent(enabled: boolean, isHorizontal: boolean, container: HTMLElement | null): number {
|
|
238
219
|
const [available, setAvailable] = useState(0);
|
|
239
220
|
|
|
240
221
|
useEffect(() => {
|
|
@@ -248,216 +229,7 @@ function useContainedResizableSize({
|
|
|
248
229
|
return () => ro.disconnect();
|
|
249
230
|
}, [enabled, container, isHorizontal]);
|
|
250
231
|
|
|
251
|
-
|
|
252
|
-
(value: number) => {
|
|
253
|
-
// Reserve 40px (the `system-m` outside-edge padding from the wrapper
|
|
254
|
-
// plus a matching gap on the inside edge) so the panel sits symmetrically
|
|
255
|
-
// inside the container. This also keeps the resize grip on-screen at
|
|
256
|
-
// maximum extent.
|
|
257
|
-
const effectiveMax = available > 0 ? Math.min(maxSize, available - 40) : maxSize;
|
|
258
|
-
return clamp(value, minSize, Math.max(minSize, effectiveMax));
|
|
259
|
-
},
|
|
260
|
-
[available, minSize, maxSize],
|
|
261
|
-
);
|
|
262
|
-
|
|
263
|
-
const readInitial = useCallback(() => {
|
|
264
|
-
if (!enabled) return defaultSize;
|
|
265
|
-
if (typeof window === 'undefined') return defaultSize;
|
|
266
|
-
if (!storageKey) return defaultSize;
|
|
267
|
-
try {
|
|
268
|
-
const raw = window.localStorage.getItem(storageKey);
|
|
269
|
-
if (!raw) return defaultSize;
|
|
270
|
-
const parsed = parseFloat(raw);
|
|
271
|
-
if (!Number.isFinite(parsed)) return defaultSize;
|
|
272
|
-
return parsed;
|
|
273
|
-
} catch {
|
|
274
|
-
return defaultSize;
|
|
275
|
-
}
|
|
276
|
-
}, [enabled, storageKey, defaultSize]);
|
|
277
|
-
|
|
278
|
-
const [rawSize, setSizeRaw] = useState<number>(readInitial);
|
|
279
|
-
|
|
280
|
-
// Re-clamp the stored size whenever the container resizes so a previously
|
|
281
|
-
// saved size never overflows after the user shrinks the viewport.
|
|
282
|
-
//
|
|
283
|
-
// Clamped where it is READ rather than written back into state from an
|
|
284
|
-
// effect: `available` is already state (the ResizeObserver above owns it), so
|
|
285
|
-
// the container shrinking re-renders regardless — and deriving here means the
|
|
286
|
-
// panel is never painted overflowing its container for the frame between the
|
|
287
|
-
// resize and the corrective commit. Keeping the raw value also means widening
|
|
288
|
-
// the viewport again restores the size the user actually chose, instead of
|
|
289
|
-
// leaving it permanently trimmed to the narrowest width ever seen.
|
|
290
|
-
const size = enabled ? clampToContainer(rawSize) : rawSize;
|
|
291
|
-
|
|
292
|
-
const setSize = useCallback((next: number) => setSizeRaw(clampToContainer(next)), [clampToContainer]);
|
|
293
|
-
|
|
294
|
-
useEffect(() => {
|
|
295
|
-
if (!enabled || !storageKey || typeof window === 'undefined') return;
|
|
296
|
-
try {
|
|
297
|
-
window.localStorage.setItem(storageKey, String(Math.round(size)));
|
|
298
|
-
} catch {
|
|
299
|
-
// ignore quota / disabled-storage
|
|
300
|
-
}
|
|
301
|
-
}, [enabled, size, storageKey]);
|
|
302
|
-
|
|
303
|
-
return { size, setSize, clampSize: clampToContainer };
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Ends a drawer resize drag: commits the size it reached and restores the
|
|
307
|
-
* page's cursor and text selection. No-op when no drag is in progress. */
|
|
308
|
-
function finishDrag(
|
|
309
|
-
startRef: RefObject<{ x: number; y: number; size: number } | null>,
|
|
310
|
-
dragSizeRef: RefObject<number | null>,
|
|
311
|
-
commit: (size: number) => void,
|
|
312
|
-
): void {
|
|
313
|
-
if (!startRef.current) return;
|
|
314
|
-
startRef.current = null;
|
|
315
|
-
if (dragSizeRef.current !== null) commit(dragSizeRef.current);
|
|
316
|
-
dragSizeRef.current = null;
|
|
317
|
-
document.body.style.cursor = '';
|
|
318
|
-
document.body.style.userSelect = '';
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
interface AppLayoutDrawerResizeHandleProps {
|
|
322
|
-
side: DrawerSide;
|
|
323
|
-
size: number;
|
|
324
|
-
minSize: number;
|
|
325
|
-
maxSize: number;
|
|
326
|
-
onSize: (next: number) => void;
|
|
327
|
-
/** Clamp a candidate size to the min/max and the container. */
|
|
328
|
-
clampSize: (next: number) => number;
|
|
329
|
-
/** The panel the size applies to — written directly while dragging. */
|
|
330
|
-
panelRef: RefObject<HTMLDivElement | null>;
|
|
331
|
-
ariaLabel?: string;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
function AppLayoutDrawerResizeHandle({
|
|
335
|
-
side,
|
|
336
|
-
size,
|
|
337
|
-
minSize,
|
|
338
|
-
maxSize,
|
|
339
|
-
onSize,
|
|
340
|
-
clampSize,
|
|
341
|
-
panelRef,
|
|
342
|
-
ariaLabel,
|
|
343
|
-
}: AppLayoutDrawerResizeHandleProps) {
|
|
344
|
-
const isHorizontal = HORIZONTAL_SIDES.has(side);
|
|
345
|
-
const startRef = useRef<{ x: number; y: number; size: number } | null>(null);
|
|
346
|
-
// Size reached by the drag in progress, committed to React state on release.
|
|
347
|
-
const dragSizeRef = useRef<number | null>(null);
|
|
348
|
-
const onSizeRef = useRef(onSize);
|
|
349
|
-
useEffect(() => {
|
|
350
|
-
onSizeRef.current = onSize;
|
|
351
|
-
}, [onSize]);
|
|
352
|
-
// The handle can unmount mid-drag (a persist-mode drawer closing), and then
|
|
353
|
-
// no pointer event ends the drag: end it here, or the panel keeps a width
|
|
354
|
-
// React state never received and the page keeps the resize cursor.
|
|
355
|
-
useEffect(() => () => finishDrag(startRef, dragSizeRef, onSizeRef.current), []);
|
|
356
|
-
|
|
357
|
-
const direction = side === 'right' || side === 'bottom' ? -1 : 1;
|
|
358
|
-
|
|
359
|
-
const handlePointerDown = (e: PointerEvent<HTMLDivElement>) => {
|
|
360
|
-
if (e.button !== 0 && e.pointerType === 'mouse') return;
|
|
361
|
-
e.preventDefault();
|
|
362
|
-
startRef.current = { x: e.clientX, y: e.clientY, size };
|
|
363
|
-
e.currentTarget.setPointerCapture(e.pointerId);
|
|
364
|
-
document.body.style.cursor = isHorizontal ? 'col-resize' : 'row-resize';
|
|
365
|
-
document.body.style.userSelect = 'none';
|
|
366
|
-
};
|
|
367
|
-
|
|
368
|
-
// The drag writes the panel's size straight to the DOM and commits it to
|
|
369
|
-
// state once, on release. Committing every pointermove re-rendered the
|
|
370
|
-
// drawer per event — ~16% of a drag's CPU in WebKit on top of the reflow the
|
|
371
|
-
// resize itself costs. ResizeObserver consumers inside the panel still see
|
|
372
|
-
// every change. A re-render of the drawer mid-drag (e.g. a streamed chunk)
|
|
373
|
-
// does not undo the write: React diffs against its previous render, where
|
|
374
|
-
// the size is unchanged, so it leaves the style alone.
|
|
375
|
-
const handlePointerMove = (e: PointerEvent<HTMLDivElement>) => {
|
|
376
|
-
const start = startRef.current;
|
|
377
|
-
const panel = panelRef.current;
|
|
378
|
-
if (!start || !panel) return;
|
|
379
|
-
const delta = isHorizontal ? e.clientX - start.x : e.clientY - start.y;
|
|
380
|
-
const next = clampSize(start.size + delta * direction);
|
|
381
|
-
dragSizeRef.current = next;
|
|
382
|
-
panel.style[isHorizontal ? 'width' : 'height'] = `${next}px`;
|
|
383
|
-
e.currentTarget.setAttribute('aria-valuenow', String(Math.round(next)));
|
|
384
|
-
};
|
|
385
|
-
|
|
386
|
-
const endDrag = (e: PointerEvent<HTMLDivElement>) => {
|
|
387
|
-
if (!startRef.current) return;
|
|
388
|
-
finishDrag(startRef, dragSizeRef, onSize);
|
|
389
|
-
try {
|
|
390
|
-
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
391
|
-
} catch {
|
|
392
|
-
// ignore — pointer may already be released
|
|
393
|
-
}
|
|
394
|
-
};
|
|
395
|
-
|
|
396
|
-
const handleKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
|
397
|
-
const step = e.shiftKey ? 40 : 16;
|
|
398
|
-
if (isHorizontal) {
|
|
399
|
-
if (e.key === 'ArrowLeft') {
|
|
400
|
-
e.preventDefault();
|
|
401
|
-
onSize(size + step * (side === 'right' ? 1 : -1));
|
|
402
|
-
} else if (e.key === 'ArrowRight') {
|
|
403
|
-
e.preventDefault();
|
|
404
|
-
onSize(size + step * (side === 'right' ? -1 : 1));
|
|
405
|
-
}
|
|
406
|
-
} else {
|
|
407
|
-
if (e.key === 'ArrowUp') {
|
|
408
|
-
e.preventDefault();
|
|
409
|
-
onSize(size + step * (side === 'bottom' ? 1 : -1));
|
|
410
|
-
} else if (e.key === 'ArrowDown') {
|
|
411
|
-
e.preventDefault();
|
|
412
|
-
onSize(size + step * (side === 'bottom' ? -1 : 1));
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
if (e.key === 'Home') {
|
|
416
|
-
e.preventDefault();
|
|
417
|
-
onSize(minSize);
|
|
418
|
-
} else if (e.key === 'End') {
|
|
419
|
-
e.preventDefault();
|
|
420
|
-
onSize(maxSize);
|
|
421
|
-
}
|
|
422
|
-
};
|
|
423
|
-
|
|
424
|
-
const trackPosition =
|
|
425
|
-
side === 'right'
|
|
426
|
-
? 'right-full top-4 bottom-4 w-6 items-center justify-end pr-1'
|
|
427
|
-
: side === 'left'
|
|
428
|
-
? 'left-full top-4 bottom-4 w-6 items-center justify-start pl-1'
|
|
429
|
-
: side === 'bottom'
|
|
430
|
-
? 'bottom-full left-4 right-4 h-6 justify-center items-end pb-1'
|
|
431
|
-
: 'top-full left-4 right-4 h-6 justify-center items-start pt-1';
|
|
432
|
-
|
|
433
|
-
const cursorClass = isHorizontal ? 'cursor-col-resize' : 'cursor-row-resize';
|
|
434
|
-
const gripClass = isHorizontal ? 'h-10 w-1' : 'w-10 h-1';
|
|
435
|
-
|
|
436
|
-
return (
|
|
437
|
-
<div
|
|
438
|
-
role="separator"
|
|
439
|
-
tabIndex={0}
|
|
440
|
-
aria-orientation={isHorizontal ? 'vertical' : 'horizontal'}
|
|
441
|
-
aria-valuenow={Math.round(size)}
|
|
442
|
-
aria-valuemin={minSize}
|
|
443
|
-
aria-valuemax={maxSize}
|
|
444
|
-
aria-label={ariaLabel ?? (isHorizontal ? 'Resize panel width' : 'Resize panel height')}
|
|
445
|
-
onPointerDown={handlePointerDown}
|
|
446
|
-
onPointerMove={handlePointerMove}
|
|
447
|
-
onPointerUp={endDrag}
|
|
448
|
-
onPointerCancel={endDrag}
|
|
449
|
-
onLostPointerCapture={endDrag}
|
|
450
|
-
onKeyDown={handleKeyDown}
|
|
451
|
-
className={cn(
|
|
452
|
-
'group absolute z-20 flex touch-none select-none',
|
|
453
|
-
'outline-none ring-0 focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0',
|
|
454
|
-
trackPosition,
|
|
455
|
-
cursorClass,
|
|
456
|
-
)}
|
|
457
|
-
>
|
|
458
|
-
<div aria-hidden className={cn('rounded-full bg-ods-border', gripClass)} />
|
|
459
|
-
</div>
|
|
460
|
-
);
|
|
232
|
+
return available;
|
|
461
233
|
}
|
|
462
234
|
|
|
463
235
|
export interface AppLayoutDrawerContentProps
|
|
@@ -628,14 +400,18 @@ const AppLayoutDrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Co
|
|
|
628
400
|
}, [mobileBreakpoint]);
|
|
629
401
|
|
|
630
402
|
const panelRef = useRef<HTMLDivElement>(null);
|
|
631
|
-
|
|
403
|
+
// Reserve 40px (the `system-m` outside-edge padding from the wrapper plus a
|
|
404
|
+
// matching gap on the inside edge) so the panel sits symmetrically inside
|
|
405
|
+
// the container and the resize grip stays on screen at maximum extent.
|
|
406
|
+
const available = useContainerExtent(resizable, isHorizontal, portalContainer);
|
|
407
|
+
const { size, setSize, clampSize } = useResizablePanelSize({
|
|
632
408
|
enabled: resizable,
|
|
633
|
-
isHorizontal,
|
|
634
409
|
minSize,
|
|
635
410
|
maxSize,
|
|
636
411
|
defaultSize: initialSize,
|
|
412
|
+
available,
|
|
413
|
+
reserve: 40,
|
|
637
414
|
storageKey,
|
|
638
|
-
container: portalContainer,
|
|
639
415
|
});
|
|
640
416
|
|
|
641
417
|
const applyInlineSize = resizable && !isMobile;
|
|
@@ -707,7 +483,8 @@ const AppLayoutDrawerContent = forwardRef<ComponentRef<typeof DialogPrimitive.Co
|
|
|
707
483
|
at the container edge. In persist mode it's also non-interactive
|
|
708
484
|
while closed, so drop it entirely until the panel reopens. */}
|
|
709
485
|
{applyInlineSize && (!persist || open) ? (
|
|
710
|
-
<
|
|
486
|
+
<PanelResizeHandle
|
|
487
|
+
variant="inLayout"
|
|
711
488
|
side={resolvedSide}
|
|
712
489
|
size={size}
|
|
713
490
|
minSize={minSize}
|