feat(agents): add PR title, draft, and status icons to sidebar (#22952)

Adds `pull_request_title` and `pull_request_draft` to the chat diff
status pipeline (DB → provider → SDK → frontend). The GitHub provider
now fetches the PR title alongside existing status fields.

The agents sidebar now displays PR-state-aware icons for chats that have
a linked pull request (when the chat is in waiting/completed state):
- **Open PR**: `GitPullRequestArrow` (green)
- **Draft PR**: `GitPullRequestDraft` (gray)
- **Merged PR**: `GitMerge` (purple)
- **Closed PR**: `GitPullRequestClosed` (red)

Running/pending/paused/error chats keep their existing activity icons
(spinner, pause, error triangle).

### Changes

**Database migration** (`000432`): Adds `pull_request_title TEXT` and
`pull_request_draft BOOLEAN` columns to `chat_diff_statuses`.

**Backend pipeline**:
- `gitprovider.PRStatus` gains a `Title` field
- GitHub provider decodes the `title` from the API response
- `gitsync` and `coderd/chats.go` pass title + draft through to the DB
upsert
- `codersdk.ChatDiffStatus` exposes both new fields in the API response

**Frontend** (`AgentsSidebar.tsx`): New `getPRIconConfig()` function
resolves the appropriate Lucide git icon based on `pull_request_state`
and `pull_request_draft`. Only applies when the chat is in a terminal
state (waiting/completed).

**Real-time sync**: No changes needed — the existing
`diff_status_change` pubsub event already propagates the full
`ChatDiffStatus` including the new fields.
This commit is contained in:
Kyle Carberry
2026-03-11 11:50:45 -04:00
committed by GitHub
parent a46336c3ec
commit 7a83d825cf
16 changed files with 214 additions and 14 deletions
+4
View File
@@ -1534,6 +1534,8 @@ func (api *API) refreshChatDiffStatus(
String: string(status.State),
Valid: status.State != "",
},
PullRequestTitle: status.Title,
PullRequestDraft: status.Draft,
ChangesRequested: status.ChangesRequested,
Additions: status.DiffStats.Additions,
Deletions: status.DiffStats.Deletions,
@@ -2314,6 +2316,8 @@ func convertChatDiffStatus(chatID uuid.UUID, status *database.ChatDiffStatus) co
result.PullRequestState = &pullRequestState
}
}
result.PullRequestTitle = status.PullRequestTitle
result.PullRequestDraft = status.PullRequestDraft
result.ChangesRequested = status.ChangesRequested
result.Additions = status.Additions
result.Deletions = status.Deletions
+3 -1
View File
@@ -1187,7 +1187,9 @@ CREATE TABLE chat_diff_statuses (
created_at timestamp with time zone DEFAULT now() NOT NULL,
updated_at timestamp with time zone DEFAULT now() NOT NULL,
git_branch text DEFAULT ''::text NOT NULL,
git_remote_origin text DEFAULT ''::text NOT NULL
git_remote_origin text DEFAULT ''::text NOT NULL,
pull_request_title text DEFAULT ''::text NOT NULL,
pull_request_draft boolean DEFAULT false NOT NULL
);
CREATE TABLE chat_files (
@@ -0,0 +1,2 @@
ALTER TABLE chat_diff_statuses DROP COLUMN pull_request_title;
ALTER TABLE chat_diff_statuses DROP COLUMN pull_request_draft;
@@ -0,0 +1,2 @@
ALTER TABLE chat_diff_statuses ADD COLUMN pull_request_title TEXT NOT NULL DEFAULT '';
ALTER TABLE chat_diff_statuses ADD COLUMN pull_request_draft BOOLEAN NOT NULL DEFAULT FALSE;
+2
View File
@@ -3924,6 +3924,8 @@ type ChatDiffStatus struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
GitBranch string `db:"git_branch" json:"git_branch"`
GitRemoteOrigin string `db:"git_remote_origin" json:"git_remote_origin"`
PullRequestTitle string `db:"pull_request_title" json:"pull_request_title"`
PullRequestDraft bool `db:"pull_request_draft" json:"pull_request_draft"`
}
type ChatFile struct {
+33 -11
View File
@@ -3056,10 +3056,10 @@ WITH acquired AS (
LIMIT
$1::int
)
RETURNING chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin
RETURNING chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft
)
SELECT
acquired.chat_id, acquired.url, acquired.pull_request_state, acquired.changes_requested, acquired.additions, acquired.deletions, acquired.changed_files, acquired.refreshed_at, acquired.stale_at, acquired.created_at, acquired.updated_at, acquired.git_branch, acquired.git_remote_origin,
acquired.chat_id, acquired.url, acquired.pull_request_state, acquired.changes_requested, acquired.additions, acquired.deletions, acquired.changed_files, acquired.refreshed_at, acquired.stale_at, acquired.created_at, acquired.updated_at, acquired.git_branch, acquired.git_remote_origin, acquired.pull_request_title, acquired.pull_request_draft,
c.owner_id
FROM
acquired
@@ -3081,6 +3081,8 @@ type AcquireStaleChatDiffStatusesRow struct {
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
GitBranch string `db:"git_branch" json:"git_branch"`
GitRemoteOrigin string `db:"git_remote_origin" json:"git_remote_origin"`
PullRequestTitle string `db:"pull_request_title" json:"pull_request_title"`
PullRequestDraft bool `db:"pull_request_draft" json:"pull_request_draft"`
OwnerID uuid.UUID `db:"owner_id" json:"owner_id"`
}
@@ -3107,6 +3109,8 @@ func (q *sqlQuerier) AcquireStaleChatDiffStatuses(ctx context.Context, limitVal
&i.UpdatedAt,
&i.GitBranch,
&i.GitRemoteOrigin,
&i.PullRequestTitle,
&i.PullRequestDraft,
&i.OwnerID,
); err != nil {
return nil, err
@@ -3266,7 +3270,7 @@ func (q *sqlQuerier) GetChatByIDForUpdate(ctx context.Context, id uuid.UUID) (Ch
const getChatDiffStatusByChatID = `-- name: GetChatDiffStatusByChatID :one
SELECT
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft
FROM
chat_diff_statuses
WHERE
@@ -3290,13 +3294,15 @@ func (q *sqlQuerier) GetChatDiffStatusByChatID(ctx context.Context, chatID uuid.
&i.UpdatedAt,
&i.GitBranch,
&i.GitRemoteOrigin,
&i.PullRequestTitle,
&i.PullRequestDraft,
)
return i, err
}
const getChatDiffStatusesByChatIDs = `-- name: GetChatDiffStatusesByChatIDs :many
SELECT
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft
FROM
chat_diff_statuses
WHERE
@@ -3326,6 +3332,8 @@ func (q *sqlQuerier) GetChatDiffStatusesByChatIDs(ctx context.Context, chatIds [
&i.UpdatedAt,
&i.GitBranch,
&i.GitRemoteOrigin,
&i.PullRequestTitle,
&i.PullRequestDraft,
); err != nil {
return nil, err
}
@@ -4272,6 +4280,8 @@ INSERT INTO chat_diff_statuses (
chat_id,
url,
pull_request_state,
pull_request_title,
pull_request_draft,
changes_requested,
additions,
deletions,
@@ -4282,17 +4292,21 @@ INSERT INTO chat_diff_statuses (
$1::uuid,
$2::text,
$3::text,
$4::boolean,
$5::integer,
$6::integer,
$4::text,
$5::boolean,
$6::boolean,
$7::integer,
$8::timestamptz,
$9::timestamptz
$8::integer,
$9::integer,
$10::timestamptz,
$11::timestamptz
)
ON CONFLICT (chat_id) DO UPDATE
SET
url = EXCLUDED.url,
pull_request_state = EXCLUDED.pull_request_state,
pull_request_title = EXCLUDED.pull_request_title,
pull_request_draft = EXCLUDED.pull_request_draft,
changes_requested = EXCLUDED.changes_requested,
additions = EXCLUDED.additions,
deletions = EXCLUDED.deletions,
@@ -4301,13 +4315,15 @@ SET
stale_at = EXCLUDED.stale_at,
updated_at = NOW()
RETURNING
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft
`
type UpsertChatDiffStatusParams struct {
ChatID uuid.UUID `db:"chat_id" json:"chat_id"`
Url sql.NullString `db:"url" json:"url"`
PullRequestState sql.NullString `db:"pull_request_state" json:"pull_request_state"`
PullRequestTitle string `db:"pull_request_title" json:"pull_request_title"`
PullRequestDraft bool `db:"pull_request_draft" json:"pull_request_draft"`
ChangesRequested bool `db:"changes_requested" json:"changes_requested"`
Additions int32 `db:"additions" json:"additions"`
Deletions int32 `db:"deletions" json:"deletions"`
@@ -4321,6 +4337,8 @@ func (q *sqlQuerier) UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDif
arg.ChatID,
arg.Url,
arg.PullRequestState,
arg.PullRequestTitle,
arg.PullRequestDraft,
arg.ChangesRequested,
arg.Additions,
arg.Deletions,
@@ -4343,6 +4361,8 @@ func (q *sqlQuerier) UpsertChatDiffStatus(ctx context.Context, arg UpsertChatDif
&i.UpdatedAt,
&i.GitBranch,
&i.GitRemoteOrigin,
&i.PullRequestTitle,
&i.PullRequestDraft,
)
return i, err
}
@@ -4378,7 +4398,7 @@ SET
stale_at = EXCLUDED.stale_at,
updated_at = NOW()
RETURNING
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin
chat_id, url, pull_request_state, changes_requested, additions, deletions, changed_files, refreshed_at, stale_at, created_at, updated_at, git_branch, git_remote_origin, pull_request_title, pull_request_draft
`
type UpsertChatDiffStatusReferenceParams struct {
@@ -4412,6 +4432,8 @@ func (q *sqlQuerier) UpsertChatDiffStatusReference(ctx context.Context, arg Upse
&i.UpdatedAt,
&i.GitBranch,
&i.GitRemoteOrigin,
&i.PullRequestTitle,
&i.PullRequestDraft,
)
return i, err
}
+6
View File
@@ -378,6 +378,8 @@ INSERT INTO chat_diff_statuses (
chat_id,
url,
pull_request_state,
pull_request_title,
pull_request_draft,
changes_requested,
additions,
deletions,
@@ -388,6 +390,8 @@ INSERT INTO chat_diff_statuses (
@chat_id::uuid,
sqlc.narg('url')::text,
sqlc.narg('pull_request_state')::text,
@pull_request_title::text,
@pull_request_draft::boolean,
@changes_requested::boolean,
@additions::integer,
@deletions::integer,
@@ -399,6 +403,8 @@ ON CONFLICT (chat_id) DO UPDATE
SET
url = EXCLUDED.url,
pull_request_state = EXCLUDED.pull_request_state,
pull_request_title = EXCLUDED.pull_request_title,
pull_request_draft = EXCLUDED.pull_request_draft,
changes_requested = EXCLUDED.changes_requested,
additions = EXCLUDED.additions,
deletions = EXCLUDED.deletions,
@@ -258,6 +258,7 @@ func (g *githubProvider) FetchPullRequestStatus(
)
var pull struct {
Title string `json:"title"`
State string `json:"state"`
Merged bool `json:"merged"`
Draft bool `json:"draft"`
@@ -298,6 +299,7 @@ func (g *githubProvider) FetchPullRequestStatus(
}
return &PRStatus{
Title: pull.Title,
State: state,
Draft: pull.Draft,
HeadSHA: pull.Head.SHA,
@@ -66,6 +66,8 @@ type DiffStats struct {
// PRStatus is the complete status of a pull/merge request.
// This is the universal return type that all providers populate.
type PRStatus struct {
// Title is the PR's title/subject line.
Title string
// State is the PR's lifecycle state.
State PRState
// Draft indicates the PR is marked as draft/WIP.
+2
View File
@@ -218,6 +218,8 @@ func (r *Refresher) refreshOne(
String: string(status.State),
Valid: status.State != "",
},
PullRequestTitle: status.Title,
PullRequestDraft: status.Draft,
ChangesRequested: status.ChangesRequested,
Additions: status.DiffStats.Additions,
Deletions: status.DiffStats.Deletions,
+2
View File
@@ -465,6 +465,8 @@ type ChatDiffStatus struct {
ChatID uuid.UUID `json:"chat_id" format:"uuid"`
URL *string `json:"url,omitempty"`
PullRequestState *string `json:"pull_request_state,omitempty"`
PullRequestTitle string `json:"pull_request_title"`
PullRequestDraft bool `json:"pull_request_draft"`
ChangesRequested bool `json:"changes_requested"`
Additions int32 `json:"additions"`
Deletions int32 `json:"deletions"`
+2
View File
@@ -1091,6 +1091,8 @@ export interface ChatDiffStatus {
readonly chat_id: string;
readonly url?: string;
readonly pull_request_state?: string;
readonly pull_request_title: string;
readonly pull_request_draft: boolean;
readonly changes_requested: boolean;
readonly additions: number;
readonly deletions: number;
@@ -144,6 +144,8 @@ const buildQueries = (
data: {
chat_id: CHAT_ID,
url: opts?.diffUrl,
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: opts?.diffUrl ? 4 : 0,
deletions: opts?.diffUrl ? 1 : 0,
@@ -475,6 +475,8 @@ export const WithDiffStats: Story = {
diff_status: {
chat_id: "diff-both",
url: "https://github.com/coder/coder/pull/1",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 42,
deletions: 7,
@@ -488,6 +490,8 @@ export const WithDiffStats: Story = {
diff_status: {
chat_id: "diff-add-only",
url: "https://github.com/coder/coder/pull/2",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 120,
deletions: 0,
@@ -501,6 +505,8 @@ export const WithDiffStats: Story = {
diff_status: {
chat_id: "diff-del-only",
url: "https://github.com/coder/coder/pull/3",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 0,
deletions: 35,
@@ -514,6 +520,8 @@ export const WithDiffStats: Story = {
diff_status: {
chat_id: "diff-none",
url: "https://github.com/coder/coder/pull/4",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 0,
deletions: 0,
@@ -558,6 +566,8 @@ export const WithDiffStatsLight: Story = {
diff_status: {
chat_id: "diff-both-light",
url: "https://github.com/coder/coder/pull/1",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 42,
deletions: 7,
@@ -571,6 +581,8 @@ export const WithDiffStatsLight: Story = {
diff_status: {
chat_id: "diff-add-only-light",
url: "https://github.com/coder/coder/pull/2",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 120,
deletions: 0,
@@ -584,6 +596,8 @@ export const WithDiffStatsLight: Story = {
diff_status: {
chat_id: "diff-del-only-light",
url: "https://github.com/coder/coder/pull/3",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 0,
deletions: 35,
@@ -607,6 +621,98 @@ export const WithDiffStatsLight: Story = {
},
};
export const WithPRStateIcons: Story = {
args: {
chats: [
buildChat({
id: "pr-open",
title: "Open pull request",
updated_at: todayTimestamp,
diff_status: {
chat_id: "pr-open",
url: "https://github.com/coder/coder/pull/100",
pull_request_state: "open",
pull_request_title: "feat: add new feature",
pull_request_draft: false,
changes_requested: false,
additions: 50,
deletions: 10,
changed_files: 4,
},
}),
buildChat({
id: "pr-draft",
title: "Draft pull request",
updated_at: todayTimestamp,
diff_status: {
chat_id: "pr-draft",
url: "https://github.com/coder/coder/pull/101",
pull_request_state: "open",
pull_request_title: "wip: draft changes",
pull_request_draft: true,
changes_requested: false,
additions: 20,
deletions: 5,
changed_files: 2,
},
}),
buildChat({
id: "pr-merged",
title: "Merged pull request",
updated_at: todayTimestamp,
diff_status: {
chat_id: "pr-merged",
url: "https://github.com/coder/coder/pull/102",
pull_request_state: "merged",
pull_request_title: "feat: completed feature",
pull_request_draft: false,
changes_requested: false,
additions: 200,
deletions: 80,
changed_files: 12,
},
}),
buildChat({
id: "pr-closed",
title: "Closed pull request",
updated_at: todayTimestamp,
diff_status: {
chat_id: "pr-closed",
url: "https://github.com/coder/coder/pull/103",
pull_request_state: "closed",
pull_request_title: "fix: abandoned approach",
pull_request_draft: false,
changes_requested: false,
additions: 15,
deletions: 3,
changed_files: 1,
},
}),
buildChat({
id: "pr-no-state",
title: "No PR state (branch only)",
updated_at: todayTimestamp,
diff_status: {
chat_id: "pr-no-state",
url: "https://github.com/coder/coder/tree/my-branch",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 10,
deletions: 2,
changed_files: 1,
},
}),
],
},
parameters: {
reactRouter: reactRouterParameters({
location: { path: "/agents" },
routing: agentsRouting,
}),
},
};
export const ArchivedAgentUnarchiveOption: Story = {
args: {
chats: [
+42 -2
View File
@@ -33,6 +33,10 @@ import {
ChevronDownIcon,
ChevronRightIcon,
EllipsisIcon,
GitMergeIcon,
GitPullRequestArrowIcon,
GitPullRequestClosedIcon,
GitPullRequestDraftIcon,
Loader2Icon,
PanelLeftCloseIcon,
PauseIcon,
@@ -98,6 +102,37 @@ const getStatusConfig = (status: ChatStatus) => {
return statusConfig[status] ?? statusConfig.completed;
};
/**
* Returns the icon and className to use for a PR state, or undefined
* if there is no PR linked. Only overrides the icon when the chat
* is not actively executing (pending/running/paused/error).
*/
const getPRIconConfig = (
diffStatus: ChatDiffStatus | undefined,
): { icon: typeof CheckIcon; className: string } | undefined => {
const state = diffStatus?.pull_request_state;
if (!state) {
return undefined;
}
if (state === "merged") {
return { icon: GitMergeIcon, className: "text-purple-500" };
}
if (state === "closed") {
return {
icon: GitPullRequestClosedIcon,
className: "text-content-destructive",
};
}
// state === "open"
if (diffStatus?.pull_request_draft) {
return {
icon: GitPullRequestDraftIcon,
className: "text-content-secondary",
};
}
return { icon: GitPullRequestArrowIcon, className: "text-green-500" };
};
const asNonEmptyString = (value: unknown): string | undefined => {
if (typeof value !== "string") {
return undefined;
@@ -309,8 +344,6 @@ const ChatTreeNode = memo<ChatTreeNodeProps>(({ chat, isChildNode }) => {
);
const hasChildren = childIDs.length > 0;
const isDelegated = Boolean(getParentChatID(chat));
const config = getStatusConfig(chat.status);
const StatusIcon = config.icon;
const isDelegatedExecuting =
isDelegated && (chat.status === "pending" || chat.status === "running");
const modelName = getModelDisplayName(
@@ -324,6 +357,13 @@ const ChatTreeNode = memo<ChatTreeNodeProps>(({ chat, isChildNode }) => {
: undefined;
const subtitle = errorReason || modelName;
const diffStatus = getChatDiffStatus(chat);
const baseConfig = getStatusConfig(chat.status);
const prConfig =
chat.status === "waiting" || chat.status === "completed"
? getPRIconConfig(diffStatus)
: undefined;
const config = prConfig ?? baseConfig;
const StatusIcon = config.icon;
const hasLinkedDiffStatus = Boolean(diffStatus?.url);
const changedFiles = diffStatus?.changed_files ?? 0;
const additions = diffStatus?.additions ?? 0;
@@ -226,6 +226,8 @@ index 1234567..abcdef0 100644
const defaultDiffStatus: ChatDiffStatusResponse = {
chat_id: "test-chat",
pull_request_title: "",
pull_request_draft: false,
changes_requested: false,
additions: 0,
deletions: 0,