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
434 changes: 434 additions & 0 deletions app/src-tauri/src/commands/chat.rs

Large diffs are not rendered by default.

1 change: 1 addition & 0 deletions app/src-tauri/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
//! Commands parse params, call core, and map results through
//! [`CommandError`](crate::error::CommandError). All logic lives in core.

pub mod chat;
pub mod shell;

use std::collections::HashSet;
Expand Down
15 changes: 15 additions & 0 deletions app/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,8 @@ pub fn run() {
Ok(())
})
.invoke_handler(tauri::generate_handler![
commands::chat::send_chat_message,
commands::chat::cancel_chat_turn,
commands::list_reviews,
commands::get_frontier,
commands::get_review,
Expand Down Expand Up @@ -792,6 +794,9 @@ fn clear_head_anchored_annotations(review: &mut Review) {
review.review_findings.clear();
review.distilled_intent = None;
review.distilled_headline = None;
// The chat thread answers questions about the previous head; stale answers
// are worse than no answers (cockpit-docs/REVIEWER_CHAT.md).
review.chat.clear();
}

/// Extract the PR number from a PR URL or reference string.
Expand Down Expand Up @@ -1426,6 +1431,7 @@ mod tests {
distilled_intent: None,
distilled_headline: None,
recap_sha: None,
chat: Vec::new(),
}
}

Expand Down Expand Up @@ -1525,6 +1531,14 @@ mod tests {
}];
review.distilled_intent = Some("intent".into());
review.recap_sha = Some("headsha9".into());
review.chat = vec![cockpit_core::model::ChatMessage {
id: cockpit_core::model::ChatMessageId::new("cm-1"),
role: cockpit_core::model::ChatRole::User,
text: "q".into(),
drafts: Vec::new(),
error: false,
created_at_epoch_ms: 1,
}];

clear_head_anchored_annotations(&mut review);

Expand All @@ -1536,6 +1550,7 @@ mod tests {
review.distilled_intent.is_none(),
"intent cleared on refresh"
);
assert!(review.chat.is_empty(), "chat cleared on refresh");
assert_eq!(
review.recap_sha.as_deref(),
Some("headsha9"),
Expand Down
9 changes: 9 additions & 0 deletions app/src-tauri/src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ pub struct AppState {
/// is only ever held for trivial map lookups/inserts — never across an
/// `.await`.
pub lsp_bridges: Mutex<HashMap<LspLanguage, LspBridge>>,

/// In-flight reviewer-chat turns, PR ref → agent pid.
///
/// Enforces one turn per PR and gives `cancel_chat_turn` its kill target.
/// Chat turns are transient (never attached to `Review::agent`), so this
/// registry is their only handle. The `std::sync::Mutex` is held only for
/// trivial map operations — never across an `.await`.
pub chat_turns: Mutex<HashMap<String, u32>>,
}

impl AppState {
Expand All @@ -67,6 +75,7 @@ impl AppState {
completion_tx,
permission_broker,
lsp_bridges: Mutex::new(HashMap::new()),
chat_turns: Mutex::new(HashMap::new()),
}
}
}
37 changes: 37 additions & 0 deletions app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ interface AgentEventEnvelope {
readonly event: AgentStreamEvent;
}

/** Payload of the reviewer-chat `"chat-stream"` event (hand-typed, no ts-rs). */
interface ChatStreamPayload {
readonly pr: string;
readonly message_id: string;
readonly delta: string;
}

/** Payload of the reviewer-chat `"chat-complete"` event (hand-typed, no ts-rs). */
interface ChatCompletePayload {
readonly pr: string;
readonly message_id: string;
readonly ok: boolean;
}

type ReviewTab = "my-prs" | "review-requests" | "all";

const SIDEBAR_COLLAPSED_KEY = "cockpit-sidebar-collapsed";
Expand Down Expand Up @@ -603,7 +617,30 @@ function App() {
},
);

// Reviewer chat: stream deltas into the in-flight bubble; on completion
// settle the turn (the persisted agent message lands via review refresh).
const unlistenChatStream = listen<ChatStreamPayload>(
"chat-stream",
(event) => {
useAppStore
.getState()
.applyChatDelta(event.payload.pr, event.payload.delta);
},
);
const unlistenChatComplete = listen<ChatCompletePayload>(
"chat-complete",
(event) => {
void useAppStore.getState().completeChatTurn(event.payload.pr);
},
);

