focus
This commit is contained in:
+33
-1
@@ -42,6 +42,10 @@ func leadActor(login string) string {
|
||||
return login + " (lead)"
|
||||
}
|
||||
|
||||
func humanActor(actor string) bool {
|
||||
return actor != "byre" && actor != agentActor && !strings.HasSuffix(actor, leadActor(""))
|
||||
}
|
||||
|
||||
func (m *Manager) LeadSessions(ctx context.Context, owner string) ([]api.AgentSession, error) {
|
||||
return m.store.LeadSessions(ctx, owner)
|
||||
}
|
||||
@@ -199,7 +203,7 @@ func (m *Manager) leadStateText(ctx context.Context, owner string) (string, erro
|
||||
pane = fmt.Sprintf("Your right pane shows project %s (%s) on its %s tab.", project.Name, project.Id, f.Tab)
|
||||
}
|
||||
m.mu.Lock()
|
||||
focused := m.paneFocused[owner]
|
||||
focused := m.paneFocus[owner].Focused
|
||||
m.mu.Unlock()
|
||||
if focused {
|
||||
pane += " The human is working in it."
|
||||
@@ -313,6 +317,10 @@ func (m *Manager) warnLeadContext(ctx context.Context, l *live) error {
|
||||
func (m *Manager) Observe(ev api.Event) {
|
||||
switch ev.Kind {
|
||||
case "inbox.created", "inbox.updated", "task.created", "task.merge_started", "task.merged", "task.discarded", "turn.ended", "task.workflow_stage", "task.workflow_finished":
|
||||
case "user.message":
|
||||
if ev.TaskId == nil || !humanActor(ev.Actor) {
|
||||
return
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
@@ -370,6 +378,9 @@ func (m *Manager) signal(ctx context.Context, ev api.Event) error {
|
||||
}
|
||||
|
||||
func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (string, error) {
|
||||
if ev.Kind == "user.message" {
|
||||
return m.waitingText(ctx, owner, ev)
|
||||
}
|
||||
switch ev.Kind {
|
||||
case "inbox.updated", "task.merge_started", "task.merged", "task.discarded":
|
||||
default:
|
||||
@@ -392,6 +403,24 @@ func (m *Manager) freedText(ctx context.Context, owner string, ev api.Event) (st
|
||||
return fmt.Sprintf("Your right pane is free: %s %s.", m.taskLabel(ctx, *f.TaskId), finished), nil
|
||||
}
|
||||
|
||||
func (m *Manager) waitingText(ctx context.Context, owner string, ev api.Event) (string, error) {
|
||||
if ev.Actor != owner {
|
||||
return "", nil
|
||||
}
|
||||
f, err := m.store.GetFocus(ctx, owner)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if f.TaskId == nil || *f.TaskId != deref(ev.TaskId) {
|
||||
return "", nil
|
||||
}
|
||||
waiting, err := m.paneWaiting(ctx, owner, f)
|
||||
if err != nil || !waiting {
|
||||
return "", err
|
||||
}
|
||||
return fmt.Sprintf("Your right pane is free: the human is waiting on %s's agent.", m.taskLabel(ctx, *f.TaskId)), nil
|
||||
}
|
||||
|
||||
func (m *Manager) taskLabel(ctx context.Context, taskID string) string {
|
||||
task, err := m.store.GetTask(ctx, taskID)
|
||||
if err != nil {
|
||||
@@ -450,6 +479,9 @@ func (m *Manager) signalText(ctx context.Context, ev api.Event) (string, error)
|
||||
case "task.workflow_finished":
|
||||
return fmt.Sprintf("%s finished workflow %q:\n%s", m.taskLabel(ctx, deref(ev.TaskId)), ev.Payload["workflow"], clip(fmt.Sprint(ev.Payload["output"]), 1500)), nil
|
||||
|
||||
case "user.message":
|
||||
return fmt.Sprintf("%s sent %s a message; its agent is working on it.", ev.Actor, m.taskLabel(ctx, deref(ev.TaskId))), nil
|
||||
|
||||
case "turn.ended":
|
||||
if ev.Payload["summary"] != true {
|
||||
return "", nil
|
||||
|
||||
@@ -327,7 +327,8 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
"Tabs: transcript, changes, files, terminal (a shell in the task's worktree), inbox (the task's open items), reviews (the task's reviews by reviewer agents), project (the project's dashboard: its active tasks and its TODO.md items); with only project_id, only project shows anything. "+
|
||||
"Omit both task_id and project_id to clear the pane. "+
|
||||
"If the pane already shows that task (or project) on that tab, only the tab order changes and you are told so, even while the human is working in the pane. "+
|
||||
"Otherwise refused while the human is working in the right pane, unless it is on the project tab or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left). "+
|
||||
"Otherwise refused while the human is working in the right pane, unless it is on the project tab, or what it shows is finished (a merged or discarded task, or an inbox tab with nothing left), "+
|
||||
"or it is on the transcript tab of a task whose agent is mid-turn, the human has no unsent text there, and either the human sent the message that started that turn since the pane last moved or the pane moved there 10 seconds ago or more; byre signals you \"Your right pane is free\" when either happens. "+
|
||||
"Also refused while the pane is on the project tab and the human started a TODO item there in the last 10 seconds, unless they switched task or tab or messaged you since; byre signals you \"The start hold is over\" when those 10 seconds pass with the pane still on the project tab.",
|
||||
`{"type": "object", "properties": {
|
||||
"task_id": {"type": "string"},
|
||||
@@ -356,7 +357,7 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
return "Already shown on that tab; the tab order was updated.", nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
focused := m.paneFocused[user.Login]
|
||||
focused := m.paneFocus[user.Login].Focused
|
||||
startedTodo := m.startedTodo[user.Login]
|
||||
m.mu.Unlock()
|
||||
if time.Since(startedTodo) < startHold && current.Tab == api.TabProject {
|
||||
@@ -367,7 +368,14 @@ func (m *Manager) leadTools(l *live) []mcp.Tool {
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
waiting := false
|
||||
if finished == "" {
|
||||
waiting, err = m.paneWaiting(ctx, user.Login, current)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
if finished == "" && !waiting {
|
||||
return "", fmt.Errorf("%w: the human is working in the right pane, so it was left as it is; tell them in chat instead", ErrConflict)
|
||||
}
|
||||
}
|
||||
@@ -417,9 +425,53 @@ func (m *Manager) SetFocus(ctx context.Context, login string, f api.Focus, actor
|
||||
if actor == login {
|
||||
m.setStartedTodo(login, false)
|
||||
}
|
||||
at := time.Now()
|
||||
m.mu.Lock()
|
||||
m.paneMoved[login] = at
|
||||
m.mu.Unlock()
|
||||
if actor == login {
|
||||
time.AfterFunc(watchHold, func() {
|
||||
err := m.endWatchHold(context.Background(), login, at)
|
||||
if err != nil {
|
||||
slog.Error("end watch hold", "login", login, "err", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
return m.store.SetFocus(ctx, login, f, actor)
|
||||
}
|
||||
|
||||
func (m *Manager) endWatchHold(ctx context.Context, login string, at time.Time) error {
|
||||
m.mu.Lock()
|
||||
moved := m.paneMoved[login]
|
||||
focused := m.paneFocus[login].Focused
|
||||
m.mu.Unlock()
|
||||
if !moved.Equal(at) || !focused {
|
||||
return nil
|
||||
}
|
||||
f, err := m.store.GetFocus(ctx, login)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if f.TaskId == nil {
|
||||
return nil
|
||||
}
|
||||
turn, running, err := m.store.RunningTurn(ctx, *f.TaskId)
|
||||
if err != nil || !running || (turn.Actor == login && turn.At.After(at)) {
|
||||
return err
|
||||
}
|
||||
waiting, err := m.paneWaiting(ctx, login, f)
|
||||
if err != nil || !waiting {
|
||||
return err
|
||||
}
|
||||
text := fmt.Sprintf("Your right pane is free: the human has watched %s's agent work for 10 seconds without typing.", m.taskLabel(ctx, *f.TaskId))
|
||||
err = m.store.AddLeadInput(ctx, store.LeadInput{Owner: login, Kind: store.LeadSignal, Actor: "byre", Text: text})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
m.deliverLead(login)
|
||||
return nil
|
||||
}
|
||||
|
||||
func sameView(a, b api.Focus) bool {
|
||||
if a.Tab != b.Tab {
|
||||
return false
|
||||
@@ -464,10 +516,33 @@ func (m *Manager) paneFinished(ctx context.Context, f api.Focus) (string, error)
|
||||
return inboxEmptied, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetPaneFocus(login string, focused bool) {
|
||||
const watchHold = 10 * time.Second
|
||||
|
||||
func (m *Manager) paneWaiting(ctx context.Context, login string, f api.Focus) (bool, error) {
|
||||
if f.TaskId == nil || f.Tab != api.TabTranscript {
|
||||
return false, nil
|
||||
}
|
||||
m.mu.Lock()
|
||||
drafting := m.paneFocus[login].Drafting
|
||||
reported := m.paneReported[login]
|
||||
moved := m.paneMoved[login]
|
||||
m.mu.Unlock()
|
||||
turn, running, err := m.store.RunningTurn(ctx, *f.TaskId)
|
||||
if err != nil || !running {
|
||||
return false, err
|
||||
}
|
||||
if turn.Actor == login && turn.At.After(moved) {
|
||||
// The report of the composer emptying on send can arrive after the turn starts.
|
||||
return !drafting || reported.Before(turn.At), nil
|
||||
}
|
||||
return !drafting && time.Since(moved) >= watchHold, nil
|
||||
}
|
||||
|
||||
func (m *Manager) SetPaneFocus(login string, p api.PaneFocus) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
m.paneFocused[login] = focused
|
||||
m.paneFocus[login] = p
|
||||
m.paneReported[login] = time.Now()
|
||||
}
|
||||
|
||||
const startHold = 10 * time.Second
|
||||
|
||||
@@ -43,7 +43,9 @@ type Manager struct {
|
||||
closing bool
|
||||
rateLimits map[api.AgentSessionVendor]map[string]any
|
||||
leadAnswered map[string]bool
|
||||
paneFocused map[string]bool
|
||||
paneFocus map[string]api.PaneFocus
|
||||
paneReported map[string]time.Time
|
||||
paneMoved map[string]time.Time
|
||||
startedTodo map[string]time.Time
|
||||
|
||||
deliverMu sync.Mutex
|
||||
@@ -105,7 +107,9 @@ func New(st *store.Store, worktreeRoot, leadRoot, codexHome string) *Manager {
|
||||
live: map[string]*live{},
|
||||
rateLimits: map[api.AgentSessionVendor]map[string]any{},
|
||||
leadAnswered: map[string]bool{},
|
||||
paneFocused: map[string]bool{},
|
||||
paneFocus: map[string]api.PaneFocus{},
|
||||
paneReported: map[string]time.Time{},
|
||||
paneMoved: map[string]time.Time{},
|
||||
startedTodo: map[string]time.Time{},
|
||||
signals: make(chan api.Event, 1024),
|
||||
snaps: map[string]*snapState{},
|
||||
|
||||
Reference in New Issue
Block a user