feat: focus committed workflow recipients (#3826)
This commit is contained in:
@@ -253,10 +253,11 @@ func (h *Handlers) applyMoveTaskImmediate(
|
||||
return ws.NewError(msg.ID, msg.Action, classifyMoveTaskError(err), moveTaskErrorMessage(err), nil)
|
||||
}
|
||||
response := dto.MoveTaskResponse{
|
||||
Task: dto.FromTask(result.Task),
|
||||
MoveID: result.MoveID,
|
||||
EntryOptions: result.EntryOptions,
|
||||
Disposition: moveDispositionApplied,
|
||||
Task: dto.FromTask(result.Task),
|
||||
WorkflowEntryIdentity: result.WorkflowEntryIdentity,
|
||||
MoveID: result.MoveID,
|
||||
EntryOptions: result.EntryOptions,
|
||||
Disposition: moveDispositionApplied,
|
||||
}
|
||||
if result.WorkflowStep != nil {
|
||||
response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep)
|
||||
|
||||
@@ -3046,6 +3046,45 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate(
|
||||
startPolicy models.WorkflowProfileSessionStartPolicy,
|
||||
endPolicy models.WorkflowProfileSessionEndPolicy,
|
||||
validatedExisting *models.TaskSession,
|
||||
) (*models.TaskSession, error) {
|
||||
return s.switchSessionForStepWithPoliciesAndCandidateAndRoute(
|
||||
ctx, taskID, currentSession, newAgentProfileID, startPolicy, endPolicy, validatedExisting, nil,
|
||||
)
|
||||
}
|
||||
|
||||
func (s *Service) resolveWorkflowSessionSwitchExisting(
|
||||
ctx context.Context,
|
||||
taskID, newAgentProfileID string,
|
||||
currentSession *models.TaskSession,
|
||||
startPolicy models.WorkflowProfileSessionStartPolicy,
|
||||
validatedExisting *models.TaskSession,
|
||||
) *models.TaskSession {
|
||||
if startPolicy != models.WorkflowProfileSessionStartPolicyReuse {
|
||||
return nil
|
||||
}
|
||||
if validatedExisting != nil {
|
||||
return validatedExisting
|
||||
}
|
||||
existing, err := s.findReusableSessionForProfile(ctx, taskID, newAgentProfileID, currentSession.ID)
|
||||
if err != nil {
|
||||
s.logger.Warn("failed to look up reusable session, falling through to create new",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("agent_profile_id", newAgentProfileID),
|
||||
zap.Error(err))
|
||||
return nil
|
||||
}
|
||||
return existing
|
||||
}
|
||||
|
||||
func (s *Service) switchSessionForStepWithPoliciesAndCandidateAndRoute(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
currentSession *models.TaskSession,
|
||||
newAgentProfileID string,
|
||||
startPolicy models.WorkflowProfileSessionStartPolicy,
|
||||
endPolicy models.WorkflowProfileSessionEndPolicy,
|
||||
validatedExisting *models.TaskSession,
|
||||
workflowRoute *models.WorkflowSessionRoute,
|
||||
) (*models.TaskSession, error) {
|
||||
startPolicy = models.NormalizeWorkflowProfileSessionStartPolicy(string(startPolicy))
|
||||
endPolicy = models.NormalizeWorkflowProfileSessionEndPolicy(string(endPolicy))
|
||||
@@ -3056,21 +3095,9 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate(
|
||||
zap.String("new_profile", newAgentProfileID),
|
||||
zap.String("profile_session_start_policy", string(startPolicy)),
|
||||
zap.String("profile_session_end_policy", string(endPolicy)))
|
||||
var existing *models.TaskSession
|
||||
if startPolicy == models.WorkflowProfileSessionStartPolicyReuse {
|
||||
if validatedExisting != nil {
|
||||
existing = validatedExisting
|
||||
} else {
|
||||
var lookupErr error
|
||||
existing, lookupErr = s.findReusableSessionForProfile(ctx, taskID, newAgentProfileID, currentSession.ID)
|
||||
if lookupErr != nil {
|
||||
s.logger.Warn("failed to look up reusable session, falling through to create new",
|
||||
zap.String("task_id", taskID),
|
||||
zap.String("agent_profile_id", newAgentProfileID),
|
||||
zap.Error(lookupErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
existing := s.resolveWorkflowSessionSwitchExisting(
|
||||
ctx, taskID, newAgentProfileID, currentSession, startPolicy, validatedExisting,
|
||||
)
|
||||
targetSession := currentSession
|
||||
if existing != nil {
|
||||
targetSession = existing
|
||||
@@ -3101,7 +3128,19 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate(
|
||||
}
|
||||
|
||||
if existing != nil {
|
||||
reused, err := s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, existing, endPolicy)
|
||||
var reused *models.TaskSession
|
||||
var err error
|
||||
if workflowRoute != nil {
|
||||
route := *workflowRoute
|
||||
route.DestinationID = existing.ID
|
||||
route.Phase = workflowSessionRoutePrepared
|
||||
if err := s.persistWorkflowSessionRoute(ctx, taskID, route); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reused, err = s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, existing, endPolicy, &route)
|
||||
} else {
|
||||
reused, err = s.reuseSessionForStepWithEndPolicy(ctx, taskID, currentSession, existing, endPolicy)
|
||||
}
|
||||
if err == nil {
|
||||
return reused, nil
|
||||
}
|
||||
@@ -3114,7 +3153,9 @@ func (s *Service) switchSessionForStepWithPoliciesAndCandidate(
|
||||
zap.String("agent_profile_id", newAgentProfileID))
|
||||
}
|
||||
|
||||
return s.createNewSessionForStepWithEndPolicy(ctx, taskID, currentSession, newAgentProfileID, endPolicy)
|
||||
return s.createNewSessionForStepWithEndPolicyAndRoute(
|
||||
ctx, taskID, currentSession, newAgentProfileID, endPolicy, workflowRoute,
|
||||
)
|
||||
}
|
||||
|
||||
// findReusableSessionForProfile returns the most-recently-updated
|
||||
@@ -3807,6 +3848,14 @@ func (s *Service) prepareWorkflowStepSession(
|
||||
}
|
||||
effectiveProfile := s.resolveStepAgentProfile(ctx, step)
|
||||
startPolicy := s.resolveStepProfileSessionStartPolicy(step)
|
||||
profileRoute := workflowProfileSessionRoute(
|
||||
taskID,
|
||||
session,
|
||||
step,
|
||||
effectiveProfile,
|
||||
s.workflowEntryIdentity(ctx, taskID, entryIDs...),
|
||||
startPolicy,
|
||||
)
|
||||
if shouldKeepCurrentWorkflowStepSession(effectiveProfile, session.AgentProfileID, startPolicy) {
|
||||
requiresFreshSession, err := s.workflowEntryRequiresFreshExactModelSession(ctx, session, step, sourceStep, effectiveProfile)
|
||||
if err != nil {
|
||||
@@ -3817,9 +3866,9 @@ func (s *Service) prepareWorkflowStepSession(
|
||||
return nil, false, fmt.Errorf("workflow profile switch source step is unavailable")
|
||||
}
|
||||
endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep)
|
||||
return s.replaceExactModelWorkflowStepSession(ctx, taskID, session, step, effectiveProfile, endPolicy, entryIDs...)
|
||||
return s.replaceExactModelWorkflowStepSession(ctx, taskID, session, step, effectiveProfile, endPolicy, profileRoute, entryIDs...)
|
||||
}
|
||||
return s.keepCurrentWorkflowStepSession(ctx, taskID, session, step, entryIDs...)
|
||||
return s.keepCurrentWorkflowStepSession(ctx, taskID, session, step, profileRoute, entryIDs...)
|
||||
}
|
||||
if sourceStep == nil {
|
||||
return nil, false, fmt.Errorf("workflow profile switch source step is unavailable")
|
||||
@@ -3829,7 +3878,7 @@ func (s *Service) prepareWorkflowStepSession(
|
||||
return nil, false, err
|
||||
}
|
||||
endPolicy := s.resolveStepProfileSessionEndPolicy(sourceStep)
|
||||
newSession, err := s.switchSessionForStepWithPoliciesAndCandidate(ctx, taskID, session, effectiveProfile, startPolicy, endPolicy, validatedExisting)
|
||||
newSession, err := s.switchSessionForStepWithPoliciesAndCandidateAndRoute(ctx, taskID, session, effectiveProfile, startPolicy, endPolicy, validatedExisting, profileRoute)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -3850,9 +3899,10 @@ func (s *Service) replaceExactModelWorkflowStepSession(
|
||||
step *wfmodels.WorkflowStep,
|
||||
profileID string,
|
||||
endPolicy models.WorkflowProfileSessionEndPolicy,
|
||||
workflowRoute *models.WorkflowSessionRoute,
|
||||
entryIDs ...int64,
|
||||
) (*models.TaskSession, bool, error) {
|
||||
newSession, err := s.createNewSessionForStepWithEndPolicy(ctx, taskID, session, profileID, endPolicy)
|
||||
newSession, err := s.createNewSessionForStepWithEndPolicyAndRoute(ctx, taskID, session, profileID, endPolicy, workflowRoute)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
@@ -3960,10 +4010,15 @@ func (s *Service) keepCurrentWorkflowStepSession(
|
||||
taskID string,
|
||||
session *models.TaskSession,
|
||||
step *wfmodels.WorkflowStep,
|
||||
workflowRoute *models.WorkflowSessionRoute,
|
||||
entryIDs ...int64,
|
||||
) (*models.TaskSession, bool, error) {
|
||||
s.tagSessionAsWorkflowSwitchedForSnapshot(ctx, session)
|
||||
if !session.IsPrimary {
|
||||
if workflowRoute != nil {
|
||||
if err := s.promoteKeptWorkflowStepSession(ctx, taskID, session, workflowRoute); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
} else if !session.IsPrimary {
|
||||
if err := s.SetPrimarySession(ctx, session.ID); err != nil {
|
||||
s.logger.Warn("failed to preserve session as primary for workflow step",
|
||||
zap.String("task_id", taskID),
|
||||
@@ -3980,6 +4035,28 @@ func (s *Service) keepCurrentWorkflowStepSession(
|
||||
return session, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) promoteKeptWorkflowStepSession(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
session *models.TaskSession,
|
||||
workflowRoute *models.WorkflowSessionRoute,
|
||||
) error {
|
||||
preparedRoute := *workflowRoute
|
||||
preparedRoute.DestinationID = session.ID
|
||||
preparedRoute.Phase = workflowSessionRoutePrepared
|
||||
if err := s.persistWorkflowSessionRoute(ctx, taskID, preparedRoute); err != nil {
|
||||
return err
|
||||
}
|
||||
promoted, err := s.promoteWorkflowSessionRoute(ctx, taskID, session, &preparedRoute)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !promoted {
|
||||
return errReusableSessionNoLongerActive
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Service) preflightWorkflowStepCredentials(
|
||||
ctx context.Context,
|
||||
taskID string,
|
||||
@@ -4360,8 +4437,10 @@ func (s *Service) launchAfterOnEnterDispatch(
|
||||
}
|
||||
s.logger.Info("implicit profile switch: reused session terminalized before dispatch, creating replacement",
|
||||
zap.String("task_id", taskID), zap.String("session_id", sessionID))
|
||||
replacement, replacementErr := s.createNewSessionForStep(
|
||||
replacementRoute := s.workflowReplacementRoute(asyncCtx, taskID, step.ID, sessionID)
|
||||
replacement, replacementErr := s.createNewSessionForStepWithEndPolicyAndRoute(
|
||||
asyncCtx, taskID, session, session.AgentProfileID,
|
||||
models.WorkflowProfileSessionEndPolicyComplete, replacementRoute,
|
||||
)
|
||||
if replacementErr != nil {
|
||||
s.logger.Error("failed to create replacement after terminalized profile switch",
|
||||
@@ -4399,8 +4478,10 @@ func (s *Service) launchAfterOnEnterDispatch(
|
||||
}
|
||||
s.logger.Info("implicit profile switch: reused session terminalized during dispatch, creating replacement",
|
||||
zap.String("task_id", taskID), zap.String("session_id", sessionID))
|
||||
replacement, replacementErr := s.createNewSessionForStep(
|
||||
replacementRoute := s.workflowReplacementRoute(asyncCtx, taskID, step.ID, sessionID)
|
||||
replacement, replacementErr := s.createNewSessionForStepWithEndPolicyAndRoute(
|
||||
asyncCtx, taskID, fresh, fresh.AgentProfileID,
|
||||
models.WorkflowProfileSessionEndPolicyComplete, replacementRoute,
|
||||
)
|
||||
if replacementErr != nil {
|
||||
s.logger.Error("failed to create replacement after terminalized dispatch",
|
||||
@@ -4473,8 +4554,10 @@ func (s *Service) replaceTerminalizedAutoStartSession(
|
||||
}
|
||||
s.logger.Info("creating fresh workflow session after reused session terminalized",
|
||||
zap.String("task_id", taskID), zap.String("session_id", sessionID))
|
||||
replacement, replacementErr := s.createNewSessionForStep(
|
||||
replacementRoute := s.workflowReplacementRoute(ctx, taskID, step.ID, sessionID)
|
||||
replacement, replacementErr := s.createNewSessionForStepWithEndPolicyAndRoute(
|
||||
ctx, taskID, session, session.AgentProfileID,
|
||||
models.WorkflowProfileSessionEndPolicyComplete, replacementRoute,
|
||||
)
|
||||
if replacementErr != nil {
|
||||
s.logger.Error("failed to create replacement after reused session terminalized",
|
||||
|
||||
+4
@@ -43,6 +43,10 @@ func (r failProfileSwitchPromotionRepo) SetSessionPrimary(context.Context, strin
|
||||
return r.err
|
||||
}
|
||||
|
||||
func (r failProfileSwitchPromotionRepo) SetSessionPrimaryIfNonterminal(context.Context, string) (bool, error) {
|
||||
return false, r.err
|
||||
}
|
||||
|
||||
type profileSwitchFixture struct {
|
||||
repo *sqliterepo.Repository
|
||||
svc *Service
|
||||
|
||||
@@ -526,6 +526,17 @@ func TestPrepareWorkflowStepSession_PreservesMatchingProfileSession(t *testing.T
|
||||
if !updated.IsPrimary {
|
||||
t.Fatal("matching profile session must remain primary")
|
||||
}
|
||||
task, err := repo.GetTask(ctx, "t1")
|
||||
if err != nil {
|
||||
t.Fatalf("reload task: %v", err)
|
||||
}
|
||||
route, ok := models.LoadWorkflowSessionRoute(task.Metadata)
|
||||
if !ok {
|
||||
t.Fatal("profile-only keep-current path must record a workflow session route")
|
||||
}
|
||||
if route.Phase != "committed" || route.DestinationID != session.ID || route.TargetKind != workflowSessionRouteTargetProfile {
|
||||
t.Fatalf("profile-only keep-current route = %+v", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareWorkflowStepSession_ClearsCompletionFollowUpWithoutProfile(t *testing.T) {
|
||||
@@ -556,6 +567,14 @@ func TestPrepareWorkflowStepSession_ClearsCompletionFollowUpWithoutProfile(t *te
|
||||
if models.IsCompletionFollowUpSession(updated.Metadata) {
|
||||
t.Fatal("explicit workflow step entry retained completion follow-up ownership")
|
||||
}
|
||||
task, err := repo.GetTask(ctx, "t1")
|
||||
if err != nil {
|
||||
t.Fatalf("reload task: %v", err)
|
||||
}
|
||||
route, ok := models.LoadWorkflowSessionRoute(task.Metadata)
|
||||
if !ok || route.Phase != "committed" || route.DestinationID != session.ID {
|
||||
t.Fatalf("profile-only default route = %+v, present=%t", route, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareWorkflowStepSession_PreservesNewerCompletionFollowUpWithoutProfile(t *testing.T) {
|
||||
@@ -590,6 +609,48 @@ func TestPrepareWorkflowStepSession_PreservesNewerCompletionFollowUpWithoutProfi
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrepareWorkflowStepSession_ProfileOnlyReuseCommitsEntryCorrelatedRecipient(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
existing := &models.TaskSession{
|
||||
ID: "session-b-existing", TaskID: "t1", AgentProfileID: "profile-b",
|
||||
ExecutorID: "exec-local", ExecutorProfileID: "ep1", TaskEnvironmentID: "env-1",
|
||||
State: models.TaskSessionStateWaitingForInput,
|
||||
StartedAt: time.Now().UTC().Add(-time.Minute), UpdatedAt: time.Now().UTC().Add(-time.Minute),
|
||||
}
|
||||
if err := fixture.repo.CreateTaskSession(ctx, existing); err != nil {
|
||||
t.Fatalf("create reusable destination: %v", err)
|
||||
}
|
||||
target := &wfmodels.WorkflowStep{
|
||||
ID: "step-b", WorkflowID: "wf1", Position: 1, AgentProfileID: "profile-b",
|
||||
ProfileSessionStartPolicy: models.WorkflowProfileSessionStartPolicyReuse,
|
||||
}
|
||||
source := &wfmodels.WorkflowStep{
|
||||
ID: "step-a", WorkflowID: "wf1", Position: 0, AgentProfileID: "profile-a",
|
||||
ProfileSessionEndPolicy: models.WorkflowProfileSessionEndPolicyPark,
|
||||
}
|
||||
|
||||
selected, switched, err := fixture.svc.prepareWorkflowStepSession(ctx, "t1", fixture.current, target, source, 42)
|
||||
if err != nil {
|
||||
t.Fatalf("prepare profile-only reuse: %v", err)
|
||||
}
|
||||
if !switched || selected.ID != existing.ID {
|
||||
t.Fatalf("selected profile-only reuse = %v, switched=%t", selected.ID, switched)
|
||||
}
|
||||
task, err := fixture.repo.GetTask(ctx, "t1")
|
||||
if err != nil {
|
||||
t.Fatalf("reload task: %v", err)
|
||||
}
|
||||
route, ok := models.LoadWorkflowSessionRoute(task.Metadata)
|
||||
if !ok {
|
||||
t.Fatal("profile-only reuse must record a workflow session route")
|
||||
}
|
||||
if route.Phase != workflowSessionRouteCommitted || route.DestinationID != existing.ID ||
|
||||
route.EntryIdentity != "entry:00000000000000000042" || route.DestinationStepID != target.ID {
|
||||
t.Fatalf("profile-only reuse route = %+v", route)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSwitchWorkflowDispatcherRoutesOnEnterToDestinationProfileSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := setupTestRepo(t)
|
||||
|
||||
@@ -15,6 +15,10 @@ import (
|
||||
const (
|
||||
workflowSessionRoutePrepared = "prepared"
|
||||
workflowSessionRouteCommitted = "committed"
|
||||
// workflowSessionRouteTargetProfile identifies the ordinary profile-only
|
||||
// workflow path. It has no target step because the recipient is selected
|
||||
// by the destination step's effective profile policy.
|
||||
workflowSessionRouteTargetProfile = "profile"
|
||||
)
|
||||
|
||||
type workflowSessionBindingStore interface {
|
||||
@@ -254,14 +258,22 @@ func (s *Service) promoteWorkflowSessionRoute(
|
||||
promoteErr = err
|
||||
return
|
||||
}
|
||||
destinationParkingStamp = s.captureWorkflowParkingStamp(admissionCtx, destination.ID)
|
||||
if promoter, ok := s.repo.(workflowSessionRoutePromoter); ok && route != nil {
|
||||
promoter, hasAtomicRoutePromotion := s.repo.(workflowSessionRoutePromoter)
|
||||
if hasAtomicRoutePromotion {
|
||||
destinationParkingStamp = s.captureWorkflowParkingStamp(admissionCtx, destination.ID)
|
||||
} else {
|
||||
destinationParkingStamp = workflowParkingStamp(destination)
|
||||
}
|
||||
if hasAtomicRoutePromotion && route != nil {
|
||||
committed := *route
|
||||
committed.DestinationID = destination.ID
|
||||
committed.Phase = workflowSessionRouteCommitted
|
||||
promoted, promoteErr = promoter.SetSessionPrimaryWithWorkflowSessionRouteIfNonterminal(admissionCtx, destination.ID, committed)
|
||||
return
|
||||
}
|
||||
// Legacy repository adapters do not expose the atomic route promoter.
|
||||
// Keep the nonterminal promotion guard and persist a route only after
|
||||
// the selected destination has been promoted.
|
||||
promoted, promoteErr = s.setNonterminalSessionPrimary(admissionCtx, destination.ID)
|
||||
if promoteErr != nil || !promoted || route == nil {
|
||||
return
|
||||
@@ -282,10 +294,26 @@ func (s *Service) promoteWorkflowSessionRoute(
|
||||
// session. Clear the selected destination's current parking projection;
|
||||
// the stamped stop-intent tombstone remains for delayed callbacks.
|
||||
s.clearWorkflowParkingForExplicitExecution(ctx, destination.ID, destinationParkingStamp)
|
||||
s.publishPrimarySessionUpdate(ctx, taskID, destination.ID)
|
||||
if task, err := s.repo.GetTask(ctx, taskID); err == nil && task != nil {
|
||||
s.publishTaskUpdated(ctx, task)
|
||||
} else if err != nil {
|
||||
s.logger.Warn("failed to fetch task after workflow session route promotion",
|
||||
zap.String("task_id", taskID), zap.Error(err))
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func workflowParkingStamp(session *models.TaskSession) string {
|
||||
if session == nil {
|
||||
return ""
|
||||
}
|
||||
parking, ok := models.LoadWorkflowParking(session.Metadata)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return parking.Stamp
|
||||
}
|
||||
|
||||
func (s *Service) clearWorkflowParkingForExplicitExecution(
|
||||
ctx context.Context,
|
||||
destinationID string,
|
||||
@@ -310,9 +338,15 @@ func (s *Service) clearWorkflowParkingForExplicitExecution(
|
||||
}
|
||||
|
||||
func workflowSessionRouteMatchesStep(route *models.WorkflowSessionRoute, step *wfmodels.WorkflowStep, profileID string) bool {
|
||||
if route == nil || step == nil || step.SessionTarget == nil {
|
||||
if route == nil || step == nil {
|
||||
return false
|
||||
}
|
||||
if step.SessionTarget == nil {
|
||||
return route.DestinationStepID == step.ID &&
|
||||
route.TargetKind == workflowSessionRouteTargetProfile &&
|
||||
route.TargetStepID == "" &&
|
||||
route.AgentProfileID == profileID
|
||||
}
|
||||
return route.DestinationStepID == step.ID &&
|
||||
route.TargetKind == string(step.SessionTarget.Kind) &&
|
||||
route.TargetStepID == step.SessionTarget.StepID &&
|
||||
@@ -373,7 +407,7 @@ func (s *Service) reuseRecordedWorkflowSession(
|
||||
|
||||
func workflowSessionRouteID(taskID, stepID, entryIdentity string, target *wfmodels.WorkflowSessionTarget, startPolicy models.WorkflowProfileSessionStartPolicy) string {
|
||||
targetID := ""
|
||||
targetKind := ""
|
||||
targetKind := workflowSessionRouteTargetProfile
|
||||
if target != nil {
|
||||
targetKind = string(target.Kind)
|
||||
targetID = target.StepID
|
||||
@@ -381,6 +415,47 @@ func workflowSessionRouteID(taskID, stepID, entryIdentity string, target *wfmode
|
||||
return fmt.Sprintf("workflow-session:%s:%s:%s:%s:%s:%s", taskID, stepID, entryIdentity, targetKind, targetID, startPolicy)
|
||||
}
|
||||
|
||||
func workflowProfileSessionRoute(
|
||||
taskID string,
|
||||
currentSession *models.TaskSession,
|
||||
step *wfmodels.WorkflowStep,
|
||||
profileID string,
|
||||
entryIdentity string,
|
||||
startPolicy models.WorkflowProfileSessionStartPolicy,
|
||||
) *models.WorkflowSessionRoute {
|
||||
if currentSession == nil || step == nil || step.SessionTarget != nil {
|
||||
return nil
|
||||
}
|
||||
return &models.WorkflowSessionRoute{
|
||||
OperationID: workflowSessionRouteID(taskID, step.ID, entryIdentity, nil, startPolicy),
|
||||
DestinationStepID: step.ID,
|
||||
EntryIdentity: entryIdentity,
|
||||
TargetKind: workflowSessionRouteTargetProfile,
|
||||
AgentProfileID: profileID,
|
||||
SourceSessionID: currentSession.ID,
|
||||
Phase: workflowSessionRoutePrepared,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Service) workflowReplacementRoute(
|
||||
ctx context.Context,
|
||||
taskID, stepID, terminalSessionID string,
|
||||
) *models.WorkflowSessionRoute {
|
||||
if s == nil || s.repo == nil || taskID == "" || stepID == "" || terminalSessionID == "" {
|
||||
return nil
|
||||
}
|
||||
task, err := s.repo.GetTask(ctx, taskID)
|
||||
if err != nil || task == nil {
|
||||
return nil
|
||||
}
|
||||
route, ok := models.LoadWorkflowSessionRoute(task.Metadata)
|
||||
if !ok || route.DestinationStepID != stepID || route.DestinationID != terminalSessionID {
|
||||
return nil
|
||||
}
|
||||
route.Phase = workflowSessionRoutePrepared
|
||||
return &route
|
||||
}
|
||||
|
||||
// workflowEntryIdentity is the durable identity of one workflow-step entry.
|
||||
// Transition deliveries carry the immutable ledger id directly. Direct/manual
|
||||
// starts use the task's latest transition, while creation entries fall back to
|
||||
@@ -422,6 +497,18 @@ func (s *Service) reuseResolvedWorkflowSession(
|
||||
return nil, false, nil
|
||||
}
|
||||
if targetSession.ID == currentSession.ID {
|
||||
baseRoute.DestinationID = currentSession.ID
|
||||
baseRoute.Phase = workflowSessionRoutePrepared
|
||||
if err := s.persistWorkflowSessionRoute(ctx, taskID, *baseRoute); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
promoted, err := s.promoteWorkflowSessionRoute(ctx, taskID, currentSession, baseRoute)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if !promoted {
|
||||
return nil, false, nil
|
||||
}
|
||||
return currentSession, false, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -432,6 +432,42 @@ func TestWorkflowRouteRetryRecoversAfterLegacyCommitMetadataFailure(t *testing.T
|
||||
require.Equal(t, workflowSessionRouteCommitted, committed.Phase)
|
||||
}
|
||||
|
||||
func TestPromoteWorkflowSessionRouteUsesNonterminalGuardOnLegacyRepositories(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
destination := &models.TaskSession{
|
||||
ID: "workflow-terminal-destination",
|
||||
TaskID: "t1",
|
||||
AgentProfileID: "profile-a",
|
||||
State: models.TaskSessionStateCompleted,
|
||||
}
|
||||
require.NoError(t, fixture.repo.CreateTaskSession(ctx, destination))
|
||||
route := models.WorkflowSessionRoute{
|
||||
OperationID: "workflow-route-terminal-destination",
|
||||
DestinationStepID: "step-review",
|
||||
EntryIdentity: "entry:00000000000000000031",
|
||||
TargetKind: string(wfmodels.WorkflowSessionTargetInitial),
|
||||
AgentProfileID: "profile-a",
|
||||
DestinationID: destination.ID,
|
||||
Phase: workflowSessionRoutePrepared,
|
||||
}
|
||||
require.NoError(t, fixture.repo.SetTaskMetadataKey(ctx, "t1", models.MetaKeyWorkflowSessionRoute, route))
|
||||
|
||||
fixture.svc.repo = &failWorkflowRouteMetadataRepo{repoStore: fixture.repo}
|
||||
promoted, err := fixture.svc.promoteWorkflowSessionRoute(ctx, "t1", destination, &route)
|
||||
require.NoError(t, err)
|
||||
require.False(t, promoted)
|
||||
|
||||
updatedDestination, err := fixture.repo.GetTaskSession(ctx, destination.ID)
|
||||
require.NoError(t, err)
|
||||
require.False(t, updatedDestination.IsPrimary)
|
||||
updatedTask, err := fixture.repo.GetTask(ctx, "t1")
|
||||
require.NoError(t, err)
|
||||
updatedRoute, ok := models.LoadWorkflowSessionRoute(updatedTask.Metadata)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, workflowSessionRoutePrepared, updatedRoute.Phase)
|
||||
}
|
||||
|
||||
func TestPromoteWorkflowSessionRouteClearsSelectedDestinationParkingOnly(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
@@ -492,6 +528,37 @@ func TestPromoteWorkflowSessionRouteClearsSelectedDestinationParkingOnly(t *test
|
||||
require.Equal(t, sourceParking.Stamp, parking.Stamp, "promotion cleared the source marker instead of destination")
|
||||
}
|
||||
|
||||
func TestReuseResolvedWorkflowSessionCommitsRouteForCurrentSession(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
fixture := newProfileSwitchFixture(t, models.WorkflowProfileSessionStartPolicyReuse, models.WorkflowProfileSessionEndPolicyPark)
|
||||
route := &models.WorkflowSessionRoute{
|
||||
OperationID: "workflow-route-current-session",
|
||||
DestinationStepID: "step-review",
|
||||
EntryIdentity: "entry:00000000000000000022",
|
||||
TargetKind: string(wfmodels.WorkflowSessionTargetInitial),
|
||||
AgentProfileID: fixture.current.AgentProfileID,
|
||||
}
|
||||
|
||||
reused, switched, err := fixture.svc.reuseResolvedWorkflowSession(
|
||||
ctx,
|
||||
"t1",
|
||||
fixture.current,
|
||||
fixture.current,
|
||||
route,
|
||||
models.WorkflowProfileSessionEndPolicyPark,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
require.False(t, switched)
|
||||
require.Equal(t, fixture.current.ID, reused.ID)
|
||||
|
||||
task, err := fixture.repo.GetTask(ctx, "t1")
|
||||
require.NoError(t, err)
|
||||
committed, ok := models.LoadWorkflowSessionRoute(task.Metadata)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, workflowSessionRouteCommitted, committed.Phase)
|
||||
require.Equal(t, fixture.current.ID, committed.DestinationID)
|
||||
}
|
||||
|
||||
func fixtureSessionState(t *testing.T, repo interface {
|
||||
GetTaskSession(context.Context, string) (*models.TaskSession, error)
|
||||
}, sessionID string) models.TaskSessionState {
|
||||
|
||||
@@ -1247,10 +1247,11 @@ type StepActionDTO struct {
|
||||
|
||||
// MoveTaskResponse includes the task and the target workflow step info
|
||||
type MoveTaskResponse struct {
|
||||
Task TaskDTO `json:"task"`
|
||||
WorkflowStep WorkflowStepDTO `json:"workflow_step"`
|
||||
MoveID string `json:"move_id,omitempty"`
|
||||
EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"`
|
||||
Task TaskDTO `json:"task"`
|
||||
WorkflowStep WorkflowStepDTO `json:"workflow_step"`
|
||||
WorkflowEntryIdentity string `json:"workflow_entry_identity,omitempty"`
|
||||
MoveID string `json:"move_id,omitempty"`
|
||||
EntryOptions *workflowmove.EntryOptions `json:"entry_options,omitempty"`
|
||||
// Disposition reports how an MCP move_task call was resolved: "applied" when
|
||||
// the move committed immediately, or "deferred" when it was recorded to run
|
||||
// at the source session's turn-end. It lets an agent distinguish deferred
|
||||
|
||||
@@ -1838,9 +1838,10 @@ func (h *TaskHandlers) httpMoveTask(c *gin.Context) {
|
||||
}
|
||||
|
||||
response := dto.MoveTaskResponse{
|
||||
Task: dto.FromTask(result.Task),
|
||||
MoveID: result.MoveID,
|
||||
EntryOptions: result.EntryOptions,
|
||||
Task: dto.FromTask(result.Task),
|
||||
WorkflowEntryIdentity: result.WorkflowEntryIdentity,
|
||||
MoveID: result.MoveID,
|
||||
EntryOptions: result.EntryOptions,
|
||||
}
|
||||
if result.WorkflowStep != nil {
|
||||
response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep)
|
||||
|
||||
@@ -499,9 +499,10 @@ func (h *TaskHandlers) wsMoveTask(ctx context.Context, msg *ws.Message) (*ws.Mes
|
||||
}
|
||||
|
||||
response := dto.MoveTaskResponse{
|
||||
Task: dto.FromTask(result.Task),
|
||||
MoveID: result.MoveID,
|
||||
EntryOptions: result.EntryOptions,
|
||||
Task: dto.FromTask(result.Task),
|
||||
WorkflowEntryIdentity: result.WorkflowEntryIdentity,
|
||||
MoveID: result.MoveID,
|
||||
EntryOptions: result.EntryOptions,
|
||||
}
|
||||
if result.WorkflowStep != nil {
|
||||
response.WorkflowStep = dto.FromWorkflowStep(result.WorkflowStep)
|
||||
|
||||
@@ -420,6 +420,9 @@ type MoveTaskResult struct {
|
||||
// this call's earlier pre-move snapshot — see Task.FromStepID's doc.
|
||||
FromStepID string
|
||||
Transitioned bool
|
||||
// WorkflowEntryIdentity identifies the committed workflow-step entry that
|
||||
// accepted this move. Empty when the write did not transition the task.
|
||||
WorkflowEntryIdentity string
|
||||
// MoveID correlates the one-shot entry options carried on the transient
|
||||
// move marker with the target-step entry. Empty for an option-less move.
|
||||
MoveID string
|
||||
@@ -732,6 +735,10 @@ func (s *Service) MoveTaskWithOptions(
|
||||
resultFromWorkflowID := task.FromWorkflowID
|
||||
resultFromStepID := task.FromStepID
|
||||
resultTransitioned := task.WorkflowStepTransitionID != 0
|
||||
workflowEntryIdentity := ""
|
||||
if resultTransitioned && task.WorkflowStepTransitionID > 0 {
|
||||
workflowEntryIdentity = fmt.Sprintf("entry:%020d", task.WorkflowStepTransitionID)
|
||||
}
|
||||
if resultTransitioned && resultFromWorkflowID == "" {
|
||||
// Keep compatibility with repository implementations that predate the
|
||||
// transient source-workflow field. SQLite populates it from the write
|
||||
@@ -797,7 +804,14 @@ func (s *Service) MoveTaskWithOptions(
|
||||
zap.String("workflow_step_id", workflowStepID),
|
||||
zap.Int("position", position))
|
||||
|
||||
result := &MoveTaskResult{Task: task, FromStepID: resultFromStepID, Transitioned: resultTransitioned, MoveID: moveID, EntryOptions: entryOptions}
|
||||
result := &MoveTaskResult{
|
||||
Task: task,
|
||||
FromStepID: resultFromStepID,
|
||||
Transitioned: resultTransitioned,
|
||||
WorkflowEntryIdentity: workflowEntryIdentity,
|
||||
MoveID: moveID,
|
||||
EntryOptions: entryOptions,
|
||||
}
|
||||
|
||||
// Fetch the workflow step info if getter is available
|
||||
if s.workflowStepGetter != nil {
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
var workflowEntryIdentityPattern = regexp.MustCompile(`^entry:[0-9]{20}$`)
|
||||
|
||||
func TestService_MoveTaskWorkflowFocusReturnsCommittedEntryIdentity(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
seedMoveSteps(svc)
|
||||
createMoveTask(t, ctx, repo, "task-focus", "wf-source", "step-source", nil)
|
||||
|
||||
result, err := svc.MoveTask(ctx, "task-focus", "wf-source", "step-review-target", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveTask: %v", err)
|
||||
}
|
||||
if !workflowEntryIdentityPattern.MatchString(result.WorkflowEntryIdentity) {
|
||||
t.Fatalf("workflow entry identity = %q, want entry:<20-digit transition id>", result.WorkflowEntryIdentity)
|
||||
}
|
||||
if result.MoveID != "" {
|
||||
t.Fatalf("ordinary move unexpectedly returned move id %q", result.MoveID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestService_MoveTaskWorkflowFocusOmitsIdentityForNoOpMove(t *testing.T) {
|
||||
svc, _, repo := createTestService(t)
|
||||
ctx := context.Background()
|
||||
seedMoveWorkflows(t, ctx, repo)
|
||||
seedMoveSteps(svc)
|
||||
createMoveTask(t, ctx, repo, "task-focus-noop", "wf-source", "step-source", nil)
|
||||
|
||||
result, err := svc.MoveTask(ctx, "task-focus-noop", "wf-source", "step-source", 0)
|
||||
if err != nil {
|
||||
t.Fatalf("MoveTask: %v", err)
|
||||
}
|
||||
if result.Transitioned {
|
||||
t.Fatal("same-step move reported a transition")
|
||||
}
|
||||
if result.WorkflowEntryIdentity != "" {
|
||||
t.Fatalf("same-step move returned workflow entry identity %q", result.WorkflowEntryIdentity)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
/* eslint-disable max-lines -- board preview owns the responsive board and desktop preview surface. */
|
||||
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -311,18 +313,109 @@ function useSyncSelectedTaskActivity(params: {
|
||||
selectedTaskId: string | null | undefined;
|
||||
activeSessionId: string | null;
|
||||
setActiveSession: (taskId: string, sessionId: string) => void;
|
||||
setActiveSessionAuto: (taskId: string, sessionId: string) => void;
|
||||
workflowFocusRequestActive: boolean;
|
||||
setActiveTask: (taskId: string) => void;
|
||||
}) {
|
||||
const { isOpen, selectedTaskId, activeSessionId, setActiveSession, setActiveTask } = params;
|
||||
const {
|
||||
isOpen,
|
||||
selectedTaskId,
|
||||
activeSessionId,
|
||||
setActiveSession,
|
||||
setActiveSessionAuto,
|
||||
workflowFocusRequestActive,
|
||||
setActiveTask,
|
||||
} = params;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen || !selectedTaskId) return;
|
||||
if (activeSessionId) {
|
||||
setActiveSession(selectedTaskId, activeSessionId);
|
||||
if (workflowFocusRequestActive) {
|
||||
setActiveSessionAuto(selectedTaskId, activeSessionId);
|
||||
} else {
|
||||
setActiveSession(selectedTaskId, activeSessionId);
|
||||
}
|
||||
} else {
|
||||
setActiveTask(selectedTaskId);
|
||||
}
|
||||
}, [activeSessionId, isOpen, selectedTaskId, setActiveSession, setActiveTask]);
|
||||
}, [
|
||||
activeSessionId,
|
||||
isOpen,
|
||||
selectedTaskId,
|
||||
setActiveSession,
|
||||
setActiveSessionAuto,
|
||||
workflowFocusRequestActive,
|
||||
setActiveTask,
|
||||
]);
|
||||
}
|
||||
|
||||
function usePreviewSessionFocus({
|
||||
previewIsOpen,
|
||||
previewTaskId,
|
||||
selectedTask,
|
||||
selectedTaskSessionId,
|
||||
userSelectedSessionId,
|
||||
setUserSelectedSessionId,
|
||||
}: {
|
||||
previewIsOpen: boolean;
|
||||
previewTaskId: string | null | undefined;
|
||||
selectedTask: Task | null;
|
||||
selectedTaskSessionId: string | null;
|
||||
userSelectedSessionId: string | null;
|
||||
setUserSelectedSessionId: (sessionId: string | null) => void;
|
||||
}) {
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
||||
const setActiveSessionAuto = useAppStore((state) => state.setActiveSessionAuto);
|
||||
const workflowFocusRequest = useAppStore((state) => state.workflowSessionFocus.request);
|
||||
const acknowledgeWorkflowSessionFocus = useAppStore(
|
||||
(state) => state.acknowledgeWorkflowSessionFocus,
|
||||
);
|
||||
let activeSessionId: string | null = null;
|
||||
if (previewTaskId) {
|
||||
if (workflowFocusRequest?.taskId === previewTaskId) {
|
||||
activeSessionId = workflowFocusRequest.sessionId;
|
||||
} else {
|
||||
activeSessionId =
|
||||
userSelectedSessionId ?? selectedTask?.primarySessionId ?? selectedTaskSessionId;
|
||||
}
|
||||
}
|
||||
|
||||
const handleSessionChange = useCallback(
|
||||
(sessionId: string | null) => {
|
||||
setUserSelectedSessionId(sessionId);
|
||||
if (previewTaskId && sessionId) setActiveSession(previewTaskId, sessionId);
|
||||
},
|
||||
[previewTaskId, setActiveSession, setUserSelectedSessionId],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!previewIsOpen || !previewTaskId || workflowFocusRequest?.taskId !== previewTaskId) {
|
||||
return;
|
||||
}
|
||||
setUserSelectedSessionId(null);
|
||||
setActiveSessionAuto(previewTaskId, workflowFocusRequest.sessionId);
|
||||
acknowledgeWorkflowSessionFocus(workflowFocusRequest.requestId);
|
||||
}, [
|
||||
acknowledgeWorkflowSessionFocus,
|
||||
previewIsOpen,
|
||||
previewTaskId,
|
||||
setActiveSessionAuto,
|
||||
setUserSelectedSessionId,
|
||||
workflowFocusRequest,
|
||||
]);
|
||||
|
||||
useSyncSelectedTaskActivity({
|
||||
isOpen: previewIsOpen,
|
||||
selectedTaskId: previewTaskId,
|
||||
activeSessionId,
|
||||
setActiveSession,
|
||||
setActiveSessionAuto,
|
||||
workflowFocusRequestActive: workflowFocusRequest?.taskId === previewTaskId,
|
||||
setActiveTask,
|
||||
});
|
||||
|
||||
return { activeSessionId, handleSessionChange };
|
||||
}
|
||||
|
||||
export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWithPreviewProps) {
|
||||
@@ -334,8 +427,6 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
|
||||
const kanbanWorkflowId = useAppStore((state) => state.kanban.workflowId);
|
||||
const kanbanIsLoading = useAppStore((state) => state.kanban.isLoading ?? false);
|
||||
const kanbanMultiSnapshots = useAppStore((state) => state.kanbanMulti.snapshots);
|
||||
const setActiveTask = useAppStore((state) => state.setActiveTask);
|
||||
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
||||
const setKanbanPreviewedTaskId = useAppStore((state) => state.setKanbanPreviewedTaskId);
|
||||
const hasLoadedTaskSources = hasLoadedKanbanTaskSources({
|
||||
activeWorkflowId: kanbanWorkflowId,
|
||||
@@ -403,16 +494,13 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
|
||||
[router],
|
||||
);
|
||||
|
||||
const activeSessionId = previewTaskId
|
||||
? (userSelectedSessionId ?? selectedTask?.primarySessionId ?? selectedTaskSessionId)
|
||||
: null;
|
||||
|
||||
useSyncSelectedTaskActivity({
|
||||
isOpen: previewIsOpen,
|
||||
selectedTaskId: previewTaskId,
|
||||
activeSessionId,
|
||||
setActiveSession,
|
||||
setActiveTask,
|
||||
const { activeSessionId, handleSessionChange } = usePreviewSessionFocus({
|
||||
previewIsOpen,
|
||||
previewTaskId,
|
||||
selectedTask,
|
||||
selectedTaskSessionId,
|
||||
userSelectedSessionId,
|
||||
setUserSelectedSessionId,
|
||||
});
|
||||
|
||||
useUrlSync(previewTaskId ?? null, previewIsOpen ? (activeSessionId ?? null) : null);
|
||||
@@ -446,7 +534,7 @@ export function KanbanWithPreview({ initialTaskId, initialSessionId }: KanbanWit
|
||||
onPreviewTask={handlePreviewTaskWithData}
|
||||
onNavigateToTask={handleNavigateToTask}
|
||||
onClose={close}
|
||||
onSessionChange={setUserSelectedSessionId}
|
||||
onSessionChange={handleSessionChange}
|
||||
onResizeMouseDown={handleResizeMouseDown}
|
||||
onActionsMenuOpenChange={setActionsMenuOpen}
|
||||
/>
|
||||
|
||||
@@ -2,6 +2,20 @@ import { describe, expect, it } from "vitest";
|
||||
import { shouldActivateSessionPanel } from "./dockview-session-tab-activation";
|
||||
|
||||
describe("shouldActivateSessionPanel", () => {
|
||||
it("activates a committed manual workflow recipient even when another panel is selected", () => {
|
||||
expect(
|
||||
shouldActivateSessionPanel({
|
||||
sessionPanelExistedBefore: true,
|
||||
prevTaskId: "task-1",
|
||||
prevSessionId: "session-astra",
|
||||
currentTaskId: "task-1",
|
||||
currentSessionId: "session-luna",
|
||||
currentActivePanelId: "plan",
|
||||
workflowFocusRequested: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
const sessionId = "s-current";
|
||||
const baseArgs = {
|
||||
prevTaskId: null,
|
||||
|
||||
@@ -7,6 +7,7 @@ export type SessionPanelActivationArgs = {
|
||||
currentTaskId: string | null;
|
||||
currentSessionId: string;
|
||||
currentActivePanelId: string | null;
|
||||
workflowFocusRequested?: boolean;
|
||||
};
|
||||
|
||||
export function shouldPreserveActivePanel(
|
||||
@@ -47,8 +48,10 @@ export function shouldActivateSessionPanel(args: SessionPanelActivationArgs): bo
|
||||
currentTaskId,
|
||||
currentSessionId,
|
||||
currentActivePanelId,
|
||||
workflowFocusRequested = false,
|
||||
} = args;
|
||||
const sessionPanelId = `session:${currentSessionId}`;
|
||||
if (workflowFocusRequested) return true;
|
||||
if (!sessionPanelExistedBefore) {
|
||||
return (
|
||||
!currentActivePanelId ||
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, render } from "@testing-library/react";
|
||||
import type { DockviewApi } from "dockview-react";
|
||||
import { StateProvider } from "@/components/state-provider";
|
||||
import type { StoreApi } from "zustand";
|
||||
import { StateProvider, useAppStoreApi } from "@/components/state-provider";
|
||||
import { defaultState } from "@/lib/state/default-state";
|
||||
import type { AppState } from "@/lib/state/store";
|
||||
import { useDockviewStore } from "@/lib/state/dockview-store";
|
||||
import type { TaskSession, TaskId } from "@/lib/types/http";
|
||||
import { makeReorderingAutoSessionApi } from "./dockview-session-tabs.test-utils";
|
||||
@@ -17,6 +19,14 @@ function Harness() {
|
||||
return null;
|
||||
}
|
||||
|
||||
let capturedStore: StoreApi<AppState> | null = null;
|
||||
|
||||
function FocusHarness() {
|
||||
capturedStore = useAppStoreApi();
|
||||
useAutoSessionTab(ACTIVE_SESSION_ID);
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderHookWithHydratedSessions() {
|
||||
const sessions = [ACTIVE_SESSION_ID, SIBLING_SESSION_ID].map((id) => ({ id }) as TaskSession);
|
||||
|
||||
@@ -60,4 +70,90 @@ describe("useAutoSessionTab", () => {
|
||||
expect.arrayContaining([`session:${ACTIVE_SESSION_ID}`, `session:${SIBLING_SESSION_ID}`]),
|
||||
);
|
||||
});
|
||||
|
||||
it("subscribes to a same-session focus request and acknowledges after activation", () => {
|
||||
const { api, activationSequence, centerActivePanelId } = makeReorderingAutoSessionApi("files");
|
||||
const route = {
|
||||
operation_id: "workflow-session:task-1:step-b:entry:00000000000000000042:profile::reuse",
|
||||
destination_step_id: "step-b",
|
||||
entry_identity: "entry:00000000000000000042",
|
||||
destination_session_id: ACTIVE_SESSION_ID,
|
||||
phase: "committed",
|
||||
};
|
||||
capturedStore = null;
|
||||
|
||||
render(
|
||||
<StateProvider
|
||||
initialState={{
|
||||
...defaultState,
|
||||
tasks: {
|
||||
...defaultState.tasks,
|
||||
activeTaskId: TASK_ID,
|
||||
activeSessionId: ACTIVE_SESSION_ID,
|
||||
},
|
||||
kanban: {
|
||||
...defaultState.kanban,
|
||||
tasks: [
|
||||
{
|
||||
id: TASK_ID,
|
||||
workflowId: "workflow-1",
|
||||
workflowStepId: "step-b",
|
||||
title: "Task",
|
||||
position: 0,
|
||||
updatedAt: "2026-09-19T10:00:01Z",
|
||||
metadata: { workflow_session_route: route },
|
||||
},
|
||||
],
|
||||
},
|
||||
taskSessionsByTask: {
|
||||
...defaultState.taskSessionsByTask,
|
||||
itemsByTaskId: {
|
||||
[TASK_ID]: [
|
||||
{ id: ACTIVE_SESSION_ID } as TaskSession,
|
||||
{ id: SIBLING_SESSION_ID } as TaskSession,
|
||||
],
|
||||
},
|
||||
},
|
||||
}}
|
||||
>
|
||||
<FocusHarness />
|
||||
</StateProvider>,
|
||||
);
|
||||
|
||||
expect(capturedStore).not.toBeNull();
|
||||
const store = capturedStore as unknown as StoreApi<AppState>;
|
||||
act(() => {
|
||||
useDockviewStore.setState({ api: api as DockviewApi });
|
||||
});
|
||||
activationSequence.length = 0;
|
||||
|
||||
const acknowledge = store.getState().acknowledgeWorkflowSessionFocus;
|
||||
const acknowledgeSpy = vi.fn((requestId: number) => acknowledge(requestId));
|
||||
store.setState({ acknowledgeWorkflowSessionFocus: acknowledgeSpy });
|
||||
|
||||
act(() => {
|
||||
const requestId = store.getState().beginWorkflowSessionFocus({
|
||||
taskId: TASK_ID,
|
||||
workflowId: "workflow-1",
|
||||
destinationStepId: "step-b",
|
||||
presentationToken: 1,
|
||||
navigationRevision: 0,
|
||||
});
|
||||
expect(requestId).toBe(1);
|
||||
store.getState().bindWorkflowSessionFocus({
|
||||
requestId: requestId!,
|
||||
presentationToken: 1,
|
||||
entryIdentity: route.entry_identity,
|
||||
});
|
||||
store.getState().reconcileWorkflowSessionFocus(TASK_ID);
|
||||
});
|
||||
|
||||
expect(centerActivePanelId()).toBe(`session:${ACTIVE_SESSION_ID}`);
|
||||
expect(acknowledgeSpy).toHaveBeenCalledTimes(1);
|
||||
expect(acknowledgeSpy).toHaveBeenCalledWith(1);
|
||||
expect(activationSequence.filter((id) => id === `session:${ACTIVE_SESSION_ID}`)).toHaveLength(
|
||||
1,
|
||||
);
|
||||
expect(store.getState().workflowSessionFocus.request).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -297,16 +297,26 @@ type AutoSessionTabRefs = {
|
||||
* Activate the newly-ensured session panel and update the center-group store
|
||||
* entry. Returns the resolved active panel for sibling anchoring.
|
||||
*/
|
||||
function activateSessionPanel(
|
||||
api: DockviewApi,
|
||||
effectiveSessionId: string,
|
||||
function activateSessionPanel({
|
||||
api,
|
||||
effectiveSessionId,
|
||||
activation,
|
||||
refs,
|
||||
tid,
|
||||
appStore,
|
||||
workflowFocusRequestId,
|
||||
}: {
|
||||
api: DockviewApi;
|
||||
effectiveSessionId: string;
|
||||
activation: {
|
||||
sessionPanelExistedBefore: boolean;
|
||||
activePanelIdBeforeEnsure: string | null;
|
||||
},
|
||||
refs: AutoSessionTabRefs,
|
||||
tid: string | null,
|
||||
): ReturnType<DockviewApi["getPanel"]> {
|
||||
};
|
||||
refs: AutoSessionTabRefs;
|
||||
tid: string | null;
|
||||
appStore: ReturnType<typeof useAppStoreApi>;
|
||||
workflowFocusRequestId: number | null;
|
||||
}): ReturnType<DockviewApi["getPanel"]> {
|
||||
const activePanel = api.getPanel(`session:${effectiveSessionId}`);
|
||||
if (!activePanel) return activePanel;
|
||||
|
||||
@@ -318,6 +328,7 @@ function activateSessionPanel(
|
||||
currentTaskId: tid,
|
||||
currentSessionId: effectiveSessionId,
|
||||
currentActivePanelId: activation.activePanelIdBeforeEnsure,
|
||||
workflowFocusRequested: workflowFocusRequestId !== null,
|
||||
});
|
||||
if (isDebug()) {
|
||||
debug("useAutoSessionTab: activation decision", {
|
||||
@@ -332,7 +343,12 @@ function activateSessionPanel(
|
||||
activeGroupId: activePanel.group.id,
|
||||
});
|
||||
}
|
||||
if (shouldActivate) activePanel.api.setActive();
|
||||
if (shouldActivate) {
|
||||
activePanel.api.setActive();
|
||||
if (workflowFocusRequestId !== null) {
|
||||
appStore.getState().acknowledgeWorkflowSessionFocus(workflowFocusRequestId);
|
||||
}
|
||||
}
|
||||
useDockviewStore.setState({
|
||||
centerGroupId: isCenterCandidateGroupId(activePanel.group.id)
|
||||
? activePanel.group.id
|
||||
@@ -484,6 +500,15 @@ function updateAutoSessionTabRefs(
|
||||
refs.prevSessionIdRef.current = effectiveSessionId;
|
||||
}
|
||||
|
||||
function workflowFocusRequestIdForSession(
|
||||
appStore: ReturnType<typeof useAppStoreApi>,
|
||||
taskId: string | null,
|
||||
sessionId: string,
|
||||
): number | null {
|
||||
const request = appStore.getState().workflowSessionFocus?.request;
|
||||
return request?.taskId === taskId && request.sessionId === sessionId ? request.requestId : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Core effect body for useAutoSessionTab — extracted to reduce complexity of
|
||||
* the hook itself.
|
||||
@@ -533,16 +558,20 @@ export function runAutoSessionTabEffect(
|
||||
}
|
||||
|
||||
const chatWasSelected = isChatPlaceholderSelected(api);
|
||||
const workflowFocusRequestId = workflowFocusRequestIdForSession(
|
||||
appStore,
|
||||
tid,
|
||||
effectiveSessionId,
|
||||
);
|
||||
const initialPosition = resolveInitialPosition(api, chatWasSelected);
|
||||
const sessionPanelExistedBefore = !!api.getPanel(`session:${effectiveSessionId}`);
|
||||
// Preserve the restored/user-selected panel before adding and reordering the
|
||||
// session tab. Dockview's same-group move briefly activates a sibling even
|
||||
// with skipSetActive, so reading api.activePanel afterward loses this intent.
|
||||
const activePanelIdBeforeEnsure = api.activePanel?.id ?? null;
|
||||
const preserveActivePanel = shouldPreserveActivePanel(
|
||||
sessionPanelExistedBefore,
|
||||
activePanelIdBeforeEnsure,
|
||||
);
|
||||
const preserveActivePanel =
|
||||
workflowFocusRequestId === null &&
|
||||
shouldPreserveActivePanel(sessionPanelExistedBefore, activePanelIdBeforeEnsure);
|
||||
|
||||
logSessionPanelEnsure(effectiveSessionId, sessionPanelExistedBefore, initialPosition);
|
||||
|
||||
@@ -567,13 +596,15 @@ export function runAutoSessionTabEffect(
|
||||
removeChatPlaceholder(api);
|
||||
ensureSessionTabPrecedesNonSessionTabs(api, effectiveSessionId);
|
||||
|
||||
const activePanel = activateSessionPanel(
|
||||
const activePanel = activateSessionPanel({
|
||||
api,
|
||||
effectiveSessionId,
|
||||
{ sessionPanelExistedBefore, activePanelIdBeforeEnsure },
|
||||
activation: { sessionPanelExistedBefore, activePanelIdBeforeEnsure },
|
||||
refs,
|
||||
tid,
|
||||
);
|
||||
appStore,
|
||||
workflowFocusRequestId,
|
||||
});
|
||||
|
||||
restorePreservedActivePanel(api, activePanelIdBeforeEnsure, preserveActivePanel);
|
||||
|
||||
@@ -623,6 +654,12 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
|
||||
if (!list || list.length === 0) return EMPTY_SESSION_IDS_KEY;
|
||||
return list.map((ss) => ss.id).join(",");
|
||||
});
|
||||
const workflowFocusRequestId = useAppStore((s) => {
|
||||
const request = s.workflowSessionFocus.request;
|
||||
return request?.taskId === s.tasks.activeTaskId && request.sessionId === effectiveSessionId
|
||||
? request.requestId
|
||||
: null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
runAutoSessionTabEffect(effectiveSessionId, appStore, {
|
||||
@@ -630,5 +667,5 @@ export function useAutoSessionTab(effectiveSessionId: string | null) {
|
||||
prevTaskIdRef,
|
||||
prevSessionIdRef,
|
||||
});
|
||||
}, [appStore, dockviewApi, effectiveSessionId, sessionIdsKey]);
|
||||
}, [appStore, dockviewApi, effectiveSessionId, sessionIdsKey, workflowFocusRequestId]);
|
||||
}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconArrowLeft } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { DrawerHeader, DrawerTitle } from "@kandev/ui/drawer";
|
||||
import { SheetHeader, SheetTitle } from "@kandev/ui/sheet";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { usePresentationToken } from "@/hooks/domains/kanban/use-workflow-step-move";
|
||||
import { useWorkflowMove } from "@/hooks/domains/kanban/use-workflow-move";
|
||||
import type { WorkflowMoveResponse } from "@/lib/api/domains/kanban-api";
|
||||
import { useTaskMoveOptions, type TaskMoveStep } from "../task-move-context-menu";
|
||||
import { WorkflowMoveOptionsForm, type WorkflowMoveOptionsSubmit } from "../workflow-move-options";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import { getTaskMoveErrorMessage } from "../task-move-error-message";
|
||||
|
||||
export type MobileMoveOptionsRequest = {
|
||||
taskId: string;
|
||||
@@ -15,18 +21,172 @@ export type MobileMoveOptionsRequest = {
|
||||
targetStep: TaskMoveStep;
|
||||
};
|
||||
|
||||
type MobileFocusRequest = {
|
||||
requestId: number;
|
||||
taskId: string;
|
||||
presentationToken: number;
|
||||
};
|
||||
|
||||
function useMobileWorkflowSessionFocus(activeTaskId: string | null) {
|
||||
const presentationToken = usePresentationToken(activeTaskId);
|
||||
const navigationRevision = useAppStore((state) => state.taskRemoval.navigationRevision);
|
||||
const beginWorkflowSessionFocus = useAppStore((state) => state.beginWorkflowSessionFocus);
|
||||
const bindWorkflowSessionFocus = useAppStore((state) => state.bindWorkflowSessionFocus);
|
||||
const reconcileWorkflowSessionFocus = useAppStore((state) => state.reconcileWorkflowSessionFocus);
|
||||
const cancelWorkflowSessionFocus = useAppStore((state) => state.cancelWorkflowSessionFocus);
|
||||
const focusRequestRef = useRef<MobileFocusRequest | null>(null);
|
||||
|
||||
const beginFocus = useCallback(
|
||||
(taskId: string, workflowId: string, destinationStepId: string) => {
|
||||
if (taskId !== activeTaskId) return null;
|
||||
const requestId = beginWorkflowSessionFocus({
|
||||
taskId,
|
||||
workflowId,
|
||||
destinationStepId,
|
||||
presentationToken,
|
||||
navigationRevision,
|
||||
});
|
||||
if (requestId === null) return null;
|
||||
focusRequestRef.current = { requestId, taskId, presentationToken };
|
||||
return requestId;
|
||||
},
|
||||
[activeTaskId, beginWorkflowSessionFocus, navigationRevision, presentationToken],
|
||||
);
|
||||
const cancelFocus = useCallback(
|
||||
(requestId: number) => {
|
||||
const focusRequest = focusRequestRef.current;
|
||||
if (!focusRequest || focusRequest.requestId !== requestId) return;
|
||||
cancelWorkflowSessionFocus({
|
||||
requestId: focusRequest.requestId,
|
||||
presentationToken: focusRequest.presentationToken,
|
||||
});
|
||||
focusRequestRef.current = null;
|
||||
},
|
||||
[cancelWorkflowSessionFocus],
|
||||
);
|
||||
const clearFocus = useCallback((requestId: number) => {
|
||||
if (focusRequestRef.current?.requestId === requestId) focusRequestRef.current = null;
|
||||
}, []);
|
||||
const cancelCurrentFocus = useCallback(() => {
|
||||
const focusRequest = focusRequestRef.current;
|
||||
if (!focusRequest) return;
|
||||
cancelWorkflowSessionFocus({
|
||||
requestId: focusRequest.requestId,
|
||||
presentationToken: focusRequest.presentationToken,
|
||||
});
|
||||
focusRequestRef.current = null;
|
||||
}, [cancelWorkflowSessionFocus]);
|
||||
const handleMoveCommitted = useCallback(
|
||||
(response: WorkflowMoveResponse, expectedRequestId?: number) => {
|
||||
const focusRequest = focusRequestRef.current;
|
||||
if (!focusRequest) return;
|
||||
if (expectedRequestId !== undefined && focusRequest.requestId !== expectedRequestId) return;
|
||||
if (!response.workflow_entry_identity) {
|
||||
cancelFocus(focusRequest.requestId);
|
||||
return;
|
||||
}
|
||||
bindWorkflowSessionFocus({
|
||||
requestId: focusRequest.requestId,
|
||||
presentationToken: focusRequest.presentationToken,
|
||||
entryIdentity: response.workflow_entry_identity,
|
||||
});
|
||||
reconcileWorkflowSessionFocus(focusRequest.taskId, {
|
||||
metadata: response.task?.metadata,
|
||||
updatedAt: response.task?.updated_at,
|
||||
workflowStepId: response.task?.workflow_step_id,
|
||||
entryIdentity: response.workflow_entry_identity,
|
||||
});
|
||||
},
|
||||
[bindWorkflowSessionFocus, cancelFocus, reconcileWorkflowSessionFocus],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const focusRequest = focusRequestRef.current;
|
||||
if (!focusRequest) return;
|
||||
cancelWorkflowSessionFocus({
|
||||
requestId: focusRequest.requestId,
|
||||
presentationToken: focusRequest.presentationToken,
|
||||
});
|
||||
},
|
||||
[cancelWorkflowSessionFocus],
|
||||
);
|
||||
|
||||
return {
|
||||
beginFocus,
|
||||
cancelFocus,
|
||||
cancelCurrentFocus,
|
||||
clearFocus,
|
||||
handleMoveCommitted,
|
||||
navigationRevision,
|
||||
presentationToken,
|
||||
};
|
||||
}
|
||||
|
||||
function useMobileDirectTaskMove({
|
||||
stepsByWorkflowId,
|
||||
focus,
|
||||
}: {
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
focus: ReturnType<typeof useMobileWorkflowSessionFocus>;
|
||||
}) {
|
||||
const { move, isMoving } = useWorkflowMove();
|
||||
const { toast } = useToast();
|
||||
const { t } = useTranslation("task");
|
||||
const handleMove = useCallback(
|
||||
(taskId: string, workflowId: string, targetStepId: string) => {
|
||||
const targetStep = stepsByWorkflowId[workflowId]?.find((step) => step.id === targetStepId);
|
||||
if (!targetStep) return;
|
||||
const focusRequestId = focus.beginFocus(taskId, workflowId, targetStepId);
|
||||
void move(taskId, {
|
||||
workflow_id: workflowId,
|
||||
workflow_step_id: targetStepId,
|
||||
position: 0,
|
||||
}).then((result) => {
|
||||
if (result.disposition === "failed") {
|
||||
if (focusRequestId !== null) focus.cancelFocus(focusRequestId);
|
||||
const fallback = t("taskMoveErrorGeneric");
|
||||
toast({
|
||||
title: t("failedToMoveTask"),
|
||||
description: getTaskMoveErrorMessage(result.error, fallback, t),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (focusRequestId !== null) {
|
||||
focus.handleMoveCommitted(result.response, focusRequestId);
|
||||
focus.clearFocus(focusRequestId);
|
||||
}
|
||||
toast({
|
||||
title: t("movedTasksToStep", { count: 1 }),
|
||||
description: t("movedTasksStepDescription", { count: 1 }),
|
||||
variant: "success",
|
||||
});
|
||||
});
|
||||
},
|
||||
[focus, move, stepsByWorkflowId, t, toast],
|
||||
);
|
||||
|
||||
return { handleMove, isMoving };
|
||||
}
|
||||
|
||||
export function useMobileTaskMoveOptions({
|
||||
open,
|
||||
activeTaskId,
|
||||
stepsByWorkflowId,
|
||||
}: {
|
||||
open: boolean;
|
||||
activeTaskId: string | null;
|
||||
stepsByWorkflowId: Record<string, TaskMoveStep[]>;
|
||||
}) {
|
||||
const [request, setRequest] = useState<MobileMoveOptionsRequest | null>(null);
|
||||
const focus = useMobileWorkflowSessionFocus(activeTaskId);
|
||||
const directMove = useMobileDirectTaskMove({ stepsByWorkflowId, focus });
|
||||
const moveOptions = useTaskMoveOptions({
|
||||
taskId: request?.taskId ?? "",
|
||||
workflowId: request?.workflowId,
|
||||
steps: request ? [request.targetStep] : undefined,
|
||||
onMoveCommitted: focus.handleMoveCommitted,
|
||||
});
|
||||
const handleRequest = useCallback(
|
||||
(taskId: string, workflowId: string, targetStepId: string) => {
|
||||
@@ -44,24 +204,31 @@ export function useMobileTaskMoveOptions({
|
||||
}, [moveOptions.closeMoveOptions]);
|
||||
const handleSubmit = useCallback(
|
||||
async (entryOptions: Parameters<WorkflowMoveOptionsSubmit>[0]) => {
|
||||
const focusRequestId = request?.workflowId
|
||||
? focus.beginFocus(request.taskId, request.workflowId, request.targetStep.id)
|
||||
: null;
|
||||
const moved = await moveOptions.submitMoveOptions(entryOptions);
|
||||
if (!moved && focusRequestId !== null) focus.cancelFocus(focusRequestId);
|
||||
if (moved && focusRequestId !== null) focus.clearFocus(focusRequestId);
|
||||
if (moved) setRequest(null);
|
||||
return moved;
|
||||
},
|
||||
[moveOptions.submitMoveOptions],
|
||||
[focus, moveOptions.submitMoveOptions, request],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (open || !request) return;
|
||||
moveOptions.closeMoveOptions();
|
||||
setRequest(null);
|
||||
}, [moveOptions.closeMoveOptions, open, request]);
|
||||
focus.cancelCurrentFocus();
|
||||
}, [focus.cancelCurrentFocus, moveOptions.closeMoveOptions, open, request]);
|
||||
|
||||
return {
|
||||
request,
|
||||
moveOptionsStep: moveOptions.moveOptionsStep,
|
||||
isMoving: moveOptions.isMoving,
|
||||
isMoving: moveOptions.isMoving || directMove.isMoving,
|
||||
handleRequest,
|
||||
handleMove: directMove.handleMove,
|
||||
handleClose,
|
||||
handleSubmit,
|
||||
};
|
||||
|
||||
@@ -664,6 +664,20 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
|
||||
handleOpenFile,
|
||||
handlePanelChangeAndClearSheet,
|
||||
} = useMobilePanelHandlers({ effectiveSessionId, handlePanelChange });
|
||||
const workflowFocusRequest = useAppStore((state) => {
|
||||
const request = state.workflowSessionFocus.request;
|
||||
return request && request.taskId === activeTaskId && request.sessionId === effectiveSessionId
|
||||
? request
|
||||
: null;
|
||||
});
|
||||
const acknowledgeWorkflowSessionFocus = useAppStore(
|
||||
(state) => state.acknowledgeWorkflowSessionFocus,
|
||||
);
|
||||
useEffect(() => {
|
||||
if (!workflowFocusRequest) return;
|
||||
handlePanelChangeAndClearSheet("chat");
|
||||
acknowledgeWorkflowSessionFocus(workflowFocusRequest.requestId);
|
||||
}, [acknowledgeWorkflowSessionFocus, handlePanelChangeAndClearSheet, workflowFocusRequest]);
|
||||
const [mobileScrollTarget, setMobileScrollTarget] = useState<PendingMessageScrollTarget | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
@@ -25,6 +25,7 @@ export function buildMobileTaskSwitcherProps(
|
||||
stepsByWorkflowId: props.stepsByWorkflowId,
|
||||
activeTaskId: props.activeTaskId,
|
||||
selectedTaskId: props.selectedTaskId,
|
||||
onMoveToStep: props.onMoveToStep,
|
||||
onRequestMoveOptions: props.onRequestMoveOptions,
|
||||
onBeforeMoveOptionsOpen: props.onBeforeMoveOptionsOpen,
|
||||
collapsedGroupKeys: helpers.collapsedGroupKeys,
|
||||
|
||||
@@ -97,6 +97,7 @@ export type MobileTaskListProps = {
|
||||
activeTaskId: string | null;
|
||||
selectedTaskId: string | null;
|
||||
onSelectTask: (taskId: string) => void;
|
||||
onMoveToStep?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onRequestMoveOptions?: (taskId: string, workflowId: string, targetStepId: string) => void;
|
||||
onBeforeMoveOptionsOpen?: () => void;
|
||||
onEditTask?: (task: TaskSwitcherItem) => void;
|
||||
@@ -341,6 +342,7 @@ function TaskSwitcherSurfaceContent({
|
||||
: data.retryArchivedTasks;
|
||||
const moveOptions = useMobileTaskMoveOptions({
|
||||
open,
|
||||
activeTaskId: data.activeTaskId,
|
||||
stepsByWorkflowId: data.stepsByWorkflowId,
|
||||
});
|
||||
if (moveOptions.moveOptionsStep && moveOptions.request) {
|
||||
@@ -392,6 +394,7 @@ function TaskSwitcherSurfaceContent({
|
||||
activeTaskId={data.activeTaskId}
|
||||
selectedTaskId={data.selectedTaskId}
|
||||
onSelectTask={actions.handleSelectTask}
|
||||
onMoveToStep={presentation === "drawer" ? moveOptions.handleMove : undefined}
|
||||
onRequestMoveOptions={presentation === "drawer" ? moveOptions.handleRequest : undefined}
|
||||
onBeforeMoveOptionsOpen={
|
||||
presentation === "drawer" ? () => onOpenChange(false) : undefined
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
type WorkflowMoveOptionsSubmit,
|
||||
} from "./workflow-move-options";
|
||||
import { useTouchDrawer } from "@/hooks/use-compact-task-chrome";
|
||||
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
|
||||
import type { WorkflowMoveEntryOptions, WorkflowMoveResponse } from "@/lib/api/domains/kanban-api";
|
||||
import type { WorkflowStepProgress } from "@/hooks/domains/kanban/use-workflow-step-progress";
|
||||
import { StepProgressDetails } from "./workflow-step-progress-details";
|
||||
|
||||
@@ -45,11 +45,13 @@ export function useTaskMoveOptions({
|
||||
workflowId,
|
||||
steps,
|
||||
closeMenu,
|
||||
onMoveCommitted,
|
||||
}: {
|
||||
taskId: string;
|
||||
workflowId?: string | null;
|
||||
steps?: TaskMoveStep[];
|
||||
closeMenu?: () => void;
|
||||
onMoveCommitted?: (response: WorkflowMoveResponse) => void;
|
||||
}) {
|
||||
const [moveOptionsStep, setMoveOptionsStep] = useState<TaskMoveStep | null>(null);
|
||||
const { move, isMoving } = useWorkflowMove();
|
||||
@@ -87,6 +89,7 @@ export function useTaskMoveOptions({
|
||||
});
|
||||
return false;
|
||||
}
|
||||
onMoveCommitted?.(result.response);
|
||||
closeMenu?.();
|
||||
return true;
|
||||
};
|
||||
|
||||
@@ -13,6 +13,11 @@ const { appStoreState, moveTaskMock, previewWorkflowMoveMock } = vi.hoisted(() =
|
||||
workspaceContextGeneration: 1,
|
||||
workflows: { items: [], activeId: null },
|
||||
tasks: { activeSessionId: null },
|
||||
taskRemoval: { navigationRevision: 0 },
|
||||
beginWorkflowSessionFocus: vi.fn(() => 1),
|
||||
bindWorkflowSessionFocus: vi.fn(),
|
||||
reconcileWorkflowSessionFocus: vi.fn(),
|
||||
cancelWorkflowSessionFocus: vi.fn(),
|
||||
chatInput: { planModeBySessionId: {} },
|
||||
kanban: {
|
||||
tasks: [
|
||||
|
||||
@@ -12,6 +12,11 @@ const { moveTaskMock, previewWorkflowMoveMock, appStoreState } = vi.hoisted(() =
|
||||
workspaceContextGeneration: 1,
|
||||
workflows: { items: [], activeId: null },
|
||||
tasks: { activeSessionId: null },
|
||||
taskRemoval: { navigationRevision: 0 },
|
||||
beginWorkflowSessionFocus: vi.fn(() => 1),
|
||||
bindWorkflowSessionFocus: vi.fn(),
|
||||
reconcileWorkflowSessionFocus: vi.fn(),
|
||||
cancelWorkflowSessionFocus: vi.fn(),
|
||||
chatInput: { planModeBySessionId: {} },
|
||||
kanban: { tasks: [] },
|
||||
kanbanMulti: { snapshots: {} },
|
||||
@@ -600,7 +605,7 @@ describe("WorkflowStepper fallback states", () => {
|
||||
// request must not paint a banner describing a move nobody is waiting on.
|
||||
let rejectFirst!: (error: unknown) => void;
|
||||
moveTaskMock.mockReturnValueOnce(new Promise((_res, rej) => (rejectFirst = rej)));
|
||||
moveTaskMock.mockResolvedValueOnce(undefined);
|
||||
moveTaskMock.mockResolvedValueOnce({});
|
||||
const onMoveError = vi.fn();
|
||||
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
const props = {
|
||||
@@ -626,7 +631,7 @@ describe("WorkflowStepper fallback states", () => {
|
||||
|
||||
it("notifies the owning surface when a move starts", () => {
|
||||
const onMoveStart = vi.fn();
|
||||
moveTaskMock.mockResolvedValueOnce(undefined);
|
||||
moveTaskMock.mockResolvedValueOnce({});
|
||||
const props = {
|
||||
steps: STEPS,
|
||||
currentStepId: "b",
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import {
|
||||
createWorkflowSessionFocusScenario,
|
||||
waitForNewWorkflowSession,
|
||||
} from "./workflow-session-focus-helpers";
|
||||
|
||||
test.describe("Mobile workflow session focus", () => {
|
||||
test("shows the new committed recipient in chat without opening the keyboard", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "new");
|
||||
await testPage.goto(`/t/${scenario.task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
const moveResponse = testPage.waitForResponse(
|
||||
(response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === `/api/v1/tasks/${scenario.task.id}/move`,
|
||||
);
|
||||
await testPage.getByTestId("mobile-session-menu").tap();
|
||||
const sheet = testPage.getByRole("dialog", { name: "Tasks" });
|
||||
const taskRow = sheet.locator(`[data-task-row-id="${scenario.task.id}"]`);
|
||||
await expect(taskRow).toBeVisible();
|
||||
await taskRow.getByRole("button", { name: "Task actions" }).tap();
|
||||
await testPage.getByTestId("task-context-move-to").tap();
|
||||
await testPage.getByTestId(`task-context-step-${scenario.destination.id}`).tap();
|
||||
const response = await moveResponse;
|
||||
expect(response.ok()).toBe(true);
|
||||
expect((await response.json()).workflow_entry_identity).toMatch(/^entry:/);
|
||||
|
||||
const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [
|
||||
scenario.sourceSessionId,
|
||||
scenario.secondarySessionId,
|
||||
]);
|
||||
await expect(session.activeChat()).toHaveAttribute("data-session-id", destinationSessionId);
|
||||
await expect(testPage.getByTestId("mobile-sessions-pill")).toBeVisible();
|
||||
await expect(testPage.getByTestId("workflow-step-disclosure")).not.toBeVisible();
|
||||
|
||||
const focusedEditable = await testPage.evaluate(() => {
|
||||
const active = document.activeElement;
|
||||
return active instanceof HTMLElement && active.matches("input, textarea, [contenteditable]");
|
||||
});
|
||||
expect(focusedEditable).toBe(false);
|
||||
|
||||
const documentWidth = await testPage.evaluate(() => ({
|
||||
clientWidth: document.documentElement.clientWidth,
|
||||
scrollWidth: document.documentElement.scrollWidth,
|
||||
}));
|
||||
expect(documentWidth.scrollWidth).toBeLessThanOrEqual(documentWidth.clientWidth + 1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,114 @@
|
||||
import { expect, type SeedData } from "../../fixtures/test-base";
|
||||
import type { ApiClient } from "../../helpers/api-client";
|
||||
import {
|
||||
createWorkflowAgentProfiles,
|
||||
waitForWorkflowProfileSession,
|
||||
} from "./workflow-agent-switch-helpers";
|
||||
|
||||
export type WorkflowSessionFocusScenario = {
|
||||
workflow: { id: string };
|
||||
source: { id: string; name: string };
|
||||
destination: { id: string; name: string };
|
||||
task: { id: string };
|
||||
sourceSessionId: string;
|
||||
secondarySessionId: string;
|
||||
};
|
||||
|
||||
function destinationProfileOptions(target: "new" | "initial" | "profile", profileBId: string) {
|
||||
if (target === "new") {
|
||||
return {
|
||||
session_target: { kind: "initial" as const },
|
||||
profile_session_start_policy: "new" as const,
|
||||
};
|
||||
}
|
||||
if (target === "initial") return { session_target: { kind: "initial" as const } };
|
||||
return { agent_profile_id: profileBId, profile_session_start_policy: "new" as const };
|
||||
}
|
||||
|
||||
export async function createWorkflowSessionFocusScenario(
|
||||
apiClient: ApiClient,
|
||||
seedData: SeedData,
|
||||
target: "new" | "initial" | "profile",
|
||||
): Promise<WorkflowSessionFocusScenario> {
|
||||
const { profileA, profileB } = await createWorkflowAgentProfiles(apiClient);
|
||||
const workflow = await apiClient.createWorkflow(
|
||||
seedData.workspaceId,
|
||||
`Workflow session focus ${target}`,
|
||||
);
|
||||
const source = await apiClient.createWorkflowStep(workflow.id, "Source", 0, {
|
||||
is_start_step: true,
|
||||
});
|
||||
const destination = await apiClient.createWorkflowStep(workflow.id, "Destination", 1);
|
||||
|
||||
await apiClient.updateWorkflowStep(source.id, {
|
||||
agent_profile_id: profileA.id,
|
||||
profile_session_end_policy: "park",
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
});
|
||||
await apiClient.updateWorkflowStep(destination.id, {
|
||||
...destinationProfileOptions(target, profileB.id),
|
||||
prompt: `e2e:message("workflow-focus-${target}")`,
|
||||
profile_session_end_policy: "park",
|
||||
events: { on_enter: [{ type: "auto_start_agent" }] },
|
||||
});
|
||||
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
`Workflow session focus ${target} task`,
|
||||
profileA.id,
|
||||
{
|
||||
workflow_id: workflow.id,
|
||||
workflow_step_id: source.id,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
const sourceSessionId = await waitForWorkflowProfileSession(apiClient, task.id, profileA.id);
|
||||
|
||||
const secondary = await apiClient.launchSession({
|
||||
task_id: task.id,
|
||||
agent_profile_id: profileA.id,
|
||||
executor_profile_id: seedData.worktreeExecutorProfileId,
|
||||
workflow_step_id: source.id,
|
||||
prompt: 'e2e:message("workflow-focus-secondary")',
|
||||
});
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(task.id);
|
||||
return sessions.find((session) => session.id === secondary.session_id)?.state;
|
||||
},
|
||||
{ timeout: 30_000, message: "secondary focus session never became visible" },
|
||||
)
|
||||
.not.toBe("CREATED");
|
||||
await apiClient.setPrimarySession(sourceSessionId);
|
||||
|
||||
return {
|
||||
workflow,
|
||||
source: { ...source, name: "Source" },
|
||||
destination: { ...destination, name: "Destination" },
|
||||
task,
|
||||
sourceSessionId,
|
||||
secondarySessionId: secondary.session_id,
|
||||
};
|
||||
}
|
||||
|
||||
export async function waitForNewWorkflowSession(
|
||||
apiClient: ApiClient,
|
||||
taskId: string,
|
||||
excludedSessionIds: readonly string[],
|
||||
): Promise<string> {
|
||||
let sessionId = "";
|
||||
await expect
|
||||
.poll(
|
||||
async () => {
|
||||
const { sessions } = await apiClient.listTaskSessions(taskId);
|
||||
sessionId =
|
||||
sessions.find((session) => !excludedSessionIds.includes(session.id) && session.is_primary)
|
||||
?.id ?? "";
|
||||
return sessionId;
|
||||
},
|
||||
{ timeout: 30_000, message: "workflow move did not create its destination session" },
|
||||
)
|
||||
.not.toBe("");
|
||||
return sessionId;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import { test, expect } from "../../fixtures/test-base";
|
||||
import { SessionPage } from "../../pages/session-page";
|
||||
import { KanbanPage } from "../../pages/kanban-page";
|
||||
import { waitForHttp } from "../../helpers/causal-waits";
|
||||
import { waitForFiniteAnimations } from "../../helpers/animations";
|
||||
import {
|
||||
createWorkflowSessionFocusScenario,
|
||||
waitForNewWorkflowSession,
|
||||
} from "./workflow-session-focus-helpers";
|
||||
|
||||
test.describe("Workflow session focus", () => {
|
||||
test("focuses a new committed recipient on the task detail page", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "new");
|
||||
await testPage.goto(`/t/${scenario.task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await session.sessionTabBySessionId(scenario.secondarySessionId).click();
|
||||
await expect(session.activeChat()).toHaveAttribute(
|
||||
"data-session-id",
|
||||
scenario.secondarySessionId,
|
||||
);
|
||||
|
||||
const moveResponse = waitForHttp(
|
||||
testPage,
|
||||
"POST",
|
||||
new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`),
|
||||
);
|
||||
await session.moveToWorkflowStep(scenario.destination);
|
||||
const response = await moveResponse;
|
||||
expect(response.ok()).toBe(true);
|
||||
expect((await response.json()).workflow_entry_identity).toMatch(/^entry:/);
|
||||
|
||||
const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [
|
||||
scenario.sourceSessionId,
|
||||
scenario.secondarySessionId,
|
||||
]);
|
||||
await expect(session.sessionTabBySessionId(destinationSessionId)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(session.activeChat()).toHaveAttribute("data-session-id", destinationSessionId);
|
||||
await expect(session.sessionTabBySessionId(destinationSessionId)).toBeVisible();
|
||||
});
|
||||
|
||||
test("focuses a reused recipient in the desktop task preview", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "initial");
|
||||
await apiClient.saveUserSettings({
|
||||
enable_preview_on_click: true,
|
||||
workflow_filter_id: scenario.workflow.id,
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
const card = kanban.taskCardByTitle(`Workflow session focus initial task`);
|
||||
await expect(card).toBeVisible({ timeout: 15_000 });
|
||||
await card.click();
|
||||
|
||||
const preview = testPage.getByTestId("task-preview-panel");
|
||||
await expect(preview).toBeVisible({ timeout: 15_000 });
|
||||
const secondaryTab = preview.getByTestId(`preview-session-tab-${scenario.secondarySessionId}`);
|
||||
await expect(secondaryTab).toBeVisible({ timeout: 15_000 });
|
||||
await secondaryTab.click();
|
||||
await expect(secondaryTab).toHaveAttribute("data-state", "active");
|
||||
|
||||
const moveResponse = waitForHttp(
|
||||
testPage,
|
||||
"POST",
|
||||
new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`),
|
||||
);
|
||||
await preview.getByTestId("workflow-stepper-minimal").hover();
|
||||
const disclosure = testPage.getByTestId("workflow-step-disclosure");
|
||||
await expect(disclosure).toBeVisible();
|
||||
await waitForFiniteAnimations(testPage.locator('[data-slot="popover-content"]:visible'));
|
||||
const destinationRow = disclosure.getByTestId(
|
||||
`workflow-step-disclosure-row-${scenario.destination.id}`,
|
||||
);
|
||||
await expect(destinationRow).toBeVisible();
|
||||
const moveButton = destinationRow.getByTestId(
|
||||
`workflow-step-disclosure-move-${scenario.destination.id}`,
|
||||
);
|
||||
await expect(moveButton).toBeVisible();
|
||||
await moveButton.click();
|
||||
const response = await moveResponse;
|
||||
const movePayload = await response.json();
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(movePayload.workflow_entry_identity).toMatch(/^entry:/);
|
||||
|
||||
await expect
|
||||
.poll(async () => (await apiClient.getTask(scenario.task.id)).workflow_step_id)
|
||||
.toBe(scenario.destination.id);
|
||||
await expect
|
||||
.poll(
|
||||
async () =>
|
||||
(await apiClient.getTask(scenario.task.id)).metadata?.workflow_session_route?.phase,
|
||||
)
|
||||
.toBe("committed");
|
||||
const sourceTab = preview.getByTestId(`preview-session-tab-${scenario.sourceSessionId}`);
|
||||
await expect(sourceTab).toHaveAttribute("data-state", "active");
|
||||
await expect(secondaryTab).toHaveAttribute("data-state", "inactive");
|
||||
});
|
||||
|
||||
test("keeps a later explicit session choice during a delayed move response", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "new");
|
||||
await testPage.goto(`/t/${scenario.task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
await expect(session.activeChat()).toHaveAttribute("data-session-id", scenario.sourceSessionId);
|
||||
|
||||
let releaseResponse!: () => void;
|
||||
let responseFetched!: () => void;
|
||||
const responseReleased = new Promise<void>((resolve) => {
|
||||
releaseResponse = resolve;
|
||||
});
|
||||
const backendMoveCommitted = new Promise<void>((resolve) => {
|
||||
responseFetched = resolve;
|
||||
});
|
||||
await testPage.route(`**/api/v1/tasks/${scenario.task.id}/move`, async (route) => {
|
||||
const response = await route.fetch();
|
||||
responseFetched();
|
||||
await responseReleased;
|
||||
await route.fulfill({ response });
|
||||
});
|
||||
|
||||
try {
|
||||
const moveResponse = waitForHttp(
|
||||
testPage,
|
||||
"POST",
|
||||
new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`),
|
||||
);
|
||||
await session.moveToWorkflowStep(scenario.destination);
|
||||
await backendMoveCommitted;
|
||||
|
||||
// The destination may be arriving over WS while the HTTP response is
|
||||
// still held. This click is the user's later, explicit choice.
|
||||
await session.sessionTabBySessionId(scenario.secondarySessionId).click();
|
||||
releaseResponse();
|
||||
const response = await moveResponse;
|
||||
expect(response.ok()).toBe(true);
|
||||
|
||||
const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [
|
||||
scenario.sourceSessionId,
|
||||
scenario.secondarySessionId,
|
||||
]);
|
||||
await expect(session.sessionTabBySessionId(destinationSessionId)).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(session.activeChat()).toHaveAttribute(
|
||||
"data-session-id",
|
||||
scenario.secondarySessionId,
|
||||
);
|
||||
} finally {
|
||||
releaseResponse();
|
||||
await testPage.unroute(`**/api/v1/tasks/${scenario.task.id}/move`);
|
||||
}
|
||||
});
|
||||
|
||||
test("focuses a profile-only recipient instead of the pinned source session", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(120_000);
|
||||
const scenario = await createWorkflowSessionFocusScenario(apiClient, seedData, "profile");
|
||||
await testPage.goto(`/t/${scenario.task.id}`);
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await session.sessionTabBySessionId(scenario.secondarySessionId).click();
|
||||
await expect(session.activeChat()).toHaveAttribute(
|
||||
"data-session-id",
|
||||
scenario.secondarySessionId,
|
||||
);
|
||||
|
||||
const moveResponse = waitForHttp(
|
||||
testPage,
|
||||
"POST",
|
||||
new RegExp(`/api/v1/tasks/${scenario.task.id}/move$`),
|
||||
);
|
||||
await session.moveToWorkflowStep(scenario.destination);
|
||||
const response = await moveResponse;
|
||||
const payload = await response.json();
|
||||
expect(response.ok()).toBe(true);
|
||||
expect(payload.workflow_entry_identity).toMatch(/^entry:/);
|
||||
|
||||
const destinationSessionId = await waitForNewWorkflowSession(apiClient, scenario.task.id, [
|
||||
scenario.sourceSessionId,
|
||||
scenario.secondarySessionId,
|
||||
]);
|
||||
await expect(session.activeChat()).toHaveAttribute("data-session-id", destinationSessionId);
|
||||
await expect(
|
||||
apiClient.getTask(scenario.task.id).then((task) => task.metadata?.workflow_session_route),
|
||||
).resolves.toMatchObject({
|
||||
phase: "committed",
|
||||
destination_step_id: scenario.destination.id,
|
||||
destination_session_id: destinationSessionId,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -6,9 +6,14 @@ import { usePresentationToken, useWorkflowStepMove } from "./use-workflow-step-m
|
||||
const mockAppState = vi.hoisted(() => ({
|
||||
value: {
|
||||
tasks: { activeSessionId: null as string | null },
|
||||
taskRemoval: { navigationRevision: 4 },
|
||||
chatInput: { planModeBySessionId: {} as Record<string, boolean> },
|
||||
setPlanMode: vi.fn(),
|
||||
setActiveDocument: vi.fn(),
|
||||
beginWorkflowSessionFocus: vi.fn(() => 1),
|
||||
bindWorkflowSessionFocus: vi.fn(),
|
||||
reconcileWorkflowSessionFocus: vi.fn(),
|
||||
cancelWorkflowSessionFocus: vi.fn(),
|
||||
},
|
||||
}));
|
||||
const mockLayoutStore = vi.hoisted(() => ({ closeDocument: vi.fn() }));
|
||||
@@ -37,11 +42,13 @@ vi.mock("@/lib/api", () => ({
|
||||
|
||||
const TASK_ID = "task-1";
|
||||
const WORKFLOW_ID = "workflow-1";
|
||||
const ENTRY_IDENTITY = "entry:00000000000000000042";
|
||||
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockAppState.value.tasks.activeSessionId = null;
|
||||
mockAppState.value.chatInput.planModeBySessionId = {};
|
||||
mockAppState.value.taskRemoval.navigationRevision = 4;
|
||||
});
|
||||
|
||||
describe("usePresentationToken", () => {
|
||||
@@ -70,6 +77,7 @@ describe("usePresentationToken", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- move races share one hook fixture
|
||||
describe("useWorkflowStepMove", () => {
|
||||
it("issues the move request with the task's workflow id, target step, and position 0", async () => {
|
||||
vi.mocked(moveTask).mockResolvedValue({} as Awaited<ReturnType<typeof moveTask>>);
|
||||
@@ -88,6 +96,102 @@ describe("useWorkflowStepMove", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("binds the committed response identity and reconciles its task route", async () => {
|
||||
const task = {
|
||||
metadata: {
|
||||
workflow_session_route: {
|
||||
operation_id: "operation-1",
|
||||
destination_step_id: "step-b",
|
||||
entry_identity: ENTRY_IDENTITY,
|
||||
destination_session_id: "session-b",
|
||||
phase: "committed",
|
||||
},
|
||||
},
|
||||
};
|
||||
vi.mocked(moveTask).mockResolvedValue({
|
||||
workflow_entry_identity: ENTRY_IDENTITY,
|
||||
task,
|
||||
} as unknown as Awaited<ReturnType<typeof moveTask>>);
|
||||
const { result } = renderHook(() =>
|
||||
useWorkflowStepMove({
|
||||
taskId: TASK_ID,
|
||||
workflowId: WORKFLOW_ID,
|
||||
presentationToken: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleMove("step-b");
|
||||
});
|
||||
|
||||
expect(mockAppState.value.beginWorkflowSessionFocus).toHaveBeenCalledWith({
|
||||
taskId: TASK_ID,
|
||||
workflowId: WORKFLOW_ID,
|
||||
destinationStepId: "step-b",
|
||||
presentationToken: 0,
|
||||
navigationRevision: 4,
|
||||
});
|
||||
expect(mockAppState.value.bindWorkflowSessionFocus).toHaveBeenCalledWith({
|
||||
requestId: 1,
|
||||
presentationToken: 0,
|
||||
entryIdentity: ENTRY_IDENTITY,
|
||||
});
|
||||
expect(mockAppState.value.reconcileWorkflowSessionFocus).toHaveBeenCalledWith(TASK_ID, {
|
||||
metadata: task.metadata,
|
||||
updatedAt: undefined,
|
||||
workflowStepId: undefined,
|
||||
entryIdentity: ENTRY_IDENTITY,
|
||||
});
|
||||
});
|
||||
|
||||
it("cancels the local focus intent when the move response has no committed identity", async () => {
|
||||
vi.mocked(moveTask).mockResolvedValue({} as Awaited<ReturnType<typeof moveTask>>);
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWorkflowStepMove({
|
||||
taskId: TASK_ID,
|
||||
workflowId: WORKFLOW_ID,
|
||||
presentationToken: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
await result.current.handleMove("step-b");
|
||||
});
|
||||
|
||||
expect(mockAppState.value.cancelWorkflowSessionFocus).toHaveBeenCalledWith({
|
||||
requestId: 1,
|
||||
presentationToken: 0,
|
||||
});
|
||||
vi.clearAllMocks();
|
||||
unmount();
|
||||
expect(mockAppState.value.cancelWorkflowSessionFocus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("cancels only the active request when the move hook unmounts", async () => {
|
||||
let resolveMove!: () => void;
|
||||
vi.mocked(moveTask).mockReturnValueOnce(
|
||||
new Promise<Awaited<ReturnType<typeof moveTask>>>((resolve) => {
|
||||
resolveMove = () => resolve({} as Awaited<ReturnType<typeof moveTask>>);
|
||||
}),
|
||||
);
|
||||
const { result, unmount } = renderHook(() =>
|
||||
useWorkflowStepMove({
|
||||
taskId: TASK_ID,
|
||||
workflowId: WORKFLOW_ID,
|
||||
presentationToken: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
act(() => {
|
||||
void result.current.handleMove("step-b");
|
||||
});
|
||||
unmount();
|
||||
|
||||
expect(mockAppState.value.cancelWorkflowSessionFocus).toHaveBeenCalledWith({ requestId: 1 });
|
||||
resolveMove();
|
||||
await act(async () => {});
|
||||
});
|
||||
|
||||
it("notifies onMoveStart and disables plan mode before the request resolves", async () => {
|
||||
mockAppState.value.tasks.activeSessionId = "session-1";
|
||||
mockAppState.value.chatInput.planModeBySessionId = { "session-1": true };
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { moveTask } from "@/lib/api";
|
||||
import type { WorkflowMoveEntryOptions } from "@/lib/api/domains/kanban-api";
|
||||
import type { WorkflowMoveEntryOptions, WorkflowMoveResponse } from "@/lib/api/domains/kanban-api";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import { useContextFilesStore } from "@/lib/state/context-files-store";
|
||||
import { useLayoutStore } from "@/lib/state/layout-store";
|
||||
@@ -101,6 +101,89 @@ function shouldClearProgressMove(
|
||||
);
|
||||
}
|
||||
|
||||
function useWorkflowStepMoveFocus({
|
||||
taskId,
|
||||
workflowId,
|
||||
presentationToken,
|
||||
}: Pick<UseWorkflowStepMoveParams, "taskId" | "workflowId" | "presentationToken">) {
|
||||
const navigationRevision = useAppStore((s) => s.taskRemoval.navigationRevision);
|
||||
const beginWorkflowSessionFocus = useAppStore((s) => s.beginWorkflowSessionFocus);
|
||||
const bindWorkflowSessionFocus = useAppStore((s) => s.bindWorkflowSessionFocus);
|
||||
const reconcileWorkflowSessionFocus = useAppStore((s) => s.reconcileWorkflowSessionFocus);
|
||||
const cancelWorkflowSessionFocus = useAppStore((s) => s.cancelWorkflowSessionFocus);
|
||||
const activeFocusRequestIdRef = useRef<number | null>(null);
|
||||
|
||||
const beginFocus = useCallback(
|
||||
(destinationStepId: string) => {
|
||||
if (!taskId || !workflowId) return null;
|
||||
const requestId = beginWorkflowSessionFocus({
|
||||
taskId,
|
||||
workflowId,
|
||||
destinationStepId,
|
||||
presentationToken,
|
||||
navigationRevision,
|
||||
});
|
||||
activeFocusRequestIdRef.current = requestId;
|
||||
return requestId;
|
||||
},
|
||||
[beginWorkflowSessionFocus, navigationRevision, presentationToken, taskId, workflowId],
|
||||
);
|
||||
const commitFocus = useCallback(
|
||||
(requestId: number | null, response: WorkflowMoveResponse) => {
|
||||
if (requestId === null) return;
|
||||
try {
|
||||
if (!response.workflow_entry_identity) {
|
||||
cancelWorkflowSessionFocus({ requestId, presentationToken });
|
||||
return;
|
||||
}
|
||||
bindWorkflowSessionFocus({
|
||||
requestId,
|
||||
presentationToken,
|
||||
entryIdentity: response.workflow_entry_identity,
|
||||
});
|
||||
if (!taskId) return;
|
||||
reconcileWorkflowSessionFocus(taskId, {
|
||||
metadata: response.task?.metadata,
|
||||
updatedAt: response.task?.updated_at,
|
||||
workflowStepId: response.task?.workflow_step_id,
|
||||
entryIdentity: response.workflow_entry_identity,
|
||||
});
|
||||
} finally {
|
||||
if (activeFocusRequestIdRef.current === requestId) {
|
||||
activeFocusRequestIdRef.current = null;
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
bindWorkflowSessionFocus,
|
||||
cancelWorkflowSessionFocus,
|
||||
presentationToken,
|
||||
reconcileWorkflowSessionFocus,
|
||||
taskId,
|
||||
],
|
||||
);
|
||||
const cancelFocus = useCallback(
|
||||
(requestId: number | null) => {
|
||||
if (requestId === null) return;
|
||||
cancelWorkflowSessionFocus({ requestId, presentationToken });
|
||||
if (activeFocusRequestIdRef.current === requestId) {
|
||||
activeFocusRequestIdRef.current = null;
|
||||
}
|
||||
},
|
||||
[cancelWorkflowSessionFocus, presentationToken],
|
||||
);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
const requestId = activeFocusRequestIdRef.current;
|
||||
if (requestId !== null) cancelWorkflowSessionFocus({ requestId });
|
||||
},
|
||||
[cancelWorkflowSessionFocus],
|
||||
);
|
||||
|
||||
return { beginFocus, cancelFocus, commitFocus };
|
||||
}
|
||||
|
||||
/**
|
||||
* The single implementation of the compact stepper's move request. Extracted
|
||||
* so the task top bar and the kanban preview header share one plan-mode
|
||||
@@ -119,6 +202,7 @@ export function useWorkflowStepMove({
|
||||
onMoveError,
|
||||
}: UseWorkflowStepMoveParams): UseWorkflowStepMoveResult {
|
||||
const disablePlanMode = useDisablePlanMode();
|
||||
const focus = useWorkflowStepMoveFocus({ taskId, workflowId, presentationToken });
|
||||
const [movingToStepId, setMovingToStepId] = useState<string | null>(null);
|
||||
const [progressingToStepId, setProgressingToStepId] = useState<string | null>(null);
|
||||
// Only the in-flight step's own button is disabled, so every other step stays
|
||||
@@ -162,6 +246,7 @@ export function useWorkflowStepMove({
|
||||
const handleMove = useCallback(
|
||||
async (stepId: string, entryOptions?: WorkflowMoveEntryOptions): Promise<boolean> => {
|
||||
if (!taskId || !workflowId) return false;
|
||||
const focusRequestId = focus.beginFocus(stepId);
|
||||
onMoveStart?.();
|
||||
disablePlanMode();
|
||||
const requestId = ++moveRequestRef.current;
|
||||
@@ -179,15 +264,17 @@ export function useWorkflowStepMove({
|
||||
setMovingToStepId(stepId);
|
||||
setProgressingToStepId(stepId);
|
||||
try {
|
||||
await moveTask(taskId, {
|
||||
const response = await moveTask(taskId, {
|
||||
workflow_id: workflowId,
|
||||
workflow_step_id: stepId,
|
||||
position: 0,
|
||||
entry_options: entryOptions,
|
||||
});
|
||||
if (requestId === moveRequestRef.current) focus.commitFocus(focusRequestId, response);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error("[useWorkflowStepMove] Failed to move task:", err);
|
||||
focus.cancelFocus(focusRequestId);
|
||||
if (requestId === moveRequestRef.current) {
|
||||
progressMoveRef.current = null;
|
||||
priorMoveTargetsRef.current.clear();
|
||||
@@ -201,7 +288,7 @@ export function useWorkflowStepMove({
|
||||
if (requestId === moveRequestRef.current) setMovingToStepId(null);
|
||||
}
|
||||
},
|
||||
[taskId, workflowId, currentStepId, disablePlanMode, onMoveStart, onMoveError],
|
||||
[taskId, workflowId, currentStepId, focus, disablePlanMode, onMoveStart, onMoveError],
|
||||
);
|
||||
|
||||
return { movingToStepId, progressingToStepId, handleMove };
|
||||
|
||||
@@ -49,6 +49,7 @@ async function hydrateTaskSessions({
|
||||
[...fetchedSessions, ...sessionsAddedDuringLoad],
|
||||
activityEpochsAtRequestStart,
|
||||
);
|
||||
getStoreState().reconcileWorkflowSessionFocus?.(taskId);
|
||||
return sessionsAddedDuringLoad.length > 0;
|
||||
} catch (error) {
|
||||
console.error("Failed to load task sessions:", error);
|
||||
@@ -58,6 +59,7 @@ async function hydrateTaskSessions({
|
||||
const currentSessions = storedTaskSessions(getStoreState, taskId);
|
||||
if (!force && currentSessions.length > 0) {
|
||||
setTaskSessionsForTask(taskId, currentSessions, activityEpochsAtRequestStart);
|
||||
getStoreState().reconcileWorkflowSessionFocus?.(taskId);
|
||||
}
|
||||
setTaskSessionsError(taskId, error instanceof Error ? error.message : String(error));
|
||||
return false;
|
||||
|
||||
@@ -49,6 +49,7 @@ export const defaultState = {
|
||||
workspaceContextGeneration: defaultKanbanState.workspaceContextGeneration,
|
||||
workspaceContextRead: defaultKanbanState.workspaceContextRead,
|
||||
tasks: defaultKanbanState.tasks,
|
||||
workflowSessionFocus: defaultKanbanState.workflowSessionFocus,
|
||||
taskRemoval: defaultKanbanState.taskRemoval,
|
||||
workspaces: defaultWorkspaceState.workspaces,
|
||||
repositories: defaultWorkspaceState.repositories,
|
||||
@@ -431,6 +432,7 @@ function mergeTaskSessionState(initialState: HydrationState) {
|
||||
export function mergeInitialState(initialState?: HydrationState): DefaultState {
|
||||
if (!initialState) return defaultState;
|
||||
const hydration = { ...initialState };
|
||||
delete hydration.workflowSessionFocus;
|
||||
delete hydration.taskRemoval;
|
||||
return {
|
||||
...defaultState,
|
||||
|
||||
@@ -10,6 +10,17 @@ import {
|
||||
releaseTaskRemoval,
|
||||
} from "@/lib/state/task-removal";
|
||||
import { mergeStepOrderRevisions } from "@/lib/kanban/workflow-step-order";
|
||||
import {
|
||||
acknowledgeWorkflowSessionFocus,
|
||||
beginWorkflowSessionFocus,
|
||||
bindWorkflowSessionFocus,
|
||||
cancelWorkflowSessionFocus,
|
||||
createWorkflowSessionFocusState,
|
||||
reconcileWorkflowSessionFocus,
|
||||
type WorkflowSessionFocusCancelScope,
|
||||
type WorkflowSessionFocusStart,
|
||||
} from "@/lib/state/workflow-session-focus";
|
||||
import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions";
|
||||
import {
|
||||
WORKSPACE_CONTEXT_COLLECTIONS,
|
||||
type WorkspaceContextCollection,
|
||||
@@ -73,12 +84,16 @@ export const defaultKanbanState: KanbanSliceState = {
|
||||
lastSessionByTaskId: {},
|
||||
resumeSkippedSessionIds: {},
|
||||
},
|
||||
workflowSessionFocus: createWorkflowSessionFocusState(),
|
||||
taskRemoval: createTaskRemovalState(),
|
||||
};
|
||||
|
||||
type KanbanSliceSet = Parameters<
|
||||
StateCreator<KanbanSlice, [["zustand/immer", never]], [], KanbanSlice>
|
||||
>[0];
|
||||
type KanbanSliceGet = Parameters<
|
||||
StateCreator<KanbanSlice, [["zustand/immer", never]], [], KanbanSlice>
|
||||
>[1];
|
||||
|
||||
type SidebarArchivedTaskActions = Pick<
|
||||
KanbanSliceActions,
|
||||
@@ -153,6 +168,11 @@ type TaskActions = Pick<
|
||||
| "setActiveSession"
|
||||
| "setActiveSessionAuto"
|
||||
| "clearActiveSession"
|
||||
| "beginWorkflowSessionFocus"
|
||||
| "bindWorkflowSessionFocus"
|
||||
| "reconcileWorkflowSessionFocus"
|
||||
| "cancelWorkflowSessionFocus"
|
||||
| "acknowledgeWorkflowSessionFocus"
|
||||
| "setResumeSkipped"
|
||||
| "beginTaskRemoval"
|
||||
| "recordTaskRemovalResult"
|
||||
@@ -292,10 +312,118 @@ function createWorkspaceContextActions(set: KanbanSliceSet): WorkspaceContextAct
|
||||
};
|
||||
}
|
||||
|
||||
function createTaskActions(set: KanbanSliceSet): TaskActions {
|
||||
type FocusLookupState = {
|
||||
taskSessionsByTask?: {
|
||||
itemsByTaskId?: Record<string, Array<{ id: string }>>;
|
||||
};
|
||||
taskSessions?: {
|
||||
items?: Record<string, { id: string; task_id?: string }>;
|
||||
};
|
||||
};
|
||||
|
||||
type WorkflowSessionFocusActions = Pick<
|
||||
TaskActions,
|
||||
| "beginWorkflowSessionFocus"
|
||||
| "bindWorkflowSessionFocus"
|
||||
| "reconcileWorkflowSessionFocus"
|
||||
| "cancelWorkflowSessionFocus"
|
||||
| "acknowledgeWorkflowSessionFocus"
|
||||
>;
|
||||
|
||||
function createWorkflowSessionFocusActions(
|
||||
set: KanbanSliceSet,
|
||||
get: KanbanSliceGet,
|
||||
): WorkflowSessionFocusActions {
|
||||
const knownSessionIdsForTask = (taskId: string): string[] => {
|
||||
const state = get() as KanbanSlice & FocusLookupState;
|
||||
const ids = new Set(
|
||||
state.taskSessionsByTask?.itemsByTaskId?.[taskId]?.map((session) => session.id) ?? [],
|
||||
);
|
||||
for (const session of Object.values(state.taskSessions?.items ?? {})) {
|
||||
if (session.task_id === taskId) ids.add(session.id);
|
||||
}
|
||||
return [...ids];
|
||||
};
|
||||
|
||||
const taskProjectionForTask = (
|
||||
taskId: string,
|
||||
): { metadata: unknown; updatedAt?: string; workflowStepId?: string } => {
|
||||
const state = get();
|
||||
const tasks = [
|
||||
...state.kanban.tasks,
|
||||
...Object.values(state.kanbanMulti.snapshots).flatMap((snapshot) => snapshot.tasks),
|
||||
].filter((candidate) => candidate.id === taskId);
|
||||
let task = tasks[0];
|
||||
for (const candidate of tasks.slice(1)) {
|
||||
const selectedTime = parseTurnTimestamp(task?.updatedAt);
|
||||
const candidateTime = parseTurnTimestamp(candidate.updatedAt);
|
||||
if (
|
||||
task === undefined ||
|
||||
(selectedTime === null && candidateTime !== null) ||
|
||||
(candidateTime !== null && selectedTime !== null && candidateTime > selectedTime)
|
||||
) {
|
||||
task = candidate;
|
||||
}
|
||||
}
|
||||
return {
|
||||
metadata: task?.metadata,
|
||||
updatedAt: task?.updatedAt,
|
||||
workflowStepId: task?.workflowStepId,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
beginWorkflowSessionFocus: (input: WorkflowSessionFocusStart) => {
|
||||
let requestId: number | null = null;
|
||||
set((draft) => {
|
||||
const result = beginWorkflowSessionFocus(draft.workflowSessionFocus, input);
|
||||
draft.workflowSessionFocus = result.state;
|
||||
requestId = result.requestId;
|
||||
});
|
||||
return requestId;
|
||||
},
|
||||
bindWorkflowSessionFocus: (input) =>
|
||||
set((draft) => {
|
||||
draft.workflowSessionFocus = bindWorkflowSessionFocus(draft.workflowSessionFocus, input);
|
||||
}),
|
||||
reconcileWorkflowSessionFocus: (taskId, responseProjection) =>
|
||||
set((draft) => {
|
||||
const liveProjection = taskProjectionForTask(taskId);
|
||||
const result = reconcileWorkflowSessionFocus(draft.workflowSessionFocus, {
|
||||
activeTaskId: draft.tasks.activeTaskId,
|
||||
navigationRevision: draft.taskRemoval.navigationRevision,
|
||||
routeMetadata: liveProjection.metadata,
|
||||
routeUpdatedAt: liveProjection.updatedAt,
|
||||
workflowStepId: liveProjection.workflowStepId,
|
||||
responseProjection,
|
||||
knownSessionIds: knownSessionIdsForTask(taskId),
|
||||
});
|
||||
draft.workflowSessionFocus = result.state;
|
||||
if (!result.sessionId) return;
|
||||
draft.tasks.activeSessionId = result.sessionId;
|
||||
draft.tasks.pinnedSessionId = null;
|
||||
draft.tasks.lastSessionByTaskId[taskId] = result.sessionId;
|
||||
}),
|
||||
cancelWorkflowSessionFocus: (scope?: WorkflowSessionFocusCancelScope) =>
|
||||
set((draft) => {
|
||||
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus, scope);
|
||||
}),
|
||||
acknowledgeWorkflowSessionFocus: (requestId) =>
|
||||
set((draft) => {
|
||||
draft.workflowSessionFocus = acknowledgeWorkflowSessionFocus(
|
||||
draft.workflowSessionFocus,
|
||||
requestId,
|
||||
);
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createTaskActions(set: KanbanSliceSet, get: KanbanSliceGet): TaskActions {
|
||||
return {
|
||||
...createWorkflowSessionFocusActions(set, get),
|
||||
setActiveTask: (taskId) =>
|
||||
set((draft) => {
|
||||
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
|
||||
draft.taskRemoval = advanceTaskNavigationRevision(draft.taskRemoval);
|
||||
draft.tasks.activeTaskId = taskId;
|
||||
draft.tasks.activeSessionId = null;
|
||||
@@ -304,12 +432,16 @@ function createTaskActions(set: KanbanSliceSet): TaskActions {
|
||||
}),
|
||||
setActiveTaskAuto: (taskId) =>
|
||||
set((draft) => {
|
||||
if (draft.tasks.activeTaskId !== taskId) {
|
||||
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
|
||||
}
|
||||
draft.tasks.activeTaskId = taskId;
|
||||
draft.tasks.activeSessionId = null;
|
||||
draft.tasks.pinnedSessionId = null;
|
||||
}),
|
||||
setActiveSession: (taskId, sessionId) =>
|
||||
set((draft) => {
|
||||
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
|
||||
draft.taskRemoval = advanceTaskNavigationRevision(draft.taskRemoval);
|
||||
draft.tasks.activeTaskId = taskId;
|
||||
draft.tasks.activeSessionId = sessionId;
|
||||
@@ -320,6 +452,7 @@ function createTaskActions(set: KanbanSliceSet): TaskActions {
|
||||
setActiveSessionAuto: (taskId, sessionId) =>
|
||||
set((draft) => {
|
||||
if (draft.tasks.activeTaskId !== taskId) {
|
||||
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
|
||||
draft.tasks.pinnedSessionId = null;
|
||||
}
|
||||
draft.tasks.activeTaskId = taskId;
|
||||
@@ -347,10 +480,12 @@ function createTaskActions(set: KanbanSliceSet): TaskActions {
|
||||
}),
|
||||
advanceTaskNavigationRevision: () =>
|
||||
set((draft) => {
|
||||
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
|
||||
draft.taskRemoval = advanceTaskNavigationRevision(draft.taskRemoval);
|
||||
}),
|
||||
clearActiveSession: () =>
|
||||
set((draft) => {
|
||||
draft.workflowSessionFocus = cancelWorkflowSessionFocus(draft.workflowSessionFocus);
|
||||
draft.tasks.activeSessionId = null;
|
||||
draft.tasks.pinnedSessionId = null;
|
||||
}),
|
||||
@@ -374,10 +509,10 @@ export const createKanbanSlice: StateCreator<
|
||||
[["zustand/immer", never]],
|
||||
[],
|
||||
KanbanSlice
|
||||
> = (set) => ({
|
||||
> = (set, get) => ({
|
||||
...defaultKanbanState,
|
||||
...createSidebarArchivedTaskActions(set),
|
||||
...createTaskActions(set),
|
||||
...createTaskActions(set, get),
|
||||
...createWorkspaceContextActions(set),
|
||||
resetKanbanWorkspaceContext: () =>
|
||||
set((draft) => {
|
||||
|
||||
@@ -12,6 +12,12 @@ import type {
|
||||
} from "@/lib/types/http";
|
||||
import type { TaskStatusSummary } from "@/lib/types/task-status-summary";
|
||||
import type { BeginTaskRemovalInput, TaskRemovalState } from "@/lib/state/task-removal";
|
||||
import type {
|
||||
WorkflowSessionFocusCancelScope,
|
||||
WorkflowSessionFocusState,
|
||||
WorkflowSessionFocusStart,
|
||||
WorkflowSessionFocusTaskProjection,
|
||||
} from "@/lib/state/workflow-session-focus";
|
||||
|
||||
export type KanbanStepEvents = {
|
||||
on_enter?: Array<{ type: string; config?: Record<string, unknown> }>;
|
||||
@@ -326,6 +332,8 @@ export type KanbanSliceState = {
|
||||
workspaceContextGeneration: number;
|
||||
workspaceContextRead: WorkspaceContextReadState;
|
||||
tasks: TaskState;
|
||||
/** Browser-local one-shot focus handoff for an explicitly moved task. */
|
||||
workflowSessionFocus: WorkflowSessionFocusState;
|
||||
/** Browser-local removal intent. It is deliberately excluded from hydration. */
|
||||
taskRemoval: TaskRemovalState;
|
||||
};
|
||||
@@ -361,6 +369,18 @@ export type KanbanSliceActions = {
|
||||
// it explicitly after checking no non-terminal manual pin should be preserved.
|
||||
setActiveSessionAuto: (taskId: string, sessionId: string) => void;
|
||||
clearActiveSession: () => void;
|
||||
beginWorkflowSessionFocus: (input: WorkflowSessionFocusStart) => number | null;
|
||||
bindWorkflowSessionFocus: (input: {
|
||||
requestId: number;
|
||||
presentationToken: number;
|
||||
entryIdentity: string;
|
||||
}) => void;
|
||||
reconcileWorkflowSessionFocus: (
|
||||
taskId: string,
|
||||
responseProjection?: WorkflowSessionFocusTaskProjection,
|
||||
) => void;
|
||||
cancelWorkflowSessionFocus: (scope?: WorkflowSessionFocusCancelScope) => void;
|
||||
acknowledgeWorkflowSessionFocus: (requestId: number) => void;
|
||||
// setResumeSkipped records/clears the resume-skipped marker for a session.
|
||||
// Recording is guarded at the call site (the session-resumption hook reads
|
||||
// the live session row with typed store access), so a stale status response
|
||||
|
||||
@@ -13,6 +13,7 @@ export function buildStateOverrides(m: DefaultState) {
|
||||
workspaceContextGeneration: m.workspaceContextGeneration,
|
||||
workspaceContextRead: m.workspaceContextRead,
|
||||
tasks: m.tasks,
|
||||
workflowSessionFocus: m.workflowSessionFocus,
|
||||
taskRemoval: m.taskRemoval,
|
||||
workspaces: m.workspaces,
|
||||
repositories: m.repositories,
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createAppStore } from "./store";
|
||||
import {
|
||||
acknowledgeWorkflowSessionFocus,
|
||||
bindWorkflowSessionFocus,
|
||||
cancelWorkflowSessionFocus,
|
||||
createWorkflowSessionFocusState,
|
||||
beginWorkflowSessionFocus,
|
||||
reconcileWorkflowSessionFocus,
|
||||
} from "./workflow-session-focus";
|
||||
|
||||
const START = {
|
||||
taskId: "task-1",
|
||||
workflowId: "workflow-1",
|
||||
destinationStepId: "step-implement",
|
||||
presentationToken: 7,
|
||||
navigationRevision: 3,
|
||||
};
|
||||
const SOURCE_SESSION_ID = "session-astra";
|
||||
const DESTINATION_SESSION_ID = "session-luna";
|
||||
const NEWER_SESSION_ID = "session-newer";
|
||||
const LATER_STEP_ID = "step-review";
|
||||
const LIVE_UPDATED_AT = "2026-09-19T10:00:02Z";
|
||||
const RESPONSE_UPDATED_AT = "2026-09-19T10:00:01Z";
|
||||
const FOCUS_INTENT_ERROR = "test focus intent did not start";
|
||||
|
||||
const COMMITTED_ROUTE = {
|
||||
operation_id: "operation-1",
|
||||
destination_step_id: "step-implement",
|
||||
entry_identity: "entry:00000000000000000042",
|
||||
destination_session_id: DESTINATION_SESSION_ID,
|
||||
phase: "committed",
|
||||
};
|
||||
|
||||
function started(): {
|
||||
state: ReturnType<typeof createWorkflowSessionFocusState>;
|
||||
requestId: number;
|
||||
} {
|
||||
const result = beginWorkflowSessionFocus(createWorkflowSessionFocusState(), START);
|
||||
if (result.requestId === null) throw new Error(FOCUS_INTENT_ERROR);
|
||||
return { state: result.state, requestId: result.requestId };
|
||||
}
|
||||
|
||||
// eslint-disable-next-line max-lines-per-function -- state-machine scenarios share one transition fixture
|
||||
describe("workflow session focus state", () => {
|
||||
it("applies the resolved recipient atomically in the app store and clears the old pin", () => {
|
||||
const store = createAppStore();
|
||||
store.setState((state) => {
|
||||
state.tasks.activeTaskId = START.taskId;
|
||||
state.tasks.activeSessionId = SOURCE_SESSION_ID;
|
||||
state.tasks.pinnedSessionId = SOURCE_SESSION_ID;
|
||||
state.taskRemoval.navigationRevision = START.navigationRevision;
|
||||
state.kanban.tasks = [
|
||||
{
|
||||
id: START.taskId,
|
||||
workflowStepId: START.destinationStepId,
|
||||
metadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
},
|
||||
] as never;
|
||||
state.taskSessionsByTask.itemsByTaskId[START.taskId] = [
|
||||
{ id: SOURCE_SESSION_ID },
|
||||
{ id: DESTINATION_SESSION_ID },
|
||||
] as never;
|
||||
state.taskSessions.items[DESTINATION_SESSION_ID] = {
|
||||
id: DESTINATION_SESSION_ID,
|
||||
task_id: START.taskId,
|
||||
} as never;
|
||||
});
|
||||
|
||||
const requestId = store.getState().beginWorkflowSessionFocus(START);
|
||||
expect(requestId).toBe(1);
|
||||
if (requestId === null) throw new Error(FOCUS_INTENT_ERROR);
|
||||
store.getState().bindWorkflowSessionFocus({
|
||||
requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
store.getState().reconcileWorkflowSessionFocus(START.taskId);
|
||||
|
||||
expect(store.getState().tasks.activeSessionId).toBe(DESTINATION_SESSION_ID);
|
||||
expect(store.getState().tasks.pinnedSessionId).toBeNull();
|
||||
expect(store.getState().tasks.lastSessionByTaskId[START.taskId]).toBe(DESTINATION_SESSION_ID);
|
||||
expect(store.getState().workflowSessionFocus.request?.sessionId).toBe(DESTINATION_SESSION_ID);
|
||||
|
||||
store.getState().setActiveSession(START.taskId, SOURCE_SESSION_ID);
|
||||
expect(store.getState().workflowSessionFocus.request).toBeNull();
|
||||
});
|
||||
|
||||
it("reconsiders the cached live route when a move response is delayed", () => {
|
||||
const store = createAppStore();
|
||||
store.setState((state) => {
|
||||
state.tasks.activeTaskId = START.taskId;
|
||||
state.tasks.activeSessionId = SOURCE_SESSION_ID;
|
||||
state.taskRemoval.navigationRevision = START.navigationRevision;
|
||||
state.kanban.tasks = [
|
||||
{
|
||||
id: START.taskId,
|
||||
workflowStepId: START.destinationStepId,
|
||||
updatedAt: LIVE_UPDATED_AT,
|
||||
metadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
},
|
||||
] as never;
|
||||
state.taskSessionsByTask.itemsByTaskId[START.taskId] = [
|
||||
{ id: SOURCE_SESSION_ID },
|
||||
{ id: DESTINATION_SESSION_ID },
|
||||
] as never;
|
||||
});
|
||||
|
||||
const requestId = store.getState().beginWorkflowSessionFocus(START);
|
||||
if (requestId === null) throw new Error(FOCUS_INTENT_ERROR);
|
||||
store.getState().bindWorkflowSessionFocus({
|
||||
requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
store.getState().reconcileWorkflowSessionFocus(START.taskId, {
|
||||
metadata: { workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" } },
|
||||
updatedAt: RESPONSE_UPDATED_AT,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
|
||||
expect(store.getState().tasks.activeSessionId).toBe(DESTINATION_SESSION_ID);
|
||||
expect(store.getState().workflowSessionFocus.request?.sessionId).toBe(DESTINATION_SESSION_ID);
|
||||
});
|
||||
|
||||
it("accepts the response projection when the live task is still on the source step", () => {
|
||||
const startedState = started();
|
||||
const bound = bindWorkflowSessionFocus(startedState.state, {
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
|
||||
const result = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: "step-plan",
|
||||
routeMetadata: undefined,
|
||||
routeUpdatedAt: "2026-09-19T10:00:00Z",
|
||||
responseProjection: {
|
||||
metadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
updatedAt: RESPONSE_UPDATED_AT,
|
||||
workflowStepId: START.destinationStepId,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
},
|
||||
knownSessionIds: [DESTINATION_SESSION_ID],
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBe(DESTINATION_SESSION_ID);
|
||||
});
|
||||
|
||||
it("keeps an intent pending when its response route is older than a newer live entry", () => {
|
||||
const store = createAppStore();
|
||||
const newerLiveRoute = {
|
||||
...COMMITTED_ROUTE,
|
||||
entry_identity: "entry:00000000000000000043",
|
||||
destination_session_id: NEWER_SESSION_ID,
|
||||
};
|
||||
store.setState((state) => {
|
||||
state.tasks.activeTaskId = START.taskId;
|
||||
state.tasks.activeSessionId = SOURCE_SESSION_ID;
|
||||
state.taskRemoval.navigationRevision = START.navigationRevision;
|
||||
state.kanban.tasks = [
|
||||
{
|
||||
id: START.taskId,
|
||||
workflowStepId: START.destinationStepId,
|
||||
updatedAt: LIVE_UPDATED_AT,
|
||||
metadata: { workflow_session_route: newerLiveRoute },
|
||||
},
|
||||
] as never;
|
||||
state.taskSessionsByTask.itemsByTaskId[START.taskId] = [
|
||||
{ id: SOURCE_SESSION_ID },
|
||||
{ id: DESTINATION_SESSION_ID },
|
||||
{ id: NEWER_SESSION_ID },
|
||||
] as never;
|
||||
});
|
||||
|
||||
const requestId = store.getState().beginWorkflowSessionFocus(START);
|
||||
if (requestId === null) throw new Error(FOCUS_INTENT_ERROR);
|
||||
store.getState().bindWorkflowSessionFocus({
|
||||
requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
store.getState().reconcileWorkflowSessionFocus(START.taskId, {
|
||||
metadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
updatedAt: RESPONSE_UPDATED_AT,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
|
||||
expect(store.getState().tasks.activeSessionId).toBe(SOURCE_SESSION_ID);
|
||||
expect(store.getState().workflowSessionFocus.intent).not.toBeNull();
|
||||
expect(store.getState().workflowSessionFocus.request).toBeNull();
|
||||
});
|
||||
|
||||
it("binds a response entry and resolves it only for the committed task-owned recipient", () => {
|
||||
const startedState = started();
|
||||
expect(startedState.requestId).toBe(1);
|
||||
|
||||
const bound = bindWorkflowSessionFocus(startedState.state, {
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
const unresolved = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: START.destinationStepId,
|
||||
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
knownSessionIds: [SOURCE_SESSION_ID],
|
||||
});
|
||||
|
||||
expect(unresolved.sessionId).toBeNull();
|
||||
expect(unresolved.state.intent).not.toBeNull();
|
||||
|
||||
const resolved = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: START.destinationStepId,
|
||||
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
knownSessionIds: [SOURCE_SESSION_ID, DESTINATION_SESSION_ID],
|
||||
});
|
||||
|
||||
expect(resolved.sessionId).toBe(DESTINATION_SESSION_ID);
|
||||
expect(resolved.state.intent).toBeNull();
|
||||
expect(resolved.state.request).toEqual({
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
taskId: START.taskId,
|
||||
sessionId: DESTINATION_SESSION_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects prepared, foreign, stale, and mismatched routes", () => {
|
||||
const startedState = started();
|
||||
const bound = bindWorkflowSessionFocus(startedState.state, {
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
|
||||
for (const routeMetadata of [
|
||||
{ workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" } },
|
||||
{ workflow_session_route: { ...COMMITTED_ROUTE, entry_identity: "entry:other" } },
|
||||
{ workflow_session_route: { ...COMMITTED_ROUTE, destination_step_id: "step-review" } },
|
||||
{ workflow_session_route: { ...COMMITTED_ROUTE, destination_session_id: "session-other" } },
|
||||
{ workflow_session_route: { ...COMMITTED_ROUTE, operation_id: "" } },
|
||||
]) {
|
||||
const result = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: START.destinationStepId,
|
||||
routeMetadata,
|
||||
knownSessionIds: [DESTINATION_SESSION_ID],
|
||||
});
|
||||
expect(result.sessionId).toBeNull();
|
||||
expect(result.state.intent).not.toBeNull();
|
||||
}
|
||||
});
|
||||
|
||||
it("cancels on a later navigation, newer request, and a missing response identity", () => {
|
||||
const first = started();
|
||||
const canceled = cancelWorkflowSessionFocus(first.state, {
|
||||
taskId: START.taskId,
|
||||
presentationToken: START.presentationToken,
|
||||
});
|
||||
expect(canceled.intent).toBeNull();
|
||||
|
||||
const second = beginWorkflowSessionFocus(canceled, { ...START, presentationToken: 8 });
|
||||
const newer = beginWorkflowSessionFocus(second.state, {
|
||||
...START,
|
||||
destinationStepId: LATER_STEP_ID,
|
||||
});
|
||||
expect(newer.requestId).toBe(3);
|
||||
expect(newer.state.intent?.destinationStepId).toBe(LATER_STEP_ID);
|
||||
if (newer.requestId === null) throw new Error("test focus intent did not restart");
|
||||
|
||||
const missingIdentity = bindWorkflowSessionFocus(newer.state, {
|
||||
requestId: newer.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: "",
|
||||
});
|
||||
expect(missingIdentity.intent).toBeNull();
|
||||
expect(missingIdentity.request).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps a focus request until its desktop or phone consumer acknowledges it", () => {
|
||||
const startedState = started();
|
||||
const bound = bindWorkflowSessionFocus(startedState.state, {
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
const resolved = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: START.destinationStepId,
|
||||
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
knownSessionIds: [DESTINATION_SESSION_ID],
|
||||
});
|
||||
|
||||
const acknowledged = acknowledgeWorkflowSessionFocus(resolved.state, startedState.requestId);
|
||||
expect(acknowledged.request).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the live committed route when the response arrives after its task event", () => {
|
||||
const startedState = started();
|
||||
const bound = bindWorkflowSessionFocus(startedState.state, {
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
|
||||
const resolved = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: START.destinationStepId,
|
||||
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
routeUpdatedAt: LIVE_UPDATED_AT,
|
||||
responseProjection: {
|
||||
metadata: {
|
||||
workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" },
|
||||
},
|
||||
updatedAt: RESPONSE_UPDATED_AT,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
},
|
||||
knownSessionIds: [DESTINATION_SESSION_ID],
|
||||
});
|
||||
|
||||
expect(resolved.sessionId).toBe(DESTINATION_SESSION_ID);
|
||||
});
|
||||
|
||||
it("rejects an obsolete response route after a newer live entry", () => {
|
||||
const startedState = started();
|
||||
const bound = bindWorkflowSessionFocus(startedState.state, {
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
const newerLiveRoute = {
|
||||
...COMMITTED_ROUTE,
|
||||
entry_identity: "entry:00000000000000000043",
|
||||
destination_session_id: NEWER_SESSION_ID,
|
||||
};
|
||||
|
||||
const result = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: START.destinationStepId,
|
||||
routeMetadata: { workflow_session_route: newerLiveRoute },
|
||||
routeUpdatedAt: LIVE_UPDATED_AT,
|
||||
responseProjection: {
|
||||
metadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
updatedAt: RESPONSE_UPDATED_AT,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
},
|
||||
knownSessionIds: [DESTINATION_SESSION_ID, NEWER_SESSION_ID],
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBeNull();
|
||||
expect(result.state.intent).not.toBeNull();
|
||||
});
|
||||
|
||||
it("accepts a committed response only when its entry identity matches the bound move", () => {
|
||||
const startedState = started();
|
||||
const bound = bindWorkflowSessionFocus(startedState.state, {
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
|
||||
const result = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: START.destinationStepId,
|
||||
routeMetadata: undefined,
|
||||
responseProjection: {
|
||||
metadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
updatedAt: RESPONSE_UPDATED_AT,
|
||||
entryIdentity: "entry:other",
|
||||
},
|
||||
knownSessionIds: [DESTINATION_SESSION_ID],
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBeNull();
|
||||
expect(result.state.intent).not.toBeNull();
|
||||
});
|
||||
|
||||
it("rejects a committed route after the task reaches a later workflow step", () => {
|
||||
const startedState = started();
|
||||
const bound = bindWorkflowSessionFocus(startedState.state, {
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
|
||||
const result = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: LATER_STEP_ID,
|
||||
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
knownSessionIds: [DESTINATION_SESSION_ID],
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBeNull();
|
||||
expect(result.state.intent).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["0", "2026-02-30T10:00:00Z"])(
|
||||
"ignores malformed response timestamp %s when choosing a live route",
|
||||
(updatedAt) => {
|
||||
const startedState = started();
|
||||
const bound = bindWorkflowSessionFocus(startedState.state, {
|
||||
requestId: startedState.requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
|
||||
const result = reconcileWorkflowSessionFocus(bound, {
|
||||
activeTaskId: START.taskId,
|
||||
navigationRevision: START.navigationRevision,
|
||||
workflowStepId: START.destinationStepId,
|
||||
routeMetadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
routeUpdatedAt: LIVE_UPDATED_AT,
|
||||
responseProjection: {
|
||||
metadata: {
|
||||
workflow_session_route: { ...COMMITTED_ROUTE, phase: "prepared" },
|
||||
},
|
||||
updatedAt,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
},
|
||||
knownSessionIds: [DESTINATION_SESSION_ID],
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBe(DESTINATION_SESSION_ID);
|
||||
},
|
||||
);
|
||||
|
||||
it("selects a valid snapshot over a normalized-invalid task timestamp", () => {
|
||||
const store = createAppStore();
|
||||
const wrongRoute = {
|
||||
...COMMITTED_ROUTE,
|
||||
destination_session_id: NEWER_SESSION_ID,
|
||||
};
|
||||
store.setState((state) => {
|
||||
state.tasks.activeTaskId = START.taskId;
|
||||
state.tasks.activeSessionId = SOURCE_SESSION_ID;
|
||||
state.taskRemoval.navigationRevision = START.navigationRevision;
|
||||
state.kanban.tasks = [
|
||||
{
|
||||
id: START.taskId,
|
||||
workflowStepId: START.destinationStepId,
|
||||
updatedAt: "2026-02-30T10:00:00Z",
|
||||
metadata: { workflow_session_route: wrongRoute },
|
||||
},
|
||||
] as never;
|
||||
state.kanbanMulti.snapshots = {
|
||||
[START.workflowId]: {
|
||||
workflowId: START.workflowId,
|
||||
workflowName: "Workflow",
|
||||
steps: [],
|
||||
tasks: [
|
||||
{
|
||||
id: START.taskId,
|
||||
workflowStepId: START.destinationStepId,
|
||||
updatedAt: "2026-03-01T10:00:00Z",
|
||||
metadata: { workflow_session_route: COMMITTED_ROUTE },
|
||||
},
|
||||
],
|
||||
},
|
||||
} as never;
|
||||
state.taskSessionsByTask.itemsByTaskId[START.taskId] = [
|
||||
{ id: SOURCE_SESSION_ID },
|
||||
{ id: DESTINATION_SESSION_ID },
|
||||
{ id: NEWER_SESSION_ID },
|
||||
] as never;
|
||||
});
|
||||
|
||||
const requestId = store.getState().beginWorkflowSessionFocus(START);
|
||||
if (requestId === null) throw new Error(FOCUS_INTENT_ERROR);
|
||||
store.getState().bindWorkflowSessionFocus({
|
||||
requestId,
|
||||
presentationToken: START.presentationToken,
|
||||
entryIdentity: COMMITTED_ROUTE.entry_identity,
|
||||
});
|
||||
store.getState().reconcileWorkflowSessionFocus(START.taskId);
|
||||
|
||||
expect(store.getState().tasks.activeSessionId).toBe(DESTINATION_SESSION_ID);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,281 @@
|
||||
import { parseTurnTimestamp } from "@/lib/state/slices/session/turn-actions";
|
||||
|
||||
export type WorkflowSessionFocusStart = {
|
||||
taskId: string;
|
||||
workflowId: string;
|
||||
destinationStepId: string;
|
||||
presentationToken: number;
|
||||
navigationRevision: number;
|
||||
};
|
||||
|
||||
export type WorkflowSessionFocusIntent = WorkflowSessionFocusStart & {
|
||||
requestId: number;
|
||||
entryIdentity: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowSessionFocusRequest = {
|
||||
requestId: number;
|
||||
presentationToken: number;
|
||||
taskId: string;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
export type WorkflowSessionFocusState = {
|
||||
nextRequestId: number;
|
||||
intent: WorkflowSessionFocusIntent | null;
|
||||
request: WorkflowSessionFocusRequest | null;
|
||||
};
|
||||
|
||||
export type WorkflowSessionFocusCancelScope = {
|
||||
requestId?: number;
|
||||
presentationToken?: number;
|
||||
taskId?: string;
|
||||
};
|
||||
|
||||
export type WorkflowSessionFocusRoute = {
|
||||
operationId: string;
|
||||
destinationStepId: string;
|
||||
entryIdentity: string;
|
||||
destinationSessionId: string;
|
||||
phase: "committed";
|
||||
};
|
||||
|
||||
/**
|
||||
* The task projection returned by the move request. It is a candidate only;
|
||||
* the live task projection remains authoritative when it is newer.
|
||||
*/
|
||||
export type WorkflowSessionFocusTaskProjection = {
|
||||
metadata: unknown;
|
||||
updatedAt?: string | null;
|
||||
entryIdentity?: string | null;
|
||||
workflowStepId?: string | null;
|
||||
};
|
||||
|
||||
export type WorkflowSessionFocusReconcileInput = {
|
||||
activeTaskId: string | null;
|
||||
navigationRevision: number;
|
||||
routeMetadata: unknown;
|
||||
routeUpdatedAt?: string | null;
|
||||
workflowStepId?: string | null;
|
||||
responseProjection?: WorkflowSessionFocusTaskProjection;
|
||||
knownSessionIds: readonly string[];
|
||||
};
|
||||
|
||||
export type WorkflowSessionFocusReconcileResult = {
|
||||
state: WorkflowSessionFocusState;
|
||||
sessionId: string | null;
|
||||
};
|
||||
|
||||
export function createWorkflowSessionFocusState(): WorkflowSessionFocusState {
|
||||
return { nextRequestId: 0, intent: null, request: null };
|
||||
}
|
||||
|
||||
function clearWorkflowSessionFocusState(
|
||||
state: WorkflowSessionFocusState,
|
||||
): WorkflowSessionFocusState {
|
||||
return { ...state, intent: null, request: null };
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | null {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.trim();
|
||||
return normalized === "" ? null : normalized;
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function timestampValue(value: string | null | undefined): bigint | null {
|
||||
return parseTurnTimestamp(value ?? undefined);
|
||||
}
|
||||
|
||||
function acceptedTaskProjection(
|
||||
intent: WorkflowSessionFocusIntent,
|
||||
input: WorkflowSessionFocusReconcileInput,
|
||||
): WorkflowSessionFocusTaskProjection {
|
||||
const liveProjection: WorkflowSessionFocusTaskProjection = {
|
||||
metadata: input.routeMetadata,
|
||||
updatedAt: input.routeUpdatedAt,
|
||||
workflowStepId: input.workflowStepId,
|
||||
};
|
||||
const liveRoute = readCommittedWorkflowSessionRoute(input.routeMetadata);
|
||||
if (liveRoute?.entryIdentity === intent.entryIdentity) return liveProjection;
|
||||
|
||||
const response = input.responseProjection;
|
||||
if (!response) return liveProjection;
|
||||
|
||||
const responseRoute = readCommittedWorkflowSessionRoute(response.metadata);
|
||||
const responseEntryIdentity = nonEmptyString(response.entryIdentity);
|
||||
const liveUpdatedAt = timestampValue(input.routeUpdatedAt);
|
||||
const responseUpdatedAt = timestampValue(response.updatedAt);
|
||||
const responseIsEntryCorrelated =
|
||||
responseEntryIdentity === intent.entryIdentity &&
|
||||
responseRoute?.entryIdentity === intent.entryIdentity;
|
||||
const responseIsFreshEnough =
|
||||
responseIsEntryCorrelated &&
|
||||
(liveUpdatedAt === null || (responseUpdatedAt !== null && responseUpdatedAt >= liveUpdatedAt));
|
||||
if (!responseIsFreshEnough) return liveProjection;
|
||||
|
||||
// When timestamps tie, retain a different live committed entry. Equal
|
||||
// timestamps are common for grouped task writes, and the live entry is
|
||||
// the projection that won the store's ordering guard.
|
||||
const liveHasDifferentCommittedEntry =
|
||||
liveRoute !== null && liveRoute.entryIdentity !== intent.entryIdentity;
|
||||
return liveHasDifferentCommittedEntry && responseUpdatedAt === liveUpdatedAt
|
||||
? liveProjection
|
||||
: response;
|
||||
}
|
||||
|
||||
export function readCommittedWorkflowSessionRoute(
|
||||
routeMetadata: unknown,
|
||||
): WorkflowSessionFocusRoute | null {
|
||||
const metadata = asRecord(routeMetadata);
|
||||
const route = asRecord(metadata?.workflow_session_route ?? routeMetadata);
|
||||
if (!route) return null;
|
||||
|
||||
const operationId = nonEmptyString(route.operation_id);
|
||||
const destinationStepId = nonEmptyString(route.destination_step_id);
|
||||
const entryIdentity = nonEmptyString(route.entry_identity);
|
||||
const destinationSessionId = nonEmptyString(route.destination_session_id);
|
||||
if (
|
||||
!operationId ||
|
||||
!destinationStepId ||
|
||||
!entryIdentity ||
|
||||
!destinationSessionId ||
|
||||
route.phase !== "committed"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
operationId,
|
||||
destinationStepId,
|
||||
entryIdentity,
|
||||
destinationSessionId,
|
||||
phase: "committed",
|
||||
};
|
||||
}
|
||||
|
||||
export function beginWorkflowSessionFocus(
|
||||
state: WorkflowSessionFocusState,
|
||||
input: WorkflowSessionFocusStart,
|
||||
): { state: WorkflowSessionFocusState; requestId: number | null } {
|
||||
if (!input.taskId || !input.workflowId || !input.destinationStepId) {
|
||||
return { state, requestId: null };
|
||||
}
|
||||
|
||||
const requestId = state.nextRequestId + 1;
|
||||
return {
|
||||
requestId,
|
||||
state: {
|
||||
nextRequestId: requestId,
|
||||
intent: { ...input, requestId, entryIdentity: null },
|
||||
request: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function bindWorkflowSessionFocus(
|
||||
state: WorkflowSessionFocusState,
|
||||
input: {
|
||||
requestId: number;
|
||||
presentationToken: number;
|
||||
entryIdentity: string;
|
||||
},
|
||||
): WorkflowSessionFocusState {
|
||||
const intent = state.intent;
|
||||
if (
|
||||
!intent ||
|
||||
intent.requestId !== input.requestId ||
|
||||
intent.presentationToken !== input.presentationToken
|
||||
) {
|
||||
return state;
|
||||
}
|
||||
|
||||
const entryIdentity = nonEmptyString(input.entryIdentity);
|
||||
if (!entryIdentity) return clearWorkflowSessionFocusState(state);
|
||||
|
||||
return { ...state, intent: { ...intent, entryIdentity } };
|
||||
}
|
||||
|
||||
function scopeMatches(
|
||||
value: WorkflowSessionFocusIntent | WorkflowSessionFocusRequest,
|
||||
scope: WorkflowSessionFocusCancelScope,
|
||||
): boolean {
|
||||
if (scope.requestId !== undefined && value.requestId !== scope.requestId) return false;
|
||||
if (
|
||||
scope.presentationToken !== undefined &&
|
||||
value.presentationToken !== scope.presentationToken
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (scope.taskId !== undefined && value.taskId !== scope.taskId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function cancelWorkflowSessionFocus(
|
||||
state: WorkflowSessionFocusState,
|
||||
scope: WorkflowSessionFocusCancelScope = {},
|
||||
): WorkflowSessionFocusState {
|
||||
const activeValues = [state.intent, state.request].filter(
|
||||
(value): value is WorkflowSessionFocusIntent | WorkflowSessionFocusRequest => value !== null,
|
||||
);
|
||||
if (activeValues.length > 0 && !activeValues.some((value) => scopeMatches(value, scope))) {
|
||||
return state;
|
||||
}
|
||||
return clearWorkflowSessionFocusState(state);
|
||||
}
|
||||
|
||||
export function reconcileWorkflowSessionFocus(
|
||||
state: WorkflowSessionFocusState,
|
||||
input: WorkflowSessionFocusReconcileInput,
|
||||
): WorkflowSessionFocusReconcileResult {
|
||||
const intent = state.intent;
|
||||
if (!intent || !intent.entryIdentity) return { state, sessionId: null };
|
||||
|
||||
if (
|
||||
input.activeTaskId !== intent.taskId ||
|
||||
input.navigationRevision !== intent.navigationRevision
|
||||
) {
|
||||
return { state: clearWorkflowSessionFocusState(state), sessionId: null };
|
||||
}
|
||||
|
||||
const projection = acceptedTaskProjection(intent, input);
|
||||
const route = readCommittedWorkflowSessionRoute(projection.metadata);
|
||||
if (
|
||||
!route ||
|
||||
route.destinationStepId !== intent.destinationStepId ||
|
||||
route.entryIdentity !== intent.entryIdentity ||
|
||||
!input.knownSessionIds.includes(route.destinationSessionId)
|
||||
) {
|
||||
return { state, sessionId: null };
|
||||
}
|
||||
if (projection.workflowStepId !== intent.destinationStepId) {
|
||||
return { state: clearWorkflowSessionFocusState(state), sessionId: null };
|
||||
}
|
||||
|
||||
return {
|
||||
state: {
|
||||
...state,
|
||||
intent: null,
|
||||
request: {
|
||||
requestId: intent.requestId,
|
||||
presentationToken: intent.presentationToken,
|
||||
taskId: intent.taskId,
|
||||
sessionId: route.destinationSessionId,
|
||||
},
|
||||
},
|
||||
sessionId: route.destinationSessionId,
|
||||
};
|
||||
}
|
||||
|
||||
export function acknowledgeWorkflowSessionFocus(
|
||||
state: WorkflowSessionFocusState,
|
||||
requestId: number,
|
||||
): WorkflowSessionFocusState {
|
||||
if (state.request?.requestId !== requestId) return state;
|
||||
return { ...state, request: null };
|
||||
}
|
||||
@@ -567,6 +567,7 @@ export type WorkflowStepDTO = {
|
||||
export type MoveTaskResponse = {
|
||||
task: Task;
|
||||
workflow_step: WorkflowStepDTO;
|
||||
workflow_entry_identity?: string;
|
||||
move_id?: string;
|
||||
entry_options?: {
|
||||
reset_context?: boolean;
|
||||
|
||||
@@ -458,6 +458,7 @@ function syncEnvFromAgentctlPayload(
|
||||
...getAgentctlWorktreeFields(payload, isSibling),
|
||||
workspace_path: payload.workspace_path ?? payload.task_workspace_path ?? payload.worktree_path,
|
||||
});
|
||||
store.getState().reconcileWorkflowSessionFocus?.(taskId);
|
||||
}
|
||||
|
||||
/** Builds the partial-session patch applied for an agentctl_ready event.
|
||||
@@ -858,6 +859,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
return {
|
||||
"message.queue.status_changed": (message) =>
|
||||
handleQueueStatusChangedMessage(store, message.payload),
|
||||
// eslint-disable-next-line complexity -- ordered session reconciliation keeps stale-event guards together
|
||||
"session.state_changed": (message) => {
|
||||
const payload = message.payload;
|
||||
if (!payload?.task_id) return;
|
||||
@@ -900,6 +902,7 @@ export function registerTaskSessionHandlers(store: StoreApi<AppState>): WsHandle
|
||||
updatedAt: payload.updated_at,
|
||||
});
|
||||
upsertTaskSessionList(store, taskId, sessionId, payload, sessionUpdate);
|
||||
store.getState().reconcileWorkflowSessionFocus?.(taskId);
|
||||
syncKanbanPrimarySessionState(store, taskId, sessionId, newState);
|
||||
extractContextWindow(store, sessionId, payload);
|
||||
maybePromoteAgentctlReady(store, sessionId, newState, message.timestamp);
|
||||
|
||||
@@ -368,6 +368,8 @@ function handleTaskUpdated(store: StoreApi<AppState>, message: TaskUpdatedMessag
|
||||
}),
|
||||
);
|
||||
|
||||
store.getState().reconcileWorkflowSessionFocus?.(taskId);
|
||||
|
||||
if (archivedAt) {
|
||||
if (!localRemovalOwnsDeparture) redirectAwayFromRemovedTask(taskId);
|
||||
return;
|
||||
@@ -403,6 +405,7 @@ export function registerTasksHandlers(store: StoreApi<AppState>): WsHandlers {
|
||||
"task.deleted": (message) => {
|
||||
const deletedId = message.payload.task_id;
|
||||
const currentState = store.getState();
|
||||
currentState.cancelWorkflowSessionFocus?.({ taskId: deletedId });
|
||||
removeRecentTask(deletedId);
|
||||
// A quick chat closed on another device must not linger here as a tab
|
||||
// pointing at a task the backend already deleted.
|
||||
|
||||
Reference in New Issue
Block a user