Skip to content

Commit 2272d4c

Browse files
authored
fix(skills): show the new skill after creating it (#5949)
* fix(skills): show the new skill after creating it The create page navigated to the new skill's detail route, but the unsaved-changes guard immediately undid it: `isDirty` was derived from `createSkill.isSuccess`, so on success the guard's effect fired `history.back()` to pop its sentinel entry and cancelled the navigation that had just run. The header stayed on "New skill" with the fields intact, and nothing confirmed the save. The guard now exposes `release()` to retire itself for the rest of the mount, and the create page calls it before navigating with `replace` so the sentinel entry is consumed rather than stacked. Also from a cleanup pass over the same surfaces: - `useCreateSkill` resolves the created row, so the page reads `created.id` instead of re-deriving it from the response list - seed the list cache with the upsert's authoritative list verbatim; the id-merge kept the previous ordering and appended the new skill last - treat placeholder data as loading in the detail page, which could otherwise flash "Skill not found." on a workspace switch - seed credential-detail drafts on id change rather than in a value-keyed effect, so a background refetch can't clobber an in-progress edit - toast on save and on delete failure, which were both silent * 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 * fix(skills): retire the in-app back guard on release too release() suppressed the unload warning and the browser Back trap, but the in-app back link still keyed only on isDirty — so the Skills chip could open the unsaved-changes modal after a successful create, while the drafts were still populated and the navigation was already in flight. * fix(skills): track a sentinel consumed while the guard is released release() intentionally leaves the seeded history entry in place, but it also drops the popstate listener — so Back during the released window (an optimistic delete's round-trip) consumed that entry with nothing to record it. hasSentinelRef stayed true, and a failed delete's rearm() then skipped re-seeding, leaving the surface with no Back confirm despite unsaved edits. The released branch now keeps a bookkeeping-only popstate listener that clears the ref, so rearm() seeds a fresh entry when the old one is gone.
1 parent 919a98d commit 2272d4c

13 files changed

Lines changed: 131 additions & 63 deletions

File tree

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

Lines changed: 37 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,22 @@ interface UseUnsavedChangesGuardParams {
2626
export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesGuardParams) {
2727
const router = useRouter()
2828
const [showUnsavedAlert, setShowUnsavedAlert] = useState(false)
29+
const [isReleased, setIsReleased] = useState(false)
2930
const hasSentinelRef = useRef(false)
3031

3132
useEffect(() => {
33+
// The caller is navigating away — popping the seeded entry would cancel it. But
34+
// Back during that window consumes the entry with no listener left to re-push
35+
// it, so track that: a later rearm() must seed a fresh one rather than trust a
36+
// stale ref and leave the surface unguarded.
37+
if (isReleased) {
38+
if (!hasSentinelRef.current) return
39+
const handleSentinelConsumed = () => {
40+
hasSentinelRef.current = false
41+
}
42+
window.addEventListener('popstate', handleSentinelConsumed)
43+
return () => window.removeEventListener('popstate', handleSentinelConsumed)
44+
}
3245
if (!isDirty) {
3346
// Clean again while still mounted (saved/reverted): pop the seeded entry so
3447
// it can't pile up across edit/save cycles. This runs in the effect body,
@@ -58,22 +71,42 @@ export function useUnsavedChangesGuard({ isDirty, backHref }: UseUnsavedChangesG
5871
window.removeEventListener('beforeunload', handleBeforeUnload)
5972
window.removeEventListener('popstate', handlePopState)
6073
}
61-
}, [isDirty])
74+
}, [isDirty, isReleased])
6275

6376
const handleBackClick = useCallback(
6477
(event: MouseEvent<HTMLAnchorElement>) => {
65-
if (isDirty) {
78+
if (isDirty && !isReleased) {
6679
event.preventDefault()
6780
setShowUnsavedAlert(true)
6881
}
6982
},
70-
[isDirty]
83+
[isDirty, isReleased]
7184
)
7285

7386
const confirmDiscard = useCallback(() => {
7487
setShowUnsavedAlert(false)
7588
router.push(backHref)
7689
}, [router, backHref])
7790

78-
return { showUnsavedAlert, setShowUnsavedAlert, handleBackClick, confirmDiscard }
91+
/**
92+
* Retires the guard: no unload warning, no Back trap (browser or the in-app back
93+
* link), and no pop of the seeded entry when the form goes clean. Call it before
94+
* navigating away on a successful save, and navigate with `router.replace` so the
95+
* seeded entry is the one consumed. An operation that goes clean before it
96+
* resolves (an optimistic delete) must release up front and {@link rearm} if it
97+
* fails.
98+
*/
99+
const release = useCallback(() => setIsReleased(true), [])
100+
101+
/** Restores guarding after a released operation failed and the surface stays. */
102+
const rearm = useCallback(() => setIsReleased(false), [])
103+
104+
return {
105+
showUnsavedAlert,
106+
setShowUnsavedAlert,
107+
handleBackClick,
108+
confirmDiscard,
109+
release,
110+
rearm,
111+
}
79112
}
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: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,11 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
4444
const router = useRouter()
4545
const skillsHref = `/workspace/${workspaceId}/skills`
4646

47-
const { data: skills = [], isPending: skillsLoading } = useSkills(workspaceId)
47+
const { data: skills = [], isPending, isPlaceholderData } = useSkills(workspaceId)
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."
51+
const skillsLoading = isPending || isPlaceholderData
4852
const updateSkill = useUpdateSkill()
4953
const deleteSkill = useDeleteSkill()
5054
const skill = skills.find((s) => s.id === skillId) ?? null
@@ -112,6 +116,7 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
112116
},
113117
})
114118
setErrors({})
119+
toast.success(`Saved "${nameDraft}"`)
115120
} catch (error) {
116121
if (isSkillNameConflictError(error)) {
117122
setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') })
@@ -127,10 +132,17 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
127132
const handleConfirmDelete = async () => {
128133
if (!skill) return
129134
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()
130138
try {
131139
await deleteSkill.mutateAsync({ workspaceId, skillId: skill.id })
132-
router.push(skillsHref)
140+
router.replace(skillsHref)
133141
} catch (error) {
142+
guard.rearm()
143+
toast.error("Couldn't delete skill", {
144+
description: getErrorMessage(error, 'Please try again in a moment.'),
145+
})
134146
logger.error('Failed to delete skill', error)
135147
}
136148
}
@@ -172,7 +184,10 @@ export function SkillDetail({ workspaceId, skillId }: SkillDetailProps) {
172184
</>
173185
) : null
174186

175-
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) {
176191
return (
177192
<CredentialDetailLayout back={back} actions={actions}>
178193
<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: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,7 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
5050
const [contentSeed, setContentSeed] = useState(0)
5151
const [errors, setErrors] = useState<SkillFieldErrors>({})
5252

53-
// Drops on success so the guard pops its history sentinel before we navigate —
54-
// otherwise Back from the new skill lands on a stale, empty create form.
55-
const isDirty =
56-
!createSkill.isSuccess &&
57-
(!!nameDraft.trim() || !!descriptionDraft.trim() || !!contentDraft.trim())
53+
const isDirty = !!nameDraft.trim() || !!descriptionDraft.trim() || !!contentDraft.trim()
5854

5955
const guard = useUnsavedChangesGuard({ isDirty, backHref: skillsHref })
6056

@@ -72,16 +68,16 @@ export function SkillCreate({ workspaceId }: SkillCreateProps) {
7268
}
7369

7470
try {
75-
const created = await createSkill.mutateAsync({
71+
const { created } = await createSkill.mutateAsync({
7672
workspaceId,
7773
skill: { name: nameDraft, description: descriptionDraft, content: contentDraft },
7874
})
7975
setErrors({})
80-
// The upsert responds with the caller's whole skill list (built-ins
81-
// included), not just the new row — match by name, which is unique per
82-
// workspace, rather than trusting the first element.
83-
const createdId = created.find((skill) => skill.name === nameDraft)?.id
84-
router.push(createdId ? `${skillsHref}/${createdId}` : skillsHref)
76+
toast.success(`Created "${nameDraft}"`)
77+
// Detach the guard so its Back trap can't fire mid-navigation; `replace` then
78+
// consumes the seeded entry rather than stacking another.
79+
guard.release()
80+
router.replace(created ? `${skillsHref}/${created.id}` : skillsHref)
8581
} catch (error) {
8682
if (isSkillNameConflictError(error)) {
8783
setErrors({ name: getErrorMessage(error, 'This skill name is already taken.') })

apps/sim/app/workspace/[workspaceId]/skills/skills.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,10 @@ function SkillItem({ name, description, onClick }: SkillItemProps) {
3434
className='flex items-center gap-2.5 rounded-lg p-2 text-left transition-colors hover-hover:bg-[var(--surface-active)]'
3535
>
3636
<SkillTile />
37-
<div className='flex min-w-0 flex-1 flex-col'>
38-
<span className='truncate text-[14px] text-[var(--text-body)]'>{name}</span>
37+
<div className='flex min-w-0 flex-1 flex-col justify-center gap-[1px]'>
38+
<span className='truncate text-[var(--text-body)] text-sm'>{name}</span>
3939
{description && (
40-
<span className='truncate text-[12px] text-[var(--text-muted)]'>{description}</span>
40+
<span className='truncate text-[var(--text-muted)] text-caption'>{description}</span>
4141
)}
4242
</div>
4343
<ArrowRight className='size-4 flex-shrink-0 text-[var(--text-icon)]' />

0 commit comments

Comments
 (0)