1- import { settlePromise , squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime" ;
1+ import {
2+ isAtomCommandInterrupted ,
3+ settlePromise ,
4+ squashAtomCommandFailure ,
5+ } from "@t3tools/client-runtime/state/runtime" ;
26import { AsyncResult } from "effect/reactivity" ;
37import * as Dialog from "../../components/ui/Dialog" ;
48import { useAtomValue } from "@effect/atom-react" ;
@@ -8,7 +12,13 @@ import {
812 useThreadShell ,
913} from "../../state/entities" ;
1014import { UsageLimitRecoveryCard } from "./UsageLimitRecoveryCard" ;
11- import { StackActions , useNavigation } from "@react-navigation/native" ;
15+ import { StackActions , useFocusEffect , useNavigation } from "@react-navigation/native" ;
16+ import {
17+ effectiveSnoozed ,
18+ threadWokeAt ,
19+ isThreadWokeUnseen ,
20+ snoozeWakeLabel ,
21+ } from "@t3tools/client-runtime/state/thread-settled" ;
1222import type { WorktreeSetupCardProps } from "./worktree-setup-card" ;
1323import type { ComposerTextPaste } from "../../native/T3ComposerEditor.types" ;
1424import { type EnvironmentConnectionPhase } from "@t3tools/client-runtime/connection" ;
@@ -516,6 +526,96 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
516526 }
517527 } ) ( ) ;
518528 const threadSyncLabel = useDelayedStatus ( selectedThreadKey , realThreadSyncLabel ) ;
529+ const interruptThreadTurn = useAtomCommand ( threadEnvironment . interruptTurn , {
530+ reportFailure : false ,
531+ } ) ;
532+ const unsnoozeThread = useAtomCommand ( threadEnvironment . unsnooze , { reportFailure : false } ) ;
533+ const unsettleThread = useAtomCommand ( threadEnvironment . unsettle , { reportFailure : false } ) ;
534+ const updateMetadata = useAtomCommand ( threadEnvironment . updateMetadata , { reportFailure : false } ) ;
535+ const visitThread = useAtomCommand ( threadEnvironment . visit , { reportFailure : false } ) ;
536+ const [ statusRequest , setStatusRequest ] = useState < { threadKey : string } | null > ( null ) ;
537+ const statusPending = statusRequest ?. threadKey === selectedThreadKey ;
538+ const [ lifecycleNow , setLifecycleNow ] = useState ( ( ) => new Date ( ) . toISOString ( ) ) ;
539+ useFocusEffect (
540+ useCallback ( ( ) => {
541+ setLifecycleNow ( new Date ( ) . toISOString ( ) ) ;
542+ const timer = setInterval ( ( ) => setLifecycleNow ( new Date ( ) . toISOString ( ) ) , 60_000 ) ;
543+ return ( ) => clearInterval ( timer ) ;
544+ } , [ ] ) ,
545+ ) ;
546+ const snoozed =
547+ props . serverConfig ?. environment . capabilities . threadSnooze === true &&
548+ effectiveSnoozed ( props . selectedThread , { now : lifecycleNow } ) ;
549+ const snoozedUntil = props . selectedThread . snoozedUntil ;
550+ useEffect ( ( ) => {
551+ if ( ! snoozed || snoozedUntil === null ) return ;
552+ const timer = setTimeout (
553+ ( ) => setLifecycleNow ( new Date ( ) . toISOString ( ) ) ,
554+ Math . min ( Math . max ( 0 , Date . parse ( snoozedUntil ) - Date . now ( ) ) + 50 , 2_147_483_647 ) ,
555+ ) ;
556+ return ( ) => clearTimeout ( timer ) ;
557+ // oxlint-disable-next-line react/exhaustive-effect-dependencies -- Re-arm if timer clamping fires before the wake time.
558+ } , [ snoozed , snoozedUntil , lifecycleNow ] ) ;
559+ const settled =
560+ props . serverConfig ?. environment . capabilities . threadSettlement === true &&
561+ props . selectedThread . settledOverride === "settled" ;
562+ const wokeAt =
563+ props . serverConfig ?. environment . capabilities . threadSnooze === true
564+ ? threadWokeAt ( props . selectedThread , { now : lifecycleNow } )
565+ : null ;
566+ const woke = isThreadWokeUnseen ( {
567+ wokeAt,
568+ settled,
569+ lastVisitedAt : props . selectedThread . lastVisitedAt ,
570+ latestRunCompletedAt : props . selectedThread . latestRun ?. completedAt ?? null ,
571+ } ) ;
572+ const runStatusAction = async ( action : "stop" | "wake" | "unsettle" | "dismiss" ) => {
573+ if ( statusPending || ! props . canOperateThread ) return ;
574+ const request = { threadKey : selectedThreadKey } ;
575+ setStatusRequest ( request ) ;
576+ const target = {
577+ environmentId : props . environmentId ,
578+ input : { threadId : props . selectedThread . id } ,
579+ } ;
580+ const recovery = props . selectedThread . limitRecovery ;
581+ const recoveryOwnsSnooze =
582+ recovery ?. snooze === true &&
583+ recovery . runId === props . selectedThread . latestRun ?. runId &&
584+ snoozedUntil !== null &&
585+ Date . parse ( snoozedUntil ) === Date . parse ( recovery . resetAt ) ;
586+ const result =
587+ action === "stop"
588+ ? await interruptThreadTurn ( target )
589+ : action === "wake"
590+ ? recoveryOwnsSnooze
591+ ? await updateMetadata ( {
592+ ...target ,
593+ input : {
594+ ...target . input ,
595+ limitRecovery : {
596+ runId : recovery . runId ,
597+ resetAt : recovery . resetAt ,
598+ snooze : false ,
599+ } ,
600+ } ,
601+ } )
602+ : await unsnoozeThread ( { ...target , input : { ...target . input , reason : "user" } } )
603+ : action === "unsettle"
604+ ? await unsettleThread ( { ...target , input : { ...target . input , reason : "user" } } )
605+ : await visitThread ( {
606+ ...target ,
607+ input : { ...target . input , visitedAt : wokeAt ?? lifecycleNow } ,
608+ } ) ;
609+ // An accepted interrupt can leave tasks running. Release the action for retry.
610+ setStatusRequest ( ( current ) => ( current === request ? null : current ) ) ;
611+ if ( result . _tag === "Failure" && ! isAtomCommandInterrupted ( result ) ) {
612+ const error = squashAtomCommandFailure ( result ) ;
613+ Dialog . alert (
614+ "Could not update thread" ,
615+ error instanceof Error ? error . message : "Try again." ,
616+ ) ;
617+ }
618+ } ;
519619 const pendingBackgroundWork = presentPendingBackgroundWork (
520620 props . selectedThread . pendingBackgroundTasks ,
521621 ) ;
@@ -576,6 +676,46 @@ export const ThreadDetailScreen = function ThreadDetailScreen(props: ThreadDetai
576676 . map ( ( item ) => item . label )
577677 . join ( ", " ) } `,
578678 waiting : pendingBackgroundWork . waiting ,
679+ onPress : ( ) => {
680+ Keyboard . dismiss ( ) ;
681+ navigation . navigate ( "ThreadBackgroundWork" , {
682+ environmentId : props . environmentId ,
683+ threadId : props . selectedThread . id ,
684+ } ) ;
685+ } ,
686+ action : {
687+ label : statusPending ? "Stopping..." : "Stop" ,
688+ accessibilityLabel : "Stop background work" ,
689+ disabled : ! props . canOperateThread || statusPending ,
690+ onPress : ( ) => void runStatusAction ( "stop" ) ,
691+ } ,
692+ } ;
693+ }
694+ if ( contentPresentationKind === "ready" && ( snoozed || settled || woke ) ) {
695+ const state = snoozed ? "snoozed" : settled ? "settled" : "woke" ;
696+ const label =
697+ snoozed && snoozedUntil !== null
698+ ? `Snoozed, wakes in ${ snoozeWakeLabel ( snoozedUntil , { now : lifecycleNow } ) } `
699+ : settled
700+ ? "Settled"
701+ : "Woke from snooze" ;
702+ const actionLabel = snoozed ? "Wake now" : settled ? "Un-settle" : "Dismiss" ;
703+ return {
704+ kind : "lifecycle" ,
705+ state,
706+ label,
707+ action : {
708+ label : statusPending
709+ ? snoozed
710+ ? "Waking..."
711+ : settled
712+ ? "Un-settling..."
713+ : "Dismissing..."
714+ : actionLabel ,
715+ accessibilityLabel : actionLabel ,
716+ disabled : ! props . canOperateThread || statusPending ,
717+ onPress : ( ) => void runStatusAction ( snoozed ? "wake" : settled ? "unsettle" : "dismiss" ) ,
718+ } ,
579719 } ;
580720 }
581721 if ( props . selectedThread . goal !== null && contentPresentationKind === "ready" ) {
0 commit comments