mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
fix: stabilize git tab during edit_files (#24648)
- feat(agent/agentgit): shorten fallback poll to 5s
- fix(site/AgentsPage): keep git tab visible after reverting to clean
- feat(site/AgentsPage): show last-checked time in git tab
> 🤖
This commit is contained in:
@@ -44,8 +44,12 @@ const (
|
|||||||
// scanCooldown is the minimum interval between successive scans.
|
// scanCooldown is the minimum interval between successive scans.
|
||||||
scanCooldown = 1 * time.Second
|
scanCooldown = 1 * time.Second
|
||||||
// fallbackPollInterval is the safety-net poll period used when no
|
// fallbackPollInterval is the safety-net poll period used when no
|
||||||
// filesystem events arrive.
|
// filesystem events arrive. scanCooldown caps the actual scan
|
||||||
fallbackPollInterval = 30 * time.Second
|
// frequency; an outer guard in RunLoop further skips the tick
|
||||||
|
// when a trigger-driven scan already ran within this interval.
|
||||||
|
// Each tick forks 6 git subprocesses per subscribed repo plus
|
||||||
|
// one diff --no-index per untracked file.
|
||||||
|
fallbackPollInterval = 5 * time.Second
|
||||||
// maxTotalDiffSize is the maximum size of the combined
|
// maxTotalDiffSize is the maximum size of the combined
|
||||||
// unified diff for an entire repository sent over the wire.
|
// unified diff for an entire repository sent over the wire.
|
||||||
// This must stay under the WebSocket message size limit.
|
// This must stay under the WebSocket message size limit.
|
||||||
@@ -224,10 +228,9 @@ func (h *Handler) Scan(ctx context.Context) *codersdk.WorkspaceAgentGitServerMes
|
|||||||
|
|
||||||
h.lastScanAt = now
|
h.lastScanAt = now
|
||||||
|
|
||||||
if len(repos) == 0 {
|
// Always emit when any root is subscribed. A no-delta scan sends
|
||||||
return nil
|
// ScannedAt + empty Repositories (omitted via omitempty) so the
|
||||||
}
|
// client's "checked Ns ago" label stays honest on idle repos.
|
||||||
|
|
||||||
return &codersdk.WorkspaceAgentGitServerMessage{
|
return &codersdk.WorkspaceAgentGitServerMessage{
|
||||||
Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges,
|
Type: codersdk.WorkspaceAgentGitServerMessageTypeChanges,
|
||||||
ScannedAt: &now,
|
ScannedAt: &now,
|
||||||
@@ -252,6 +255,15 @@ func (h *Handler) RunLoop(ctx context.Context, scanFn func()) {
|
|||||||
h.rateLimitedScan(ctx, scanFn)
|
h.rateLimitedScan(ctx, scanFn)
|
||||||
|
|
||||||
case <-fallbackTicker.C:
|
case <-fallbackTicker.C:
|
||||||
|
// Skip when a recent trigger-driven scan already covered
|
||||||
|
// this interval, so a busy chat pays near-zero poll cost.
|
||||||
|
h.mu.Lock()
|
||||||
|
recent := !h.lastScanAt.IsZero() &&
|
||||||
|
h.clock.Since(h.lastScanAt) < fallbackPollInterval
|
||||||
|
h.mu.Unlock()
|
||||||
|
if recent {
|
||||||
|
continue
|
||||||
|
}
|
||||||
h.rateLimitedScan(ctx, scanFn)
|
h.rateLimitedScan(ctx, scanFn)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+263
-12
@@ -253,9 +253,13 @@ func TestScanDeltaEmission(t *testing.T) {
|
|||||||
require.NotNil(t, msg1)
|
require.NotNil(t, msg1)
|
||||||
require.Len(t, msg1.Repositories, 1)
|
require.Len(t, msg1.Repositories, 1)
|
||||||
|
|
||||||
// Second scan with no changes — should return nil (no delta).
|
// Second scan with no changes. Should emit a heartbeat with a
|
||||||
|
// fresh ScannedAt but no repositories. This lets the UI's
|
||||||
|
// "checked Ns ago" label stay honest on an idle clean repo.
|
||||||
msg2 := h.Scan(ctx)
|
msg2 := h.Scan(ctx)
|
||||||
require.Nil(t, msg2, "no changes since last scan should return nil")
|
require.NotNil(t, msg2, "heartbeat should fire even with no delta")
|
||||||
|
require.NotNil(t, msg2.ScannedAt)
|
||||||
|
require.Empty(t, msg2.Repositories, "heartbeat must not report per-repo changes")
|
||||||
|
|
||||||
// Revert the dirty file (make repo clean).
|
// Revert the dirty file (make repo clean).
|
||||||
require.NoError(t, os.Remove(dirtyFile))
|
require.NoError(t, os.Remove(dirtyFile))
|
||||||
@@ -269,6 +273,59 @@ func TestScanDeltaEmission(t *testing.T) {
|
|||||||
require.NotContains(t, msg3.Repositories[0].UnifiedDiff, "dirty.go")
|
require.NotContains(t, msg3.Repositories[0].UnifiedDiff, "dirty.go")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestScanHeartbeatOnCleanRepo pins the heartbeat contract: while any
|
||||||
|
// repo is subscribed, every scan emits a non-nil message with a fresh
|
||||||
|
// ScannedAt, even when no repo produced a delta. The UI's
|
||||||
|
// "checked Ns ago" label depends on this so an idle clean repo does
|
||||||
|
// not drift while the agent is still polling.
|
||||||
|
func TestScanHeartbeatOnCleanRepo(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
repoDir := initTestRepo(t)
|
||||||
|
logger := slogtest.Make(t, nil)
|
||||||
|
|
||||||
|
h := agentgit.NewHandler(logger)
|
||||||
|
require.True(t, h.Subscribe([]string{repoDir}))
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// First scan on a clean repo captures branch/remote/empty-diff.
|
||||||
|
msg1 := h.Scan(ctx)
|
||||||
|
require.NotNil(t, msg1)
|
||||||
|
require.NotNil(t, msg1.ScannedAt)
|
||||||
|
require.Len(t, msg1.Repositories, 1)
|
||||||
|
require.Empty(t, msg1.Repositories[0].UnifiedDiff)
|
||||||
|
firstScanAt := *msg1.ScannedAt
|
||||||
|
|
||||||
|
// Second scan: no delta, but heartbeat must still advance
|
||||||
|
// ScannedAt so clients can render an honest "checked Ns ago".
|
||||||
|
msg2 := h.Scan(ctx)
|
||||||
|
require.NotNil(t, msg2, "heartbeat should fire on a no-delta scan")
|
||||||
|
require.NotNil(t, msg2.ScannedAt)
|
||||||
|
require.Empty(t, msg2.Repositories, "heartbeat carries no per-repo changes")
|
||||||
|
require.False(t, msg2.ScannedAt.Before(firstScanAt),
|
||||||
|
"heartbeat ScannedAt must not go backwards")
|
||||||
|
|
||||||
|
// Third scan: also a heartbeat. Still non-nil, still empty.
|
||||||
|
msg3 := h.Scan(ctx)
|
||||||
|
require.NotNil(t, msg3)
|
||||||
|
require.Empty(t, msg3.Repositories)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestScanNoHeartbeatWithoutSubscribedRoots pins that the heartbeat
|
||||||
|
// only fires when there is at least one subscribed repo. Before any
|
||||||
|
// subscribe call, Scan() must still short-circuit to nil so the
|
||||||
|
// WebSocket handler does not spam empty messages to a client that
|
||||||
|
// has not registered any paths yet.
|
||||||
|
func TestScanNoHeartbeatWithoutSubscribedRoots(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slogtest.Make(t, nil)
|
||||||
|
h := agentgit.NewHandler(logger)
|
||||||
|
|
||||||
|
msg := h.Scan(context.Background())
|
||||||
|
require.Nil(t, msg, "no subscribed roots should mean no heartbeat")
|
||||||
|
}
|
||||||
|
|
||||||
func TestScanDeltaDetectsContentChanges(t *testing.T) {
|
func TestScanDeltaDetectsContentChanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -291,9 +348,10 @@ func TestScanDeltaDetectsContentChanges(t *testing.T) {
|
|||||||
|
|
||||||
require.Contains(t, msg1.Repositories[0].UnifiedDiff, "README.md")
|
require.Contains(t, msg1.Repositories[0].UnifiedDiff, "README.md")
|
||||||
|
|
||||||
// Second scan with no changes — should return nil (no delta).
|
// Second scan with no changes: heartbeat, no repositories.
|
||||||
msg2 := h.Scan(ctx)
|
msg2 := h.Scan(ctx)
|
||||||
require.Nil(t, msg2, "no changes since last scan should return nil")
|
require.NotNil(t, msg2, "heartbeat should fire even with no delta")
|
||||||
|
require.Empty(t, msg2.Repositories)
|
||||||
|
|
||||||
// Now modify the SAME file further (still "Modified" status, but
|
// Now modify the SAME file further (still "Modified" status, but
|
||||||
// different content).
|
// different content).
|
||||||
@@ -318,9 +376,10 @@ func TestScanDeltaDetectsContentChanges(t *testing.T) {
|
|||||||
|
|
||||||
require.Contains(t, msg4.Repositories[0].UnifiedDiff, "untracked.go")
|
require.Contains(t, msg4.Repositories[0].UnifiedDiff, "untracked.go")
|
||||||
|
|
||||||
// No changes — should return nil.
|
// No changes: heartbeat, no repositories.
|
||||||
msg5 := h.Scan(ctx)
|
msg5 := h.Scan(ctx)
|
||||||
require.Nil(t, msg5, "no changes since last scan should return nil")
|
require.NotNil(t, msg5, "heartbeat should fire even with no delta")
|
||||||
|
require.Empty(t, msg5.Repositories)
|
||||||
|
|
||||||
// Modify the untracked file further.
|
// Modify the untracked file further.
|
||||||
require.NoError(t, os.WriteFile(untrackedPath, []byte("package main\n\nfunc init() {}\n"), 0o600))
|
require.NoError(t, os.WriteFile(untrackedPath, []byte("package main\n\nfunc init() {}\n"), 0o600))
|
||||||
@@ -875,7 +934,7 @@ func TestFallbackPollTriggersScan(t *testing.T) {
|
|||||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "poll.go"), []byte("package poll\n"), 0o600))
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "poll.go"), []byte("package poll\n"), 0o600))
|
||||||
ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoDir, "poll.go")})
|
ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoDir, "poll.go")})
|
||||||
|
|
||||||
// Only the 30s fallback poll can trigger scans (no filesystem
|
// Only the fallback poll can trigger scans (no filesystem
|
||||||
// watcher).
|
// watcher).
|
||||||
stream := dialGitWatchWithPathStore(t, ps, chatID, agentgit.WithClock(mClock))
|
stream := dialGitWatchWithPathStore(t, ps, chatID, agentgit.WithClock(mClock))
|
||||||
ch := stream.Chan()
|
ch := stream.Chan()
|
||||||
@@ -887,9 +946,9 @@ func TestFallbackPollTriggersScan(t *testing.T) {
|
|||||||
// Add a new dirty file so the next scan has a delta to report.
|
// Add a new dirty file so the next scan has a delta to report.
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "poll2.go"), []byte("package poll\n"), 0o600))
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "poll2.go"), []byte("package poll\n"), 0o600))
|
||||||
|
|
||||||
// Advance to the 30s fallback poll interval. This should
|
// Advance to the fallback poll interval. This should trigger a
|
||||||
// trigger a scan without any explicit refresh.
|
// scan without any explicit refresh.
|
||||||
mClock.Advance(30 * time.Second).MustWait(context.Background())
|
mClock.Advance(5 * time.Second).MustWait(context.Background())
|
||||||
|
|
||||||
msg2 := recvMsg(ctx, t, ch)
|
msg2 := recvMsg(ctx, t, ch)
|
||||||
require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg2.Type)
|
require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg2.Type)
|
||||||
@@ -1002,9 +1061,10 @@ func TestScanLargeFileDeltaTracking(t *testing.T) {
|
|||||||
msg1 := h.Scan(ctx)
|
msg1 := h.Scan(ctx)
|
||||||
require.NotNil(t, msg1)
|
require.NotNil(t, msg1)
|
||||||
|
|
||||||
// Second scan with no changes — should return nil (no delta).
|
// Second scan with no changes: heartbeat, no repositories.
|
||||||
msg2 := h.Scan(ctx)
|
msg2 := h.Scan(ctx)
|
||||||
require.Nil(t, msg2, "no changes should mean no delta")
|
require.NotNil(t, msg2, "heartbeat should fire even with no delta")
|
||||||
|
require.Empty(t, msg2.Repositories, "no delta means no repo entries")
|
||||||
|
|
||||||
// Remove the large file — should emit a clean delta.
|
// Remove the large file — should emit a clean delta.
|
||||||
require.NoError(t, os.Remove(largeFile))
|
require.NoError(t, os.Remove(largeFile))
|
||||||
@@ -1422,3 +1482,194 @@ func TestE2E_RepoDeletionEmitsRemoved(t *testing.T) {
|
|||||||
}
|
}
|
||||||
require.True(t, foundRemoved, "expected repo %s to be marked as removed", repoDir)
|
require.True(t, foundRemoved, "expected repo %s to be marked as removed", repoDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRunLoopExitsPromptlyOnCancel_DuringPoll pins that RunLoop
|
||||||
|
// returns quickly when its context is cancelled while it is blocked
|
||||||
|
// on the fallback poll ticker. Regression guard for the fallback
|
||||||
|
// interval: if a future change introduces a non-cancellable wait
|
||||||
|
// here, this test will hang and fail.
|
||||||
|
func TestRunLoopExitsPromptlyOnCancel_DuringPoll(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
logger := slogtest.Make(t, nil)
|
||||||
|
mClock := quartz.NewMock(t)
|
||||||
|
h := agentgit.NewHandler(logger, agentgit.WithClock(mClock))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
// Trap NewTicker so the test can synchronize on RunLoop's
|
||||||
|
// ticker creation rather than racing against it with a
|
||||||
|
// best-effort Advance.
|
||||||
|
tickerTrap := mClock.Trap().NewTicker()
|
||||||
|
defer tickerTrap.Close()
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
h.RunLoop(ctx, func() {})
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait until RunLoop has actually called clock.NewTicker, then
|
||||||
|
// release the trap so the ticker is installed. At this point
|
||||||
|
// RunLoop is deterministically inside its select, blocked on
|
||||||
|
// <-ticker.C / <-scanTrigger / <-ctx.Done().
|
||||||
|
tickerTrap.MustWait(ctx).MustRelease(ctx)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(testutil.WaitShort):
|
||||||
|
t.Fatal("RunLoop did not return within WaitShort after ctx cancel")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRunLoopExitsPromptlyOnCancel_DuringCooldown pins that RunLoop
|
||||||
|
// returns quickly when its context is cancelled while a
|
||||||
|
// rateLimitedScan is sleeping out the cooldown between scans.
|
||||||
|
// Regression guard: all waits inside the cooldown path must select
|
||||||
|
// on ctx.Done().
|
||||||
|
func TestRunLoopExitsPromptlyOnCancel_DuringCooldown(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
repoDir := initTestRepo(t)
|
||||||
|
logger := slogtest.Make(t, nil)
|
||||||
|
mClock := quartz.NewMock(t)
|
||||||
|
h := agentgit.NewHandler(logger, agentgit.WithClock(mClock))
|
||||||
|
|
||||||
|
// Subscribe a real repo so Scan() actually does work and, on
|
||||||
|
// completion, updates lastScanAt. Without this, Scan() early-
|
||||||
|
// returns on empty roots and the cooldown branch never arms.
|
||||||
|
require.True(t, h.Subscribe([]string{repoDir}))
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
t.Cleanup(cancel)
|
||||||
|
|
||||||
|
// Trap NewTicker (for RunLoop) and NewTimer (for the cooldown
|
||||||
|
// wait inside rateLimitedScan) so the test synchronizes on each
|
||||||
|
// wait point instead of racing against goroutine scheduling.
|
||||||
|
tickerTrap := mClock.Trap().NewTicker()
|
||||||
|
defer tickerTrap.Close()
|
||||||
|
timerTrap := mClock.Trap().NewTimer()
|
||||||
|
defer timerTrap.Close()
|
||||||
|
|
||||||
|
scanStarted := make(chan struct{}, 1)
|
||||||
|
blocked := make(chan struct{})
|
||||||
|
scanFn := func() {
|
||||||
|
// Run a real Scan so lastScanAt is set by the handler;
|
||||||
|
// that is the precondition for the cooldown branch.
|
||||||
|
_ = h.Scan(ctx)
|
||||||
|
select {
|
||||||
|
case scanStarted <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
// Block until the test releases us, mimicking a slow
|
||||||
|
// follow-up scan that parks RunLoop inside rateLimitedScan.
|
||||||
|
<-blocked
|
||||||
|
}
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
h.RunLoop(ctx, scanFn)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Release the fallback ticker so RunLoop enters its select.
|
||||||
|
tickerTrap.MustWait(ctx).MustRelease(ctx)
|
||||||
|
|
||||||
|
// First trigger: consumed immediately (lastScanAt is zero).
|
||||||
|
// scanFn runs Scan() (which sets lastScanAt), signals
|
||||||
|
// scanStarted, then blocks on <-blocked.
|
||||||
|
h.RequestScan()
|
||||||
|
<-scanStarted
|
||||||
|
|
||||||
|
// Release the first scan; RunLoop loops back to select.
|
||||||
|
close(blocked)
|
||||||
|
|
||||||
|
// Fire a second trigger. Because lastScanAt is fresh (set by
|
||||||
|
// the real Scan above), rateLimitedScan enters its cooldown
|
||||||
|
// wait and calls clock.NewTimer. The trap blocks the goroutine
|
||||||
|
// inside that call until we release it, so we know exactly
|
||||||
|
// when it is sitting on the cooldown select.
|
||||||
|
h.RequestScan()
|
||||||
|
timerCall := timerTrap.MustWait(ctx)
|
||||||
|
|
||||||
|
// Cancel while the goroutine is still paused inside NewTimer.
|
||||||
|
// Release the trap; rateLimitedScan then enters the select on
|
||||||
|
// the cooldown timer vs. ctx.Done(), and ctx.Done() is already
|
||||||
|
// ready so it wins. MustRelease uses Background because the
|
||||||
|
// test ctx is the one we just cancelled.
|
||||||
|
releaseCtx, releaseCancel := context.WithTimeout(context.Background(), testutil.WaitShort)
|
||||||
|
defer releaseCancel()
|
||||||
|
cancel()
|
||||||
|
timerCall.MustRelease(releaseCtx)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-done:
|
||||||
|
case <-time.After(testutil.WaitShort):
|
||||||
|
t.Fatal("RunLoop did not return within WaitShort after ctx cancel during cooldown")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFallbackPollSkipsWhenRecentlyScanned pins the RunLoop optimization
|
||||||
|
// that swallows a fallback tick when a trigger-driven scan already
|
||||||
|
// covered the last fallback interval. Without the skip, a busy chat
|
||||||
|
// (agent editing + PathStore notifications) would pay the full fallback
|
||||||
|
// scan cost on top of trigger-driven scans.
|
||||||
|
func TestFallbackPollSkipsWhenRecentlyScanned(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
ctx := testutil.Context(t, testutil.WaitLong)
|
||||||
|
repoDir := initTestRepo(t)
|
||||||
|
mClock := quartz.NewMock(t)
|
||||||
|
|
||||||
|
ps := agentgit.NewPathStore()
|
||||||
|
chatID := uuid.New()
|
||||||
|
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "a.go"), []byte("package a\n"), 0o600))
|
||||||
|
ps.AddPaths([]uuid.UUID{chatID}, []string{filepath.Join(repoDir, "a.go")})
|
||||||
|
|
||||||
|
stream := dialGitWatchWithPathStore(t, ps, chatID, agentgit.WithClock(mClock))
|
||||||
|
ch := stream.Chan()
|
||||||
|
|
||||||
|
// Consume the initial scan from subscribe.
|
||||||
|
msg1 := recvMsg(ctx, t, ch)
|
||||||
|
require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg1.Type)
|
||||||
|
|
||||||
|
// A trigger-driven scan within the fallback interval should
|
||||||
|
// cause the next fallback tick to be skipped. Advance part-way
|
||||||
|
// to the 5s tick, fire a notification to trigger a scan, then
|
||||||
|
// advance the rest of the way to the tick. The tick should be
|
||||||
|
// swallowed because lastScanAt is recent.
|
||||||
|
mClock.Advance(4 * time.Second).MustWait(context.Background())
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "a.go"), []byte("package a\n// edit\n"), 0o600))
|
||||||
|
ps.Notify([]uuid.UUID{chatID})
|
||||||
|
|
||||||
|
// Consume the trigger-driven scan. lastScanAt is now ~t=4s.
|
||||||
|
msg2 := recvMsg(ctx, t, ch)
|
||||||
|
require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg2.Type)
|
||||||
|
|
||||||
|
// Dirty the tree further so the fallback tick would have
|
||||||
|
// something to emit if it were not skipped.
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(repoDir, "b.go"), []byte("package b\n"), 0o600))
|
||||||
|
|
||||||
|
// Advance to the 5s ticker boundary. The tick fires but is
|
||||||
|
// skipped because Since(lastScanAt) = 1s < fallbackPollInterval.
|
||||||
|
mClock.Advance(1 * time.Second).MustWait(context.Background())
|
||||||
|
|
||||||
|
// Confirm no scan arrived for the skipped tick.
|
||||||
|
select {
|
||||||
|
case msg := <-ch:
|
||||||
|
t.Fatalf("unexpected scan after skipped fallback tick: %+v", msg)
|
||||||
|
case <-time.After(testutil.IntervalFast):
|
||||||
|
}
|
||||||
|
|
||||||
|
// Advance to the next ticker boundary (t=10s). lastScanAt is
|
||||||
|
// ~4s, so Since = 6s >= fallbackPollInterval and the tick
|
||||||
|
// should no longer be skipped.
|
||||||
|
mClock.Advance(5 * time.Second).MustWait(context.Background())
|
||||||
|
|
||||||
|
msg3 := recvMsg(ctx, t, ch)
|
||||||
|
require.Equal(t, codersdk.WorkspaceAgentGitServerMessageTypeChanges, msg3.Type)
|
||||||
|
}
|
||||||
|
|||||||
@@ -67,15 +67,16 @@ func (a *API) handleWatch(rw http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
handler := NewHandler(a.logger, a.opts...)
|
handler := NewHandler(a.logger, a.opts...)
|
||||||
|
|
||||||
// scanAndSend performs a scan and sends results if there are
|
// Scan returns nil only when no roots are subscribed; once any
|
||||||
// changes.
|
// root lands it returns either a delta or a heartbeat message.
|
||||||
scanAndSend := func() {
|
scanAndSend := func() {
|
||||||
msg := handler.Scan(ctx)
|
msg := handler.Scan(ctx)
|
||||||
if msg != nil {
|
if msg == nil {
|
||||||
if err := stream.Send(*msg); err != nil {
|
return
|
||||||
a.logger.Debug(ctx, "failed to send changes", slog.Error(err))
|
}
|
||||||
cancel()
|
if err := stream.Send(*msg); err != nil {
|
||||||
}
|
a.logger.Debug(ctx, "failed to send changes", slog.Error(err))
|
||||||
|
cancel()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ const buildGitWatcher = (): ComponentProps<
|
|||||||
typeof AgentChatPageView
|
typeof AgentChatPageView
|
||||||
>["gitWatcher"] => ({
|
>["gitWatcher"] => ({
|
||||||
repositories: new Map(),
|
repositories: new Map(),
|
||||||
|
everDirty: new Set(),
|
||||||
|
lastCheckedAt: undefined,
|
||||||
refresh: fn().mockReturnValue(true),
|
refresh: fn().mockReturnValue(true),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -127,6 +127,8 @@ interface AgentChatPageViewProps {
|
|||||||
debugLoggingEnabled: boolean;
|
debugLoggingEnabled: boolean;
|
||||||
gitWatcher: {
|
gitWatcher: {
|
||||||
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
|
repositories: ReadonlyMap<string, TypesGen.WorkspaceAgentRepoChanges>;
|
||||||
|
everDirty: ReadonlySet<string>;
|
||||||
|
lastCheckedAt: Date | undefined;
|
||||||
refresh: () => boolean;
|
refresh: () => boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -343,6 +345,8 @@ export const AgentChatPageView: FC<AgentChatPageViewProps> = ({
|
|||||||
prNumber && agentId ? { prNumber, chatId: agentId } : undefined
|
prNumber && agentId ? { prNumber, chatId: agentId } : undefined
|
||||||
}
|
}
|
||||||
repositories={gitWatcher.repositories}
|
repositories={gitWatcher.repositories}
|
||||||
|
everDirty={gitWatcher.everDirty}
|
||||||
|
lastCheckedAt={gitWatcher.lastCheckedAt}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onCommit={handleCommit}
|
onCommit={handleCommit}
|
||||||
isExpanded={visualExpanded}
|
isExpanded={visualExpanded}
|
||||||
|
|||||||
@@ -339,3 +339,94 @@ export const LargeDiff: Story = {
|
|||||||
]),
|
]),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Regression: when a repo was dirty during this session and then went
|
||||||
|
* clean (empty unified_diff), the tab must remain visible. Before the
|
||||||
|
* ever-dirty fix, the tab vanished the moment the diff became empty,
|
||||||
|
* which is what users saw as "diff disappears between edit_files".
|
||||||
|
*/
|
||||||
|
export const EverDirtyRepoGoneClean: Story = {
|
||||||
|
args: {
|
||||||
|
repositories: new Map([
|
||||||
|
["/home/coder/coder", makeRepo({ unified_diff: "" })],
|
||||||
|
]),
|
||||||
|
everDirty: new Set(["/home/coder/coder"]),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
// The repo tab is still present (identified by the 'Working'
|
||||||
|
// prefix used by GitPanel's tab-strip button) even though the
|
||||||
|
// current diff is empty, because it was dirty earlier in the
|
||||||
|
// session.
|
||||||
|
const tabs = Array.from(canvasElement.querySelectorAll("button")).filter(
|
||||||
|
(b) => (b.textContent ?? "").startsWith("Working"),
|
||||||
|
);
|
||||||
|
expect(tabs).toHaveLength(1);
|
||||||
|
|
||||||
|
// The content pane shows the diff viewer's empty-diff state.
|
||||||
|
expect(canvasElement.textContent ?? "").toContain("No file changes");
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Baseline: a repo reported clean from the start (never dirty in
|
||||||
|
* this session) has no tab. Ensures the ever-dirty fix did not
|
||||||
|
* regress the "nothing to show" case.
|
||||||
|
*/
|
||||||
|
export const CleanRepoFromStart: Story = {
|
||||||
|
args: {
|
||||||
|
repositories: new Map([
|
||||||
|
["/home/coder/coder", makeRepo({ unified_diff: "" })],
|
||||||
|
]),
|
||||||
|
everDirty: new Set(),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
// No local repo tab should appear in the tab strip. The
|
||||||
|
// 'Working' prefix is GitPanel's tab-strip label contract.
|
||||||
|
const tabs = Array.from(canvasElement.querySelectorAll("button")).filter(
|
||||||
|
(b) => (b.textContent ?? "").startsWith("Working"),
|
||||||
|
);
|
||||||
|
expect(tabs).toHaveLength(0);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders the relative-time label once a scan has been observed.
|
||||||
|
*/
|
||||||
|
export const ShowsLastCheckedLabel: Story = {
|
||||||
|
args: {
|
||||||
|
repositories: new Map([["/home/coder/coder", makeRepo()]]),
|
||||||
|
// Fixed past date keeps the rendered "ago" text deterministic
|
||||||
|
// for pixel snapshots. dayjs formats "X months ago" or "X
|
||||||
|
// years ago" at this scale, and those buckets do not flip
|
||||||
|
// between story collection and story render.
|
||||||
|
lastCheckedAt: new Date("2024-01-01T00:00:00Z"),
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const label = canvasElement.querySelector(
|
||||||
|
'[data-testid="git-last-checked"]',
|
||||||
|
);
|
||||||
|
expect(label).not.toBeNull();
|
||||||
|
// dayjs' relativeTime renders sub-45s as 'a few seconds ago'
|
||||||
|
// and longer spans as '<n> <unit> ago'. Accept either shape
|
||||||
|
// so the story is not coupled to dayjs' specific bucketing.
|
||||||
|
expect(label?.textContent ?? "").toMatch(/^checked .+ ago$/);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* With no scan observed yet, the label renders nothing so the
|
||||||
|
* toolbar collapses cleanly.
|
||||||
|
*/
|
||||||
|
export const NoLastCheckedYet: Story = {
|
||||||
|
args: {
|
||||||
|
repositories: new Map([["/home/coder/coder", makeRepo()]]),
|
||||||
|
lastCheckedAt: undefined,
|
||||||
|
},
|
||||||
|
play: async ({ canvasElement }) => {
|
||||||
|
const label = canvasElement.querySelector(
|
||||||
|
'[data-testid="git-last-checked"]',
|
||||||
|
);
|
||||||
|
expect(label).toBeNull();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import {
|
|||||||
} from "../DiffViewer/DiffViewer";
|
} from "../DiffViewer/DiffViewer";
|
||||||
import { LocalDiffPanel } from "../DiffViewer/LocalDiffPanel";
|
import { LocalDiffPanel } from "../DiffViewer/LocalDiffPanel";
|
||||||
import { RemoteDiffPanel } from "../DiffViewer/RemoteDiffPanel";
|
import { RemoteDiffPanel } from "../DiffViewer/RemoteDiffPanel";
|
||||||
|
import { LastCheckedLabel } from "./LastCheckedLabel";
|
||||||
|
|
||||||
type GitView = { type: "remote" } | { type: "local"; repoRoot: string };
|
type GitView = { type: "remote" } | { type: "local"; repoRoot: string };
|
||||||
|
|
||||||
@@ -55,6 +56,18 @@ interface GitPanelProps {
|
|||||||
remoteDiffStats?: ChatDiffStatus;
|
remoteDiffStats?: ChatDiffStatus;
|
||||||
/** Ref to the chat input, forwarded to RemoteDiffPanel. */
|
/** Ref to the chat input, forwarded to RemoteDiffPanel. */
|
||||||
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
chatInputRef?: RefObject<ChatMessageInputRef | null>;
|
||||||
|
/**
|
||||||
|
* Repo roots that have been dirty at some point during this session.
|
||||||
|
* Used to keep a repo's tab visible after its diff goes empty, so the
|
||||||
|
* tab strip does not visibly flip when the agent edits a file and
|
||||||
|
* then reverts it.
|
||||||
|
*/
|
||||||
|
everDirty?: ReadonlySet<string>;
|
||||||
|
/**
|
||||||
|
* Timestamp of the last scan received from the server. Rendered as a
|
||||||
|
* live-updating "checked Ns ago" label next to the refresh button.
|
||||||
|
*/
|
||||||
|
lastCheckedAt?: Date | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function repoTabLabel(repoRoot: string): string {
|
function repoTabLabel(repoRoot: string): string {
|
||||||
@@ -70,6 +83,8 @@ export const GitPanel: FC<GitPanelProps> = ({
|
|||||||
isExpanded,
|
isExpanded,
|
||||||
remoteDiffStats,
|
remoteDiffStats,
|
||||||
chatInputRef,
|
chatInputRef,
|
||||||
|
everDirty,
|
||||||
|
lastCheckedAt,
|
||||||
}) => {
|
}) => {
|
||||||
const hasRemoteStats =
|
const hasRemoteStats =
|
||||||
(remoteDiffStats?.additions ?? 0) > 0 ||
|
(remoteDiffStats?.additions ?? 0) > 0 ||
|
||||||
@@ -102,9 +117,19 @@ export const GitPanel: FC<GitPanelProps> = ({
|
|||||||
return stats;
|
return stats;
|
||||||
})();
|
})();
|
||||||
|
|
||||||
const localRepos = Array.from(repoStats.keys()).sort((a, b) =>
|
// Union of currently-dirty and ever-dirty repos (still known to
|
||||||
a.localeCompare(b),
|
// the watcher) so a clean-revert does not hide the tab.
|
||||||
);
|
const localRepos = (() => {
|
||||||
|
const roots = new Set<string>(repoStats.keys());
|
||||||
|
if (everDirty) {
|
||||||
|
for (const root of everDirty) {
|
||||||
|
if (repositories.has(root)) {
|
||||||
|
roots.add(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Array.from(roots).sort((a, b) => a.localeCompare(b));
|
||||||
|
})();
|
||||||
|
|
||||||
// Default to the first local repo when there are only local
|
// Default to the first local repo when there are only local
|
||||||
// changes and no remote stats.
|
// changes and no remote stats.
|
||||||
@@ -122,7 +147,9 @@ export const GitPanel: FC<GitPanelProps> = ({
|
|||||||
setView({ type: "local", repoRoot: localRepos[0] });
|
setView({ type: "local", repoRoot: localRepos[0] });
|
||||||
}
|
}
|
||||||
} else if (view.type === "local") {
|
} else if (view.type === "local") {
|
||||||
if (!repoStats.has(view.repoRoot)) {
|
// localRepos includes ever-dirty repos with empty diffs, so
|
||||||
|
// the active tab stays valid until its root leaves the set.
|
||||||
|
if (!localRepos.includes(view.repoRoot)) {
|
||||||
if (showRemoteTab) {
|
if (showRemoteTab) {
|
||||||
setView({ type: "remote" });
|
setView({ type: "remote" });
|
||||||
} else if (localRepos.length > 0) {
|
} else if (localRepos.length > 0) {
|
||||||
@@ -130,7 +157,7 @@ export const GitPanel: FC<GitPanelProps> = ({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [view, showRemoteTab, localRepos, repoStats]);
|
}, [view, showRemoteTab, localRepos]);
|
||||||
|
|
||||||
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
|
const [diffStyle, setDiffStyle] = useState<DiffStyle>(loadDiffStyle);
|
||||||
|
|
||||||
@@ -231,6 +258,10 @@ export const GitPanel: FC<GitPanelProps> = ({
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
{/* Controls */}
|
{/* Controls */}
|
||||||
<div className="flex shrink-0 items-center gap-1 py-1.5">
|
<div className="flex shrink-0 items-center gap-1 py-1.5">
|
||||||
|
<LastCheckedLabel
|
||||||
|
at={lastCheckedAt}
|
||||||
|
className="mr-1 whitespace-nowrap text-[11px] text-content-secondary"
|
||||||
|
/>
|
||||||
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default">
|
<div className="flex h-6 items-stretch overflow-hidden rounded-md border border-solid border-border-default">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import type { FC } from "react";
|
||||||
|
import { cn } from "#/utils/cn";
|
||||||
|
import { relativeTime } from "#/utils/time";
|
||||||
|
|
||||||
|
interface LastCheckedLabelProps {
|
||||||
|
at: Date | undefined;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders "checked <relative time>" next to the refresh button.
|
||||||
|
* Compiled by React Compiler, so re-renders are driven by a fresh
|
||||||
|
* `at` reference; the server's 5s scan heartbeat supplies that.
|
||||||
|
* Returns null before the first scan so the toolbar collapses.
|
||||||
|
*/
|
||||||
|
export const LastCheckedLabel: FC<LastCheckedLabelProps> = ({
|
||||||
|
at,
|
||||||
|
className,
|
||||||
|
}) => {
|
||||||
|
if (!at) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
data-testid="git-last-checked"
|
||||||
|
className={cn(["whitespace-nowrap", className])}
|
||||||
|
title={at.toLocaleString()}
|
||||||
|
>
|
||||||
|
checked {relativeTime(at)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -664,4 +664,432 @@ describe("useGitWatcher", () => {
|
|||||||
});
|
});
|
||||||
expect(result.current.repositories).toBe(ref1);
|
expect(result.current.repositories).toBe(ref1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("tracks everDirty: adds repo on first non-empty diff", async () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket.simulateOpen());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "main",
|
||||||
|
unified_diff: "some diff",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks everDirty: retains repo after diff goes empty", async () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket.simulateOpen());
|
||||||
|
|
||||||
|
// First, dirty the repo.
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "main",
|
||||||
|
unified_diff: "diff1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Revert the repo to clean (empty diff). The repo stays in
|
||||||
|
// repositories, and everDirty retains it so the UI can keep
|
||||||
|
// the tab visible.
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "main",
|
||||||
|
unified_diff: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.repositories.get("/repo")?.unified_diff).toBe("");
|
||||||
|
});
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks everDirty: does not add on first empty diff", async () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket.simulateOpen());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "main",
|
||||||
|
unified_diff: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.repositories.size).toBe(1);
|
||||||
|
});
|
||||||
|
expect(result.current.everDirty.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks everDirty: removes repo on removed: true", async () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket.simulateOpen());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "main",
|
||||||
|
unified_diff: "diff1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "",
|
||||||
|
removed: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.repositories.size).toBe(0);
|
||||||
|
});
|
||||||
|
expect(result.current.everDirty.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks everDirty: clears on chatId change", async () => {
|
||||||
|
const socket1 = createMockSocket();
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ chatId }: { chatId: string }) =>
|
||||||
|
useGitWatcher({ chatId, agentStatus: "connected" }),
|
||||||
|
{ initialProps: { chatId: "chat-A" } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket1.simulateOpen());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket1.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
scanned_at: "2024-01-02T03:04:05Z",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "main",
|
||||||
|
unified_diff: "diff1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
});
|
||||||
|
expect(result.current.lastCheckedAt).toBeInstanceOf(Date);
|
||||||
|
|
||||||
|
// Switch to a different chat. The hook tears down and recreates
|
||||||
|
// the socket; everDirty, repositories, and lastCheckedAt must
|
||||||
|
// all reset so chat-B starts with a clean slate.
|
||||||
|
createMockSocket();
|
||||||
|
rerender({ chatId: "chat-B" });
|
||||||
|
|
||||||
|
expect(result.current.repositories.size).toBe(0);
|
||||||
|
expect(result.current.everDirty.size).toBe(0);
|
||||||
|
expect(result.current.lastCheckedAt).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks lastCheckedAt from scanned_at", async () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket.simulateOpen());
|
||||||
|
expect(result.current.lastCheckedAt).toBeUndefined();
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
scanned_at: "2024-01-02T03:04:05Z",
|
||||||
|
repositories: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.lastCheckedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
expect(result.current.lastCheckedAt?.toISOString()).toBe(
|
||||||
|
"2024-01-02T03:04:05.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heartbeat message (no repositories) advances lastCheckedAt without touching repos", async () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket.simulateOpen());
|
||||||
|
|
||||||
|
// Prime a known-dirty repo via a delta message.
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
scanned_at: "2024-01-02T03:04:05Z",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "main",
|
||||||
|
unified_diff: "diff1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.repositories.size).toBe(1);
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
});
|
||||||
|
const repoStateBeforeHeartbeat = result.current.repositories;
|
||||||
|
const everDirtyBeforeHeartbeat = result.current.everDirty;
|
||||||
|
|
||||||
|
// Heartbeat: scanned_at advances, but repositories is absent.
|
||||||
|
// The server sends this shape when Scan() observed no deltas
|
||||||
|
// (idle clean-staying-clean or no-change-since-last-emit).
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
scanned_at: "2024-01-02T03:04:10Z",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.lastCheckedAt?.toISOString()).toBe(
|
||||||
|
"2024-01-02T03:04:10.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Heartbeat must not mutate repo state.
|
||||||
|
expect(result.current.repositories).toBe(repoStateBeforeHeartbeat);
|
||||||
|
expect(result.current.everDirty).toBe(everDirtyBeforeHeartbeat);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("heartbeat with explicit empty repositories array is also a no-op for repos", async () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket.simulateOpen());
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
scanned_at: "2024-01-02T03:04:05Z",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "main",
|
||||||
|
unified_diff: "diff1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.repositories.size).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Some JSON encoders may preserve an empty array rather than
|
||||||
|
// omitting the field. Either shape must be treated as a pure
|
||||||
|
// heartbeat on the client.
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
scanned_at: "2024-01-02T03:04:15Z",
|
||||||
|
repositories: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.lastCheckedAt?.toISOString()).toBe(
|
||||||
|
"2024-01-02T03:04:15.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
// Repo entry survives an empty-array heartbeat.
|
||||||
|
expect(result.current.repositories.has("/repo")).toBe(true);
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores malformed scanned_at without clearing existing value", async () => {
|
||||||
|
const socket = createMockSocket();
|
||||||
|
|
||||||
|
const { result } = renderHook(() =>
|
||||||
|
useGitWatcher({ chatId: "chat-123", agentStatus: "connected" }),
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket.simulateOpen());
|
||||||
|
|
||||||
|
// Prime a valid timestamp.
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
scanned_at: "2024-01-02T03:04:05Z",
|
||||||
|
repositories: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.lastCheckedAt).toBeDefined();
|
||||||
|
});
|
||||||
|
const firstIso = result.current.lastCheckedAt?.toISOString();
|
||||||
|
|
||||||
|
// A malformed scanned_at must not wipe the previous value.
|
||||||
|
act(() => {
|
||||||
|
socket.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
scanned_at: "not-a-date",
|
||||||
|
repositories: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Compare by ISO string, not reference. A future refactor that
|
||||||
|
// re-creates the Date object identically should still pass; what
|
||||||
|
// matters is that the observed timestamp did not change.
|
||||||
|
expect(result.current.lastCheckedAt?.toISOString()).toBe(firstIso);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("tracks everDirty: preserves across agentStatus flap on the same chat", async () => {
|
||||||
|
const socket1 = createMockSocket();
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ agentStatus }: { agentStatus: WorkspaceAgentStatus | undefined }) =>
|
||||||
|
useGitWatcher({ chatId: "chat-stable", agentStatus }),
|
||||||
|
{ initialProps: { agentStatus: "connected" as WorkspaceAgentStatus } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket1.simulateOpen());
|
||||||
|
act(() => {
|
||||||
|
socket1.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
repositories: [
|
||||||
|
{
|
||||||
|
repo_root: "/repo",
|
||||||
|
branch: "main",
|
||||||
|
unified_diff: "diff1",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Simulate a transient agentStatus flap. This tears down the
|
||||||
|
// socket but must not forget that /repo was dirty during this
|
||||||
|
// chat session.
|
||||||
|
createMockSocket();
|
||||||
|
rerender({ agentStatus: "connecting" as WorkspaceAgentStatus });
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
|
||||||
|
createMockSocket();
|
||||||
|
rerender({ agentStatus: "connected" as WorkspaceAgentStatus });
|
||||||
|
expect(result.current.everDirty.has("/repo")).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves lastCheckedAt across agentStatus flap on the same chat", async () => {
|
||||||
|
const socket1 = createMockSocket();
|
||||||
|
|
||||||
|
const { result, rerender } = renderHook(
|
||||||
|
({ agentStatus }: { agentStatus: WorkspaceAgentStatus | undefined }) =>
|
||||||
|
useGitWatcher({ chatId: "chat-stable", agentStatus }),
|
||||||
|
{ initialProps: { agentStatus: "connected" as WorkspaceAgentStatus } },
|
||||||
|
);
|
||||||
|
|
||||||
|
act(() => socket1.simulateOpen());
|
||||||
|
act(() => {
|
||||||
|
socket1.simulateMessage({
|
||||||
|
type: "changes",
|
||||||
|
scanned_at: "2024-01-02T03:04:05Z",
|
||||||
|
repositories: [],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(result.current.lastCheckedAt?.toISOString()).toBe(
|
||||||
|
"2024-01-02T03:04:05.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Transient flap: the socket is torn down but the stale-data
|
||||||
|
// timestamp must keep advancing so the UI's "checked Ns ago"
|
||||||
|
// label does not disappear during reconnection backoff.
|
||||||
|
createMockSocket();
|
||||||
|
rerender({ agentStatus: "connecting" as WorkspaceAgentStatus });
|
||||||
|
expect(result.current.lastCheckedAt?.toISOString()).toBe(
|
||||||
|
"2024-01-02T03:04:05.000Z",
|
||||||
|
);
|
||||||
|
|
||||||
|
createMockSocket();
|
||||||
|
rerender({ agentStatus: "connected" as WorkspaceAgentStatus });
|
||||||
|
expect(result.current.lastCheckedAt?.toISOString()).toBe(
|
||||||
|
"2024-01-02T03:04:05.000Z",
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -29,6 +29,16 @@ interface UseGitWatcherOptions {
|
|||||||
interface UseGitWatcherResult {
|
interface UseGitWatcherResult {
|
||||||
/** Current repo state, keyed by repo root path. */
|
/** Current repo state, keyed by repo root path. */
|
||||||
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
|
repositories: ReadonlyMap<string, WorkspaceAgentRepoChanges>;
|
||||||
|
/**
|
||||||
|
* Repo roots seen with a non-empty unified_diff during this chat.
|
||||||
|
* Survives reconnects; evicted on `removed: true`, cleared on
|
||||||
|
* chatId change. Consumers should intersect with `repositories`.
|
||||||
|
*/
|
||||||
|
everDirty: ReadonlySet<string>;
|
||||||
|
/** ScannedAt from the latest server message. Undefined until the
|
||||||
|
* first message arrives. Preserved across reconnects so the UI's
|
||||||
|
* relative-time label keeps advancing during backoff. */
|
||||||
|
lastCheckedAt: Date | undefined;
|
||||||
/** Whether the WebSocket is currently connected. */
|
/** Whether the WebSocket is currently connected. */
|
||||||
isConnected: boolean;
|
isConnected: boolean;
|
||||||
/** Send a refresh request. Returns true if sent, false if disconnected. */
|
/** Send a refresh request. Returns true if sent, false if disconnected. */
|
||||||
@@ -42,9 +52,24 @@ export function useGitWatcher({
|
|||||||
const [repositories, setRepositories] = useState<
|
const [repositories, setRepositories] = useState<
|
||||||
ReadonlyMap<string, WorkspaceAgentRepoChanges>
|
ReadonlyMap<string, WorkspaceAgentRepoChanges>
|
||||||
>(new Map());
|
>(new Map());
|
||||||
|
const [everDirty, setEverDirty] = useState<ReadonlySet<string>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
const [lastCheckedAt, setLastCheckedAt] = useState<Date | undefined>(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
|
|
||||||
const socketRef = useRef<WebSocket | null>(null);
|
const socketRef = useRef<WebSocket | null>(null);
|
||||||
|
// Chat-scoped state (everDirty, lastCheckedAt) resets on chatId
|
||||||
|
// change but must survive agentStatus flaps on the same chat.
|
||||||
|
// https://react.dev/reference/react/useState#storing-information-from-previous-renders
|
||||||
|
const [lastChatId, setLastChatId] = useState<string | undefined>(chatId);
|
||||||
|
if (lastChatId !== chatId) {
|
||||||
|
setLastChatId(chatId);
|
||||||
|
setEverDirty((prev) => (prev.size === 0 ? prev : new Set()));
|
||||||
|
setLastCheckedAt(undefined);
|
||||||
|
}
|
||||||
|
|
||||||
const sendMessage = (msg: WorkspaceAgentGitClientMessage): boolean => {
|
const sendMessage = (msg: WorkspaceAgentGitClientMessage): boolean => {
|
||||||
const socket = socketRef.current;
|
const socket = socketRef.current;
|
||||||
@@ -86,31 +111,54 @@ export function useGitWatcher({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === "changes" && data.repositories) {
|
if (data.type === "changes") {
|
||||||
setRepositories((prev) => {
|
if (data.scanned_at) {
|
||||||
let changed = false;
|
const parsed = new Date(data.scanned_at);
|
||||||
const next = new Map(prev);
|
if (!Number.isNaN(parsed.getTime())) {
|
||||||
for (const repo of data.repositories!) {
|
setLastCheckedAt(parsed);
|
||||||
if (repo.removed) {
|
}
|
||||||
if (next.has(repo.repo_root)) {
|
}
|
||||||
next.delete(repo.repo_root);
|
if (data.repositories) {
|
||||||
changed = true;
|
setRepositories((prev) => {
|
||||||
|
let changed = false;
|
||||||
|
const next = new Map(prev);
|
||||||
|
for (const repo of data.repositories!) {
|
||||||
|
if (repo.removed) {
|
||||||
|
if (next.has(repo.repo_root)) {
|
||||||
|
next.delete(repo.repo_root);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const existing = next.get(repo.repo_root);
|
||||||
|
if (
|
||||||
|
!existing ||
|
||||||
|
existing.branch !== repo.branch ||
|
||||||
|
existing.remote_origin !== repo.remote_origin ||
|
||||||
|
existing.unified_diff !== repo.unified_diff
|
||||||
|
) {
|
||||||
|
next.set(repo.repo_root, repo);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
const existing = next.get(repo.repo_root);
|
return changed ? next : prev;
|
||||||
if (
|
});
|
||||||
!existing ||
|
setEverDirty((prev) => {
|
||||||
existing.branch !== repo.branch ||
|
let changed = false;
|
||||||
existing.remote_origin !== repo.remote_origin ||
|
const next = new Set(prev);
|
||||||
existing.unified_diff !== repo.unified_diff
|
for (const repo of data.repositories!) {
|
||||||
) {
|
if (repo.removed) {
|
||||||
next.set(repo.repo_root, repo);
|
if (next.delete(repo.repo_root)) {
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (repo.unified_diff && !next.has(repo.repo_root)) {
|
||||||
|
next.add(repo.repo_root);
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
return changed ? next : prev;
|
||||||
return changed ? next : prev;
|
});
|
||||||
});
|
}
|
||||||
} else if (data.type === "error") {
|
} else if (data.type === "error") {
|
||||||
console.warn("[useGitWatcher] server error:", data.message);
|
console.warn("[useGitWatcher] server error:", data.message);
|
||||||
}
|
}
|
||||||
@@ -134,8 +182,9 @@ export function useGitWatcher({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
// dispose() suppresses onDisconnect, so reset state
|
// Reset connection-scoped state only. `everDirty` and
|
||||||
// explicitly.
|
// `lastCheckedAt` are chat-scoped and persist across
|
||||||
|
// reconnects, so a slow backoff keeps the label advancing.
|
||||||
dispose();
|
dispose();
|
||||||
setIsConnected(false);
|
setIsConnected(false);
|
||||||
setRepositories(new Map());
|
setRepositories(new Map());
|
||||||
@@ -143,5 +192,5 @@ export function useGitWatcher({
|
|||||||
};
|
};
|
||||||
}, [chatId, agentStatus]);
|
}, [chatId, agentStatus]);
|
||||||
|
|
||||||
return { repositories, isConnected, refresh };
|
return { repositories, everDirty, lastCheckedAt, isConnected, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user