Skip to content

Commit 2228383

Browse files
committed
improvement(skills): align the custom tools row and harden the guard lifecycle
Follow-ups from an audit of the skills and custom tools surfaces. Custom tools row, now matching the skills row exactly: - the trailing arrow could be squeezed by a long description; the shared row owns `flex-shrink-0` for its trailing slot, so the five callers that hand-rolled it (and the two that forgot) all get it - drop a redundant `cursor-pointer` — Tailwind v3's preflight already sets it on `button` - the tile chrome was defined twice, in ResourceTile and again in SettingsResourceRow, despite ResourceTile's doc claiming to be the single source. Both now share RESOURCE_TILE_BASE/RESOURCE_TILE_FILL, and ResourceTile's redundant wrapper div is gone - skills' row uses the text-sm/text-caption tokens instead of literal pixels; the added gap-[1px] offsets the 21px->20px line-height so the row stays 39px Guard and cache correctness: - the delete path released the guard after the await, but the delete is optimistic — the row leaves the cache first, so the form went clean mid-flight and popped the sentinel before the release landed. Release up front and rearm if the request fails - hold the loading frame across the whole optimistic delete instead of flashing "Skill not found." on the way out - custom tools had the same placeholder-data bug just fixed in skill detail, and worse: a deep-linked id could resolve against the workspace just left - keep cached rows the create response omits, so a concurrent create isn't dropped until the refetch lands
1 parent e538a56 commit 2228383

14 files changed

Lines changed: 102 additions & 73 deletions

File tree

apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-credential-detail-form.ts

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
'use client'
22

