From 957b7fa0acf62e9435dcb9d7fa2dbcf1f711f885 Mon Sep 17 00:00:00 2001 From: CrewCoder Date: Fri, 4 Sep 2026 20:55:25 -0400 Subject: [PATCH] feat: enhance pull request handling and sidebar UI with improved navigation and styling --- AGENTS.md | 6 ++++ docs/git-workspace.md | 3 ++ docs/github-pull-requests.md | 7 ++++ src/main/hub-relay.test.ts | 12 ++++++- .../src/components/git/GitSidebar.tsx | 36 +++++++++++-------- .../src/components/git/PullRequestBrowser.tsx | 5 ++- .../git/pull-request-browser.test.ts | 1 + .../components/git/pull-request-card.test.ts | 4 ++- src/renderer/src/hooks/useGitSidebar.test.ts | 26 ++++++++++++++ src/renderer/src/styles/git-sidebar.css | 10 ++++++ 10 files changed, 93 insertions(+), 17 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 3023676..3054f54 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -86,6 +86,12 @@ successful submission. Never retain credentials, mutation locks, destructive confirmations, or infer repository outcomes from UI memory. See `docs/git-workspace.md` and `docs/github-pull-requests.md`. +Git Sidebar Pull Requests lists bounded observed repository PRs and prioritizes +the current branch PR. After successful creation, refresh authoritative GitHub +status so the new PR appears immediately. Clicking a PR row must open the +canonical in-app PR Browser with that exact PR number selected, never default to +an external GitHub page. + Inactive standalone terminal tabs stay mounted to preserve their PTYs, but must pass `active={false}` through `TermColumn` to `XTermPane`. Buffer their output without `term.write()`, then refit and replay it with bounded frame work and diff --git a/docs/git-workspace.md b/docs/git-workspace.md index 27703ce..a76b65d 100644 --- a/docs/git-workspace.md +++ b/docs/git-workspace.md @@ -49,6 +49,9 @@ available only for real working-tree changes. - **Sidebar sections** — history, branches, and the remaining Git Sidebar sections render on the right (with the commit/changes sections hidden, since the page has its own). +- **Pull requests** — shows the latest observed repository PRs. Select a row to + open that exact PR in CrewCode's PR Browser; newly created PRs appear after + their successful post-create GitHub refresh. ## Switching tabs without losing work diff --git a/docs/github-pull-requests.md b/docs/github-pull-requests.md index 39f5a5c..e8fa8ca 100644 --- a/docs/github-pull-requests.md +++ b/docs/github-pull-requests.md @@ -58,6 +58,13 @@ creation flow also restores its current step, branch/commit selection, title, structured body fields, and draft choice. Explicit cancel or successful submit clears the creation draft; mutation locks and confirmation dialogs never carry across an unmount. + +The Git Sidebar Pull Requests card shows up to six observed repository PRs, +placing the current branch's PR first when one exists. A successful PR creation +refreshes authoritative GitHub status before the card reports the result, so the +new PR appears without reopening the sidebar. Selecting any row opens the +canonical PR Browser inside CrewCode with that exact PR number preselected; +sidebar rows never use GitHub as the primary navigation path. browser. CrewCode loads up to 100 open, closed, and merged pull requests in one catalogue request, then filters that observed result locally by **All**, **Open**, **Closed**, or **Assigned to you**. Closed includes merged pull diff --git a/src/main/hub-relay.test.ts b/src/main/hub-relay.test.ts index 73b42bc..07b967d 100644 --- a/src/main/hub-relay.test.ts +++ b/src/main/hub-relay.test.ts @@ -65,7 +65,17 @@ async function fixture(scopes: Array<'workspace:read' | 'workspace:write' | 'ter } const brain = await startBrainRelay({ credential, dataDir: brainData, allowedWorkspaceRoots: [workspaceRoot], allowedScopes: scopes }) cleanups.push(() => brain.close()) - return { hub, brain, machineId: enrolled.machine.id, machineToken: enrolled.token, cookie: `crewcode_hub_session=${encodeURIComponent(session.token)}`, csrf: session.csrf, publicKey, workspaceRoot } + const cookie = `crewcode_hub_session=${encodeURIComponent(session.token)}` + const deadline = Date.now() + 5_000 + while (true) { + const response = await fetch(`${hub.url}/api/v1/hub/machines`, { headers: { cookie } }) + const body = await response.json() as { machines?: Array<{ id?: string; status?: string }>; error?: string } + if (!response.ok) throw new Error(`machine readiness check failed (${response.status}): ${body.error ?? 'unknown error'}`) + if (body.machines?.some(machine => machine.id === enrolled.machine.id && machine.status === 'online')) break + if (Date.now() >= deadline) throw new Error('Brain relay did not become online before the fixture timeout') + await new Promise(resolve => setTimeout(resolve, 10)) + } + return { hub, brain, machineId: enrolled.machine.id, machineToken: enrolled.token, cookie, csrf: session.csrf, publicKey, workspaceRoot } } function onceFrame(socket: WebSocket, predicate: (frame: HubRelayControlFrame) => boolean): Promise { diff --git a/src/renderer/src/components/git/GitSidebar.tsx b/src/renderer/src/components/git/GitSidebar.tsx index ca8dafa..69f5d82 100644 --- a/src/renderer/src/components/git/GitSidebar.tsx +++ b/src/renderer/src/components/git/GitSidebar.tsx @@ -631,36 +631,38 @@ interface PullRequestsBodyProps { branch: string hasUnpushed: boolean onCreate?: () => void - onBrowse?: () => void + onBrowse?: (number?: number) => void } function PullRequestsBody({ prs, branch, hasUnpushed, onCreate, onBrowse }: PullRequestsBodyProps) { const branchPr = prs.find(p => p.head === branch) const statusName = (status: GitPrRef['status']): React.ComponentProps['name'] => status === 'open' ? 'circleDot' : status === 'merged' ? 'merged' : status === 'draft' ? 'gitPullRequest' : 'x' - const passed = branchPr?.checks?.filter(check => check === 'ok').length ?? 0 - const failed = branchPr?.checks?.filter(check => check === 'f').length ?? 0 + const orderedPrs = branchPr ? [branchPr, ...prs.filter(pr => pr.num !== branchPr.num)] : prs - if (!branchPr) { + if (prs.length === 0) { return (
- No pull request for {branch} -

{prs.length ? `${prs.length} other repository pull request${prs.length === 1 ? '' : 's'} available.` : 'Create a pull request when this branch is ready.'}

-
+ No pull requests yet +

Create a pull request when {branch} is ready.

+
) } return (
-
-
{branchPr.status}#{branchPr.num}
-

{branchPr.title}

-
{branchPr.head}{branchPr.base}
-
{failed ? `${failed} checks failing` : branchPr.checks?.length ? `${passed}/${branchPr.checks.length} checks passed` : 'No checks reported'}{(branchPr.mergeStateStatus ?? 'merge state unknown').toLowerCase().replaceAll('_', ' ')}
+
+ {orderedPrs.slice(0, 6).map(pr => ( + + ))}
-
+
{hasUnpushed ? `${branch} has unpushed commits` : `Working on ${branch}`}{prs.length} repository PR{prs.length === 1 ? '' : 's'}
) @@ -741,6 +743,7 @@ export function GitSidebar({ const [publishOpen, setPublishOpen] = useState(false) const [prCreateOpen, setPrCreateOpenState] = useState(rememberedSidebar?.createPullRequestOpen ?? false) const [prBrowserOpen, setPrBrowserOpenState] = useState(rememberedSidebar?.pullRequestBrowserOpen ?? false) + const [prBrowserTarget, setPrBrowserTarget] = useState(null) const setPrCreateOpen = (value: boolean) => { setPrCreateOpenState(value) writeGitTabMemory(sidebarMemoryKey, { createPullRequestOpen: value, pullRequestBrowserOpen: prBrowserOpen }) @@ -749,6 +752,10 @@ export function GitSidebar({ setPrBrowserOpenState(value) writeGitTabMemory(sidebarMemoryKey, { createPullRequestOpen: prCreateOpen, pullRequestBrowserOpen: value }) } + const openPrBrowser = (number?: number) => { + setPrBrowserTarget(number ?? null) + setPrBrowserOpen(true) + } // Open which cards by default — conflicts always; changes when dirty; others closed. const [open, setOpen] = useState({ @@ -961,7 +968,7 @@ export function GitSidebar({ branch={workspace.branch} hasUnpushed={state.ahead > 0} onCreate={() => setPrCreateOpen(true)} - onBrowse={() => setPrBrowserOpen(true)} + onBrowse={openPrBrowser} /> @@ -1004,6 +1011,7 @@ export function GitSidebar({ open={prBrowserOpen} repoPath={workspace.path} currentBranch={workspace.branch} + initialSelectedNumber={prBrowserTarget} onMerge={onMergePR} onUpdateBranch={onUpdatePRBranch} onReady={onReadyPR} diff --git a/src/renderer/src/components/git/PullRequestBrowser.tsx b/src/renderer/src/components/git/PullRequestBrowser.tsx index ad6c98d..7df6a31 100644 --- a/src/renderer/src/components/git/PullRequestBrowser.tsx +++ b/src/renderer/src/components/git/PullRequestBrowser.tsx @@ -55,6 +55,7 @@ interface PullRequestBrowserProps { open: boolean repoPath: string currentBranch: string + initialSelectedNumber?: number | null onMerge?: (num: number, method: GitHubMergeMethod, headCommitId?: string) => Promise onUpdateBranch?: (num: number) => Promise onReady?: (num: number) => Promise @@ -103,6 +104,7 @@ export function PullRequestBrowser({ open, repoPath, currentBranch, + initialSelectedNumber, onMerge, onUpdateBranch, onReady, @@ -190,6 +192,7 @@ export function PullRequestBrowser({ if ('error' in result) throw new Error(result.error) setCatalogue(result) setSelectedNumber(current => { + if (initialSelectedNumber && result.items.some(item => item.number === initialSelectedNumber)) return initialSelectedNumber if (current && result.items.some(item => item.number === current)) return current return result.items.find(item => item.head === currentBranch)?.number ?? result.items[0]?.number ?? null }) @@ -198,7 +201,7 @@ export function PullRequestBrowser({ } finally { if (!background) setLoading(false) } - }, [currentBranch, open, repoPath]) + }, [currentBranch, initialSelectedNumber, open, repoPath]) useEffect(() => { if (!open) return diff --git a/src/renderer/src/components/git/pull-request-browser.test.ts b/src/renderer/src/components/git/pull-request-browser.test.ts index 488e1d2..1729761 100644 --- a/src/renderer/src/components/git/pull-request-browser.test.ts +++ b/src/renderer/src/components/git/pull-request-browser.test.ts @@ -21,6 +21,7 @@ describe('repository pull request browser', () => { expect(page).toContain(' { }) it('keeps common review actions in the canonical browser workspace', () => { - expect(sidebar).toContain('Open PR workspace') + expect(sidebar).toContain('className="pr-sidebar-list"') + expect(sidebar).toContain('onClick={() => onBrowse?.(pr.num)}') + expect(sidebar).toContain('initialSelectedNumber={prBrowserTarget}') expect(sidebar).toContain(' { hook.unmount() vi.useRealTimers() }) + + it('refreshes a newly created pull request into sidebar state', async () => { + const api = apiStub() + api.ghPrCreate = vi.fn(async () => ({ ok: true, output: 'https://github.com/crew/code/pull/14' })) + api.githubStatus.mockResolvedValue({ + owner: 'crew', repo: 'code', runs: [], + prs: [{ + number: 14, title: 'Fresh pull request', state: 'OPEN', branch: 'dev', base: 'main', + url: 'https://github.com/crew/code/pull/14', isDraft: false, author: 'viewer', + updatedAt: '2026-09-04T12:00:00Z', body: '', mergeStateStatus: 'CLEAN', reviewDecision: null, + }], + }) + vi.stubGlobal('window', { electronAPI: api }) + const hook = renderHook(useGitSidebar, { + repoPath: '/repo', workspacePath: '/repo', mainBranch: 'main', currentWorktreeId: null, + enabled: false, onSwitchWorktree: vi.fn(), + }) + + await act(async () => { + await hook.result.current.handlers.onCreatePR?.({ title: 'Fresh pull request', base: 'main', draft: false }) + }) + + expect(api.ghPrCreate).toHaveBeenCalled() + expect(hook.result.current.state.prs).toEqual([expect.objectContaining({ num: 14, title: 'Fresh pull request' })]) + hook.unmount() + }) }) diff --git a/src/renderer/src/styles/git-sidebar.css b/src/renderer/src/styles/git-sidebar.css index a1d608f..1d8bf80 100644 --- a/src/renderer/src/styles/git-sidebar.css +++ b/src/renderer/src/styles/git-sidebar.css @@ -860,6 +860,16 @@ body.dark .gs-pr-detail .check-row .ico.f { color: #f87171; } .pr-single-empty > span { width: 34px; height: 34px; display: grid; place-items: center; border: 1px solid var(--border); color: var(--primary); } .pr-single-empty strong { color: var(--foreground); font-size: 13px; } .pr-single-empty p { margin: 0 0 4px; color: var(--muted-foreground); font-size: 10.5px; line-height: 1.5; } +.pr-sidebar-list { display: grid; max-height: 300px; overflow: auto; } +.pr-sidebar-list > button { display: grid; gap: 6px; width: 100%; padding: 11px 12px; border: 0; border-bottom: 1px solid var(--border); background: transparent; color: var(--foreground); text-align: left; cursor: pointer; } +.pr-sidebar-list > button:hover, +.pr-sidebar-list > button.current { background: color-mix(in srgb, var(--primary) 8%, transparent); } +.pr-sidebar-list > button.current { box-shadow: inset 2px 0 var(--primary); } +.pr-sidebar-list > button > strong { overflow: hidden; color: var(--foreground); font-size: 12.5px; line-height: 1.35; text-overflow: ellipsis; white-space: nowrap; } +.pr-sidebar-row-state, +.pr-sidebar-row-route { min-width: 0; display: flex; align-items: center; gap: 5px; color: var(--muted-foreground); font-family: var(--font-family-mono); font-size: 9px; text-transform: uppercase; } +.pr-sidebar-row-state code { margin-left: auto; color: var(--muted-foreground); } +.pr-sidebar-row-route code { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-transform: none; } /* Full pull-request review workspace ----------------------------------- */ .pr-review-shell { position: fixed; inset: 0; z-index: 2100; display: grid; grid-template-rows: auto auto minmax(0, 1fr); background: #0f120f; color: var(--foreground); animation: pr-fade-in 150ms ease-out both; }