@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.
Files changed (75) hide show
  1. package/package.json +35 -5
  2. package/src/agents/mcpServerPresets.test.ts +105 -0
  3. package/src/agents/mcpServerPresets.ts +380 -0
  4. package/src/agents/runtime/index.ts +7 -0
  5. package/src/agents/runtime/mapAgentSnapshot.ts +11 -8
  6. package/src/agents/runtime/pendingAsks.test.ts +117 -0
  7. package/src/agents/runtime/pendingAsks.ts +110 -0
  8. package/src/bridge/agentScopeDefaults.ts +65 -0
  9. package/src/bridge/agentTypes.ts +36 -2
  10. package/src/bridge/agents.d.mts +6 -0
  11. package/src/bridge/agents.mjs +10 -0
  12. package/src/bridge/agents.ts +4 -0
  13. package/src/bridge/credentials.d.mts +62 -0
  14. package/src/bridge/credentials.mjs +53 -0
  15. package/src/bridge/events.d.mts +1 -0
  16. package/src/bridge/events.mjs +1 -0
  17. package/src/bridge/googleAuth.ts +7 -152
  18. package/src/bridge/mcpClient.d.mts +74 -0
  19. package/src/bridge/mcpClient.mjs +79 -0
  20. package/src/bridge/methods.d.mts +22 -0
  21. package/src/bridge/methods.mjs +23 -0
  22. package/src/bridge/notifications.d.mts +12 -0
  23. package/src/bridge/notifications.mjs +36 -0
  24. package/src/bridge/operations.d.mts +44 -0
  25. package/src/bridge/operations.mjs +40 -0
  26. package/src/bridge/react/usePlatformAgentSessionDrive.test.tsx +29 -10
  27. package/src/bridge/react/usePlatformAgentSessionDrive.tsx +10 -81
  28. package/src/bridge/react/usePlatformAppSettings.test.tsx +105 -105
  29. package/src/bridge/react/usePlatformAppSettings.tsx +104 -104
  30. package/src/bridge/react/usePlatformJsonStore.test.tsx +152 -152
  31. package/src/bridge/react/usePlatformJsonStore.tsx +149 -149
  32. package/src/bridge/react/usePlatformOperations.tsx +75 -0
  33. package/src/bridge/storage.d.mts +17 -4
  34. package/src/bridge/storage.mjs +6 -3
  35. package/src/components/agents/AgentComposer.tsx +10 -6
  36. package/src/components/agents/AgentDrawerPanel.test.tsx +2 -205
  37. package/src/components/agents/AgentDrawerPanel.tsx +23 -64
  38. package/src/components/agents/AgentMessageBubble.tsx +59 -33
  39. package/src/components/agents/AgentMessageList.tsx +4 -9
  40. package/src/components/agents/AgentQuestionPromptList.tsx +34 -212
  41. package/src/components/agents/AgentToolApprovalActions.tsx +13 -11
  42. package/src/components/agents/AgentToolPendingList.tsx +6 -6
  43. package/src/components/agents/AgentToolPendingReview.tsx +6 -6
  44. package/src/components/agents/QuestionRequestList.tsx +278 -0
  45. package/src/components/agents/agentPanelStyles.ts +9 -33
  46. package/src/components/agents/agentTypes.ts +25 -43
  47. package/src/components/agents/index.ts +4 -0
  48. package/src/components/assets/asset-library/sidebar/AssetLibraryList.tsx +10 -4
  49. package/src/components/chrome/WorkspaceTabStrip.tsx +4 -3
  50. package/src/components/common/chat/ChatBox.tsx +9 -9
  51. package/src/components/common/chat/ChatModelMenu.tsx +34 -4
  52. package/src/components/common/chat/ChatThread.tsx +23 -7
  53. package/src/components/common/connections/ProviderConnection.test.tsx +208 -0
  54. package/src/components/common/connections/ProviderConnection.tsx +277 -0
  55. package/src/components/common/connections/index.ts +5 -0
  56. package/src/components/common/containers/AppFrame.tsx +26 -19
  57. package/src/components/common/containers/AppHeader.tsx +20 -20
  58. package/src/components/common/documents/DocumentSwitcher.tsx +63 -58
  59. package/src/components/common/dropdown/MenuDropdownItem.tsx +14 -2
  60. package/src/components/common/dropdown/MenuPortal.tsx +3 -1
  61. package/src/components/common/dropdown/menuViewportPosition.test.ts +20 -10
  62. package/src/components/common/dropdown/menuViewportPosition.ts +35 -27
  63. package/src/components/common/overlays/Modal.tsx +13 -2
  64. package/src/components/editor/EditorLinkPromptDialog.tsx +1 -1
  65. package/src/ics/generateIcs.test.ts +153 -0
  66. package/src/ics/generateIcs.ts +197 -0
  67. package/src/index.ts +3 -0
  68. package/src/net/httpRetry.test.ts +117 -0
  69. package/src/net/httpRetry.ts +111 -0
  70. package/src/theme/appAccents.ts +6 -0
  71. package/src/theme/appIdentityCss.mjs +11 -0
  72. package/src/theme/appIdentityCss.ts +11 -0
  73. package/src/theme/themes/dark.ts +3 -1
  74. package/src/theme/themes/light.ts +3 -1
  75. 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
+ }
@@ -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);
@@ -46,7 +46,9 @@ export const darkTheme: PlatformTheme = createPlatformTheme(
46
46
  semanticRedMuted: '#3a211d',
47
47
  semanticRedBorder: '#753f38',
48
48
  focus: accent,
49
- selection: '#313135',
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
- selection: neutral[100],
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],