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
14 changes: 13 additions & 1 deletion apps/web/src/components/cloud-agent-next/PrHoverCardContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ export function PrHoverCardContent({ pr, sessionId, gitBranch }: PrHoverCardCont
const trpc = useTRPC();
const queryClient = useQueryClient();
const state = normalizePrBadgeState(pr.state);
// A verified link whose per-PR cache row has not synced yet renders with
// state `unknown` and no title. The Refresh action must stay reachable for
// it, or a missed webhook leaves the session's own PR stuck without live
// state. (An unverified link never reaches this component: the server shows
// no PR for it.)
const awaitingSync = pr.state === 'unknown';
const headShaShort = pr.headSha?.slice(0, 7) ?? null;
const truncatedTitle = truncatePrTitle(pr.title, 80);

Expand Down Expand Up @@ -85,6 +91,12 @@ export function PrHoverCardContent({ pr, sessionId, gitBranch }: PrHoverCardCont
<span className="text-muted-foreground text-xs leading-5">PR #{pr.number}</span>
</div>

{awaitingSync && (
<p className="text-muted-foreground text-xs leading-snug">
Pull request status has not synced yet. Refresh to fetch the latest status from GitHub.
</p>
)}

{truncatedTitle && (
<p className="text-foreground line-clamp-2 text-sm leading-snug">{truncatedTitle}</p>
)}
Expand Down Expand Up @@ -115,7 +127,7 @@ export function PrHoverCardContent({ pr, sessionId, gitBranch }: PrHoverCardCont
</div>

<div className="border-border/60 flex items-center gap-2 border-t pt-3">
{!isWithinRefreshThrottle(pr.lastSyncedAt) && (
{(awaitingSync || !isWithinRefreshThrottle(pr.lastSyncedAt)) && (
<Button
variant="ghost"
size="sm"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,13 @@ type SessionPrIndicatorProps = {
* Tri-state behavior driven by `session.associatedPr`:
* - `undefined` + the session has a `branch` → render a fixed-width skeleton
* so the column does not jitter while the list query is in flight.
* - `null` → render nothing (server confirmed the session has no PR).
* - `null` → render nothing (no stored link, or a stored link GitHub has not
* verified).
* - `AssociatedPr` → render the badge wrapped in a hover card; clicking the
* badge opens the PR on GitHub and `stopPropagation` prevents the row's
* own click handler from selecting the session.
* own click handler from selecting the session. Only a GitHub-verified link
* reaches this branch; a verified link whose cache row has not synced
* renders as a pending partial (state `unknown`) with a Refresh action.
*/
export function SessionPrIndicator({ session }: SessionPrIndicatorProps) {
const handleBadgeClick = useCallback(
Expand Down
12 changes: 8 additions & 4 deletions apps/web/src/components/cloud-agent-next/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -425,10 +425,14 @@ export type StoredSession = {
sessionStatus?: string | null;
sessionStatusUpdatedAt?: string | null;
/**
* Associated GitHub pull request for this session's branch, if any.
* - `AssociatedPr`: a cache row exists, populated by the webhook handler or a
* manual refresh.
* - `null`: server confirmed no matching PR for this `(git_url, git_branch)`.
* Associated GitHub pull request for this session's own verified link, if
* any.
* - `AssociatedPr`: the session's own stored link passed GitHub verification.
* Live cache fields (title, state, review decision) are served once the
* per-PR cache has synced; a verified link whose cache row is missing
* renders as a pending partial (state `unknown`) so it can be refreshed.
* - `null`: no stored link, or a stored link GitHub has not verified, so
* there is nothing trustworthy to show.
* - `undefined`: PR data was not requested or not yet loaded for this row.
*/
associatedPr?: AssociatedPr | null;
Expand Down
60 changes: 59 additions & 1 deletion apps/web/src/lib/active-sessions-list.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,17 @@ jest.mock('@/routers/cli-sessions-v2-router', () => {
const { z } = jest.requireActual('zod');
return {
associatedPrSchema: z.object({}).passthrough(),
formatAssociatedPr: () => null,
// Mirror the real gate: only a session whose own stored link GitHub has
// verified renders an associatedPr; no link or an unverified link renders
// nothing.
formatAssociatedPr: (session: {
pr_url: string | null;
pr_number: number | null;
pr_link_verified_at: string | null;
}) =>
session.pr_url === null || session.pr_link_verified_at === null
? null
: { number: session.pr_number, verified: true },
sessionPrJoinPredicate: undefined,
};
});
Expand Down Expand Up @@ -96,6 +106,7 @@ describe('listActiveSessions cloud candidates', () => {
session_pr_platform: null,
session_pr_url: null,
session_pr_number: null,
session_pr_verified_at: null,
pr_url: null,
pr_number: null,
pr_state: null,
Expand All @@ -116,4 +127,51 @@ describe('listActiveSessions cloud candidates', () => {
expect(limitMock).toHaveBeenCalledWith(CLOUD_AGENT_CANDIDATE_LIMIT);
expect(result.sessions).toHaveLength(60);
});

it('forwards the session verified-at so an unverified link renders no associatedPr', async () => {
const baseRow = {
created_on_platform: null,
created_at: '2026-09-22T09:00:00.000Z',
updated_at: '2026-09-22T09:30:00.000Z',
status: 'busy',
title: 'candidate',
organization_id: null,
git_url: 'https://github.com/kilo/repo',
git_branch: 'feature/x',
last_activity_at: null,
status_updated_at: null,
total_cost_microdollars: null,
cloud_agent_session_id: 'cas-1',
run_open: true,
session_pr_platform: 'github',
session_pr_url: 'https://github.com/kilo/repo/pull/7',
session_pr_number: 7,
pr_url: null,
pr_number: null,
pr_state: null,
pr_title: null,
pr_head_sha: null,
pr_last_synced_at: null,
pr_review_decision: null,
review_decision_pending: null,
};

limitMock.mockResolvedValue([
{ ...baseRow, session_id: 'verified', session_pr_verified_at: '2026-01-01T00:00:00.000Z' },
{ ...baseRow, session_id: 'unverified', session_pr_verified_at: null },
]);

const result = await listActiveSessions({
userId: 'user-1',
organizationId: undefined,
includeCloudAgentSessions: true,
});

const verified = result.sessions.find(s => s.id === 'verified');
const unverified = result.sessions.find(s => s.id === 'unverified');
// Only the verified link is surfaced at all; the unverified link is a guess
// and the key is omitted rather than emitted as `associatedPr: null`.
expect(verified?.associatedPr).toMatchObject({ number: 7, verified: true });
expect(unverified?.associatedPr).toBeUndefined();
});
});
9 changes: 9 additions & 0 deletions apps/web/src/lib/active-sessions-list.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ type EnrichmentRow = {
status: string | null;
title: string | null;
organization_id: string | null;
git_url: string | null;
last_activity_at: string | null;
status_updated_at: string | null;
total_cost_microdollars: number | null;
Expand All @@ -146,6 +147,9 @@ type EnrichmentRow = {
session_pr_platform: string | null;
session_pr_url: string | null;
session_pr_number: number | null;
// Set only after the session's own link passed GitHub identity verification.
// Null means the link must not be shown.
session_pr_verified_at: string | null;
// Per-tenant PR cache columns from the LEFT JOIN.
pr_url: string | null;
pr_number: number | null;
Expand Down Expand Up @@ -182,6 +186,8 @@ function associatedPrFromRow(row: EnrichmentRow): z.infer<typeof associatedPrSch
pr_url: row.session_pr_url,
pr_number: row.session_pr_number,
updated_at: row.updated_at,
git_url: row.git_url,
pr_link_verified_at: row.session_pr_verified_at,
},
{
pr_url: row.pr_url,
Expand Down Expand Up @@ -426,12 +432,14 @@ export async function listActiveSessions({
status: cli_sessions_v2.status,
title: cli_sessions_v2.title,
organization_id: cli_sessions_v2.organization_id,
git_url: cli_sessions_v2.git_url,
last_activity_at: cli_sessions_v2.last_activity_at,
status_updated_at: cli_sessions_v2.status_updated_at,
total_cost_microdollars: cli_sessions_v2.total_cost_microdollars,
session_pr_platform: cli_sessions_v2.platform,
session_pr_url: cli_sessions_v2.pr_url,
session_pr_number: cli_sessions_v2.pr_number,
session_pr_verified_at: cli_sessions_v2.pr_link_verified_at,
pr_url: github_branch_pull_requests.pr_url,
pr_number: github_branch_pull_requests.pr_number,
pr_state: github_branch_pull_requests.pr_state,
Expand Down Expand Up @@ -536,6 +544,7 @@ export async function listActiveSessions({
session_pr_platform: cli_sessions_v2.platform,
session_pr_url: cli_sessions_v2.pr_url,
session_pr_number: cli_sessions_v2.pr_number,
session_pr_verified_at: cli_sessions_v2.pr_link_verified_at,
pr_url: github_branch_pull_requests.pr_url,
pr_number: github_branch_pull_requests.pr_number,
pr_state: github_branch_pull_requests.pr_state,
Expand Down
Loading