11import { ArrowLeftIcon , RepeatIcon } from "@phosphor-icons/react" ;
22import type { LoopSchemas } from "@posthog/api-client/loops" ;
3+ import { isUploadableSkillSource } from "@posthog/core/message-editor/skillTags" ;
4+ import { useHostTRPC } from "@posthog/host-router/react" ;
35import {
46 AlertDialog ,
57 AlertDialogClose ,
@@ -20,6 +22,7 @@ import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore
2022import { useOrgMembers } from "@posthog/ui/features/canvas/hooks/useOrgMembers" ;
2123import { userDisplayName } from "@posthog/ui/features/canvas/utils/userDisplay" ;
2224import { useSetHeaderContent } from "@posthog/ui/hooks/useSetHeaderContent" ;
25+ import { Button as ActionButton } from "@posthog/ui/primitives/Button" ;
2326import { TimezoneTimestamp } from "@posthog/ui/primitives/TimezoneTimestamp" ;
2427import { systemTimezone } from "@posthog/ui/primitives/timezone" ;
2528import { toast } from "@posthog/ui/primitives/toast" ;
@@ -28,7 +31,9 @@ import {
2831 navigateToLoops ,
2932} from "@posthog/ui/router/navigationBridge" ;
3033import { track } from "@posthog/ui/shell/analytics" ;
34+ import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities" ;
3135import { Flex , Text } from "@radix-ui/themes" ;
36+ import { useQuery } from "@tanstack/react-query" ;
3237import { useEffect , useRef , useState } from "react" ;
3338import { useLoop } from "../hooks/useLoop" ;
3439import {
@@ -37,6 +42,7 @@ import {
3742 useUpdateLoop ,
3843} from "../hooks/useLoopMutations" ;
3944import { RECENT_RUNS_LIMIT , useLoopRuns } from "../hooks/useLoopRuns" ;
45+ import { useSyncLoopSkillBundles } from "../hooks/useLoopSkillBundles" ;
4046import {
4147 buildLoopEnabledToggledProps ,
4248 buildLoopViewedProps ,
@@ -51,6 +57,7 @@ import {
5157 summarizeNotificationDestinations ,
5258} from "../loopDisplay" ;
5359import { formatLoopModel } from "../loopModels" ;
60+ import { loopSkillBundles , primaryLoopSkillBundle } from "../loopSkill" ;
5461import { LoopLoadError } from "./LoopFallbacks" ;
5562import { LoopRunRow } from "./LoopRunRow" ;
5663
@@ -430,6 +437,12 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
430437 . join ( " · " ) }
431438 </ SummaryRow >
432439
440+ { loopSkillBundles ( loop ) . length > 0 ? (
441+ < SummaryRow label = "Skill" >
442+ < LoopSkillSummary loop = { loop } />
443+ </ SummaryRow >
444+ ) : null }
445+
433446 < SummaryRow label = "Repository" >
434447 { loop . repositories . length > 0
435448 ? loop . repositories . map ( ( repo ) => repo . full_name ) . join ( ", " )
@@ -465,8 +478,91 @@ function ConfigSummarySection({ loop }: { loop: LoopSchemas.Loop }) {
465478 ) ;
466479}
467480
481+ function LoopSkillSummary ( { loop } : { loop : LoopSchemas . Loop } ) {
482+ const { localWorkspaces } = useHostCapabilities ( ) ;
483+ const trpc = useHostTRPC ( ) ;
484+ const { data : localSkillData } = useQuery ( {
485+ ...trpc . skills . list . queryOptions ( ) ,
486+ enabled : localWorkspaces ,
487+ } ) ;
488+ const syncSkillBundles = useSyncLoopSkillBundles ( ) ;
489+
490+ const primary = primaryLoopSkillBundle ( loop ) ;
491+ if ( ! primary ) return null ;
492+ const dependencyCount = loopSkillBundles ( loop ) . length - 1 ;
493+
494+ // The one-click refresh must be unambiguous about which skill it snapshots: it
495+ // requires exactly one local skill matching the stored name AND source, so a
496+ // same-named skill from another source (say, an opened repo) can never silently
497+ // replace the loop's snapshot. Ambiguous cases go through the edit form, where
498+ // the picker shows each candidate.
499+ const candidates = ( localSkillData ?? [ ] ) . filter (
500+ ( skill ) =>
501+ skill . name === primary . skill_name &&
502+ skill . source === primary . skill_source ,
503+ ) ;
504+ const localMatch = candidates . length === 1 ? candidates [ 0 ] : undefined ;
505+ const updateDisabledReason = ! localWorkspaces
506+ ? "updating the snapshot needs the desktop app"
507+ : localMatch
508+ ? null
509+ : candidates . length > 1
510+ ? `several local skills are named ${ primary . skill_name } ; pick the right one from the edit form`
511+ : `no local ${ primary . skill_source } skill named ${ primary . skill_name } was found on this machine` ;
512+
513+ const handleUpdate = ( ) => {
514+ if ( ! localMatch || ! isUploadableSkillSource ( localMatch . source ) ) return ;
515+ syncSkillBundles . mutate (
516+ {
517+ loopId : loop . id ,
518+ skill : {
519+ name : localMatch . name ,
520+ source : localMatch . source ,
521+ path : localMatch . path ,
522+ } ,
523+ } ,
524+ {
525+ onSuccess : ( ) => toast . success ( "Skill snapshot updated" ) ,
526+ onError : ( error ) =>
527+ toast . error ( "Failed to update the skill snapshot" , {
528+ description : error . message ,
529+ } ) ,
530+ } ,
531+ ) ;
532+ } ;
533+
534+ return (
535+ < Flex align = "center" gap = "2" wrap = "wrap" >
536+ < Text className = "text-[12.5px] text-gray-12" >
537+ { primary . skill_name }
538+ { dependencyCount > 0
539+ ? ` (+${ dependencyCount } ${ dependencyCount === 1 ? "dependency" : "dependencies" } )`
540+ : "" }
541+ </ Text >
542+ < Text
543+ className = "text-[11px] text-gray-10"
544+ title = { new Date ( primary . uploaded_at ) . toLocaleString ( ) }
545+ >
546+ Snapshot { primary . content_sha256 . slice ( 0 , 8 ) }
547+ </ Text >
548+ < ActionButton
549+ variant = "soft"
550+ color = "gray"
551+ size = "1"
552+ loading = { syncSkillBundles . isPending }
553+ disabled = { syncSkillBundles . isPending || ! ! updateDisabledReason }
554+ disabledReason = { updateDisabledReason }
555+ onClick = { handleUpdate }
556+ >
557+ Update from local skill
558+ </ ActionButton >
559+ </ Flex >
560+ ) ;
561+ }
562+
468563function InstructionsSection ( { loop } : { loop : LoopSchemas . Loop } ) {
469564 const updateLoop = useUpdateLoop ( loop . id ) ;
565+ const primarySkill = primaryLoopSkillBundle ( loop ) ;
470566 const [ draft , setDraft ] = useState < string | null > ( null ) ;
471567 // Escape reverts and blurs; skip the resulting onBlur save.
472568 const skipCommit = useRef ( false ) ;
@@ -528,6 +624,13 @@ function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
528624 }
529625 } }
530626 />
627+ { primarySkill ? (
628+ < Text className = "text-[11px] text-gray-10 leading-snug" >
629+ This loop runs the { primarySkill . skill_name } skill: the leading /
630+ { primarySkill . skill_name } line invokes its attached snapshot. Editing
631+ here changes only the text; use Edit to change or detach the skill.
632+ </ Text >
633+ ) : null }
531634 </ Flex >
532635 ) ;
533636}
0 commit comments