@puredesktop/puredesktop-ui-bridge 2.1.8 → 2.1.9
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 +35 -5
- package/src/agents/mcpServerPresets.test.ts +105 -0
- package/src/agents/mcpServerPresets.ts +380 -0
- package/src/agents/runtime/index.ts +7 -0
- package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
- package/src/agents/runtime/pendingAsks.test.ts +117 -0
- package/src/agents/runtime/pendingAsks.ts +110 -0
- package/src/bridge/agentScopeDefaults.ts +65 -0
- package/src/bridge/agentTypes.ts +36 -2
- package/src/bridge/agents.d.mts +6 -0
- package/src/bridge/agents.mjs +10 -0
- package/src/bridge/agents.ts +4 -0
- package/src/bridge/credentials.d.mts +62 -0
- package/src/bridge/credentials.mjs +53 -0
- package/src/bridge/events.d.mts +1 -0
- package/src/bridge/events.mjs +1 -0
- package/src/bridge/googleAuth.ts +7 -152
- package/src/bridge/mcpClient.d.mts +74 -0
- package/src/bridge/mcpClient.mjs +79 -0
- package/src/bridge/methods.d.mts +22 -0
- package/src/bridge/methods.mjs +23 -0
- package/src/bridge/notifications.d.mts +12 -0
- package/src/bridge/notifications.mjs +36 -0
- package/src/bridge/operations.d.mts +44 -0
- package/src/bridge/operations.mjs +40 -0
- package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
- package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
- package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -105
- package/src/bridge/react/usePlatformAppSettings.tsx +104 -104
- package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -152
- package/src/bridge/react/usePlatformJsonStore.tsx +149 -149
- package/src/bridge/react/usePlatformOperations.tsx +75 -0
- package/src/bridge/storage.d.mts +17 -4
- package/src/bridge/storage.mjs +6 -3
- package/src/components/agents/AgentComposer.tsx +10 -6
- package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
- package/src/components/agents/AgentDrawerPanel.tsx +23 -64
- package/src/components/agents/AgentMessageBubble.tsx +59 -33
- package/src/components/agents/AgentMessageList.tsx +4 -9
- package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
- package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
- package/src/components/agents/AgentToolPendingList.tsx +6 -6
- package/src/components/agents/AgentToolPendingReview.tsx +6 -6
- package/src/components/agents/QuestionRequestList.tsx +278 -0
- package/src/components/agents/agentPanelStyles.ts +9 -33
- package/src/components/agents/agentTypes.ts +25 -43
- package/src/components/agents/index.ts +4 -0
- package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
- package/src/components/chrome/WorkspaceTabStrip.tsx +4 -3
- package/src/components/common/chat/ChatBox.tsx +9 -9
- package/src/components/common/chat/ChatModelMenu.tsx +34 -4
- package/src/components/common/chat/ChatThread.tsx +23 -7
- package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
- package/src/components/common/connections/ProviderConnection.tsx +277 -0
- package/src/components/common/connections/index.ts +5 -0
- package/src/components/common/containers/AppFrame.tsx +26 -19
- package/src/components/common/containers/AppHeader.tsx +20 -20
- package/src/components/common/documents/DocumentSwitcher.tsx +63 -58
- package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
- package/src/components/common/dropdown/MenuPortal.tsx +3 -1
- package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
- package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
- package/src/components/common/overlays/Modal.tsx +13 -2
- package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
- package/src/ics/generateIcs.test.ts +153 -0
- package/src/ics/generateIcs.ts +197 -0
- package/src/index.ts +3 -0
- package/src/net/httpRetry.test.ts +117 -0
- package/src/net/httpRetry.ts +111 -0
- package/src/theme/appAccents.ts +6 -0
- package/src/theme/appIdentityCss.mjs +11 -0
- package/src/theme/appIdentityCss.ts +11 -0
- package/src/theme/themes/dark.ts +3 -1
- package/src/theme/themes/light.ts +3 -1
- package/src/bridge/googleProviderConfig.ts +0 -73
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
escapeIcsText,
|
|
4
|
+
foldIcsLine,
|
|
5
|
+
generateIcsCalendar,
|
|
6
|
+
generateIcsEventExport,
|
|
7
|
+
generateIcsReply,
|
|
8
|
+
} from './generateIcs.js'
|
|
9
|
+
|
|
10
|
+
const BASE = {
|
|
11
|
+
uid: 'uid-123@pure.science',
|
|
12
|
+
timestamp: '2026-07-04T10:00:00.000Z',
|
|
13
|
+
title: 'Project kickoff',
|
|
14
|
+
startsAt: '2026-07-10T09:00:00.000Z',
|
|
15
|
+
endsAt: '2026-07-10T10:00:00.000Z',
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('generateIcsCalendar', () => {
|
|
19
|
+
it('emits a valid REQUEST with organizer and attendees', () => {
|
|
20
|
+
const ics = generateIcsCalendar({
|
|
21
|
+
...BASE,
|
|
22
|
+
method: 'REQUEST',
|
|
23
|
+
sequence: 1,
|
|
24
|
+
description: 'Agenda:\n- intro; goals, scope',
|
|
25
|
+
location: 'Room 4',
|
|
26
|
+
organizer: { email: 'adam@pure.science', name: 'Adam' },
|
|
27
|
+
attendees: [
|
|
28
|
+
{ email: 'boss@pure.science', name: 'Boss', rsvp: true, partstat: 'NEEDS-ACTION' },
|
|
29
|
+
],
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
expect(ics).toContain('BEGIN:VCALENDAR')
|
|
33
|
+
expect(ics).toContain('METHOD:REQUEST')
|
|
34
|
+
expect(ics).toContain('UID:uid-123@pure.science')
|
|
35
|
+
expect(ics).toContain('SEQUENCE:1')
|
|
36
|
+
expect(ics).toContain('DTSTART:20260710T090000Z')
|
|
37
|
+
expect(ics).toContain('DTEND:20260710T100000Z')
|
|
38
|
+
expect(ics).toContain('DTSTAMP:20260704T100000Z')
|
|
39
|
+
expect(ics).toContain('SUMMARY:Project kickoff')
|
|
40
|
+
expect(ics).toContain('DESCRIPTION:Agenda:\\n- intro\\; goals\\, scope')
|
|
41
|
+
expect(ics).toContain('ORGANIZER;CN=Adam:mailto:adam@pure.science')
|
|
42
|
+
expect(ics).toContain(
|
|
43
|
+
'ATTENDEE;CN=Boss;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:boss@pure.science',
|
|
44
|
+
)
|
|
45
|
+
expect(ics.endsWith('END:VCALENDAR\r\n')).toBe(true)
|
|
46
|
+
// CRLF line endings throughout.
|
|
47
|
+
expect(ics.includes('\n')).toBe(true)
|
|
48
|
+
expect(ics.split('\r\n').some(line => line.includes('\n'))).toBe(false)
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('emits DATE values for all-day events', () => {
|
|
52
|
+
const ics = generateIcsCalendar({
|
|
53
|
+
...BASE,
|
|
54
|
+
method: 'REQUEST',
|
|
55
|
+
allDay: true,
|
|
56
|
+
startsAt: '2026-07-10',
|
|
57
|
+
endsAt: '2026-07-11',
|
|
58
|
+
})
|
|
59
|
+
expect(ics).toContain('DTSTART;VALUE=DATE:20260710')
|
|
60
|
+
expect(ics).toContain('DTEND;VALUE=DATE:20260711')
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
it('defaults CANCEL to STATUS:CANCELLED', () => {
|
|
64
|
+
const ics = generateIcsCalendar({ ...BASE, method: 'CANCEL', sequence: 3 })
|
|
65
|
+
expect(ics).toContain('METHOD:CANCEL')
|
|
66
|
+
expect(ics).toContain('STATUS:CANCELLED')
|
|
67
|
+
expect(ics).toContain('SEQUENCE:3')
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
it('includes RRULE when provided', () => {
|
|
71
|
+
const ics = generateIcsCalendar({
|
|
72
|
+
...BASE,
|
|
73
|
+
method: 'REQUEST',
|
|
74
|
+
rrule: 'FREQ=WEEKLY;INTERVAL=2;COUNT=5',
|
|
75
|
+
})
|
|
76
|
+
expect(ics).toContain('RRULE:FREQ=WEEKLY;INTERVAL=2;COUNT=5')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('rejects invalid dates', () => {
|
|
80
|
+
expect(() =>
|
|
81
|
+
generateIcsCalendar({ ...BASE, method: 'REQUEST', startsAt: 'garbage' }),
|
|
82
|
+
).toThrow(/Invalid ICS date-time/)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('omits METHOD and forces no STATUS for a plain export (no method)', () => {
|
|
86
|
+
const ics = generateIcsCalendar({ ...BASE })
|
|
87
|
+
expect(ics).toContain('BEGIN:VCALENDAR')
|
|
88
|
+
expect(ics).toContain('END:VCALENDAR')
|
|
89
|
+
expect(ics).toContain('BEGIN:VEVENT')
|
|
90
|
+
expect(ics).toContain('END:VEVENT')
|
|
91
|
+
expect(ics).toContain('SUMMARY:Project kickoff')
|
|
92
|
+
expect(ics).toContain('DTSTART:20260710T090000Z')
|
|
93
|
+
expect(ics).toContain('DTEND:20260710T100000Z')
|
|
94
|
+
expect(ics).not.toContain('METHOD:')
|
|
95
|
+
expect(ics).not.toContain('STATUS:')
|
|
96
|
+
})
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
describe('generateIcsEventExport', () => {
|
|
100
|
+
it('produces a plain VCALENDAR with the event and no METHOD', () => {
|
|
101
|
+
const ics = generateIcsEventExport({
|
|
102
|
+
...BASE,
|
|
103
|
+
description: 'Agenda',
|
|
104
|
+
location: 'Room 4',
|
|
105
|
+
})
|
|
106
|
+
expect(ics).toContain('BEGIN:VEVENT')
|
|
107
|
+
expect(ics).toContain('SUMMARY:Project kickoff')
|
|
108
|
+
expect(ics).toContain('DTSTART:20260710T090000Z')
|
|
109
|
+
expect(ics).toContain('LOCATION:Room 4')
|
|
110
|
+
expect(ics).not.toContain('METHOD:')
|
|
111
|
+
})
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
describe('generateIcsReply', () => {
|
|
115
|
+
it('answers an invite with the attendee partstat and echoed sequence', () => {
|
|
116
|
+
const ics = generateIcsReply({
|
|
117
|
+
uid: 'uid-123@pure.science',
|
|
118
|
+
sequence: 2,
|
|
119
|
+
timestamp: '2026-07-04T10:00:00.000Z',
|
|
120
|
+
title: 'Project kickoff',
|
|
121
|
+
startsAt: '2026-07-10T09:00:00.000Z',
|
|
122
|
+
endsAt: '2026-07-10T10:00:00.000Z',
|
|
123
|
+
organizer: { email: 'boss@pure.science' },
|
|
124
|
+
attendee: { email: 'adam@pure.science', name: 'Adam' },
|
|
125
|
+
response: 'accepted',
|
|
126
|
+
})
|
|
127
|
+
expect(ics).toContain('METHOD:REPLY')
|
|
128
|
+
expect(ics).toContain('SEQUENCE:2')
|
|
129
|
+
expect(ics).toContain(
|
|
130
|
+
'ATTENDEE;CN=Adam;PARTSTAT=ACCEPTED:mailto:adam@pure.science',
|
|
131
|
+
)
|
|
132
|
+
expect(ics).toContain('ORGANIZER:mailto:boss@pure.science')
|
|
133
|
+
})
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
describe('formatting helpers', () => {
|
|
137
|
+
it('escapes TEXT values per RFC 5545', () => {
|
|
138
|
+
expect(escapeIcsText('a,b;c\\d\ne')).toBe('a\\,b\\;c\\\\d\\ne')
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('folds long lines with leading-space continuations', () => {
|
|
142
|
+
const folded = foldIcsLine(`SUMMARY:${'x'.repeat(200)}`)
|
|
143
|
+
const lines = folded.split('\r\n')
|
|
144
|
+
expect(lines.length).toBeGreaterThan(2)
|
|
145
|
+
expect(lines[0]?.length).toBe(75)
|
|
146
|
+
for (const continuation of lines.slice(1)) {
|
|
147
|
+
expect(continuation.startsWith(' ')).toBe(true)
|
|
148
|
+
expect(continuation.length).toBeLessThanOrEqual(75)
|
|
149
|
+
}
|
|
150
|
+
// Unfolding restores the original.
|
|
151
|
+
expect(folded.replace(/\r\n /g, '')).toBe(`SUMMARY:${'x'.repeat(200)}`)
|
|
152
|
+
})
|
|
153
|
+
})
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal iCalendar (RFC 5545) writer for the mail↔calendar invite loop:
|
|
3
|
+
* `REQUEST` (send/update an invite), `REPLY` (RSVP), `CANCEL`.
|
|
4
|
+
*
|
|
5
|
+
* All date-times are emitted in UTC (`...Z`) so no VTIMEZONE component is
|
|
6
|
+
* needed; all-day events use DATE values. Output round-trips through the
|
|
7
|
+
* ICAL.js parser used by PureMail's invite reader.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type IcsMethod = 'REQUEST' | 'REPLY' | 'CANCEL'
|
|
11
|
+
|
|
12
|
+
export type IcsPartstat =
|
|
13
|
+
| 'ACCEPTED'
|
|
14
|
+
| 'DECLINED'
|
|
15
|
+
| 'TENTATIVE'
|
|
16
|
+
| 'NEEDS-ACTION'
|
|
17
|
+
|
|
18
|
+
export interface IcsPerson {
|
|
19
|
+
email: string
|
|
20
|
+
name?: string
|
|
21
|
+
partstat?: IcsPartstat
|
|
22
|
+
/** RSVP requested from this attendee (REQUEST invites). */
|
|
23
|
+
rsvp?: boolean
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface IcsEventInput {
|
|
27
|
+
uid: string
|
|
28
|
+
/**
|
|
29
|
+
* iTIP method. Omit for a plain (non-iTIP) VCALENDAR — e.g. "Download .ics"
|
|
30
|
+
* of an event to share. When undefined, no `METHOD:` line is emitted.
|
|
31
|
+
*/
|
|
32
|
+
method?: IcsMethod
|
|
33
|
+
/** Bumped by the organizer on every update; echoed in replies. */
|
|
34
|
+
sequence?: number
|
|
35
|
+
/** ISO timestamp for DTSTAMP — pass explicitly so output is deterministic. */
|
|
36
|
+
timestamp: string
|
|
37
|
+
title: string
|
|
38
|
+
description?: string
|
|
39
|
+
location?: string
|
|
40
|
+
/** ISO date-time (or YYYY-MM-DD when allDay). */
|
|
41
|
+
startsAt: string
|
|
42
|
+
endsAt: string
|
|
43
|
+
allDay?: boolean
|
|
44
|
+
organizer?: IcsPerson
|
|
45
|
+
attendees?: IcsPerson[]
|
|
46
|
+
status?: 'CONFIRMED' | 'CANCELLED' | 'TENTATIVE'
|
|
47
|
+
/** Full RRULE value, e.g. `FREQ=WEEKLY;INTERVAL=1` (no `RRULE:` prefix). */
|
|
48
|
+
rrule?: string
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** RFC 5545 §3.3.11 TEXT escaping. */
|
|
52
|
+
export function escapeIcsText(value: string): string {
|
|
53
|
+
return value
|
|
54
|
+
.replace(/\\/g, '\\\\')
|
|
55
|
+
.replace(/;/g, '\\;')
|
|
56
|
+
.replace(/,/g, '\\,')
|
|
57
|
+
.replace(/\r?\n/g, '\\n')
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** RFC 5545 §3.1 line folding at 75 octets (approximated per char). */
|
|
61
|
+
export function foldIcsLine(line: string): string {
|
|
62
|
+
if (line.length <= 75) return line
|
|
63
|
+
const parts: string[] = []
|
|
64
|
+
let rest = line
|
|
65
|
+
parts.push(rest.slice(0, 75))
|
|
66
|
+
rest = rest.slice(75)
|
|
67
|
+
while (rest.length > 74) {
|
|
68
|
+
parts.push(` ${rest.slice(0, 74)}`)
|
|
69
|
+
rest = rest.slice(74)
|
|
70
|
+
}
|
|
71
|
+
if (rest) parts.push(` ${rest}`)
|
|
72
|
+
return parts.join('\r\n')
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function utcBasic(iso: string): string {
|
|
76
|
+
const date = new Date(iso)
|
|
77
|
+
if (Number.isNaN(date.getTime())) {
|
|
78
|
+
throw new Error(`Invalid ICS date-time: ${iso}`)
|
|
79
|
+
}
|
|
80
|
+
return date
|
|
81
|
+
.toISOString()
|
|
82
|
+
.replace(/[-:]/g, '')
|
|
83
|
+
.replace(/\.\d{3}Z$/, 'Z')
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function dateBasic(iso: string): string {
|
|
87
|
+
const day = iso.slice(0, 10)
|
|
88
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) {
|
|
89
|
+
throw new Error(`Invalid ICS date: ${iso}`)
|
|
90
|
+
}
|
|
91
|
+
return day.replace(/-/g, '')
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function personProperty(
|
|
95
|
+
property: 'ORGANIZER' | 'ATTENDEE',
|
|
96
|
+
person: IcsPerson,
|
|
97
|
+
): string {
|
|
98
|
+
const params: string[] = []
|
|
99
|
+
if (person.name) params.push(`CN=${escapeIcsText(person.name)}`)
|
|
100
|
+
if (property === 'ATTENDEE') {
|
|
101
|
+
if (person.partstat) params.push(`PARTSTAT=${person.partstat}`)
|
|
102
|
+
if (person.rsvp) params.push('RSVP=TRUE')
|
|
103
|
+
}
|
|
104
|
+
const paramText = params.length ? `;${params.join(';')}` : ''
|
|
105
|
+
return `${property}${paramText}:mailto:${person.email}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function generateIcsCalendar(input: IcsEventInput): string {
|
|
109
|
+
const lines: string[] = [
|
|
110
|
+
'BEGIN:VCALENDAR',
|
|
111
|
+
'PRODID:-//PureScience//PureDesktop//EN',
|
|
112
|
+
'VERSION:2.0',
|
|
113
|
+
...(input.method ? [`METHOD:${input.method}`] : []),
|
|
114
|
+
'BEGIN:VEVENT',
|
|
115
|
+
`UID:${escapeIcsText(input.uid)}`,
|
|
116
|
+
`DTSTAMP:${utcBasic(input.timestamp)}`,
|
|
117
|
+
`SEQUENCE:${input.sequence ?? 0}`,
|
|
118
|
+
input.allDay
|
|
119
|
+
? `DTSTART;VALUE=DATE:${dateBasic(input.startsAt)}`
|
|
120
|
+
: `DTSTART:${utcBasic(input.startsAt)}`,
|
|
121
|
+
input.allDay
|
|
122
|
+
? `DTEND;VALUE=DATE:${dateBasic(input.endsAt)}`
|
|
123
|
+
: `DTEND:${utcBasic(input.endsAt)}`,
|
|
124
|
+
`SUMMARY:${escapeIcsText(input.title)}`,
|
|
125
|
+
]
|
|
126
|
+
if (input.description) {
|
|
127
|
+
lines.push(`DESCRIPTION:${escapeIcsText(input.description)}`)
|
|
128
|
+
}
|
|
129
|
+
if (input.location) {
|
|
130
|
+
lines.push(`LOCATION:${escapeIcsText(input.location)}`)
|
|
131
|
+
}
|
|
132
|
+
if (input.status ?? input.method === 'CANCEL') {
|
|
133
|
+
lines.push(`STATUS:${input.status ?? 'CANCELLED'}`)
|
|
134
|
+
}
|
|
135
|
+
if (input.rrule) lines.push(`RRULE:${input.rrule}`)
|
|
136
|
+
if (input.organizer) {
|
|
137
|
+
lines.push(personProperty('ORGANIZER', input.organizer))
|
|
138
|
+
}
|
|
139
|
+
for (const attendee of input.attendees ?? []) {
|
|
140
|
+
lines.push(personProperty('ATTENDEE', attendee))
|
|
141
|
+
}
|
|
142
|
+
lines.push('END:VEVENT', 'END:VCALENDAR')
|
|
143
|
+
return `${lines.map(foldIcsLine).join('\r\n')}\r\n`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Plain event export: a VCALENDAR with no iTIP METHOD, for "Download .ics"
|
|
148
|
+
* of an event to share.
|
|
149
|
+
*/
|
|
150
|
+
export function generateIcsEventExport(
|
|
151
|
+
input: Omit<IcsEventInput, 'method'>,
|
|
152
|
+
): string {
|
|
153
|
+
return generateIcsCalendar(input)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export interface IcsReplyInput {
|
|
157
|
+
/** UID of the invite being answered — must match the original. */
|
|
158
|
+
uid: string
|
|
159
|
+
/** SEQUENCE of the invite being answered — echoed back. */
|
|
160
|
+
sequence?: number
|
|
161
|
+
timestamp: string
|
|
162
|
+
title: string
|
|
163
|
+
startsAt: string
|
|
164
|
+
endsAt: string
|
|
165
|
+
allDay?: boolean
|
|
166
|
+
organizer: IcsPerson
|
|
167
|
+
/** The account answering the invite. */
|
|
168
|
+
attendee: IcsPerson
|
|
169
|
+
response: 'accepted' | 'declined' | 'tentative'
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const PARTSTAT_BY_RESPONSE: Record<IcsReplyInput['response'], IcsPartstat> = {
|
|
173
|
+
accepted: 'ACCEPTED',
|
|
174
|
+
declined: 'DECLINED',
|
|
175
|
+
tentative: 'TENTATIVE',
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** iMIP RSVP: a `METHOD:REPLY` VCALENDAR mailed back to the organizer. */
|
|
179
|
+
export function generateIcsReply(input: IcsReplyInput): string {
|
|
180
|
+
return generateIcsCalendar({
|
|
181
|
+
uid: input.uid,
|
|
182
|
+
method: 'REPLY',
|
|
183
|
+
...(input.sequence !== undefined ? { sequence: input.sequence } : {}),
|
|
184
|
+
timestamp: input.timestamp,
|
|
185
|
+
title: input.title,
|
|
186
|
+
startsAt: input.startsAt,
|
|
187
|
+
endsAt: input.endsAt,
|
|
188
|
+
...(input.allDay ? { allDay: true } : {}),
|
|
189
|
+
organizer: input.organizer,
|
|
190
|
+
attendees: [
|
|
191
|
+
{
|
|
192
|
+
...input.attendee,
|
|
193
|
+
partstat: PARTSTAT_BY_RESPONSE[input.response],
|
|
194
|
+
},
|
|
195
|
+
],
|
|
196
|
+
})
|
|
197
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -140,11 +140,14 @@ export {
|
|
|
140
140
|
ConnectionSetupGrid,
|
|
141
141
|
ConnectionSetupPage,
|
|
142
142
|
ConnectionStepList,
|
|
143
|
+
ProviderConnection,
|
|
143
144
|
type ConnectionOptionCardProps,
|
|
144
145
|
type ConnectionOptionTone,
|
|
145
146
|
type ConnectionSavedCardProps,
|
|
146
147
|
type ConnectionSetupPageProps,
|
|
147
148
|
type ConnectionStep,
|
|
149
|
+
type ProviderConnectionClient,
|
|
150
|
+
type ProviderConnectionProps,
|
|
148
151
|
} from './components/common/connections/index.js'
|
|
149
152
|
export {
|
|
150
153
|
OpenReviewCard,
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest'
|
|
2
|
+
import {
|
|
3
|
+
retryAfterMs,
|
|
4
|
+
withHttpRetry,
|
|
5
|
+
type HttpRetryOptions,
|
|
6
|
+
} from './httpRetry.js'
|
|
7
|
+
|
|
8
|
+
function response(status: number, headers?: Record<string, string>) {
|
|
9
|
+
return { status, ok: status >= 200 && status < 300, headers }
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function options(overrides: HttpRetryOptions = {}): HttpRetryOptions {
|
|
13
|
+
return {
|
|
14
|
+
sleep: vi.fn(async () => undefined),
|
|
15
|
+
random: () => 1,
|
|
16
|
+
now: () => 1_800_000_000_000,
|
|
17
|
+
...overrides,
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('withHttpRetry', () => {
|
|
22
|
+
it('returns a success immediately', async () => {
|
|
23
|
+
const attempt = vi.fn(async () => response(200))
|
|
24
|
+
const result = await withHttpRetry(attempt, options())
|
|
25
|
+
expect(result.status).toBe(200)
|
|
26
|
+
expect(attempt).toHaveBeenCalledTimes(1)
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('does not retry non-retryable client errors', async () => {
|
|
30
|
+
const attempt = vi.fn(async () => response(403))
|
|
31
|
+
const result = await withHttpRetry(attempt, options())
|
|
32
|
+
expect(result.status).toBe(403)
|
|
33
|
+
expect(attempt).toHaveBeenCalledTimes(1)
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
it('retries 429 and 5xx with exponential backoff until success', async () => {
|
|
37
|
+
const sleep = vi.fn(async (_ms: number) => undefined)
|
|
38
|
+
const responses = [response(429), response(500), response(200)]
|
|
39
|
+
const attempt = vi.fn(async () => responses.shift() ?? response(200))
|
|
40
|
+
const result = await withHttpRetry(
|
|
41
|
+
attempt,
|
|
42
|
+
options({ sleep, maxAttempts: 3 }),
|
|
43
|
+
)
|
|
44
|
+
expect(result.status).toBe(200)
|
|
45
|
+
expect(attempt).toHaveBeenCalledTimes(3)
|
|
46
|
+
expect(sleep).toHaveBeenCalledTimes(2)
|
|
47
|
+
// Full-jitter with random()=1 → the raw step: 500ms then 1000ms.
|
|
48
|
+
expect(sleep.mock.calls.map(call => call[0])).toEqual([500, 1000])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('returns the last failure when the budget is exhausted', async () => {
|
|
52
|
+
const attempt = vi.fn(async () => response(503))
|
|
53
|
+
const result = await withHttpRetry(attempt, options({ maxAttempts: 2 }))
|
|
54
|
+
expect(result.status).toBe(503)
|
|
55
|
+
expect(attempt).toHaveBeenCalledTimes(2)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('honors Retry-After seconds over computed backoff', async () => {
|
|
59
|
+
const sleep = vi.fn(async (_ms: number) => undefined)
|
|
60
|
+
const responses = [response(429, { 'retry-after': '2' }), response(200)]
|
|
61
|
+
const attempt = vi.fn(async () => responses.shift() ?? response(200))
|
|
62
|
+
await withHttpRetry(attempt, options({ sleep }))
|
|
63
|
+
expect(sleep).toHaveBeenCalledWith(2000)
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('retries a 401 exactly once', async () => {
|
|
67
|
+
const responses = [response(401), response(401)]
|
|
68
|
+
const attempt = vi.fn(async () => responses.shift() ?? response(200))
|
|
69
|
+
const result = await withHttpRetry(attempt, options())
|
|
70
|
+
// Second 401 is returned, not retried again.
|
|
71
|
+
expect(result.status).toBe(401)
|
|
72
|
+
expect(attempt).toHaveBeenCalledTimes(2)
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('recovers when the 401 retry succeeds', async () => {
|
|
76
|
+
const responses = [response(401), response(200)]
|
|
77
|
+
const attempt = vi.fn(async () => responses.shift() ?? response(200))
|
|
78
|
+
const result = await withHttpRetry(attempt, options())
|
|
79
|
+
expect(result.status).toBe(200)
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('retries thrown network errors and rethrows the last one', async () => {
|
|
83
|
+
const attempt = vi.fn(async () => {
|
|
84
|
+
throw new Error('socket hang up')
|
|
85
|
+
})
|
|
86
|
+
await expect(
|
|
87
|
+
withHttpRetry(attempt, options({ maxAttempts: 3 })),
|
|
88
|
+
).rejects.toThrow('socket hang up')
|
|
89
|
+
expect(attempt).toHaveBeenCalledTimes(3)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('recovers from a transient network error', async () => {
|
|
93
|
+
let first = true
|
|
94
|
+
const attempt = vi.fn(async () => {
|
|
95
|
+
if (first) {
|
|
96
|
+
first = false
|
|
97
|
+
throw new Error('reset')
|
|
98
|
+
}
|
|
99
|
+
return response(200)
|
|
100
|
+
})
|
|
101
|
+
const result = await withHttpRetry(attempt, options())
|
|
102
|
+
expect(result.status).toBe(200)
|
|
103
|
+
})
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
describe('retryAfterMs', () => {
|
|
107
|
+
const NOW = Date.parse('2026-07-04T10:00:00.000Z')
|
|
108
|
+
|
|
109
|
+
it('parses seconds and http-dates and rejects garbage', () => {
|
|
110
|
+
expect(retryAfterMs({ 'retry-after': '3' }, NOW)).toBe(3000)
|
|
111
|
+
expect(
|
|
112
|
+
retryAfterMs({ 'Retry-After': 'Sat, 04 Jul 2026 10:00:05 GMT' }, NOW),
|
|
113
|
+
).toBe(5000)
|
|
114
|
+
expect(retryAfterMs({ 'retry-after': 'soon' }, NOW)).toBeNull()
|
|
115
|
+
expect(retryAfterMs(undefined, NOW)).toBeNull()
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Retry policy for provider API calls (Gmail, Google Calendar):
|
|
3
|
+
* - 429 and 5xx retry with exponential backoff, honoring `Retry-After`
|
|
4
|
+
* (seconds or HTTP-date) when the server sends one.
|
|
5
|
+
* - 401 retries exactly once — the attempt callback re-requests its access
|
|
6
|
+
* token per attempt, and the shell refreshes transparently, so a single
|
|
7
|
+
* retry covers a token revoked mid-flight without loops.
|
|
8
|
+
* - Thrown attempt errors (network failures) retry like 5xx.
|
|
9
|
+
* The final response (or error) is returned/rethrown untouched so callers
|
|
10
|
+
* keep their existing error handling.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export interface RetryableHttpResponse {
|
|
14
|
+
status: number
|
|
15
|
+
ok: boolean
|
|
16
|
+
headers?: Record<string, string>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface HttpRetryOptions {
|
|
20
|
+
/** Total attempts including the first (default 3). */
|
|
21
|
+
maxAttempts?: number
|
|
22
|
+
baseDelayMs?: number
|
|
23
|
+
maxDelayMs?: number
|
|
24
|
+
sleep?: (ms: number) => Promise<void>
|
|
25
|
+
/** 0..1 jitter source; injectable for tests. */
|
|
26
|
+
random?: () => number
|
|
27
|
+
now?: () => number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const DEFAULT_MAX_ATTEMPTS = 3
|
|
31
|
+
const DEFAULT_BASE_DELAY_MS = 500
|
|
32
|
+
const DEFAULT_MAX_DELAY_MS = 8_000
|
|
33
|
+
|
|
34
|
+
function defaultSleep(ms: number): Promise<void> {
|
|
35
|
+
return new Promise(resolve => setTimeout(resolve, ms))
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function retryAfterMs(
|
|
39
|
+
headers: Record<string, string> | undefined,
|
|
40
|
+
now: number,
|
|
41
|
+
): number | null {
|
|
42
|
+
const raw =
|
|
43
|
+
headers?.['retry-after'] ??
|
|
44
|
+
headers?.['Retry-After'] ??
|
|
45
|
+
headers?.['RETRY-AFTER']
|
|
46
|
+
if (!raw) return null
|
|
47
|
+
const seconds = Number(raw)
|
|
48
|
+
if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000
|
|
49
|
+
const date = Date.parse(raw)
|
|
50
|
+
if (!Number.isNaN(date)) return Math.max(0, date - now)
|
|
51
|
+
return null
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function isRetryableStatus(status: number): boolean {
|
|
55
|
+
return status === 429 || (status >= 500 && status <= 599)
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export async function withHttpRetry<T extends RetryableHttpResponse>(
|
|
59
|
+
attempt: (attemptIndex: number) => Promise<T>,
|
|
60
|
+
options: HttpRetryOptions = {},
|
|
61
|
+
): Promise<T> {
|
|
62
|
+
const maxAttempts = Math.max(1, options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)
|
|
63
|
+
const baseDelayMs = options.baseDelayMs ?? DEFAULT_BASE_DELAY_MS
|
|
64
|
+
const maxDelayMs = options.maxDelayMs ?? DEFAULT_MAX_DELAY_MS
|
|
65
|
+
const sleep = options.sleep ?? defaultSleep
|
|
66
|
+
const random = options.random ?? Math.random
|
|
67
|
+
const now = options.now ?? Date.now
|
|
68
|
+
|
|
69
|
+
let unauthorizedRetried = false
|
|
70
|
+
let attemptIndex = 0
|
|
71
|
+
// Retry budget counts backoff retries; the single 401 retry is separate.
|
|
72
|
+
let retriesLeft = maxAttempts - 1
|
|
73
|
+
|
|
74
|
+
for (;;) {
|
|
75
|
+
let response: T | null = null
|
|
76
|
+
let failure: unknown = null
|
|
77
|
+
try {
|
|
78
|
+
response = await attempt(attemptIndex)
|
|
79
|
+
} catch (error) {
|
|
80
|
+
failure = error
|
|
81
|
+
}
|
|
82
|
+
attemptIndex += 1
|
|
83
|
+
|
|
84
|
+
if (response) {
|
|
85
|
+
if (response.ok || !isRetryableStatus(response.status)) {
|
|
86
|
+
if (response.status === 401 && !unauthorizedRetried) {
|
|
87
|
+
unauthorizedRetried = true
|
|
88
|
+
continue
|
|
89
|
+
}
|
|
90
|
+
return response
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
if (retriesLeft <= 0) {
|
|
95
|
+
if (failure !== null) throw failure
|
|
96
|
+
return response as T
|
|
97
|
+
}
|
|
98
|
+
retriesLeft -= 1
|
|
99
|
+
|
|
100
|
+
const serverDelay = response
|
|
101
|
+
? retryAfterMs(response.headers, now())
|
|
102
|
+
: null
|
|
103
|
+
const backoffStep = Math.min(
|
|
104
|
+
maxDelayMs,
|
|
105
|
+
baseDelayMs * 2 ** (maxAttempts - 2 - retriesLeft),
|
|
106
|
+
)
|
|
107
|
+
// Half-to-full jitter keeps concurrent clients from thundering.
|
|
108
|
+
const jittered = backoffStep * (0.5 + random() / 2)
|
|
109
|
+
await sleep(Math.min(maxDelayMs, serverDelay ?? jittered))
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/theme/appAccents.ts
CHANGED
|
@@ -115,6 +115,12 @@ const APP_IDENTITY_ACCENTS: Record<string, DerivedAppAccent> = {
|
|
|
115
115
|
accentMuted: 'oklch(0.955 0.03 282)',
|
|
116
116
|
accentText: 'oklch(0.43 0.12 282)',
|
|
117
117
|
},
|
|
118
|
+
gantt: {
|
|
119
|
+
accent: 'oklch(0.55 0.125 306)',
|
|
120
|
+
accentHover: 'oklch(0.43 0.12 306)',
|
|
121
|
+
accentMuted: 'oklch(0.955 0.03 306)',
|
|
122
|
+
accentText: 'oklch(0.43 0.12 306)',
|
|
123
|
+
},
|
|
118
124
|
review: {
|
|
119
125
|
accent: 'oklch(0.55 0.125 300)',
|
|
120
126
|
accentHover: 'oklch(0.43 0.12 300)',
|
|
@@ -52,6 +52,10 @@ export const appIdentityCss = `
|
|
|
52
52
|
--pure-violet: oklch(0.55 0.125 282);
|
|
53
53
|
--pure-violet-bg: oklch(0.955 0.03 282);
|
|
54
54
|
--pure-violet-tx: oklch(0.43 0.12 282);
|
|
55
|
+
--pure-gantt: oklch(0.55 0.125 306);
|
|
56
|
+
--pure-gantt-bg: oklch(0.955 0.03 306);
|
|
57
|
+
--pure-gantt-tx: oklch(0.43 0.12 306);
|
|
58
|
+
--pure-gantt-blk: oklch(0.4 0.07 306);
|
|
55
59
|
--pure-purple: oklch(0.55 0.125 300);
|
|
56
60
|
--pure-purple-bg: oklch(0.955 0.03 300);
|
|
57
61
|
--pure-purple-tx: oklch(0.43 0.12 300);
|
|
@@ -168,6 +172,13 @@ export const appIdentityCss = `
|
|
|
168
172
|
--app-text: var(--pure-violet-tx);
|
|
169
173
|
}
|
|
170
174
|
|
|
175
|
+
[data-app='gantt'] {
|
|
176
|
+
--app-acc: var(--pure-gantt);
|
|
177
|
+
--app-block: var(--pure-gantt-blk);
|
|
178
|
+
--app-bg: var(--pure-gantt-bg);
|
|
179
|
+
--app-text: var(--pure-gantt-tx);
|
|
180
|
+
}
|
|
181
|
+
|
|
171
182
|
[data-app='review'] {
|
|
172
183
|
--app-acc: var(--pure-purple);
|
|
173
184
|
--app-bg: var(--pure-purple-bg);
|
|
@@ -68,6 +68,10 @@ export const appIdentityCss = `
|
|
|
68
68
|
--pure-violet-bg: oklch(0.955 0.03 282);
|
|
69
69
|
--pure-violet-tx: oklch(0.43 0.12 282);
|
|
70
70
|
--pure-violet-blk: oklch(0.4 0.07 282);
|
|
71
|
+
--pure-gantt: oklch(0.55 0.125 306);
|
|
72
|
+
--pure-gantt-bg: oklch(0.955 0.03 306);
|
|
73
|
+
--pure-gantt-tx: oklch(0.43 0.12 306);
|
|
74
|
+
--pure-gantt-blk: oklch(0.4 0.07 306);
|
|
71
75
|
--pure-purple: oklch(0.55 0.125 300);
|
|
72
76
|
--pure-purple-bg: oklch(0.955 0.03 300);
|
|
73
77
|
--pure-purple-tx: oklch(0.43 0.12 300);
|
|
@@ -206,6 +210,13 @@ export const appIdentityCss = `
|
|
|
206
210
|
--app-text: var(--pure-violet-tx);
|
|
207
211
|
}
|
|
208
212
|
|
|
213
|
+
[data-app='gantt'] {
|
|
214
|
+
--app-acc: var(--pure-gantt);
|
|
215
|
+
--app-block: var(--pure-gantt-blk);
|
|
216
|
+
--app-bg: var(--pure-gantt-bg);
|
|
217
|
+
--app-text: var(--pure-gantt-tx);
|
|
218
|
+
}
|
|
219
|
+
|
|
209
220
|
[data-app='review'] {
|
|
210
221
|
--app-acc: var(--pure-purple);
|
|
211
222
|
--app-block: var(--pure-purple-blk);
|
package/src/theme/themes/dark.ts
CHANGED
|
@@ -46,7 +46,9 @@ export const darkTheme: PlatformTheme = createPlatformTheme(
|
|
|
46
46
|
semanticRedMuted: '#3a211d',
|
|
47
47
|
semanticRedBorder: '#753f38',
|
|
48
48
|
focus: accent,
|
|
49
|
-
|
|
49
|
+
// Must contrast with surface-hover (#2c2c30) — otherwise text selected
|
|
50
|
+
// inside a focused/hovered field is invisible.
|
|
51
|
+
selection: neutral[600],
|
|
50
52
|
/** Embedded plugin iframe canvas — must stay dark in dark mode */
|
|
51
53
|
appViewport: '#202024',
|
|
52
54
|
chromeTitlebar: '#202024',
|
|
@@ -49,7 +49,9 @@ export const lightTheme: PlatformTheme = createPlatformTheme(
|
|
|
49
49
|
semanticRedMuted: '#f4dfdc',
|
|
50
50
|
semanticRedBorder: '#dfb7b1',
|
|
51
51
|
focus: accent,
|
|
52
|
-
|
|
52
|
+
// Must contrast with surface-hover (also neutral[100]) — otherwise text
|
|
53
|
+
// selected inside a focused/hovered field is invisible.
|
|
54
|
+
selection: neutral[200],
|
|
53
55
|
appViewport: neutral[25],
|
|
54
56
|
chromeTitlebar: neutral[25],
|
|
55
57
|
chromeTabStrip: neutral[75],
|