3-
import { useCallback, useState } from 'react'
3+
import { useCallback, useEffect, useState } from 'react'
44
import { toast } from '@sim/emcn'
55
import { createLogger } from '@sim/logger'
66
import { getErrorMessage } from '@sim/utils/errors'
@@ -30,16 +30,11 @@ export function useCredentialDetailForm({
3030

3131
const [displayNameDraft, setDisplayNameDraft] = useState('')
3232
const [descriptionDraft, setDescriptionDraft] = useState('')
33-
const [prevCredentialId, setPrevCredentialId] = useState<string | null>(null)
3433

35-
// Seed drafts when the credential first resolves (or the route id changes); a
36-
// background refetch of the same credential must not clobber an in-progress
37-
// edit — nor silently reset isDirty, which pops the guard's history sentinel.
38-
if (credential && credential.id !== prevCredentialId) {
39-
setPrevCredentialId(credential.id)
40-
setDisplayNameDraft(credential.displayName ?? '')
41-
setDescriptionDraft(credential.description ?? '')
42-
}
34+
useEffect(() => {
35+
setDisplayNameDraft(credential?.displayName ?? '')
36+
setDescriptionDraft(credential?.description ?? '')
37+
}, [credential?.id, credential?.displayName, credential?.description])
4338

4439
const isDisplayNameDirty = credential ? displayNameDraft !== credential.displayName : false
4540
const isDescriptionDirty = credential

apps/sim/app/workspace/[workspaceId]/components/credential-detail/hooks/use-unsaved-changes-guard.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,7 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG
3030
const hasSentinelRef = useRef(false)
3131

3232
useEffect(() => {
33-
// Released: the caller is navigating away, so leave the seeded entry alone —
34-
// popping it would cancel that navigation.
33+
// The caller is navigating away — popping the seeded entry would cancel it.
3534
if (isReleased) return
3635
if (!isDirty) {
3736
// Clean again while still mounted (saved/reverted): pop the seeded entry so
@@ -80,12 +79,23 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG
8079
}, [router, backHref])
8180

8281
/**
83-
* Retires the guard for the rest of this mount: no unload warning, no Back
84-
* trap, and no pop of the seeded entry when the form goes clean. Call it
85-
* immediately before navigating away on a successful save, and navigate with
86-
* `router.replace` so the seeded entry is the one consumed.
82+
* Retires the guard: no unload warning, no browser Back trap, and no pop of the
83+
* seeded entry when the form goes clean. Call it before navigating away on a
84+
* successful save, and navigate with `router.replace` so the seeded entry is the
85+
* one consumed. An operation that goes clean before it resolves (an optimistic
86+
* delete) must release up front and {@link rearm} if it fails.
8787
*/
8888
const release = useCallback(() => setIsReleased(true), [])
8989

90-
return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard, release }
90+
/** Restores guarding after a released operation failed and the surface stays. */
91+
const rearm = useCallback(() => setIsReleased(false), [])
92+
93+
return {
94+
showUnsavedAlert,
95+
setShowUnsavedAlert,
96+
handleBackClick,
97+
confirmDiscard,
98+
release,
99+
rearm,
100+
}
91101
}
Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,5 @@
1-
export { ResourceTile } from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile'
1+
export {
2+
RESOURCE_TILE_BASE,
3+
RESOURCE_TILE_FILL,
4+
ResourceTile,
5+
} from '@/app/workspace/[workspaceId]/components/resource-tile/resource-tile'
Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,30 @@
11
import type { ComponentType } from 'react'
2+
import { cn } from '@sim/emcn'
23

34
interface ResourceTileProps {
45
icon: ComponentType<{ className?: string }>
56
}
67

8+
/**
9+
* Geometry and border of the square resource tile — the single source for that
10+
* chrome, shared by {@link ResourceTile} and `SettingsResourceRow` so the skills,
11+
* custom tools, and settings surfaces cannot drift apart. Pair with a fill. Sizing
12+
* the glyph is the tile's job: the descendant rule outranks an icon's own class.
13+
*/
14+
export const RESOURCE_TILE_BASE =
15+
'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5'
16+
17+
/** Filled treatment worn by the skills and custom tools resource tiles. */
18+
export const RESOURCE_TILE_FILL = 'bg-[var(--surface-4)] dark:bg-[var(--surface-5)]'
19+
720
/**
821
* Square glyph tile identifying a workspace resource — the leading visual on a
9-
* resource's row and on its detail heading. Single source for that chrome so
10-
* the skills and custom tools surfaces cannot drift apart.
22+
* resource's row and on its detail heading.
1123
*/
1224
export function ResourceTile({ icon: Icon }: ResourceTileProps) {
1325
return (
14-
<div className='size-9 flex-shrink-0'>
15-
<div className='flex size-full items-center justify-center rounded-xl border border-[var(--border-1)] bg-[var(--surface-4)] dark:bg-[var(--surface-5)]'>
16-
<Icon className='size-5 text-[var(--text-icon)]' />
17-
</div>
26+
<div className={cn(RESOURCE_TILE_BASE, RESOURCE_TILE_FILL)}>
27+
<Icon className='text-[var(--text-icon)]' />
1828
</div>
1929
)
2030
}

apps/sim/app/workspace/[workspaceId]/settings/components/byok/byok-key-manager.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -270,7 +270,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
270270
if (props.multiKey) {
271271
const keyCount = getProviderKeys(provider.id).length
272272
return (
273-
<div className='flex flex-shrink-0 items-center gap-2'>
273+
<div className='flex items-center gap-2'>
274274
<span className='text-[var(--text-muted)] text-caption'>
275275
{keyCount} {keyCount === 1 ? 'key' : 'keys'}
276276
</span>
@@ -283,7 +283,7 @@ export function BYOKKeyManager(props: BYOKKeyManagerProps) {
283283

284284
if (readOnly) return null
285285
return (
286-
<div className='flex flex-shrink-0 items-center gap-2'>
286+
<div className='flex items-center gap-2'>
287287
<Chip onClick={() => openEditModal(provider.id)}>Update</Chip>
288288
<Chip onClick={() => openDeleteConfirm(provider.id)}>Delete</Chip>
289289
</div>

apps/sim/app/workspace/[workspaceId]/settings/components/custom-tools/custom-tools.tsx

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ export function CustomTools() {
2626
const workspacePermissions = useUserPermissionsContext()
2727
const canEdit = canMutateWorkspaceSettingsSection('custom-tools', workspacePermissions)
2828

29-
const { data: tools = [], isLoading, error } = useCustomTools(workspaceId)
29+
const { data: tools = [], isPending, isPlaceholderData, error } = useCustomTools(workspaceId)
30+
// Placeholder data is another workspace's tools reading as success — treat it as
31+
// loading, or a deep-linked id resolves against the workspace the user just left.
32+
const isLoading = isPending || isPlaceholderData
3033

3134
const [searchTerm, setSearchTerm] = useSettingsSearch()
3235
const [selectedToolId, setSelectedToolId] = useQueryState(customToolIdParam.key, {
@@ -119,7 +122,7 @@ export function CustomTools() {
119122
key={tool.id}
120123
type='button'
121124
onClick={() => void setSelectedToolId(tool.id)}
122-
className='w-full cursor-pointer rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
125+
className='w-full rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
123126
>
124127
<SettingsResourceRow
125128
icon={<Wrench className='text-[var(--text-icon)]' />}

apps/sim/app/workspace/[workspaceId]/settings/components/recently-deleted/recently-deleted.tsx

Lines changed: 3 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -464,22 +464,18 @@ export function RecentlyDeleted() {
464464
}
465465
trailing={
466466
!canRestore ? null : isRestoring ? (
467-
<Chip variant='primary' disabled className='shrink-0'>
467+
<Chip variant='primary' disabled>
468468
Restoring...
469469
</Chip>
470470
) : isRestored ? (
471-
<div className='flex shrink-0 items-center gap-2'>
471+
<div className='flex items-center gap-2'>
472472
<span className='text-[var(--text-muted)] text-small'>Restored</span>
473473
<Chip variant='primary' onClick={() => handleView(resource)}>
474474
View
475475
</Chip>
476476
</div>
477477
) : (
478-
<Chip
479-
variant='primary'
480-
onClick={() => void handleRestore(resource)}
481-
className='shrink-0'
482-
>
478+
<Chip variant='primary' onClick={() => void handleRestore(resource)}>
483479
Restore
484480
</Chip>
485481
)

apps/sim/app/workspace/[workspaceId]/settings/components/settings-resource-row/settings-resource-row.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import type { ReactNode } from 'react'
22
import { cn } from '@sim/emcn'
3+
import {
4+
RESOURCE_TILE_BASE,
5+
RESOURCE_TILE_FILL,
6+
} from '@/app/workspace/[workspaceId]/components/resource-tile'
37

48
/**
59
* The canonical settings "resource row": a rounded-bordered icon tile, a
@@ -29,13 +33,13 @@ interface SettingsResourceRowProps {
2933
title: ReactNode
3034
/** Secondary muted line — truncates. */
3135
description?: ReactNode
32-
/** Trailing element pinned to the row's end (chips, actions menu, status). */
36+
/**
37+
* Trailing element pinned to the row's end (chips, actions menu, status). The row
38+
* keeps it at its natural size — callers never need their own `flex-shrink-0`.
39+
*/
3340
trailing?: ReactNode
3441
}
3542

36-
const TILE_BASE =
37-
'flex size-9 flex-shrink-0 items-center justify-center overflow-hidden rounded-xl border border-[var(--border-1)] [&_svg]:size-5'
38-
3943
export function SettingsResourceRow({
4044
icon,
4145
iconFill = false,
@@ -49,8 +53,8 @@ export function SettingsResourceRow({
4953
<div className='flex min-w-0 items-center gap-2.5'>
5054
<div
5155
className={cn(
52-
TILE_BASE,
53-
iconFilled ? 'bg-[var(--surface-4)] dark:bg-[var(--surface-5)]' : 'bg-[var(--bg)]',
56+
RESOURCE_TILE_BASE,
57+
iconFilled ? RESOURCE_TILE_FILL : 'bg-[var(--bg)]',
5458
iconFill ? '[&_img]:size-full' : '[&_img]:size-5'
5559
)}
5660
>
@@ -63,7 +67,7 @@ export function SettingsResourceRow({
6367
)}
6468
</div>
6569
</div>
66-
{trailing}
70+
{trailing ? <div className='flex flex-shrink-0 items-center'>{trailing}</div> : null}
6771
</div>
6872
)
6973
}

apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -45,9 +45,9 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
4545
const skillsHref = `/workspace/${workspaceId}/skills`
4646

4747
const { data: skills = [], isPending, isPlaceholderData } = useSkills(workspaceId)
48-
// `keepPreviousData` can serve another workspace's list while this one loads
49-
// (the key is workspace-scoped), which reads as success with no match — treat
50-
// that as loading so the detail never flashes "Skill not found."
48+
// `keepPreviousData` carries the old cache entry across a workspace-id change,
49+
// so another workspace's list can read as success with no match — treat that as
50+
// loading so the detail never flashes "Skill not found."
5151
const skillsLoading = isPending || isPlaceholderData
5252
const updateSkill = useUpdateSkill()
5353
const deleteSkill = useDeleteSkill()
@@ -132,13 +132,14 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
132132
const handleConfirmDelete = async () => {
133133
if (!skill) return
134134
setShowDeleteConfirm(false)
135+
// Optimistic: the skill leaves the list cache before the request resolves, so
136+
// this surface goes clean mid-flight — release up front, rearm if it fails.
137+
guard.release()
135138
try {
136139
await deleteSkill.mutateAsync({ workspaceId, skillId: skill.id })
137-
// The skill is gone from the list cache, so this surface is about to have no
138-
// entity to guard — retire it before navigating, as the create page does.
139-
guard.release()
140140
router.replace(skillsHref)
141141
} catch (error) {
142+
guard.rearm()
142143
toast.error("Couldn't delete skill", {
143144
description: getErrorMessage(error, 'Please try again in a moment.'),
144145
})
@@ -183,7 +184,10 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
183184
</>
184185
) : null
185186

186-
if (skillsLoading && !skill) {
187+
// A delete is optimistic and settles before `router.replace` commits, so the row
188+
// is gone for a few frames while this surface is still mounted — hold the loading
189+
// frame through both phases instead of flashing "Skill not found." on the way out.
190+
if ((skillsLoading || deleteSkill.isPending || deleteSkill.isSuccess) && !skill) {
187191
return (
188192
<CredentialDetailLayout back={back} actions={actions}>
189193
<p className='py-12 text-center text-[var(--text-muted)] text-sm'>Loading…</p>

apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -74,9 +74,8 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
7474
})
7575
setErrors({})
7676
toast.success(`Created "${nameDraft}"`)
77-
// Retire the guard before navigating — otherwise the form going clean pops
78-
// its seeded history entry and lands us back on this create form. `replace`
79-
// consumes that entry instead of stacking another.
77+
// Detach the guard so its Back trap can't fire mid-navigation; `replace` then
78+
// consumes the seeded entry rather than stacking another.
8079
guard.release()
8180
router.replace(created ? `${skillsHref}/${created.id}` : skillsHref)
8281
} catch (error) {

0 commit comments

Comments
 (0)