return () => {
void unlistenChatStream.then((f) => {
f();
});
void unlistenChatComplete.then((f) => {
f();
});
void unlisten.then((f) => {
f();
});
Expand Down
37 changes: 37 additions & 0 deletions app/src/bindings/ChatMessage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { ChatMessageId } from "./ChatMessageId";
import type { ChatRole } from "./ChatRole";
import type { DraftComment } from "./DraftComment";

/**
* One message in a review's chat thread with the reviewer agent.
*
* The thread is head-anchored context like findings and the distilled
* intent: it is cleared when the head moves, since answers about superseded
* code go stale.
*/
export type ChatMessage = {
/**
* Locally-unique message id.
*/
id: ChatMessageId,
/**
* Who authored the message.
*/
role: ChatRole,
/**
* The message text (markdown; draft blocks already stripped).
*/
text: string,
/**
* Draft comments proposed by the agent in this turn (usually empty).
*/
drafts: Array<DraftComment>,
/**
* Whether this agent message reports a failed turn (spawn/timeout/parse).
*/
error: boolean,
/**
* Wall-clock creation time in epoch milliseconds.
*/
created_at_epoch_ms: number, };
6 changes: 6 additions & 0 deletions app/src/bindings/ChatMessageId.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

/**
* Locally-unique identifier for a [`ChatMessage`] in a review's thread.
*/
export type ChatMessageId = string;
6 changes: 6 additions & 0 deletions app/src/bindings/ChatRole.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.

/**
* Who authored a [`ChatMessage`] in a review's chat thread.
*/
export type ChatRole = "User" | "Agent";
27 changes: 27 additions & 0 deletions app/src/bindings/DraftComment.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { DiffSide } from "./DiffSide";

/**
* A review comment the chat agent proposes, for the reviewer to adopt.
*
* A draft is a *proposal only*: it becomes a real [`Comment`] exclusively
* through the reviewer's explicit adopt action, and reaches GitHub only via
* the guarded mirror / submit-review flows (Invariant §9).
*/
export type DraftComment = {
/**
* Path relative to the repo root.
*/
path: string,
/**
* Inclusive start and end line in the current head.
*/
range: [number, number],
/**
* Which side of the diff the range refers to.
*/
side: DiffSide,
/**
* The proposed comment body (markdown).
*/
body: string, };
11 changes: 10 additions & 1 deletion app/src/bindings/Review.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually.
import type { AgentRun } from "./AgentRun";
import type { ChatMessage } from "./ChatMessage";
import type { CiSummary } from "./CiSummary";
import type { Comment } from "./Comment";
import type { ConversationItem } from "./ConversationItem";
Expand Down Expand Up @@ -165,4 +166,12 @@ distilled_headline: string | null,
* tab can nudge a regenerate. Deliberately NOT cleared when the head moves
* (unlike `review_findings` / `distilled_intent`) so staleness is derivable.
*/
recap_sha: string | null, };
recap_sha: string | null,
/**
* The chat thread between the reviewer and the read-only reviewer agent.
*
* Head-anchored like `review_findings` / `distilled_intent`: cleared when
* the head moves. `#[serde(default)]` keeps legacy persisted reviews
* loading with an empty thread.
*/
chat: Array<ChatMessage>, };
125 changes: 125 additions & 0 deletions app/src/components/ChatPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, it, expect, vi } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import { makeReview } from "../test/fixtures";
import { mockInvoke, callsFor } from "../test/tauri-mock";
import type { ChatMessage } from "../bindings/ChatMessage";

vi.mock("@tauri-apps/api/core", async () => {
const mock = await import("../test/tauri-mock");
return { invoke: mock.invoke };
});
vi.mock("@tauri-apps/api/event", async () => {
const mock = await import("../test/tauri-mock");
return { listen: mock.listen };
});
vi.mock("@tauri-apps/plugin-opener", () => ({
openUrl: vi.fn(() => Promise.resolve()),
}));

const { useAppStore } = await import("../store");
const { ChatPanel } = await import("./ChatPanel");

const agentMessage: ChatMessage = {
id: "cm-a1",
role: "Agent",
text: "The flag gates every entry point.",
drafts: [
{
path: "src/a.py",
range: [57, 57],
side: "New",
body: "Use the no-retry policy.",
},
],
error: false,
created_at_epoch_ms: 2,
};

const userMessage: ChatMessage = {
id: "cm-u1",
role: "User",
text: "What does the flag gate?",
drafts: [],
error: false,
created_at_epoch_ms: 1,
};

describe("ChatPanel", () => {
it("renders the thread and adopts a draft via add_comment", () => {
const review = {
...makeReview({ gate_state: "InReview" }),
chat: [userMessage, agentMessage],
};
useAppStore.setState({
activeReview: review,
view: { kind: "diff", pr: review.pr },
});
mockInvoke("add_comment", () => review);

render(<ChatPanel review={review} onClose={() => undefined} />);

expect(screen.getByText("What does the flag gate?")).toBeDefined();
expect(
screen.getByText("The flag gates every entry point."),
).toBeDefined();

fireEvent.click(screen.getByRole("button", { name: "Add to review" }));
const calls = callsFor("add_comment");
expect(calls).toHaveLength(1);
expect(calls[0]?.args).toMatchObject({
file: "src/a.py",
lineStart: 57,
lineEnd: 57,
body: "Use the no-retry policy.",
side: "New",
});
});

it("shows the adopted state instead of the button once a comment matches", () => {
const base = makeReview({ gate_state: "InReview" });
const review = {
...base,
chat: [agentMessage],
comments: [
{
id: "c-1",
anchor: {
DiffLine: {
path: "src/a.py",
range: [57, 57] as [number, number],
side: "New" as const,
},
},
body: "Use the no-retry policy.",
origin: "Local" as const,
},
],
};
render(<ChatPanel review={review} onClose={() => undefined} />);
expect(screen.queryByRole("button", { name: "Add to review" })).toBeNull();
expect(screen.getByText("Added ✓")).toBeDefined();
});

it("disables send on empty input and marks error turns", () => {
const review = {
...makeReview({ gate_state: "InReview" }),
chat: [
{
...agentMessage,
id: "cm-err",
drafts: [],
error: true,
text: "The reviewer agent timed out after 5 minutes and was stopped.",
},
],
};
render(<ChatPanel review={review} onClose={() => undefined} />);
const send = screen.getByRole("button", { name: "Send message" });
expect((send as HTMLButtonElement).disabled).toBe(true);
expect(
screen.getByText(
"The reviewer agent timed out after 5 minutes and was stopped.",
),
).toBeDefined();
});
});
Loading
Loading