From c732675f33b643606bc0b1e781a949ad14a47c67 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?=
Date: Tue, 31 Mar 2026 22:56:09 +0100
Subject: [PATCH] fix(github): workspace-scoped PR data loading with cache and
singleflight (#509)
---
.agents/skills/playwright-cli/SKILL.md | 328 ++++++++++++++++++
.../references/element-attributes.md | 23 ++
.../references/playwright-tests.md | 39 +++
.../references/request-mocking.md | 87 +++++
.../playwright-cli/references/running-code.md | 231 ++++++++++++
.../references/session-management.md | 169 +++++++++
.../references/storage-state.md | 275 +++++++++++++++
.../references/test-generation.md | 88 +++++
.../playwright-cli/references/tracing.md | 139 ++++++++
.../references/video-recording.md | 143 ++++++++
.playwright/cli.config.json | 8 +
apps/backend/internal/github/controller.go | 17 +-
apps/backend/internal/github/service.go | 76 +++-
apps/backend/internal/github/store.go | 16 +
apps/web/app/page.tsx | 5 +
apps/web/components/github/pr-task-icon.tsx | 2 +-
apps/web/components/kanban-board.tsx | 2 +
apps/web/components/kanban-card.tsx | 4 +-
.../components/task/task-changes-panel.tsx | 10 +-
apps/web/hooks/domains/github/use-task-pr.ts | 36 +-
apps/web/hooks/use-session-file-reviews.ts | 3 +-
apps/web/lib/api/domains/github-api.ts | 7 +
22 files changed, 1679 insertions(+), 29 deletions(-)
create mode 100644 .agents/skills/playwright-cli/SKILL.md
create mode 100644 .agents/skills/playwright-cli/references/element-attributes.md
create mode 100644 .agents/skills/playwright-cli/references/playwright-tests.md
create mode 100644 .agents/skills/playwright-cli/references/request-mocking.md
create mode 100644 .agents/skills/playwright-cli/references/running-code.md
create mode 100644 .agents/skills/playwright-cli/references/session-management.md
create mode 100644 .agents/skills/playwright-cli/references/storage-state.md
create mode 100644 .agents/skills/playwright-cli/references/test-generation.md
create mode 100644 .agents/skills/playwright-cli/references/tracing.md
create mode 100644 .agents/skills/playwright-cli/references/video-recording.md
create mode 100644 .playwright/cli.config.json
diff --git a/.agents/skills/playwright-cli/SKILL.md b/.agents/skills/playwright-cli/SKILL.md
new file mode 100644
index 0000000000..19a817069a
--- /dev/null
+++ b/.agents/skills/playwright-cli/SKILL.md
@@ -0,0 +1,328 @@
+---
+name: playwright-cli
+description: Automate browser interactions, test web pages and work with Playwright tests.
+allowed-tools: Bash(playwright-cli:*) Bash(npx:*) Bash(npm:*)
+---
+
+# Browser Automation with playwright-cli
+
+## Quick start
+
+```bash
+# open new browser
+playwright-cli open
+# navigate to a page
+playwright-cli goto https://playwright.dev
+# interact with the page using refs from the snapshot
+playwright-cli click e15
+playwright-cli type "page.click"
+playwright-cli press Enter
+# take a screenshot (rarely used, as snapshot is more common)
+playwright-cli screenshot
+# close the browser
+playwright-cli close
+```
+
+## Commands
+
+### Core
+
+```bash
+playwright-cli open
+# open and navigate right away
+playwright-cli open https://example.com/
+playwright-cli goto https://playwright.dev
+playwright-cli type "search query"
+playwright-cli click e3
+playwright-cli dblclick e7
+# --submit presses Enter after filling the element
+playwright-cli fill e5 "user@example.com" --submit
+playwright-cli drag e2 e8
+playwright-cli hover e4
+playwright-cli select e9 "option-value"
+playwright-cli upload ./document.pdf
+playwright-cli check e12
+playwright-cli uncheck e12
+playwright-cli snapshot
+playwright-cli eval "document.title"
+playwright-cli eval "el => el.textContent" e5
+# get element id, class, or any attribute not visible in the snapshot
+playwright-cli eval "el => el.id" e5
+playwright-cli eval "el => el.getAttribute('data-testid')" e5
+playwright-cli dialog-accept
+playwright-cli dialog-accept "confirmation text"
+playwright-cli dialog-dismiss
+playwright-cli resize 1920 1080
+playwright-cli close
+```
+
+### Navigation
+
+```bash
+playwright-cli go-back
+playwright-cli go-forward
+playwright-cli reload
+```
+
+### Keyboard
+
+```bash
+playwright-cli press Enter
+playwright-cli press ArrowDown
+playwright-cli keydown Shift
+playwright-cli keyup Shift
+```
+
+### Mouse
+
+```bash
+playwright-cli mousemove 150 300
+playwright-cli mousedown
+playwright-cli mousedown right
+playwright-cli mouseup
+playwright-cli mouseup right
+playwright-cli mousewheel 0 100
+```
+
+### Save as
+
+```bash
+playwright-cli screenshot
+playwright-cli screenshot e5
+playwright-cli screenshot --filename=page.png
+playwright-cli pdf --filename=page.pdf
+```
+
+### Tabs
+
+```bash
+playwright-cli tab-list
+playwright-cli tab-new
+playwright-cli tab-new https://example.com/page
+playwright-cli tab-close
+playwright-cli tab-close 2
+playwright-cli tab-select 0
+```
+
+### Storage
+
+```bash
+playwright-cli state-save
+playwright-cli state-save auth.json
+playwright-cli state-load auth.json
+
+# Cookies
+playwright-cli cookie-list
+playwright-cli cookie-list --domain=example.com
+playwright-cli cookie-get session_id
+playwright-cli cookie-set session_id abc123
+playwright-cli cookie-set session_id abc123 --domain=example.com --httpOnly --secure
+playwright-cli cookie-delete session_id
+playwright-cli cookie-clear
+
+# LocalStorage
+playwright-cli localstorage-list
+playwright-cli localstorage-get theme
+playwright-cli localstorage-set theme dark
+playwright-cli localstorage-delete theme
+playwright-cli localstorage-clear
+
+# SessionStorage
+playwright-cli sessionstorage-list
+playwright-cli sessionstorage-get step
+playwright-cli sessionstorage-set step 3
+playwright-cli sessionstorage-delete step
+playwright-cli sessionstorage-clear
+```
+
+### Network
+
+```bash
+playwright-cli route "**/*.jpg" --status=404
+playwright-cli route "https://api.example.com/**" --body='{"mock": true}'
+playwright-cli route-list
+playwright-cli unroute "**/*.jpg"
+playwright-cli unroute
+```
+
+### DevTools
+
+```bash
+playwright-cli console
+playwright-cli console warning
+playwright-cli network
+playwright-cli run-code "async page => await page.context().grantPermissions(['geolocation'])"
+playwright-cli run-code --filename=script.js
+playwright-cli tracing-start
+playwright-cli tracing-stop
+playwright-cli video-start video.webm
+playwright-cli video-chapter "Chapter Title" --description="Details" --duration=2000
+playwright-cli video-stop
+```
+
+## Open parameters
+```bash
+# Use specific browser when creating session
+playwright-cli open --browser=chrome
+playwright-cli open --browser=firefox
+playwright-cli open --browser=webkit
+playwright-cli open --browser=msedge
+# Connect to browser via extension
+playwright-cli open --extension
+
+# Use persistent profile (by default profile is in-memory)
+playwright-cli open --persistent
+# Use persistent profile with custom directory
+playwright-cli open --profile=/path/to/profile
+
+# Start with config file
+playwright-cli open --config=my-config.json
+
+# Close the browser
+playwright-cli close
+# Delete user data for the default session
+playwright-cli delete-data
+```
+
+## Snapshots
+
+After each command, playwright-cli provides a snapshot of the current browser state.
+
+```bash
+> playwright-cli goto https://example.com
+### Page
+- Page URL: https://example.com/
+- Page Title: Example Domain
+### Snapshot
+[Snapshot](.playwright-cli/page-2026-02-14T19-22-42-679Z.yml)
+```
+
+You can also take a snapshot on demand using `playwright-cli snapshot` command. All the options below can be combined as needed.
+
+```bash
+# default - save to a file with timestamp-based name
+playwright-cli snapshot
+
+# save to file, use when snapshot is a part of the workflow result
+playwright-cli snapshot --filename=after-click.yaml
+
+# snapshot an element instead of the whole page
+playwright-cli snapshot "#main"
+
+# limit snapshot depth for efficiency, take a partial snapshot afterwards
+playwright-cli snapshot --depth=4
+playwright-cli snapshot e34
+```
+
+## Targeting elements
+
+By default, use refs from the snapshot to interact with page elements.
+
+```bash
+# get snapshot with refs
+playwright-cli snapshot
+
+# interact using a ref
+playwright-cli click e15
+```
+
+You can also use css selectors or Playwright locators.
+
+```bash
+# css selector
+playwright-cli click "#main > button.submit"
+
+# role locator
+playwright-cli click "getByRole('button', { name: 'Submit' })"
+
+# test id
+playwright-cli click "getByTestId('submit-button')"
+```
+
+## Browser Sessions
+
+```bash
+# create new browser session named "mysession" with persistent profile
+playwright-cli -s=mysession open example.com --persistent
+# same with manually specified profile directory (use when requested explicitly)
+playwright-cli -s=mysession open example.com --profile=/path/to/profile
+playwright-cli -s=mysession click e6
+playwright-cli -s=mysession close # stop a named browser
+playwright-cli -s=mysession delete-data # delete user data for persistent session
+
+playwright-cli list
+# Close all browsers
+playwright-cli close-all
+# Forcefully kill all browser processes
+playwright-cli kill-all
+```
+
+## Installation
+
+If global `playwright-cli` command is not available, try a local version via `npx playwright-cli`:
+
+```bash
+npx --no-install playwright-cli --version
+```
+
+When local version is available, use `npx playwright-cli` in all commands. Otherwise, install `playwright-cli` as a global command:
+
+```bash
+npm install -g @playwright/cli@latest
+```
+
+## Example: Form submission
+
+```bash
+playwright-cli open https://example.com/form
+playwright-cli snapshot
+
+playwright-cli fill e1 "user@example.com"
+playwright-cli fill e2 "password123"
+playwright-cli click e3
+playwright-cli snapshot
+playwright-cli close
+```
+
+## Example: Multi-tab workflow
+
+```bash
+playwright-cli open https://example.com
+playwright-cli tab-new https://example.com/other
+playwright-cli tab-list
+playwright-cli tab-select 0
+playwright-cli snapshot
+playwright-cli close
+```
+
+## Example: Debugging with DevTools
+
+```bash
+playwright-cli open https://example.com
+playwright-cli click e4
+playwright-cli fill e7 "test"
+playwright-cli console
+playwright-cli network
+playwright-cli close
+```
+
+```bash
+playwright-cli open https://example.com
+playwright-cli tracing-start
+playwright-cli click e4
+playwright-cli fill e7 "test"
+playwright-cli tracing-stop
+playwright-cli close
+```
+
+## Specific tasks
+
+* **Running and Debugging Playwright tests** [references/playwright-tests.md](references/playwright-tests.md)
+* **Request mocking** [references/request-mocking.md](references/request-mocking.md)
+* **Running Playwright code** [references/running-code.md](references/running-code.md)
+* **Browser session management** [references/session-management.md](references/session-management.md)
+* **Storage state (cookies, localStorage)** [references/storage-state.md](references/storage-state.md)
+* **Test generation** [references/test-generation.md](references/test-generation.md)
+* **Tracing** [references/tracing.md](references/tracing.md)
+* **Video recording** [references/video-recording.md](references/video-recording.md)
+* **Inspecting element attributes** [references/element-attributes.md](references/element-attributes.md)
diff --git a/.agents/skills/playwright-cli/references/element-attributes.md b/.agents/skills/playwright-cli/references/element-attributes.md
new file mode 100644
index 0000000000..4e9fa6b991
--- /dev/null
+++ b/.agents/skills/playwright-cli/references/element-attributes.md
@@ -0,0 +1,23 @@
+# Inspecting Element Attributes
+
+When the snapshot doesn't show an element's `id`, `class`, `data-*` attributes, or other DOM properties, use `eval` to inspect them.
+
+## Examples
+
+```bash
+playwright-cli snapshot
+# snapshot shows a button as e7 but doesn't reveal its id or data attributes
+
+# get the element's id
+playwright-cli eval "el => el.id" e7
+
+# get all CSS classes
+playwright-cli eval "el => el.className" e7
+
+# get a specific attribute
+playwright-cli eval "el => el.getAttribute('data-testid')" e7
+playwright-cli eval "el => el.getAttribute('aria-label')" e7
+
+# get a computed style property
+playwright-cli eval "el => getComputedStyle(el).display" e7
+```
diff --git a/.agents/skills/playwright-cli/references/playwright-tests.md b/.agents/skills/playwright-cli/references/playwright-tests.md
new file mode 100644
index 0000000000..47627c2a6a
--- /dev/null
+++ b/.agents/skills/playwright-cli/references/playwright-tests.md
@@ -0,0 +1,39 @@
+# Running Playwright Tests
+
+To run Playwright tests, use the `npx playwright test` command, or a package manager script. To avoid opening the interactive html report, use `PLAYWRIGHT_HTML_OPEN=never` environment variable.
+
+```bash
+# Run all tests
+PLAYWRIGHT_HTML_OPEN=never npx playwright test
+
+# Run all tests through a custom npm script
+PLAYWRIGHT_HTML_OPEN=never npm run special-test-command
+```
+
+# Debugging Playwright Tests
+
+To debug a failing Playwright test, run it with `--debug=cli` option. This command will pause the test at the start and print the debugging instructions.
+
+**IMPORTANT**: run the command in the background and check the output until "Debugging Instructions" is printed.
+
+Once instructions containing a session name are printed, use `playwright-cli` to attach the session and explore the page.
+
+```bash
+# Run the test
+PLAYWRIGHT_HTML_OPEN=never npx playwright test --debug=cli
+# ...
+# ... debugging instructions for "tw-abcdef" session ...
+# ...
+
+# Attach to the test
+playwright-cli attach tw-abcdef
+```
+
+Keep the test running in the background while you explore and look for a fix.
+The test is paused at the start, so you should step over or pause at a particular location
+where the problem is most likely to be.
+
+Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
+This code appears in the output and can be copied directly into the test. Most of the time, a specific locator or an expectation should be updated, but it could also be a bug in the app. Use your judgement.
+
+After fixing the test, stop the background test run. Rerun to check that test passes.
diff --git a/.agents/skills/playwright-cli/references/request-mocking.md b/.agents/skills/playwright-cli/references/request-mocking.md
new file mode 100644
index 0000000000..141b505c4b
--- /dev/null
+++ b/.agents/skills/playwright-cli/references/request-mocking.md
@@ -0,0 +1,87 @@
+# Request Mocking
+
+Intercept, mock, modify, and block network requests.
+
+## CLI Route Commands
+
+```bash
+# Mock with custom status
+playwright-cli route "**/*.jpg" --status=404
+
+# Mock with JSON body
+playwright-cli route "**/api/users" --body='[{"id":1,"name":"Alice"}]' --content-type=application/json
+
+# Mock with custom headers
+playwright-cli route "**/api/data" --body='{"ok":true}' --header="X-Custom: value"
+
+# Remove headers from requests
+playwright-cli route "**/*" --remove-header=cookie,authorization
+
+# List active routes
+playwright-cli route-list
+
+# Remove a route or all routes
+playwright-cli unroute "**/*.jpg"
+playwright-cli unroute
+```
+
+## URL Patterns
+
+```text
+**/api/users - Exact path match
+**/api/*/details - Wildcard in path
+**/*.{png,jpg,jpeg} - Match file extensions
+**/search?q=* - Match query parameters
+```
+
+## Advanced Mocking with run-code
+
+For conditional responses, request body inspection, response modification, or delays:
+
+### Conditional Response Based on Request
+
+```bash
+playwright-cli run-code "async page => {
+ await page.route('**/api/login', route => {
+ const body = route.request().postDataJSON();
+ if (body.username === 'admin') {
+ route.fulfill({ body: JSON.stringify({ token: 'mock-token' }) });
+ } else {
+ route.fulfill({ status: 401, body: JSON.stringify({ error: 'Invalid' }) });
+ }
+ });
+}"
+```
+
+### Modify Real Response
+
+```bash
+playwright-cli run-code "async page => {
+ await page.route('**/api/user', async route => {
+ const response = await route.fetch();
+ const json = await response.json();
+ json.isPremium = true;
+ await route.fulfill({ response, json });
+ });
+}"
+```
+
+### Simulate Network Failures
+
+```bash
+playwright-cli run-code "async page => {
+ await page.route('**/api/offline', route => route.abort('internetdisconnected'));
+}"
+# Options: connectionrefused, timedout, connectionreset, internetdisconnected
+```
+
+### Delayed Response
+
+```bash
+playwright-cli run-code "async page => {
+ await page.route('**/api/slow', async route => {
+ await new Promise(r => setTimeout(r, 3000));
+ route.fulfill({ body: JSON.stringify({ data: 'loaded' }) });
+ });
+}"
+```
diff --git a/.agents/skills/playwright-cli/references/running-code.md b/.agents/skills/playwright-cli/references/running-code.md
new file mode 100644
index 0000000000..8b35e9a41d
--- /dev/null
+++ b/.agents/skills/playwright-cli/references/running-code.md
@@ -0,0 +1,231 @@
+# Running Custom Playwright Code
+
+Use `run-code` to execute arbitrary Playwright code for advanced scenarios not covered by CLI commands.
+
+## Syntax
+
+```bash
+playwright-cli run-code "async page => {
+ // Your Playwright code here
+ // Access page.context() for browser context operations
+}"
+```
+
+## Geolocation
+
+```bash
+# Grant geolocation permission and set location
+playwright-cli run-code "async page => {
+ await page.context().grantPermissions(['geolocation']);
+ await page.context().setGeolocation({ latitude: 37.7749, longitude: -122.4194 });
+}"
+
+# Set location to London
+playwright-cli run-code "async page => {
+ await page.context().grantPermissions(['geolocation']);
+ await page.context().setGeolocation({ latitude: 51.5074, longitude: -0.1278 });
+}"
+
+# Clear geolocation override
+playwright-cli run-code "async page => {
+ await page.context().clearPermissions();
+}"
+```
+
+## Permissions
+
+```bash
+# Grant multiple permissions
+playwright-cli run-code "async page => {
+ await page.context().grantPermissions([
+ 'geolocation',
+ 'notifications',
+ 'camera',
+ 'microphone'
+ ]);
+}"
+
+# Grant permissions for specific origin
+playwright-cli run-code "async page => {
+ await page.context().grantPermissions(['clipboard-read'], {
+ origin: 'https://example.com'
+ });
+}"
+```
+
+## Media Emulation
+
+```bash
+# Emulate dark color scheme
+playwright-cli run-code "async page => {
+ await page.emulateMedia({ colorScheme: 'dark' });
+}"
+
+# Emulate light color scheme
+playwright-cli run-code "async page => {
+ await page.emulateMedia({ colorScheme: 'light' });
+}"
+
+# Emulate reduced motion
+playwright-cli run-code "async page => {
+ await page.emulateMedia({ reducedMotion: 'reduce' });
+}"
+
+# Emulate print media
+playwright-cli run-code "async page => {
+ await page.emulateMedia({ media: 'print' });
+}"
+```
+
+## Wait Strategies
+
+```bash
+# Wait for network idle
+playwright-cli run-code "async page => {
+ await page.waitForLoadState('networkidle');
+}"
+
+# Wait for specific element
+playwright-cli run-code "async page => {
+ await page.locator('.loading').waitFor({ state: 'hidden' });
+}"
+
+# Wait for function to return true
+playwright-cli run-code "async page => {
+ await page.waitForFunction(() => window.appReady === true);
+}"
+
+# Wait with timeout
+playwright-cli run-code "async page => {
+ await page.locator('.result').waitFor({ timeout: 10000 });
+}"
+```
+
+## Frames and Iframes
+
+```bash
+# Work with iframe
+playwright-cli run-code "async page => {
+ const frame = page.locator('iframe#my-iframe').contentFrame();
+ await frame.locator('button').click();
+}"
+
+# Get all frames
+playwright-cli run-code "async page => {
+ const frames = page.frames();
+ return frames.map(f => f.url());
+}"
+```
+
+## File Downloads
+
+```bash
+# Handle file download
+playwright-cli run-code "async page => {
+ const downloadPromise = page.waitForEvent('download');
+ await page.getByRole('link', { name: 'Download' }).click();
+ const download = await downloadPromise;
+ await download.saveAs('./downloaded-file.pdf');
+ return download.suggestedFilename();
+}"
+```
+
+## Clipboard
+
+```bash
+# Read clipboard (requires permission)
+playwright-cli run-code "async page => {
+ await page.context().grantPermissions(['clipboard-read']);
+ return await page.evaluate(() => navigator.clipboard.readText());
+}"
+
+# Write to clipboard
+playwright-cli run-code "async page => {
+ await page.evaluate(text => navigator.clipboard.writeText(text), 'Hello clipboard!');
+}"
+```
+
+## Page Information
+
+```bash
+# Get page title
+playwright-cli run-code "async page => {
+ return await page.title();
+}"
+
+# Get current URL
+playwright-cli run-code "async page => {
+ return page.url();
+}"
+
+# Get page content
+playwright-cli run-code "async page => {
+ return await page.content();
+}"
+
+# Get viewport size
+playwright-cli run-code "async page => {
+ return page.viewportSize();
+}"
+```
+
+## JavaScript Execution
+
+```bash
+# Execute JavaScript and return result
+playwright-cli run-code "async page => {
+ return await page.evaluate(() => {
+ return {
+ userAgent: navigator.userAgent,
+ language: navigator.language,
+ cookiesEnabled: navigator.cookieEnabled
+ };
+ });
+}"
+
+# Pass arguments to evaluate
+playwright-cli run-code "async page => {
+ const multiplier = 5;
+ return await page.evaluate(m => document.querySelectorAll('li').length * m, multiplier);
+}"
+```
+
+## Error Handling
+
+```bash
+# Try-catch in run-code
+playwright-cli run-code "async page => {
+ try {
+ await page.getByRole('button', { name: 'Submit' }).click({ timeout: 1000 });
+ return 'clicked';
+ } catch (e) {
+ return 'element not found';
+ }
+}"
+```
+
+## Complex Workflows
+
+```bash
+# Login and save state
+playwright-cli run-code "async page => {
+ await page.goto('https://example.com/login');
+ await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
+ await page.getByRole('textbox', { name: 'Password' }).fill('secret');
+ await page.getByRole('button', { name: 'Sign in' }).click();
+ await page.waitForURL('**/dashboard');
+ await page.context().storageState({ path: 'auth.json' });
+ return 'Login successful';
+}"
+
+# Scrape data from multiple pages
+playwright-cli run-code "async page => {
+ const results = [];
+ for (let i = 1; i <= 3; i++) {
+ await page.goto(\`https://example.com/page/\${i}\`);
+ const items = await page.locator('.item').allTextContents();
+ results.push(...items);
+ }
+ return results;
+}"
+```
diff --git a/.agents/skills/playwright-cli/references/session-management.md b/.agents/skills/playwright-cli/references/session-management.md
new file mode 100644
index 0000000000..fac96066c9
--- /dev/null
+++ b/.agents/skills/playwright-cli/references/session-management.md
@@ -0,0 +1,169 @@
+# Browser Session Management
+
+Run multiple isolated browser sessions concurrently with state persistence.
+
+## Named Browser Sessions
+
+Use `-s` flag to isolate browser contexts:
+
+```bash
+# Browser 1: Authentication flow
+playwright-cli -s=auth open https://app.example.com/login
+
+# Browser 2: Public browsing (separate cookies, storage)
+playwright-cli -s=public open https://example.com
+
+# Commands are isolated by browser session
+playwright-cli -s=auth fill e1 "user@example.com"
+playwright-cli -s=public snapshot
+```
+
+## Browser Session Isolation Properties
+
+Each browser session has independent:
+- Cookies
+- LocalStorage / SessionStorage
+- IndexedDB
+- Cache
+- Browsing history
+- Open tabs
+
+## Browser Session Commands
+
+```bash
+# List all browser sessions
+playwright-cli list
+
+# Stop a browser session (close the browser)
+playwright-cli close # stop the default browser
+playwright-cli -s=mysession close # stop a named browser
+
+# Stop all browser sessions
+playwright-cli close-all
+
+# Forcefully kill all daemon processes (for stale/zombie processes)
+playwright-cli kill-all
+
+# Delete browser session user data (profile directory)
+playwright-cli delete-data # delete default browser data
+playwright-cli -s=mysession delete-data # delete named browser data
+```
+
+## Environment Variable
+
+Set a default browser session name via environment variable:
+
+```bash
+export PLAYWRIGHT_CLI_SESSION="mysession"
+playwright-cli open example.com # Uses "mysession" automatically
+```
+
+## Common Patterns
+
+### Concurrent Scraping
+
+```bash
+#!/bin/bash
+# Scrape multiple sites concurrently
+
+# Start all browsers
+playwright-cli -s=site1 open https://site1.com &
+playwright-cli -s=site2 open https://site2.com &
+playwright-cli -s=site3 open https://site3.com &
+wait
+
+# Take snapshots from each
+playwright-cli -s=site1 snapshot
+playwright-cli -s=site2 snapshot
+playwright-cli -s=site3 snapshot
+
+# Cleanup
+playwright-cli close-all
+```
+
+### A/B Testing Sessions
+
+```bash
+# Test different user experiences
+playwright-cli -s=variant-a open "https://app.com?variant=a"
+playwright-cli -s=variant-b open "https://app.com?variant=b"
+
+# Compare
+playwright-cli -s=variant-a screenshot
+playwright-cli -s=variant-b screenshot
+```
+
+### Persistent Profile
+
+By default, browser profile is kept in memory only. Use `--persistent` flag on `open` to persist the browser profile to disk:
+
+```bash
+# Use persistent profile (auto-generated location)
+playwright-cli open https://example.com --persistent
+
+# Use persistent profile with custom directory
+playwright-cli open https://example.com --profile=/path/to/profile
+```
+
+## Default Browser Session
+
+When `-s` is omitted, commands use the default browser session:
+
+```bash
+# These use the same default browser session
+playwright-cli open https://example.com
+playwright-cli snapshot
+playwright-cli close # Stops default browser
+```
+
+## Browser Session Configuration
+
+Configure a browser session with specific settings when opening:
+
+```bash
+# Open with config file
+playwright-cli open https://example.com --config=.playwright/my-cli.json
+
+# Open with specific browser
+playwright-cli open https://example.com --browser=firefox
+
+# Open in headed mode
+playwright-cli open https://example.com --headed
+
+# Open with persistent profile
+playwright-cli open https://example.com --persistent
+```
+
+## Best Practices
+
+### 1. Name Browser Sessions Semantically
+
+```bash
+# GOOD: Clear purpose
+playwright-cli -s=github-auth open https://github.com
+playwright-cli -s=docs-scrape open https://docs.example.com
+
+# AVOID: Generic names
+playwright-cli -s=s1 open https://github.com
+```
+
+### 2. Always Clean Up
+
+```bash
+# Stop browsers when done
+playwright-cli -s=auth close
+playwright-cli -s=scrape close
+
+# Or stop all at once
+playwright-cli close-all
+
+# If browsers become unresponsive or zombie processes remain
+playwright-cli kill-all
+```
+
+### 3. Delete Stale Browser Data
+
+```bash
+# Remove old browser data to free disk space
+playwright-cli -s=oldsession delete-data
+```
diff --git a/.agents/skills/playwright-cli/references/storage-state.md b/.agents/skills/playwright-cli/references/storage-state.md
new file mode 100644
index 0000000000..c856db5e40
--- /dev/null
+++ b/.agents/skills/playwright-cli/references/storage-state.md
@@ -0,0 +1,275 @@
+# Storage Management
+
+Manage cookies, localStorage, sessionStorage, and browser storage state.
+
+## Storage State
+
+Save and restore complete browser state including cookies and storage.
+
+### Save Storage State
+
+```bash
+# Save to auto-generated filename (storage-state-{timestamp}.json)
+playwright-cli state-save
+
+# Save to specific filename
+playwright-cli state-save my-auth-state.json
+```
+
+### Restore Storage State
+
+```bash
+# Load storage state from file
+playwright-cli state-load my-auth-state.json
+
+# Reload page to apply cookies
+playwright-cli open https://example.com
+```
+
+### Storage State File Format
+
+The saved file contains:
+
+```json
+{
+ "cookies": [
+ {
+ "name": "session_id",
+ "value": "abc123",
+ "domain": "example.com",
+ "path": "/",
+ "expires": 1735689600,
+ "httpOnly": true,
+ "secure": true,
+ "sameSite": "Lax"
+ }
+ ],
+ "origins": [
+ {
+ "origin": "https://example.com",
+ "localStorage": [
+ { "name": "theme", "value": "dark" },
+ { "name": "user_id", "value": "12345" }
+ ]
+ }
+ ]
+}
+```
+
+## Cookies
+
+### List All Cookies
+
+```bash
+playwright-cli cookie-list
+```
+
+### Filter Cookies by Domain
+
+```bash
+playwright-cli cookie-list --domain=example.com
+```
+
+### Filter Cookies by Path
+
+```bash
+playwright-cli cookie-list --path=/api
+```
+
+### Get Specific Cookie
+
+```bash
+playwright-cli cookie-get session_id
+```
+
+### Set a Cookie
+
+```bash
+# Basic cookie
+playwright-cli cookie-set session abc123
+
+# Cookie with options
+playwright-cli cookie-set session abc123 --domain=example.com --path=/ --httpOnly --secure --sameSite=Lax
+
+# Cookie with expiration (Unix timestamp)
+playwright-cli cookie-set remember_me token123 --expires=1735689600
+```
+
+### Delete a Cookie
+
+```bash
+playwright-cli cookie-delete session_id
+```
+
+### Clear All Cookies
+
+```bash
+playwright-cli cookie-clear
+```
+
+### Advanced: Multiple Cookies or Custom Options
+
+For complex scenarios like adding multiple cookies at once, use `run-code`:
+
+```bash
+playwright-cli run-code "async page => {
+ await page.context().addCookies([
+ { name: 'session_id', value: 'sess_abc123', domain: 'example.com', path: '/', httpOnly: true },
+ { name: 'preferences', value: JSON.stringify({ theme: 'dark' }), domain: 'example.com', path: '/' }
+ ]);
+}"
+```
+
+## Local Storage
+
+### List All localStorage Items
+
+```bash
+playwright-cli localstorage-list
+```
+
+### Get Single Value
+
+```bash
+playwright-cli localstorage-get token
+```
+
+### Set Value
+
+```bash
+playwright-cli localstorage-set theme dark
+```
+
+### Set JSON Value
+
+```bash
+playwright-cli localstorage-set user_settings '{"theme":"dark","language":"en"}'
+```
+
+### Delete Single Item
+
+```bash
+playwright-cli localstorage-delete token
+```
+
+### Clear All localStorage
+
+```bash
+playwright-cli localstorage-clear
+```
+
+### Advanced: Multiple Operations
+
+For complex scenarios like setting multiple values at once, use `run-code`:
+
+```bash
+playwright-cli run-code "async page => {
+ await page.evaluate(() => {
+ localStorage.setItem('token', 'jwt_abc123');
+ localStorage.setItem('user_id', '12345');
+ localStorage.setItem('expires_at', Date.now() + 3600000);
+ });
+}"
+```
+
+## Session Storage
+
+### List All sessionStorage Items
+
+```bash
+playwright-cli sessionstorage-list
+```
+
+### Get Single Value
+
+```bash
+playwright-cli sessionstorage-get form_data
+```
+
+### Set Value
+
+```bash
+playwright-cli sessionstorage-set step 3
+```
+
+### Delete Single Item
+
+```bash
+playwright-cli sessionstorage-delete step
+```
+
+### Clear sessionStorage
+
+```bash
+playwright-cli sessionstorage-clear
+```
+
+## IndexedDB
+
+### List Databases
+
+```bash
+playwright-cli run-code "async page => {
+ return await page.evaluate(async () => {
+ const databases = await indexedDB.databases();
+ return databases;
+ });
+}"
+```
+
+### Delete Database
+
+```bash
+playwright-cli run-code "async page => {
+ await page.evaluate(() => {
+ indexedDB.deleteDatabase('myDatabase');
+ });
+}"
+```
+
+## Common Patterns
+
+### Authentication State Reuse
+
+```bash
+# Step 1: Login and save state
+playwright-cli open https://app.example.com/login
+playwright-cli snapshot
+playwright-cli fill e1 "user@example.com"
+playwright-cli fill e2 "password123"
+playwright-cli click e3
+
+# Save the authenticated state
+playwright-cli state-save auth.json
+
+# Step 2: Later, restore state and skip login
+playwright-cli state-load auth.json
+playwright-cli open https://app.example.com/dashboard
+# Already logged in!
+```
+
+### Save and Restore Roundtrip
+
+```bash
+# Set up authentication state
+playwright-cli open https://example.com
+playwright-cli eval "() => { document.cookie = 'session=abc123'; localStorage.setItem('user', 'john'); }"
+
+# Save state to file
+playwright-cli state-save my-session.json
+
+# ... later, in a new session ...
+
+# Restore state
+playwright-cli state-load my-session.json
+playwright-cli open https://example.com
+# Cookies and localStorage are restored!
+```
+
+## Security Notes
+
+- Never commit storage state files containing auth tokens
+- Add `*.auth-state.json` to `.gitignore`
+- Delete state files after automation completes
+- Use environment variables for sensitive data
+- By default, sessions run in-memory mode which is safer for sensitive operations
diff --git a/.agents/skills/playwright-cli/references/test-generation.md b/.agents/skills/playwright-cli/references/test-generation.md
new file mode 100644
index 0000000000..7a09df387f
--- /dev/null
+++ b/.agents/skills/playwright-cli/references/test-generation.md
@@ -0,0 +1,88 @@
+# Test Generation
+
+Generate Playwright test code automatically as you interact with the browser.
+
+## How It Works
+
+Every action you perform with `playwright-cli` generates corresponding Playwright TypeScript code.
+This code appears in the output and can be copied directly into your test files.
+
+## Example Workflow
+
+```bash
+# Start a session
+playwright-cli open https://example.com/login
+
+# Take a snapshot to see elements
+playwright-cli snapshot
+# Output shows: e1 [textbox "Email"], e2 [textbox "Password"], e3 [button "Sign In"]
+
+# Fill form fields - generates code automatically
+playwright-cli fill e1 "user@example.com"
+# Ran Playwright code:
+# await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
+
+playwright-cli fill e2 "password123"
+# Ran Playwright code:
+# await page.getByRole('textbox', { name: 'Password' }).fill('password123');
+
+playwright-cli click e3
+# Ran Playwright code:
+# await page.getByRole('button', { name: 'Sign In' }).click();
+```
+
+## Building a Test File
+
+Collect the generated code into a Playwright test:
+
+```typescript
+import { test, expect } from '@playwright/test';
+
+test('login flow', async ({ page }) => {
+ // Generated code from playwright-cli session:
+ await page.goto('https://example.com/login');
+ await page.getByRole('textbox', { name: 'Email' }).fill('user@example.com');
+ await page.getByRole('textbox', { name: 'Password' }).fill('password123');
+ await page.getByRole('button', { name: 'Sign In' }).click();
+
+ // Add assertions
+ await expect(page).toHaveURL(/.*dashboard/);
+});
+```
+
+## Best Practices
+
+### 1. Use Semantic Locators
+
+The generated code uses role-based locators when possible, which are more resilient:
+
+```typescript
+// Generated (good - semantic)
+await page.getByRole('button', { name: 'Submit' }).click();
+
+// Avoid (fragile - CSS selectors)
+await page.locator('#submit-btn').click();
+```
+
+### 2. Explore Before Recording
+
+Take snapshots to understand the page structure before recording actions:
+
+```bash
+playwright-cli open https://example.com
+playwright-cli snapshot
+# Review the element structure
+playwright-cli click e5
+```
+
+### 3. Add Assertions Manually
+
+Generated code captures actions but not assertions. Add expectations in your test:
+
+```typescript
+// Generated action
+await page.getByRole('button', { name: 'Submit' }).click();
+
+// Manual assertion
+await expect(page.getByText('Success')).toBeVisible();
+```
diff --git a/.agents/skills/playwright-cli/references/tracing.md b/.agents/skills/playwright-cli/references/tracing.md
new file mode 100644
index 0000000000..7ce7babbdf
--- /dev/null
+++ b/.agents/skills/playwright-cli/references/tracing.md
@@ -0,0 +1,139 @@
+# Tracing
+
+Capture detailed execution traces for debugging and analysis. Traces include DOM snapshots, screenshots, network activity, and console logs.
+
+## Basic Usage
+
+```bash
+# Start trace recording
+playwright-cli tracing-start
+
+# Perform actions
+playwright-cli open https://example.com
+playwright-cli click e1
+playwright-cli fill e2 "test"
+
+# Stop trace recording
+playwright-cli tracing-stop
+```
+
+## Trace Output Files
+
+When you start tracing, Playwright creates a `traces/` directory with several files:
+
+### `trace-{timestamp}.trace`
+
+**Action log** - The main trace file containing:
+- Every action performed (clicks, fills, navigations)
+- DOM snapshots before and after each action
+- Screenshots at each step
+- Timing information
+- Console messages
+- Source locations
+
+### `trace-{timestamp}.network`
+
+**Network log** - Complete network activity:
+- All HTTP requests and responses
+- Request headers and bodies
+- Response headers and bodies
+- Timing (DNS, connect, TLS, TTFB, download)
+- Resource sizes
+- Failed requests and errors
+
+### `resources/`
+
+**Resources directory** - Cached resources:
+- Images, fonts, stylesheets, scripts
+- Response bodies for replay
+- Assets needed to reconstruct page state
+
+## What Traces Capture
+
+| Category | Details |
+|----------|---------|
+| **Actions** | Clicks, fills, hovers, keyboard input, navigations |
+| **DOM** | Full DOM snapshot before/after each action |
+| **Screenshots** | Visual state at each step |
+| **Network** | All requests, responses, headers, bodies, timing |
+| **Console** | All console.log, warn, error messages |
+| **Timing** | Precise timing for each operation |
+
+## Use Cases
+
+### Debugging Failed Actions
+
+```bash
+playwright-cli tracing-start
+playwright-cli open https://app.example.com
+
+# This click fails - why?
+playwright-cli click e5
+
+playwright-cli tracing-stop
+# Open trace to see DOM state when click was attempted
+```
+
+### Analyzing Performance
+
+```bash
+playwright-cli tracing-start
+playwright-cli open https://slow-site.com
+playwright-cli tracing-stop
+
+# View network waterfall to identify slow resources
+```
+
+### Capturing Evidence
+
+```bash
+# Record a complete user flow for documentation
+playwright-cli tracing-start
+
+playwright-cli open https://app.example.com/checkout
+playwright-cli fill e1 "4111111111111111"
+playwright-cli fill e2 "12/25"
+playwright-cli fill e3 "123"
+playwright-cli click e4
+
+playwright-cli tracing-stop
+# Trace shows exact sequence of events
+```
+
+## Trace vs Video vs Screenshot
+
+| Feature | Trace | Video | Screenshot |
+|---------|-------|-------|------------|
+| **Format** | .trace file | .webm video | .png/.jpeg image |
+| **DOM inspection** | Yes | No | No |
+| **Network details** | Yes | No | No |
+| **Step-by-step replay** | Yes | Continuous | Single frame |
+| **File size** | Medium | Large | Small |
+| **Best for** | Debugging | Demos | Quick capture |
+
+## Best Practices
+
+### 1. Start Tracing Before the Problem
+
+```bash
+# Trace the entire flow, not just the failing step
+playwright-cli tracing-start
+playwright-cli open https://example.com
+# ... all steps leading to the issue ...
+playwright-cli tracing-stop
+```
+
+### 2. Clean Up Old Traces
+
+Traces can consume significant disk space:
+
+```bash
+# Remove traces older than 7 days
+find .playwright-cli/traces -mtime +7 -delete
+```
+
+## Limitations
+
+- Traces add overhead to automation
+- Large traces can consume significant disk space
+- Some dynamic content may not replay perfectly
diff --git a/.agents/skills/playwright-cli/references/video-recording.md b/.agents/skills/playwright-cli/references/video-recording.md
new file mode 100644
index 0000000000..6f7178be2e
--- /dev/null
+++ b/.agents/skills/playwright-cli/references/video-recording.md
@@ -0,0 +1,143 @@
+# Video Recording
+
+Capture browser automation sessions as video for debugging, documentation, or verification. Produces WebM (VP8/VP9 codec).
+
+## Basic Recording
+
+```bash
+# Open browser first
+playwright-cli open
+
+# Start recording
+playwright-cli video-start demo.webm
+
+# Add a chapter marker for section transitions
+playwright-cli video-chapter "Getting Started" --description="Opening the homepage" --duration=2000
+
+# Navigate and perform actions
+playwright-cli goto https://example.com
+playwright-cli snapshot
+playwright-cli click e1
+
+# Add another chapter
+playwright-cli video-chapter "Filling Form" --description="Entering test data" --duration=2000
+playwright-cli fill e2 "test input"
+
+# Stop and save
+playwright-cli video-stop
+```
+
+## Best Practices
+
+### 1. Use Descriptive Filenames
+
+```bash
+# Include context in filename
+playwright-cli video-start recordings/login-flow-2024-01-15.webm
+playwright-cli video-start recordings/checkout-test-run-42.webm
+```
+
+### 2. Record entire hero scripts.
+
+When recording a video for the user or as a proof of work, it is best to create a code snippet and execute it with run-code.
+It allows pulling appropriate pauses between the actions and annotating the video. There are new Playwright APIs for that.
+
+1) Perform scenario using CLI and take note of all locators and actions. You'll need those locators to request their bounding boxes for highlight.
+2) Create a file with the intended script for video (below). Use pressSequentially w/ delay for nice typing, make reasonable pauses.
+3) Use playwright-cli run-code --file your-script.js
+
+**Important**: Overlays are `pointer-events: none` — they do not interfere with page interactions. You can safely keep sticky overlays visible while clicking, filling, or performing any actions on the page.
+
+```js
+async page => {
+ await page.screencast.start({ path: 'video.webm', size: { width: 1280, height: 800 } });
+ await page.goto('https://demo.playwright.dev/todomvc');
+
+ // Show a chapter card — blurs the page and shows a dialog.
+ // Blocks until duration expires, then auto-removes.
+ // Use this for simple use cases, but always feel free to hand-craft your own beautiful
+ // overlay via await page.screencast.showOverlay().
+ await page.screencast.showChapter('Adding Todo Items', {
+ description: 'We will add several items to the todo list.',
+ duration: 2000,
+ });
+
+ // Perform action
+ await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Walk the dog', { delay: 60 });
+ await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
+ await page.waitForTimeout(1000);
+
+ // Show next chapter
+ await page.screencast.showChapter('Verifying Results', {
+ description: 'Checking the item appeared in the list.',
+ duration: 2000,
+ });
+
+ // Add a sticky annotation that stays while you perform actions.
+ // Overlays are pointer-events: none, so they won't block clicks.
+ const annotation = await page.screencast.showOverlay(`
+
+ ✓ Item added successfully
+
+ `);
+
+ // Perform more actions while the annotation is visible
+ await page.getByRole('textbox', { name: 'What needs to be done?' }).pressSequentially('Buy groceries', { delay: 60 });
+ await page.getByRole('textbox', { name: 'What needs to be done?' }).press('Enter');
+ await page.waitForTimeout(1500);
+
+ // Remove the annotation when done
+ await annotation.dispose();
+
+ // You can also highlight relevant locators and provide contextual annotations.
+ const bounds = await page.getByText('Walk the dog').boundingBox();
+ await page.screencast.showOverlay(`
+
+
+ Check it out, it is right above this text
+
+ `, { duration: 2000 });
+
+ await page.screencast.stop();
+}
+```
+
+Embrace creativity, overlays are powerful.
+
+### Overlay API Summary
+
+| Method | Use Case |
+|--------|----------|
+| `page.screencast.showChapter(title, { description?, duration?, styleSheet? })` | Full-screen chapter card with blurred backdrop — ideal for section transitions |
+| `page.screencast.showOverlay(html, { duration? })` | Custom HTML overlay — use for callouts, labels, highlights |
+| `disposable.dispose()` | Remove a sticky overlay added without duration |
+| `page.screencast.hideOverlays()` / `page.screencast.showOverlays()` | Temporarily hide/show all overlays |
+
+## Tracing vs Video
+
+| Feature | Video | Tracing |
+|---------|-------|---------|
+| Output | WebM file | Trace file (viewable in Trace Viewer) |
+| Shows | Visual recording | DOM snapshots, network, console, actions |
+| Use case | Demos, documentation | Debugging, analysis |
+| Size | Larger | Smaller |
+
+## Limitations
+
+- Recording adds slight overhead to automation
+- Large recordings can consume significant disk space
diff --git a/.playwright/cli.config.json b/.playwright/cli.config.json
new file mode 100644
index 0000000000..587f73e4f8
--- /dev/null
+++ b/.playwright/cli.config.json
@@ -0,0 +1,8 @@
+{
+ "browser": {
+ "browserName": "chromium",
+ "launchOptions": {
+ "channel": "chromium"
+ }
+ }
+}
\ No newline at end of file
diff --git a/apps/backend/internal/github/controller.go b/apps/backend/internal/github/controller.go
index c66b580e5d..336257dcdf 100644
--- a/apps/backend/internal/github/controller.go
+++ b/apps/backend/internal/github/controller.go
@@ -63,9 +63,22 @@ func (c *Controller) httpGetStatus(ctx *gin.Context) {
}
func (c *Controller) httpListTaskPRs(ctx *gin.Context) {
+ // Workspace-scoped: returns all PRs for a workspace, triggers background refresh for stale ones
+ workspaceID := ctx.Query("workspace_id")
+ if workspaceID != "" {
+ result, err := c.service.ListWorkspaceTaskPRs(ctx.Request.Context(), workspaceID)
+ if err != nil {
+ ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ ctx.JSON(http.StatusOK, gin.H{"task_prs": result})
+ return
+ }
+
+ // Legacy: filter by task IDs
taskIDsParam := ctx.Query("task_ids")
if taskIDsParam == "" {
- ctx.JSON(http.StatusBadRequest, gin.H{"error": "task_ids query parameter required"})
+ ctx.JSON(http.StatusBadRequest, gin.H{"error": "workspace_id or task_ids query parameter required"})
return
}
taskIDs := strings.Split(taskIDsParam, ",")
@@ -74,7 +87,7 @@ func (c *Controller) httpListTaskPRs(ctx *gin.Context) {
ctx.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
- ctx.JSON(http.StatusOK, result)
+ ctx.JSON(http.StatusOK, gin.H{"task_prs": result})
}
func (c *Controller) httpGetTaskPR(ctx *gin.Context) {
diff --git a/apps/backend/internal/github/service.go b/apps/backend/internal/github/service.go
index 1cb0222cfc..05ddd6bf55 100644
--- a/apps/backend/internal/github/service.go
+++ b/apps/backend/internal/github/service.go
@@ -9,6 +9,7 @@ import (
"time"
"go.uber.org/zap"
+ "golang.org/x/sync/singleflight"
"github.com/kandev/kandev/internal/common/logger"
"github.com/kandev/kandev/internal/events"
@@ -31,6 +32,9 @@ type TaskSessionChecker interface {
HasTaskSessions(ctx context.Context, taskID string) (bool, error)
}
+// prSyncFreshnessWindow is how long PR data is considered fresh (skip GitHub API).
+const prSyncFreshnessWindow = 30 * time.Second
+
// Service coordinates GitHub integration operations.
type Service struct {
mu sync.Mutex
@@ -42,6 +46,7 @@ type Service struct {
logger *logger.Logger
taskDeleter TaskDeleter
taskSessionChecker TaskSessionChecker
+ syncGroup singleflight.Group
}
// NewService creates a new GitHub service.
@@ -399,6 +404,43 @@ func (s *Service) ListTaskPRs(ctx context.Context, taskIDs []string) (map[string
return s.store.ListTaskPRsByTaskIDs(ctx, taskIDs)
}
+// ListWorkspaceTaskPRs returns all PR associations for a workspace.
+// It returns cached data immediately and triggers background refresh for stale entries.
+func (s *Service) ListWorkspaceTaskPRs(ctx context.Context, workspaceID string) (map[string]*TaskPR, error) {
+ result, err := s.store.ListTaskPRsByWorkspaceID(ctx, workspaceID)
+ if err != nil {
+ return nil, err
+ }
+
+ // Collect stale task IDs for background refresh
+ var staleTaskIDs []string
+ for _, tp := range result {
+ if tp.LastSyncedAt == nil || time.Since(*tp.LastSyncedAt) >= prSyncFreshnessWindow {
+ staleTaskIDs = append(staleTaskIDs, tp.TaskID)
+ }
+ }
+
+ // Background refresh with bounded concurrency
+ if len(staleTaskIDs) > 0 {
+ go func() {
+ sem := make(chan struct{}, 5)
+ for _, taskID := range staleTaskIDs {
+ sem <- struct{}{}
+ go func(id string) {
+ defer func() { <-sem }()
+ syncCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ if _, syncErr := s.TriggerPRSync(syncCtx, id); syncErr != nil {
+ s.logger.Debug("background PR sync failed", zap.String("task_id", id), zap.Error(syncErr))
+ }
+ }(taskID)
+ }
+ }()
+ }
+
+ return result, nil
+}
+
// SyncTaskPR updates a TaskPR record with the latest PR status.
// It only publishes a github.task_pr.updated event when data actually changed,
// preventing feedback loops with frontend sync handlers.
@@ -523,17 +565,37 @@ func (s *Service) triggerPRDetection(ctx context.Context, watch *PRWatch, taskID
}
func (s *Service) triggerPRStatusSync(ctx context.Context, watch *PRWatch, taskID string) (*TaskPR, error) {
- status, _, err := s.CheckPRWatch(ctx, watch)
+ // Freshness check: skip GitHub API if recently synced
+ if tp, _ := s.store.GetTaskPR(ctx, taskID); tp != nil && tp.LastSyncedAt != nil {
+ if time.Since(*tp.LastSyncedAt) < prSyncFreshnessWindow {
+ return tp, nil
+ }
+ }
+
+ // Coalesce concurrent syncs for the same PR
+ key := fmt.Sprintf("%s/%s/%d", watch.Owner, watch.Repo, watch.PRNumber)
+ v, err, _ := s.syncGroup.Do(key, func() (interface{}, error) {
+ bgCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+ status, _, checkErr := s.CheckPRWatch(bgCtx, watch)
+ if checkErr != nil {
+ return nil, checkErr
+ }
+ if status == nil {
+ return s.store.GetTaskPR(bgCtx, taskID)
+ }
+ if syncErr := s.SyncTaskPR(bgCtx, taskID, status); syncErr != nil {
+ return nil, syncErr
+ }
+ return s.store.GetTaskPR(bgCtx, taskID)
+ })
if err != nil {
return nil, err
}
- if status == nil {
- return s.store.GetTaskPR(ctx, taskID)
+ if v == nil {
+ return nil, nil
}
- if syncErr := s.SyncTaskPR(ctx, taskID, status); syncErr != nil {
- return nil, syncErr
- }
- return s.store.GetTaskPR(ctx, taskID)
+ return v.(*TaskPR), nil
}
// --- PR files and commits (live) ---
diff --git a/apps/backend/internal/github/store.go b/apps/backend/internal/github/store.go
index 3cbc563f5e..27f6a769a9 100644
--- a/apps/backend/internal/github/store.go
+++ b/apps/backend/internal/github/store.go
@@ -238,6 +238,22 @@ func (s *Store) ListTaskPRsByTaskIDs(ctx context.Context, taskIDs []string) (map
return result, nil
}
+// ListTaskPRsByWorkspaceID returns all PR associations for tasks in a workspace.
+func (s *Store) ListTaskPRsByWorkspaceID(ctx context.Context, workspaceID string) (map[string]*TaskPR, error) {
+ var prs []TaskPR
+ if err := s.ro.SelectContext(ctx, &prs,
+ `SELECT gtp.* FROM github_task_prs gtp
+ INNER JOIN tasks t ON gtp.task_id = t.id
+ WHERE t.workspace_id = ?`, workspaceID); err != nil {
+ return nil, err
+ }
+ result := make(map[string]*TaskPR, len(prs))
+ for i := range prs {
+ result[prs[i].TaskID] = &prs[i]
+ }
+ return result, nil
+}
+
// UpdateTaskPR updates a task-PR association.
func (s *Store) UpdateTaskPR(ctx context.Context, tp *TaskPR) error {
tp.UpdatedAt = time.Now().UTC()
diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx
index b2c1e4769e..1e8678d2cc 100644
--- a/apps/web/app/page.tsx
+++ b/apps/web/app/page.tsx
@@ -9,6 +9,7 @@ import {
listTaskSessionMessages,
listQuickChatSessions,
} from "@/lib/api";
+import { listWorkspaceTaskPRs } from "@/lib/api/domains/github-api";
import { snapshotToState } from "@/lib/ssr/mapper";
import { mapUserSettingsResponse } from "@/lib/ssr/user-settings";
import type { AppState } from "@/lib/state/store";
@@ -137,6 +138,10 @@ export default async function Page({ searchParams }: PageProps) {
);
}
+ // Fire-and-forget: warm the backend PR cache for this workspace.
+ // The client will fetch the data after mount via useWorkspacePRs.
+ listWorkspaceTaskPRs(activeWorkspaceId, { cache: "no-store" }).catch(() => {});
+
const [workflowList, repositoriesResponse, quickChatResponse] = await Promise.all([
listWorkflows(activeWorkspaceId, { cache: "no-store" }),
listRepositories(activeWorkspaceId, undefined, { cache: "no-store" }).catch(() => ({
diff --git a/apps/web/components/github/pr-task-icon.tsx b/apps/web/components/github/pr-task-icon.tsx
index 6e87d85666..cf27af16b0 100644
--- a/apps/web/components/github/pr-task-icon.tsx
+++ b/apps/web/components/github/pr-task-icon.tsx
@@ -40,7 +40,7 @@ export function PRTaskIcon({ taskId }: { taskId: string }) {
diff --git a/apps/web/components/kanban-board.tsx b/apps/web/components/kanban-board.tsx
index 76f0262c0f..d0089f07cf 100644
--- a/apps/web/components/kanban-board.tsx
+++ b/apps/web/components/kanban-board.tsx
@@ -15,6 +15,7 @@ import { MobileSearchBar } from "./kanban/mobile-search-bar";
import { MobileTaskSheet } from "./kanban/mobile-task-sheet";
import { useKanbanData, useKanbanActions, useKanbanNavigation } from "@/hooks/domains/kanban";
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
+import { useWorkspacePRs } from "@/hooks/domains/github/use-task-pr";
import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint";
import { HomepageCommands } from "./homepage-commands";
import { linkToTask } from "@/lib/links";
@@ -203,6 +204,7 @@ function useKanbanBoardSetup(
} = useKanbanBoardStore();
useAllWorkflowSnapshots(workspaceState.activeId);
+ useWorkspacePRs(workspaceState.activeId);
const hooks = useKanbanBoardHooks(searchQuery, workspaceState, workflowsState);
const { handleOpenTask, handleCardClick } = useKanbanNavigation({
diff --git a/apps/web/components/kanban-card.tsx b/apps/web/components/kanban-card.tsx
index 19148f8584..9260b4ea4d 100644
--- a/apps/web/components/kanban-card.tsx
+++ b/apps/web/components/kanban-card.tsx
@@ -100,10 +100,10 @@ function KanbanCardBody({
{repoName}
)}
-
+
{task.title}
diff --git a/apps/web/components/task/task-changes-panel.tsx b/apps/web/components/task/task-changes-panel.tsx
index 2c5b5fd1ff..334fd12eab 100644
--- a/apps/web/components/task/task-changes-panel.tsx
+++ b/apps/web/components/task/task-changes-panel.tsx
@@ -125,7 +125,7 @@ function useChangesData(selectedDiff: SelectedDiff | null, onClearSelected: () =
const gitStatus = useSessionGitStatus(activeSessionId);
const { diff: cumulativeDiff, loading: cumulativeLoading } = useCumulativeDiff(activeSessionId);
const pr = useActiveTaskPR();
- const { files: prDiffFiles } = usePRDiff(
+ const { files: prDiffFiles, loading: prDiffLoading } = usePRDiff(
pr?.owner ?? null,
pr?.repo ?? null,
pr?.pr_number ?? null,
@@ -145,6 +145,12 @@ function useChangesData(selectedDiff: SelectedDiff | null, onClearSelected: () =
const { reviewedFiles, staleFiles } = useMemo(() => {
const reviewed = new Set
();
const stale = new Set();
+ // When a PR exists but its diff files are still loading, the file list
+ // temporarily uses cumulative diff content which has a different hash.
+ // Skip review computation until PR diffs arrive to avoid a 1/1 -> 0/1 flash.
+ if (pr && prDiffLoading) {
+ return { reviewedFiles: reviewed, staleFiles: stale };
+ }
for (const file of allFiles) {
const reviewState = reviews.get(file.path);
if (!reviewState?.reviewed) continue;
@@ -156,7 +162,7 @@ function useChangesData(selectedDiff: SelectedDiff | null, onClearSelected: () =
}
}
return { reviewedFiles: reviewed, staleFiles: stale };
- }, [allFiles, reviews]);
+ }, [allFiles, reviews, pr, prDiffLoading]);
const totalCommentCount = useMemo(() => {
if (!commentSessionIds || commentSessionIds.length === 0) return 0;
diff --git a/apps/web/hooks/domains/github/use-task-pr.ts b/apps/web/hooks/domains/github/use-task-pr.ts
index 033c2a051b..8971e1303c 100644
--- a/apps/web/hooks/domains/github/use-task-pr.ts
+++ b/apps/web/hooks/domains/github/use-task-pr.ts
@@ -1,35 +1,45 @@
"use client";
import { useEffect, useCallback, useRef } from "react";
-import { listTaskPRs } from "@/lib/api/domains/github-api";
+import { listWorkspaceTaskPRs } from "@/lib/api/domains/github-api";
import { getWebSocketClient } from "@/lib/ws/connection";
import { useAppStore } from "@/components/state-provider";
import type { TaskPR } from "@/lib/types/github";
-/** Fetch and cache PR associations for a batch of task IDs. */
-export function useTaskPRs(taskIds: string[]) {
- const byTaskId = useAppStore((state) => state.taskPRs.byTaskId);
- const loading = useAppStore((state) => state.taskPRs.loading);
+/** Fetch all PR associations for a workspace. */
+export function useWorkspacePRs(workspaceId: string | null) {
const setTaskPRs = useAppStore((state) => state.setTaskPRs);
const setTaskPRsLoading = useAppStore((state) => state.setTaskPRsLoading);
+ const fetchedRef = useRef(null);
+ const requestRef = useRef(0);
useEffect(() => {
- if (taskIds.length === 0 || loading) return;
+ if (!workspaceId) {
+ fetchedRef.current = null;
+ return;
+ }
+ if (fetchedRef.current === workspaceId) return;
+
+ const requestId = ++requestRef.current;
+ fetchedRef.current = workspaceId;
+
setTaskPRsLoading(true);
- listTaskPRs(taskIds, { cache: "no-store" })
+ listWorkspaceTaskPRs(workspaceId, { cache: "no-store" })
.then((response) => {
+ if (requestRef.current !== requestId) return;
setTaskPRs(response?.task_prs ?? {});
})
.catch(() => {
- // Keep existing data on error
+ if (requestRef.current === requestId) {
+ fetchedRef.current = null; // allow retry on failure
+ }
})
.finally(() => {
- setTaskPRsLoading(false);
+ if (requestRef.current === requestId) {
+ setTaskPRsLoading(false);
+ }
});
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [taskIds.join(",")]);
-
- return { byTaskId, loading };
+ }, [workspaceId, setTaskPRs, setTaskPRsLoading]);
}
const SYNC_RETRY_DELAY = 5_000; // 5 seconds
diff --git a/apps/web/hooks/use-session-file-reviews.ts b/apps/web/hooks/use-session-file-reviews.ts
index 06d6180ed7..b34f3e09d7 100644
--- a/apps/web/hooks/use-session-file-reviews.ts
+++ b/apps/web/hooks/use-session-file-reviews.ts
@@ -110,8 +110,7 @@ export function useSessionFileReviews(sessionId: string | null): UseSessionFileR
useEffect(() => {
if (!sessionId || fetchedSessions.has(sessionId)) {
if (sessionId && reviewsCache[sessionId]) {
- const cached = reviewsCache[sessionId];
- queueMicrotask(() => setReviews(cached));
+ queueMicrotask(() => setReviews(reviewsCache[sessionId]));
}
return;
}
diff --git a/apps/web/lib/api/domains/github-api.ts b/apps/web/lib/api/domains/github-api.ts
index 2b7afb8ab8..6b3b3dfdd4 100644
--- a/apps/web/lib/api/domains/github-api.ts
+++ b/apps/web/lib/api/domains/github-api.ts
@@ -28,6 +28,13 @@ export async function listTaskPRs(taskIds: string[], options?: ApiRequestOptions
return fetchJson(`/api/v1/github/task-prs?${query.toString()}`, options);
}
+export async function listWorkspaceTaskPRs(workspaceId: string, options?: ApiRequestOptions) {
+ return fetchJson(
+ `/api/v1/github/task-prs?workspace_id=${encodeURIComponent(workspaceId)}`,
+ options,
+ );
+}
+
export async function getTaskPR(taskId: string, options?: ApiRequestOptions) {
return fetchJson(`/api/v1/github/task-prs/${taskId}`, options);
}