Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions docs/git-workspace.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
7 changes: 7 additions & 0 deletions docs/github-pull-requests.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 11 additions & 1 deletion src/main/hub-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HubRelayControlFrame> {
Expand Down
36 changes: 22 additions & 14 deletions src/renderer/src/components/git/GitSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof Icon>['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 (
<div className="pr-single-empty">
<span><Icon name="gitPullRequest" size={18} /></span>
<strong>No pull request for {branch}</strong>
<p>{prs.length ? `${prs.length} other repository pull request${prs.length === 1 ? '' : 's'} available.` : 'Create a pull request when this branch is ready.'}</p>
<div className="pr-single-actions compact"><button className="gs-btn ghost" onClick={onBrowse}><Icon name="inspection" size={11} />Browse pull requests</button><button className="gs-btn primary" onClick={onCreate}><Icon name="plus" size={11} />Create pull request</button></div>
<strong>No pull requests yet</strong>
<p>Create a pull request when {branch} is ready.</p>
<div className="pr-single-actions compact"><button className="gs-btn ghost" onClick={() => onBrowse?.()}><Icon name="inspection" size={11} />Browse pull requests</button><button className="gs-btn primary" onClick={onCreate}><Icon name="plus" size={11} />Create pull request</button></div>
</div>
)
}

return (
<div className="pr-single compact">
<div className="pr-single-identity">
<div className="pr-single-state"><Icon name={statusName(branchPr.status)} size={11} />{branchPr.status}<code>#{branchPr.num}</code></div>
<h3>{branchPr.title}</h3>
<div className="pr-single-route"><code>{branchPr.head}</code><Icon name="chevRight" size={10} /><code>{branchPr.base}</code></div>
<div className="pr-single-summary"><span className={failed ? 'bad' : ''}>{failed ? `${failed} checks failing` : branchPr.checks?.length ? `${passed}/${branchPr.checks.length} checks passed` : 'No checks reported'}</span><span>{(branchPr.mergeStateStatus ?? 'merge state unknown').toLowerCase().replaceAll('_', ' ')}</span></div>
<div className="pr-sidebar-list" aria-label="Repository pull requests">
{orderedPrs.slice(0, 6).map(pr => (
<button key={pr.num} type="button" className={pr.num === branchPr?.num ? 'current' : ''} onClick={() => onBrowse?.(pr.num)}>
<span className="pr-sidebar-row-state"><Icon name={statusName(pr.status)} size={11} />{pr.status}<code>#{pr.num}</code></span>
<strong>{pr.title}</strong>
<span className="pr-sidebar-row-route"><code>{pr.head}</code><Icon name="chevRight" size={9} /><code>{pr.base}</code></span>
</button>
))}
</div>
<div className="pr-single-actions compact"><button className="gs-btn primary" onClick={onBrowse}><Icon name="inspection" size={11} />Open PR workspace</button><button className="gs-btn ghost" onClick={onCreate}><Icon name="plus" size={10} />New PR</button></div>
<div className="pr-single-actions compact"><button className="gs-btn primary" onClick={() => onBrowse?.(branchPr?.num)}><Icon name="inspection" size={11} />Browse all PRs</button><button className="gs-btn ghost" onClick={onCreate}><Icon name="plus" size={10} />New PR</button></div>
<div className="pr-single-new"><span>{hasUnpushed ? `${branch} has unpushed commits` : `Working on ${branch}`}</span><span>{prs.length} repository PR{prs.length === 1 ? '' : 's'}</span></div>
</div>
)
Expand Down Expand Up @@ -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<number | null>(null)
const setPrCreateOpen = (value: boolean) => {
setPrCreateOpenState(value)
writeGitTabMemory<GitPageMemory>(sidebarMemoryKey, { createPullRequestOpen: value, pullRequestBrowserOpen: prBrowserOpen })
Expand All @@ -749,6 +752,10 @@ export function GitSidebar({
setPrBrowserOpenState(value)
writeGitTabMemory<GitPageMemory>(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({
Expand Down Expand Up @@ -961,7 +968,7 @@ export function GitSidebar({
branch={workspace.branch}
hasUnpushed={state.ahead > 0}
onCreate={() => setPrCreateOpen(true)}
onBrowse={() => setPrBrowserOpen(true)}
onBrowse={openPrBrowser}
/>
</GsCard>

Expand Down Expand Up @@ -1004,6 +1011,7 @@ export function GitSidebar({
open={prBrowserOpen}
repoPath={workspace.path}
currentBranch={workspace.branch}
initialSelectedNumber={prBrowserTarget}
onMerge={onMergePR}
onUpdateBranch={onUpdatePRBranch}
onReady={onReadyPR}
Expand Down
5 changes: 4 additions & 1 deletion src/renderer/src/components/git/PullRequestBrowser.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ interface PullRequestBrowserProps {
open: boolean
repoPath: string
currentBranch: string
initialSelectedNumber?: number | null
onMerge?: (num: number, method: GitHubMergeMethod, headCommitId?: string) => Promise<GitActionOutcome>
onUpdateBranch?: (num: number) => Promise<GitActionOutcome>
onReady?: (num: number) => Promise<GitActionOutcome>
Expand Down Expand Up @@ -103,6 +104,7 @@ export function PullRequestBrowser({
open,
repoPath,
currentBranch,
initialSelectedNumber,
onMerge,
onUpdateBranch,
onReady,
Expand Down Expand Up @@ -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
})
Expand All @@ -198,7 +201,7 @@ export function PullRequestBrowser({
} finally {
if (!background) setLoading(false)
}
}, [currentBranch, open, repoPath])
}, [currentBranch, initialSelectedNumber, open, repoPath])

useEffect(() => {
if (!open) return
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ describe('repository pull request browser', () => {
expect(page).toContain('<PullRequestBrowser')
expect(browser).toContain('item.head === currentBranch')
expect(browser).toContain('githubPrCatalogue(repoPath)')
expect(browser).toContain('initialSelectedNumber && result.items.some')
expect(browser).toContain('loadCatalogue(true)')
expect(browser).toContain('setTimeout(pollCatalogue, 60_000)')
})
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/src/components/git/pull-request-card.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ describe('in-app pull request card', () => {
})

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('<PullRequestBrowser')
expect(sidebar).not.toContain('<PullRequestReview')
expect(browser).toContain('Submit review')
Expand Down
26 changes: 26 additions & 0 deletions src/renderer/src/hooks/useGitSidebar.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,30 @@ describe('useGitSidebar isolated branch switching', () => {
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()
})
})
10 changes: 10 additions & 0 deletions src/renderer/src/styles/git-sidebar.css
Original file line number Diff line number Diff line change
Expand Up @@ -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; }
Expand Down
Loading