Skip to content

Commit 6f6b2f9

Browse files
committed
fix(org): settle the member and design audits on the redesign
1 parent eb6603d commit 6f6b2f9

17 files changed

Lines changed: 211 additions & 81 deletions

File tree

‎apps/sim/app/o/[organizationId]/components/organization-conversation.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,8 @@ interface OrganizationConversationProps {
103103
draftId?: string
104104
/** Replaces the landing, and the blank frame before it, until the first turn takes over. */
105105
landing?: {
106+
/** Shown over the conversation once the first turn has replaced the landing. */
107+
chatHeader?: ReactNode
106108
fallback: ReactNode
107109
render: (slot: OrganizationLandingSlot) => ReactNode
108110
}
@@ -146,7 +148,7 @@ function OrganizationConversationContent({
146148
userName,
147149
chatId,
148150
requestMode: destinationMode,
149-
header,
151+
header: frameHeader,
150152
active = true,
151153
summaryRequest,
152154
onSummaryConsumed,
@@ -232,6 +234,7 @@ function OrganizationConversationContent({
232234
store.migrateDraft(initialDraftKey, draftKey)
233235
}, [initialDraftKey, draftKey, legacyDraftKey])
234236
const hasChat = Boolean(chatId || chat.messages.length)
237+
const header = frameHeader ?? (hasChat ? landing?.chatHeader : undefined)
235238
const assistantSearchLevel = 'fast'
236239
const panel = useChatResourcePanel(chat, controller, userId)
237240
const addResource = panel.addResourceFromUser

‎apps/sim/app/o/[organizationId]/search/use-organization-search.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ import { useHydrated } from '@/hooks/use-hydrated'
2121
import { useVoiceInput } from '@/hooks/use-voice-input'
2222
import { useMothershipDraftsStore } from '@/stores/mothership-drafts/store'
2323

24-
const SEARCH_MODES = [
24+
export const SEARCH_MODES = [
2525
{ value: 'search', label: 'Search' },
2626
{ value: 'assistant', label: 'Assistant' },
2727
] as const

‎apps/sim/lib/organizations/home/provider-reads.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,13 @@ import {
2323
readOutlookMessages,
2424
} from '@/lib/organizations/home/provider-reads'
2525
import { gmailThreadUrl } from '@/connectors/gmail/mailbox'
26+
import { GoogleApiError } from '@/connectors/google-workspace/api-errors'
2627

28+
const { mailboxEmail } = vi.hoisted(() => ({ mailboxEmail: vi.fn() }))
2729
vi.mock('@/lib/core/security/input-validation.server', () => inputValidationMock)
2830
vi.mock('@/connectors/gmail/mailbox', async (importOriginal) => ({
2931
...(await importOriginal<typeof import('@/connectors/gmail/mailbox')>()),
30-
getGmailMailboxEmail: async () => 'reader@example.com',
32+
getGmailMailboxEmail: mailboxEmail,
3133
}))
3234

3335
const { mockSecureFetchWithValidation: providerFetch } = inputValidationMockFns
@@ -70,10 +72,35 @@ function metadataMessage(from: string, subject: string, snippet: string) {
7072
}
7173

7274
beforeEach(() => {
75+
mailboxEmail.mockResolvedValue('reader@example.com')
7376
providerFetch.mockReset()
7477
})
7578

7679
describe('provider status classification', () => {
80+
it('does not ask for a reconnect when Google reports quota in a 403 body', async () => {
81+
providerFetch.mockResolvedValue(
82+
respond({ error: { errors: [{ reason: 'userRateLimitExceeded' }] } }, 403, {})
83+
)
84+
await expect(readOutlookMessages({ accessToken: 'token', signal })).rejects.toMatchObject({
85+
failure: 'unavailable',
86+
})
87+
})
88+
89+
it.each([
90+
[401, [], 'token_rejected'],
91+
[403, ['insufficientPermissions'], 'needs_reauth'],
92+
[403, ['userRateLimitExceeded'], 'unavailable'],
93+
[500, [], 'unavailable'],
94+
])('maps a %i mailbox lookup %o to %s', async (status, reasons, failure) => {
95+
providerFetch.mockResolvedValue(respond({ threads: [{ id: 'thread-1' }] }, 200, {}))
96+
mailboxEmail.mockRejectedValueOnce(
97+
new GoogleApiError('gmail.users.getProfile', status, reasons)
98+
)
99+
await expect(readGmailThreads({ accessToken: 'token', signal })).rejects.toMatchObject({
100+
failure,
101+
})
102+
})
103+
77104
it.each([
78105
[401, {}, 'token_rejected'],
79106
[403, {}, 'needs_reauth'],

‎apps/sim/lib/organizations/home/provider-reads.ts‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import {
1010
HOME_TEXT_LIMITS,
1111
} from '@/lib/organizations/home/limits'
1212
import { getGmailMailboxEmail, gmailThreadUrl } from '@/connectors/gmail/mailbox'
13+
import {
14+
GoogleApiError,
15+
isGoogleQuotaReason,
16+
readGoogleErrorDetails,
17+
} from '@/connectors/google-workspace/api-errors'
1318
import { gmailGetThreadV2Tool } from '@/tools/gmail/get_thread'
1419
import { gmailListThreadsV2Tool } from '@/tools/gmail/list_threads'
1520
import { listV2Tool as googleCalendarListTool } from '@/tools/google_calendar/list'
@@ -112,9 +117,15 @@ async function requestTool<P>(
112117
acceptCompressed: true,
113118
})
114119
if (response.ok) return response
115-
await response.text().catch(() => '')
120+
const body = await response.text().catch(() => '')
116121
if (response.status === 401) throw new HomeProviderReadError('token_rejected', 401)
117-
if (response.status === 403 && !hasRateLimitEvidence(response.headers))
122+
// Google reports quota as a 403 reason in the body, which reconnecting would not fix.
123+
const { reasons } = await readGoogleErrorDetails(new Response(body))
124+
if (
125+
response.status === 403 &&
126+
!hasRateLimitEvidence(response.headers) &&
127+
!reasons.some(isGoogleQuotaReason)
128+
)
118129
throw new HomeProviderReadError('needs_reauth', 403)
119130
throw new HomeProviderReadError('unavailable', response.status)
120131
}
@@ -152,6 +163,14 @@ function isoOrNull(value: string | null | undefined) {
152163
return Number.isFinite(time) ? new Date(time).toISOString() : null
153164
}
154165

166+
function mailboxReadFailure(error: unknown): HomeProviderReadError {
167+
if (!(error instanceof GoogleApiError)) return new HomeProviderReadError('unavailable')
168+
if (error.status === 401) return new HomeProviderReadError('token_rejected', 401)
169+
if (error.status === 403 && !error.rateLimited && error.category !== 'rate_limit')
170+
return new HomeProviderReadError('needs_reauth', 403)
171+
return new HomeProviderReadError('unavailable', error.status)
172+
}
173+
155174
export async function readGmailThreads({
156175
accessToken,
157176
signal,
@@ -162,7 +181,9 @@ export async function readGmailThreads({
162181
signal
163182
)
164183
if (!list.success) throw new HomeProviderReadError('unavailable')
165-
const mailbox = await getGmailMailboxEmail(accessToken, { signal })
184+
const mailbox = await getGmailMailboxEmail(accessToken, { signal }).catch((error: unknown) => {
185+
throw mailboxReadFailure(error)
186+
})
166187
const threads = await mapWithConcurrency(
167188
list.output.threads.slice(0, HOME_EMAIL_THREAD_LIMIT),
168189
GMAIL_THREAD_CONCURRENCY,

‎apps/sim/platform/app/(shell)/home/_components/activity-list/activity-list.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -98,7 +98,7 @@ export function AttentionList({ state, asOf, onRetry }: AttentionListProps) {
9898
!state.partial && (
9999
<EmptyState
100100
title='You’re all caught up'
101-
description='Reviews and requests will appear here.'
101+
description='Anything that needs you will appear here.'
102102
/>
103103
)
104104
)}

‎apps/sim/platform/app/(shell)/home/_components/calendar/calendar-day.tsx‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ function externalUrl(url: string | undefined) {
2929
return url?.startsWith('https://') && URL.canParse(url) ? url : undefined
3030
}
3131

32+
/** An hour label this close to the current time would sit under it. */
33+
const NOW_LABEL_CLEARANCE_MINUTES = 15
3234
const HOURS = Array.from({ length: 24 }, (_, hour) => formatClockTime(hour * 3_600_000))
3335
/** A short meeting still needs room for its name and time. */
3436
const MIN_MEETING_ROWS = 30
@@ -182,7 +184,14 @@ export function CalendarDay({ calendar, onDayChange, onReturnToToday, onRetry }:
182184
key={time}
183185
className={styles.hour}
184186
data-type='T035'
185-
style={{ gridRow: timeSlot(index * 60, 60) }}
187+
style={{
188+
gridRow: timeSlot(index * 60, 60),
189+
visibility:
190+
calendar.nowMinute !== null &&
191+
Math.abs(index * 60 - calendar.nowMinute) < NOW_LABEL_CLEARANCE_MINUTES
192+
? 'hidden'
193+
: undefined,
194+
}}
186195
>
187196
{time}
188197
</span>

‎apps/sim/platform/app/(shell)/home/_components/home-conversation/home-conversation.tsx‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,14 @@
11
'use client'
22

3+
import { useState, useTransition } from 'react'
4+
import { useRouter } from 'next/navigation'
5+
import { organizationRoutes } from '@/lib/navigation/paths'
36
import { OrganizationConversation } from '@/app/o/[organizationId]/components/organization-conversation'
7+
import { useOrganizationContext } from '@/app/o/[organizationId]/providers/organization-provider'
8+
import { SEARCH_MODES } from '@/app/o/[organizationId]/search/use-organization-search'
49
import { HomeDashboardSkeleton } from '@/platform/app/(shell)/home/_components/home-dashboard/home-dashboard-skeleton'
510
import { LiveHome } from '@/platform/app/(shell)/home/_components/live-home/live-home'
11+
import { SearchSurfaceHeader } from '@/platform/app/(shell)/search/_components/search-surface/search-surface-header'
612

713
/** Search's Assistant keeps its own unsent question; the agent composer keeps the default `home` key. */
814
const HOME_ASSISTANT_DRAFT_ID = 'home-assistant'
@@ -13,11 +19,39 @@ interface HomeConversationProps {
1319

1420
/** The real conversation, landing on the dashboard until the first turn takes over. */
1521
export function HomeConversation({ requestMode }: HomeConversationProps) {
22+
const { organization } = useOrganizationContext()
23+
const router = useRouter()
24+
const routes = organizationRoutes(organization.id)
25+
const [conversationInstance, setConversationInstance] = useState(0)
26+
const [isStartingNew, startNewTransition] = useTransition()
27+
// An Assistant chat started here takes its Search address without leaving this page, so it carries
28+
// Search's controls itself, and they navigate rather than switch a view this page doesn't hold.
29+
const assistantChatHeader = (
30+
<SearchSurfaceHeader
31+
search={{
32+
activeMode: 'assistant',
33+
modes: [...SEARCH_MODES],
34+
changeMode: (mode) => {
35+
if (mode === 'search') router.push(`${routes.search}?mode=search`)
36+
},
37+
newLabel: 'New chat',
38+
canStartNew: true,
39+
isStartingNew,
40+
startNew: () =>
41+
startNewTransition(() => {
42+
setConversationInstance((current) => current + 1)
43+
router.push(routes.home, { scroll: false })
44+
}),
45+
}}
46+
/>
47+
)
1648
return (
1749
<OrganizationConversation
50+
key={conversationInstance}
1851
requestMode={requestMode}
1952
draftId={requestMode === 'assistant' ? HOME_ASSISTANT_DRAFT_ID : undefined}
2053
landing={{
54+
chatHeader: requestMode === 'assistant' ? assistantChatHeader : undefined,
2155
fallback: <HomeDashboardSkeleton requestMode={requestMode} />,
2256
render: (slot) => <LiveHome {...slot} />,
2357
}}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use client'
2+
3+
import { RedesignSurface } from '@/app/o/[organizationId]/_redesign/redesign-host'
4+
import type { OrganizationSearchState } from '@/app/o/[organizationId]/search/use-organization-search'
5+
import styles from '@/platform/app/(shell)/search/_components/search-surface/search-surface.module.css'
6+
import { Button } from '@/platform/design-system/components/button/button'
7+
import { Icon } from '@/platform/design-system/components/icon/icon'
8+
9+
const SEARCH_MODE_ICONS = { search: 'I089', assistant: 'I073' } as const
10+
11+
export type SearchHeaderState = Pick<
12+
OrganizationSearchState,
13+
'activeMode' | 'modes' | 'changeMode' | 'newLabel' | 'canStartNew' | 'isStartingNew' | 'startNew'
14+
>
15+
16+
/**
17+
* A `nav` of buttons rather than `SegmentedControl`: its radios would flip on reselect, and each
18+
* view here is a history entry with `aria-current`.
19+
*/
20+
export function SearchSurfaceHeader({ search }: { search: SearchHeaderState }) {
21+
const { activeMode, newLabel } = search
22+
return (
23+
<div className={styles.transparentSurface}>
24+
<RedesignSurface>
25+
<div className={styles.header}>
26+
<nav aria-label='Search views' className={styles.views}>
27+
{search.modes.map((entry) => (
28+
<button
29+
key={entry.value}
30+
type='button'
31+
className={styles.view}
32+
data-type='T056'
33+
aria-current={activeMode === entry.value ? 'page' : undefined}
34+
onClick={() => search.changeMode(entry.value)}
35+
>
36+
<Icon name={SEARCH_MODE_ICONS[entry.value]} />
37+
{entry.label}
38+
</button>
39+
))}
40+
</nav>
41+
{search.canStartNew && (
42+
<>
43+
<span className={styles.newFull}>
44+
<Button
45+
variant='quiet'
46+
size='compact'
47+
icon='I074'
48+
disabled={search.isStartingNew}
49+
onClick={search.startNew}
50+
render={<button type='button' className={styles.newButton} />}
51+
>
52+
{newLabel}
53+
</Button>
54+
</span>
55+
<span className={styles.newShort}>
56+
<Button
57+
variant='quiet'
58+
size='compact'
59+
icon='I074'
60+
disabled={search.isStartingNew}
61+
onClick={search.startNew}
62+
render={
63+
<button type='button' className={styles.newButton} aria-label={newLabel} />
64+
}
65+
>
66+
New
67+
</Button>
68+
</span>
69+
</>
70+
)}
71+
</div>
72+
</RedesignSurface>
73+
</div>
74+
)
75+
}

‎apps/sim/platform/app/(shell)/search/_components/search-surface/search-surface.module.css‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,9 @@
8989
border: var(--spacing-stroke-default) solid var(--ui-divider);
9090
border-radius: var(--radius-composer);
9191
}
92+
.field:has(textarea:focus-visible) {
93+
border-color: var(--ui-secondary);
94+
}
9295
.fieldMark {
9396
display: inline-flex;
9497
flex: none;
@@ -129,7 +132,7 @@
129132
.dockedField,
130133
.resultsColumn {
131134
inline-size: 100%;
132-
max-inline-size: var(--container-chat);
135+
max-inline-size: calc(var(--container-chat) + 2 * var(--spacing-24));
133136
margin-inline: auto;
134137
}
135138
.dockedField {
@@ -143,7 +146,7 @@
143146
scrollbar-gutter: stable both-edges;
144147
}
145148
.resultsColumn {
146-
padding: var(--spacing-4) var(--spacing-32) var(--spacing-24);
149+
padding: var(--spacing-4) var(--spacing-24) var(--spacing-24);
147150
}
148151
.landing {
149152
display: flex;

0 commit comments

Comments
 (0)