diff --git a/apps/web/components/task/changes-panel-focus.test.ts b/apps/web/components/task/changes-panel-focus.test.ts index e43b5c2877..0d5109c33f 100644 --- a/apps/web/components/task/changes-panel-focus.test.ts +++ b/apps/web/components/task/changes-panel-focus.test.ts @@ -179,6 +179,7 @@ describe("applyChangesPanelAutoFocusState fallback session keys", () => { previousMarkers, pendingEnvKeys, isRestoringLayout: false, + isMaximized: false, activate: () => { activateCalls += 1; return "activated"; @@ -207,6 +208,7 @@ describe("applyChangesPanelAutoFocusState fallback session keys", () => { previousMarkers, pendingEnvKeys, isRestoringLayout: false, + isMaximized: false, activate: () => { activateCalls += 1; return "activated"; @@ -223,8 +225,8 @@ describe("applyChangesPanelAutoFocusState fallback session keys", () => { }); }); -describe("applyChangesPanelAutoFocusState saved layouts", () => { - it("keeps a returning environment's saved active panel over pending changes", () => { +describe("applyChangesPanelAutoFocusState returning environments", () => { + it("activates pending changes when returning from another environment", () => { const previousMarkers = { envB: { count: 1, fingerprint: "b1" }, }; @@ -240,17 +242,75 @@ describe("applyChangesPanelAutoFocusState saved layouts", () => { environmentIdBySessionId: {}, previousMarkers, pendingEnvKeys, - hasSavedLayout: true, isRestoringLayout: false, + isMaximized: false, activate: () => { activateCalls += 1; return "activated"; }, }); - expect(activateCalls).toBe(0); + expect(activateCalls).toBe(1); expect(pendingEnvKeys.size).toBe(0); }); + + it("keeps pending changes while maximized and activates them after exit", () => { + const previousMarkers = { + envB: { count: 1, fingerprint: "b1" }, + }; + const pendingEnvKeys = new Set(["envB"]); + let activateCalls = 0; + const baseArgs = { + signalsByEnv: { + envB: signal(1, "b1"), + }, + activeEnvKey: "envB", + previousActiveEnvKey: "envA", + environmentIdBySessionId: {}, + previousMarkers, + pendingEnvKeys, + isRestoringLayout: false, + }; + + applyChangesPanelAutoFocusState({ + ...baseArgs, + isMaximized: true, + activate: () => { + activateCalls += 1; + return "no-panel" as const; + }, + }); + const whileMaximized = { + activateCalls, + pendingEnvKeys: [...pendingEnvKeys], + }; + + applyChangesPanelAutoFocusState({ + ...baseArgs, + isMaximized: false, + activate: () => { + activateCalls += 1; + return "activated" as const; + }, + }); + + expect({ + whileMaximized, + afterExit: { + activateCalls, + pendingEnvKeys: [...pendingEnvKeys], + }, + }).toEqual({ + whileMaximized: { + activateCalls: 1, + pendingEnvKeys: ["envB"], + }, + afterExit: { + activateCalls: 2, + pendingEnvKeys: [], + }, + }); + }); }); describe("applyChangesPanelAutoFocusState", () => { @@ -270,6 +330,7 @@ describe("applyChangesPanelAutoFocusState", () => { previousMarkers, pendingEnvKeys, isRestoringLayout: false, + isMaximized: false, activate: () => { activateCalls += 1; return "activated"; @@ -288,6 +349,7 @@ describe("applyChangesPanelAutoFocusState", () => { previousMarkers, pendingEnvKeys, isRestoringLayout: false, + isMaximized: false, activate: () => { activateCalls += 1; return "activated"; @@ -309,6 +371,7 @@ describe("applyChangesPanelAutoFocusState", () => { previousMarkers, pendingEnvKeys, isRestoringLayout: true, + isMaximized: false, activate: () => { activateCalls += 1; return "activated"; @@ -328,6 +391,7 @@ describe("applyChangesPanelAutoFocusState", () => { previousMarkers, pendingEnvKeys, isRestoringLayout: false, + isMaximized: false, activate: () => { activateCalls += 1; return "activated"; @@ -480,6 +544,7 @@ describe("applyChangesPanelAutoFocusState restore races", () => { previousMarkers, pendingEnvKeys, isRestoringLayout: false, + isMaximized: false, getIsRestoringLayout: () => restoring, activate: () => { restoring = true; diff --git a/apps/web/components/task/changes-panel-focus.ts b/apps/web/components/task/changes-panel-focus.ts index 2972b0f1c7..34c7e67125 100644 --- a/apps/web/components/task/changes-panel-focus.ts +++ b/apps/web/components/task/changes-panel-focus.ts @@ -7,7 +7,6 @@ import { useAppStore } from "@/components/state-provider"; import { useDockviewStore } from "@/lib/state/dockview-store"; import type { AppState } from "@/lib/state/store"; import type { FileInfo, GitStatusEntry } from "@/lib/state/slices/session-runtime/types"; -import { getEnvLayout } from "@/lib/local-storage"; import { djb2Hash } from "@/lib/utils/hash"; type DockviewPanel = NonNullable>; @@ -251,9 +250,10 @@ export function applyChangesPanelAutoFocusState(args: { environmentIdBySessionId: Record; previousMarkers: Record; pendingEnvKeys: Set; - hasSavedLayout?: boolean; isRestoringLayout: boolean; getIsRestoringLayout?: () => boolean; + isMaximized: boolean; + getIsMaximized?: () => boolean; activate: () => ActivateChangesPanelResult; }): string | null { const { @@ -263,9 +263,10 @@ export function applyChangesPanelAutoFocusState(args: { environmentIdBySessionId, previousMarkers, pendingEnvKeys, - hasSavedLayout = false, isRestoringLayout, getIsRestoringLayout, + isMaximized, + getIsMaximized, activate, } = args; @@ -288,17 +289,13 @@ export function applyChangesPanelAutoFocusState(args: { }); const isCurrentlyRestoringLayout = () => isRestoringLayout || getIsRestoringLayout?.() === true; + const isCurrentlyMaximized = () => isMaximized || getIsMaximized?.() === true; - // A saved env layout records the user's active panel. Pending Changes - // attention may choose the first panel for an unseen env, but must not - // override that explicit focus when the user returns to a task. - if (activeEnvKey && hasSavedLayout) { - pendingEnvKeys.delete(activeEnvKey); - } else if (activeEnvKey && !isCurrentlyRestoringLayout() && pendingEnvKeys.has(activeEnvKey)) { + if (activeEnvKey && !isCurrentlyRestoringLayout() && pendingEnvKeys.has(activeEnvKey)) { const result = activate(); if ( shouldClearPendingChangesFocus(result) && - !(result === "no-panel" && isCurrentlyRestoringLayout()) + !(result === "no-panel" && (isCurrentlyRestoringLayout() || isCurrentlyMaximized())) ) { pendingEnvKeys.delete(activeEnvKey); } @@ -310,12 +307,12 @@ export function applyChangesPanelAutoFocusState(args: { export function useChangesPanelAutoFocus(activeEnvKey: string | null) { const api = useDockviewStore((s) => s.api); const isRestoringLayout = useDockviewStore((s) => s.isRestoringLayout); + const isMaximized = useDockviewStore((s) => s.preMaximizeLayout !== null); const signalsByEnv = useAppStore(useShallow(selectChangesSignalByEnvironment)); const environmentIdBySessionId = useAppStore((state) => state.environmentIdBySessionId); const previousMarkersRef = useRef>({}); const pendingEnvKeysRef = useRef>(new Set()); const previousActiveEnvKeyRef = useRef(null); - const hasSavedLayout = activeEnvKey !== null && getEnvLayout(activeEnvKey) !== null; useEffect(() => { previousActiveEnvKeyRef.current = applyChangesPanelAutoFocusState({ @@ -325,17 +322,11 @@ export function useChangesPanelAutoFocus(activeEnvKey: string | null) { environmentIdBySessionId, previousMarkers: previousMarkersRef.current, pendingEnvKeys: pendingEnvKeysRef.current, - hasSavedLayout, isRestoringLayout, getIsRestoringLayout: () => useDockviewStore.getState().isRestoringLayout, + isMaximized, + getIsMaximized: () => useDockviewStore.getState().preMaximizeLayout !== null, activate: () => activateChangesPanel(api), }); - }, [ - signalsByEnv, - activeEnvKey, - api, - isRestoringLayout, - environmentIdBySessionId, - hasSavedLayout, - ]); + }, [signalsByEnv, activeEnvKey, api, isRestoringLayout, isMaximized, environmentIdBySessionId]); } diff --git a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts index 8819130b9b..98e55368ef 100644 --- a/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts +++ b/apps/web/e2e/tests/layout/changes-panel-focus.spec.ts @@ -385,7 +385,7 @@ test.describe("Changes panel focus behavior", () => { await expect(changesTab(testPage)).toHaveClass(/dv-active-tab/, { timeout: 5_000 }); }); - test("new git updates do not replace a returning task's saved agent tab", async ({ + test("new git updates focus changes when returning to a task with a saved layout", async ({ testPage, apiClient, seedData, @@ -444,7 +444,6 @@ test.describe("Changes panel focus behavior", () => { await expect(testPage).toHaveURL((url) => url.pathname.includes(taskA.id), { timeout: 15_000, }); - await expect(session.activeChat()).toBeVisible({ timeout: 5_000 }); - await expect(changesTab(testPage)).not.toHaveClass(/dv-active-tab/); + await expect(changesTab(testPage)).toHaveClass(/dv-active-tab/, { timeout: 5_000 }); }); }); diff --git a/docs/plans/returning-task-changes-focus/plan.md b/docs/plans/returning-task-changes-focus/plan.md new file mode 100644 index 0000000000..23bd735842 --- /dev/null +++ b/docs/plans/returning-task-changes-focus/plan.md @@ -0,0 +1,80 @@ +--- +spec: docs/specs/ui/task-layout-profiles.md +created: 2026-07-31 +status: complete +--- + +# Implementation Plan: Restore Returning Task Changes Focus + +## Overview + +Restore the desktop workbench attention behavior for Git or commit updates that arrive while a task is inactive. The confirmed regression is the `hasSavedLayout` branch in `applyChangesPanelAutoFocusState`, introduced by `ba8d99f33`, which deletes pending focus instead of activating Changes for every previously opened task. The repair removes that blanket suppression while retaining first-observation baselining, restore-race deferral, page-refresh focus preservation, and the Agent-group safety check. + +## Frontend + +### Changes attention state + +- `apps/web/components/task/changes-panel-focus.ts`: remove the saved-layout lookup and `hasSavedLayout` branch from `applyChangesPanelAutoFocusState`. A pending environment remains deferred while Dockview is restoring and activates Changes after restoration even when an environment layout exists. +- Preserve `markInactiveChangesIncreases` semantics: an already-known non-zero count alone does not create attention on reload; only a count increase or meaningful non-zero fingerprint change while inactive queues attention. +- Preserve `activateChangesPanel` semantics: a missing Changes panel is a terminal no-op, while a Changes panel grouped with Agent sessions is not force-activated. + +### Responsive boundary + +- `TaskLayout` mounts `DockviewDesktopLayout` only for the desktop workbench; mobile and tablet use their existing dedicated layouts. This repair changes no mobile composition, touch target, scrolling, navigation, or saved mobile state. +- No mobile Playwright case is added because the affected hook is not mounted on mobile or tablet. Existing mobile task navigation remains the applicable parity path. + +## Tests + +- **Returning saved layout with pending changes:** update `apps/web/components/task/changes-panel-focus.test.ts` so a pending environment activates Changes despite having a saved layout. Write and run this expectation before changing production code; it must fail because the current branch clears the pending key. +- **Reload with existing changes:** retain the first-observation and active-environment unit coverage proving an already-known non-zero marker does not queue attention. +- **Restore race and panel safety:** retain coverage for restore deferral, missing panels, and Agent-group blocking. + +## E2E Tests + +- **Scenario:** GIVEN a desktop task with a saved Agent/Files panel and a Changes panel outside the Agent group, WHEN a Git update is detected while the task is inactive and the user returns, THEN Changes is active after layout restoration. +- **File:** `apps/web/e2e/tests/layout/changes-panel-focus.spec.ts`. +- Rename and reverse the regression added by `ba8d99f33`; run it against the current implementation before the production change and confirm it fails because the saved panel remains active. +- Keep the existing page-refresh scenario to prove already-known changes do not steal focus. + +## Implementation Tasks + +- [x] [Task 01 - Restore pending Changes attention](task-01-restore-pending-changes-attention.md) + +Execution is sequential in the primary conversation. There are no parallel-safe candidates because the unit test, state helper, and E2E scenario encode one behavior and must follow one Red-Green-Refactor cycle. + +## Risks + +- Removing too much baselining could make async Git hydration steal focus on page reload. The repair must remove only the saved-layout suppression. +- Activating before Dockview restoration completes can target a stale or missing panel. Existing restore deferral and retry behavior must remain intact. +- Changes must not steal focus when it shares a group with Agent session panels. +- The worktree currently lacks `apps/node_modules`; run `pnpm install --frozen-lockfile` from `apps/` before the RED checks. + +## Out of Scope + +- Focusing Changes for every task with an already-known dirty workspace. +- Changing mobile or tablet task-detail layouts. +- Changing Dockview layout persistence, panel placement, or Git-status transport. +- Public documentation changes; this restores previously intended workbench behavior without changing a public API or documented workflow. + +## Verification Results + +- RED unit: the saved-layout regression failed with `expected 0 to be 1`; the other 14 tests passed. +- RED E2E: Changes remained `dv-inactive-tab` after returning to the saved task. +- GREEN unit: `components/task/changes-panel-focus.test.ts` — 16 tests passed after the + maximized-layout review regression was added. +- Typecheck: `pnpm run typecheck` from `apps/web` — passed. +- GREEN E2E: focused Chromium production-build scenario — 1 test passed in 1.9s. +- Desktop PR capture: + `rtk pnpm e2e:run tests/layout/pr-capture.spec.ts -- --project=chromium` — temporary + synthetic capture scenario passed and produced the 1280×720 desktop screenshot. +- Mobile PR capture: + `rtk pnpm e2e:run tests/layout/mobile-pr-capture.spec.ts -- --project=mobile-chrome` — + temporary synthetic Pixel 5 capture scenario passed. The capture specs were removed after + producing the ignored PR assets. +- PNG compression: + `rtk pnpm dlx pngquant-bin@9.0.0 --quality 65-90 --ext .png --force web/.pr-assets/*.png` + — passed before both screenshots were embedded in PR #2091. +- Whitespace: `git diff --check` — passed. +- Mobile: no product E2E regression was added because `useChangesPanelAutoFocus` is mounted + only by `DockviewDesktopLayout`; mobile and tablet use separate task layouts and received no + state or presentation changes. diff --git a/docs/plans/returning-task-changes-focus/task-01-restore-pending-changes-attention.md b/docs/plans/returning-task-changes-focus/task-01-restore-pending-changes-attention.md new file mode 100644 index 0000000000..05e5c3e96e --- /dev/null +++ b/docs/plans/returning-task-changes-focus/task-01-restore-pending-changes-attention.md @@ -0,0 +1,93 @@ +--- +id: "01-restore-pending-changes-attention" +title: "Restore pending Changes attention" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/ui/task-layout-profiles.md" +--- + +# Task 01: Restore Pending Changes Attention + +## Intent + +Make a meaningful Git or commit update detected while a desktop task is inactive activate its existing Changes panel after the user returns, even when that task has a saved Dockview layout. Preserve reload baselining and all current restore/panel safety rules. + +## Acceptance + +- A pending inactive-task change activates Changes after Dockview restoration even when an environment-specific layout exists. +- Reloading a task with already-known changes does not activate Changes solely because the count is non-zero. +- Missing Changes panels and Changes panels grouped with Agent sessions retain their current safe behavior. + +## Files likely touched + +- `apps/web/components/task/changes-panel-focus.test.ts` +- `apps/web/components/task/changes-panel-focus.ts` +- `apps/web/e2e/tests/layout/changes-panel-focus.spec.ts` + +## Dependencies + +None. + +## Parallelism + +`sequential`. The source and both regression levels describe one focus-state transition and must be changed in a single TDD cycle. + +## Inputs + +- `docs/specs/ui/task-layout-profiles.md`: returning-task attention and reload scenarios. +- `docs/plans/returning-task-changes-focus/plan.md`: root cause, responsive boundary, and risks. +- Regression commit `ba8d99f33`, which added `hasSavedLayout` suppression and the conflicting E2E expectation. +- Existing `applyChangesPanelAutoFocusState`, `activateChangesPanel`, and `markInactiveChangesIncreases` behavior. + +## TDD Sequence + +1. Set this task and its plan item to `in_progress`. +2. RED: reverse the saved-layout unit expectation and the returning-task E2E expectation without changing production code. +3. Run both focused regressions and confirm they fail because Changes is not activated. +4. GREEN: remove only the saved-layout suppression and its now-unused lookup/argument. +5. Run the focused unit and E2E checks until green. +6. REFACTOR: remove obsolete naming/comments and keep the state transition explicit. +7. Run typecheck, mark this task `done`, and update the plan checkbox/status. + +## Verification + +Run `pnpm install --frozen-lockfile` from `apps/` first when `apps/node_modules` is absent. + +```bash +# From apps/ +pnpm --filter @kandev/web test -- --run components/task/changes-panel-focus.test.ts +pnpm --filter @kandev/web typecheck + +# From apps/web/ +pnpm e2e:run tests/layout/changes-panel-focus.spec.ts -- --grep "new git updates focus changes when returning to a task with a saved layout" +``` + +The unit and E2E regression commands must be observed failing during RED and passing after GREEN. The final reported verification results are the post-change runs. + +## Output contract + +Report the state-transition change, files changed, RED and GREEN command results, any E2E artifacts or blockers, remaining risks, and the synchronized task/plan statuses. + +## Results + +- Removed the environment-layout lookup and blanket `hasSavedLayout` suppression from Changes attention. +- Preserved inactive-change detection, first-observation baselining, restore deferral, missing-panel handling, and Agent-group blocking. +- Updated the unit scenario and browser regression to require Changes activation on return. +- RED unit failed with zero activation calls; RED E2E observed `dv-inactive-tab`. +- GREEN unit passed all 16 tests, including the maximized-layout review regression. +- Direct web typecheck passed. +- GREEN managed E2E passed the focused Chromium production-build scenario in 1.9s. +- Desktop screenshot capture passed with + `rtk pnpm e2e:run tests/layout/pr-capture.spec.ts -- --project=chromium` and produced the + synthetic 1280×720 PR asset. +- Mobile screenshot capture passed with + `rtk pnpm e2e:run tests/layout/mobile-pr-capture.spec.ts -- --project=mobile-chrome` and + produced the synthetic Pixel 5 PR asset; both temporary capture specs were then removed. +- PNG compression passed with + `rtk pnpm dlx pngquant-bin@9.0.0 --quality 65-90 --ext .png --force web/.pr-assets/*.png`, + and both assets were embedded in PR #2091. +- `git diff --check` passed. +- No mobile product regression test was required because the affected hook is + desktop-Dockview-only and no mobile/tablet behavior changed. diff --git a/docs/specs/ui/task-layout-profiles.md b/docs/specs/ui/task-layout-profiles.md index 28e7dafe1c..1e8b896022 100644 --- a/docs/specs/ui/task-layout-profiles.md +++ b/docs/specs/ui/task-layout-profiles.md @@ -22,8 +22,9 @@ Users can arrange and save the desktop task workbench only while a task is open, - Removing Terminal from the effective default prevents the default terminal panel and its backing user shell from being created when a fresh task environment is first opened. - Applying a layout preserves every configured reusable panel regardless of whether the task has repositories. Panels without applicable content remain available and show their normal empty state. - A changed default applies to task environments that have no saved task-specific layout and to an explicit Reset Layout action. It does not overwrite an existing task-specific layout merely because the setting changed. +- Returning to a desktop task restores its task-specific layout, then activates Changes when a meaningful Git or commit update was detected while that task was inactive. This pending-change attention overrides the saved active panel, but an ordinary reload with already-known changes preserves the saved active panel. - Switching between tasks whose sessions share one task environment atomically replaces task-owned Agent tabs in their existing group. The handoff preserves the live split tree and proportions instead of briefly emptying the group or rebuilding the environment layout. -- Desktop Agent-tab reconciliation preserves both the selected tab in each existing group and the globally focused panel. Replacing a selected Chat placeholder with the active session keeps Agent selected in that group without transiently selecting a neighboring Plan tab, while focus in another group such as Files or Changes remains unchanged. A valid non-Agent tab that was already selected in the Agent group remains selected. +- Desktop Agent-tab reconciliation preserves both the selected tab in each existing group and the globally focused panel unless pending inactive-task changes deliberately activate Changes. Replacing a selected Chat placeholder with the active session keeps Agent selected in that group without transiently selecting a neighboring Plan tab, while focus in another group such as Files or Changes remains unchanged. A valid non-Agent tab that was already selected in the Agent group remains selected. - The existing workbench layout menu continues to apply built-in and custom profiles and save the current workbench as a custom profile. Profile mutations from either surface remain consistent after the user-settings response is received. - Layout-profile editing is usable with pointer, keyboard, and touch input. On narrow settings viewports, profile management and all editor commands remain reachable without horizontal page scrolling. - Layout profiles configure the desktop Dockview workbench only. Mobile and tablet task-detail layouts retain their existing behavior. @@ -86,7 +87,9 @@ The frontend treats the returned settings payload as authoritative after each su - **GIVEN** a customized built-in layout, **WHEN** the user selects that built-in from the task workbench layout menu, **THEN** the saved override is applied instead of the original code-defined template. - **GIVEN** a valid custom profile, **WHEN** the user reorders tabs or moves a panel into a new split and saves, **THEN** reopening the profile shows the same tab order, active tab, split order, and proportions. - **GIVEN** a default profile without Terminal and a task environment with no saved layout, **WHEN** the user first opens that task, **THEN** the workbench has no Terminal tab and no default user shell is created. -- **GIVEN** an existing task with a task-specific layout, **WHEN** the user changes the default profile and returns to that task, **THEN** the task-specific layout is unchanged. +- **GIVEN** an existing task with a task-specific layout and no pending inactive-task changes, **WHEN** the user changes the default profile and returns to that task, **THEN** the task-specific layout and saved active panel are unchanged. +- **GIVEN** an existing desktop task with a saved active panel and a Changes panel outside the Agent group, **WHEN** a meaningful Git or commit update is detected while the task is inactive and the user returns to it, **THEN** the task-specific geometry is restored and Changes becomes the active panel after restoration completes. +- **GIVEN** an existing desktop task with already-known changes and a saved active panel, **WHEN** the user reloads that task without a new Git or commit update, **THEN** the saved active panel remains active. - **GIVEN** an existing task with a task-specific layout, **WHEN** the user chooses Reset Layout, **THEN** the latest effective default profile replaces that task's layout. - **GIVEN** two tasks whose active sessions share one task environment and a desktop workbench with Agent in the center and Files or Changes above Terminal on the right, **WHEN** the user switches between those tasks, **THEN** the incoming Agent replaces the outgoing Agent in the same group, the right column remains vertically split, the root remains horizontally split, and the same geometry survives reload. - **GIVEN** a normal desktop task layout restore completes while the active session's Agent panel is absent, **WHEN** the center group is empty, **THEN** the Agent panel is restored into that group and activated before the workbench is shown; when Plan or another valid saved center tab is active, restoring Agent does not steal focus from it, and a deliberately maximized non-Agent group remains unchanged. @@ -101,6 +104,7 @@ The frontend treats the returned settings payload as authoritative after each su ## Out of scope - Customizing mobile or tablet task-detail layouts. +- Auto-activating Changes merely because a task has an already-known non-zero change count. - Changing the global app sidebar width or other layout-profile split proportions. - Forcing a changed default onto existing task-specific layouts without Reset Layout. - Configuring task-specific panels such as individual files, diffs, commits, pull requests, extra sessions, or extra terminals.