story-kit: merge 271_story_show_assigned_agent_in_expanded_work_item_view
Adds assigned agent display to the expanded work item detail panel. Resolved conflicts by keeping master versions of bot.rs (permission handling), ChatInput.tsx, and fs.rs. Removed duplicate list_project_files endpoint and tests from io.rs. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,60 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Matrix bot structured conversation history"
|
|
||||||
agent: coder-opus
|
|
||||||
---
|
|
||||||
|
|
||||||
# Story 266: Matrix bot structured conversation history
|
|
||||||
|
|
||||||
## User Story
|
|
||||||
|
|
||||||
As a user chatting with the Matrix bot, I want it to remember and own its prior responses naturally, so that conversations feel like talking to one continuous entity rather than a new instance each message.
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] Conversation history is passed as structured API messages (user/assistant turns) rather than a flattened text prefix
|
|
||||||
- [ ] Claude recognises its prior responses as its own, maintaining consistent personality across a conversation
|
|
||||||
- [ ] Per-room history survives server restarts (persisted to disk or database)
|
|
||||||
- [ ] Rolling window trimming still applies to keep context bounded
|
|
||||||
- [ ] Multi-user rooms still attribute messages to the correct sender
|
|
||||||
|
|
||||||
## Investigation Notes (2026-03-18)
|
|
||||||
|
|
||||||
The current implementation attempts session resumption via `--resume <session_id>` but it's not working:
|
|
||||||
|
|
||||||
### Code path: how session resumption is supposed to work
|
|
||||||
|
|
||||||
1. `server/src/matrix/bot.rs:671-676` — `handle_message()` reads `conv.session_id` from the per-room `RoomConversation` to get the resume ID.
|
|
||||||
2. `server/src/matrix/bot.rs:717` — passes `resume_session_id` to `provider.chat_stream()`.
|
|
||||||
3. `server/src/llm/providers/claude_code.rs:57` — `chat_stream()` stores it as `resume_id`.
|
|
||||||
4. `server/src/llm/providers/claude_code.rs:170-173` — if `resume_session_id` is `Some`, appends `--resume <id>` to the `claude -p` command.
|
|
||||||
5. `server/src/llm/providers/claude_code.rs:348` — `process_json_event()` looks for `json["session_id"]` in each streamed NDJSON event and sends it via a oneshot channel (`sid_tx`).
|
|
||||||
6. `server/src/llm/providers/claude_code.rs:122` — after the PTY exits, `sid_rx.await.ok()` captures the session ID (or `None` if never sent).
|
|
||||||
7. `server/src/matrix/bot.rs:785-787` — stores `new_session_id` back into `conv.session_id` and persists via `save_history()`.
|
|
||||||
|
|
||||||
### What's broken
|
|
||||||
|
|
||||||
- **No session_id captured:** `.story_kit/matrix_history.json` contains conversation entries but no `session_id`. `RoomConversation.session_id` is always `None`.
|
|
||||||
- **Root cause:** `claude -p --output-format stream-json` may not emit a `session_id` in its NDJSON events, or the parser at step 5 isn't matching the actual event shape. The oneshot channel never fires.
|
|
||||||
- **Effect:** Every message spawns a fresh Claude Code process with no `--resume` flag. Each turn is a blank slate.
|
|
||||||
- **History persistence works fine** — serialization round-trips correctly (test at `bot.rs:1335-1339`). The problem is purely that `--resume` is never invoked.
|
|
||||||
|
|
||||||
### Debugging steps
|
|
||||||
|
|
||||||
1. Run `claude -p "hello" --output-format stream-json --verbose 2>/dev/null` manually and inspect the NDJSON for a `session_id` field. Check what event type carries it and whether the key name matches what `process_json_event()` expects.
|
|
||||||
2. If `session_id` is present but nested differently (e.g. inside an `event` wrapper), fix the JSON path at `claude_code.rs:348`.
|
|
||||||
3. If `-p` mode doesn't emit `session_id` at all, consider an alternative: pass conversation history as a structured prompt prefix, or switch to the Claude API directly.
|
|
||||||
|
|
||||||
### Previous attempt failed (2026-03-18)
|
|
||||||
|
|
||||||
A sonnet coder attempted this story but did NOT fix the root cause. It rewrote the `chat_stream()` call in `bot.rs` to look identical to what was already there — it never investigated why `session_id` isn't being captured. The merge auto-resolver then jammed the duplicate call inside the `tokio::select!` permission loop, producing mismatched braces. The broken merge was reverted.
|
|
||||||
|
|
||||||
**What the coder must actually do:**
|
|
||||||
|
|
||||||
1. **Do NOT rewrite the `chat_stream()` call or the `tokio::select!` loop in `bot.rs`.** That code is correct and handles permission forwarding (story 275). Do not touch it.
|
|
||||||
2. **The bug is in `claude_code.rs`, not `bot.rs`.** The `process_json_event()` function at line ~348 looks for `json["session_id"]` but it's likely never finding it. Start by running step 1 above to see what the actual NDJSON output looks like.
|
|
||||||
3. **If `claude -p` doesn't emit `session_id` at all**, the `--resume` approach won't work. In that case, the fix is to pass conversation history as a prompt prefix (prepend prior turns to the user message) or use `--continue` instead of `--resume`, or call the Claude API directly instead of shelling out to the CLI.
|
|
||||||
4. **Rebase onto current master before starting.** Master has changed significantly (spike 92, story 275 permission handling, gitignore changes).
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- TBD
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
---
|
|
||||||
name: "Detect and log when root .mcp.json port is modified"
|
|
||||||
---
|
|
||||||
|
|
||||||
# Story 276: Detect and log when root .mcp.json port is modified
|
|
||||||
|
|
||||||
## User Story
|
|
||||||
|
|
||||||
As a ..., I want ..., so that ...
|
|
||||||
|
|
||||||
## Acceptance Criteria
|
|
||||||
|
|
||||||
- [ ] TODO
|
|
||||||
|
|
||||||
## Out of Scope
|
|
||||||
|
|
||||||
- TBD
|
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
name: "Matrix bot structured conversation history"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Story 266: Matrix bot structured conversation history
|
||||||
|
|
||||||
|
## User Story
|
||||||
|
|
||||||
|
As a user chatting with the Matrix bot, I want it to remember and own its prior responses naturally, so that conversations feel like talking to one continuous entity rather than a new instance each message.
|
||||||
|
|
||||||
|
## Acceptance Criteria
|
||||||
|
|
||||||
|
- [ ] Conversation history is passed as structured API messages (user/assistant turns) rather than a flattened text prefix
|
||||||
|
- [ ] Claude recognises its prior responses as its own, maintaining consistent personality across a conversation
|
||||||
|
- [ ] Per-room history survives server restarts (persisted to disk or database)
|
||||||
|
- [ ] Rolling window trimming still applies to keep context bounded
|
||||||
|
- [ ] Multi-user rooms still attribute messages to the correct sender
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- TBD
|
||||||
@@ -115,6 +115,7 @@ export interface WorkItemContent {
|
|||||||
content: string;
|
content: string;
|
||||||
stage: string;
|
stage: string;
|
||||||
name: string | null;
|
name: string | null;
|
||||||
|
agent: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TestCaseResult {
|
export interface TestCaseResult {
|
||||||
|
|||||||
@@ -38,6 +38,8 @@ vi.mock("../api/client", () => {
|
|||||||
setModelPreference: vi.fn(),
|
setModelPreference: vi.fn(),
|
||||||
cancelChat: vi.fn(),
|
cancelChat: vi.fn(),
|
||||||
setAnthropicApiKey: vi.fn(),
|
setAnthropicApiKey: vi.fn(),
|
||||||
|
readFile: vi.fn(),
|
||||||
|
listProjectFiles: vi.fn(),
|
||||||
};
|
};
|
||||||
class ChatWebSocket {
|
class ChatWebSocket {
|
||||||
connect(handlers: WsHandlers) {
|
connect(handlers: WsHandlers) {
|
||||||
@@ -60,6 +62,8 @@ const mockedApi = {
|
|||||||
setModelPreference: vi.mocked(api.setModelPreference),
|
setModelPreference: vi.mocked(api.setModelPreference),
|
||||||
cancelChat: vi.mocked(api.cancelChat),
|
cancelChat: vi.mocked(api.cancelChat),
|
||||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||||
|
readFile: vi.mocked(api.readFile),
|
||||||
|
listProjectFiles: vi.mocked(api.listProjectFiles),
|
||||||
};
|
};
|
||||||
|
|
||||||
function setupMocks() {
|
function setupMocks() {
|
||||||
@@ -68,6 +72,8 @@ function setupMocks() {
|
|||||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||||
|
mockedApi.readFile.mockResolvedValue("");
|
||||||
|
mockedApi.listProjectFiles.mockResolvedValue([]);
|
||||||
mockedApi.cancelChat.mockResolvedValue(true);
|
mockedApi.cancelChat.mockResolvedValue(true);
|
||||||
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
mockedApi.setAnthropicApiKey.mockResolvedValue(true);
|
||||||
}
|
}
|
||||||
@@ -625,7 +631,7 @@ describe("Chat localStorage persistence (Story 145)", () => {
|
|||||||
|
|
||||||
// Verify sendChat was called with ALL prior messages + the new one
|
// Verify sendChat was called with ALL prior messages + the new one
|
||||||
expect(lastSendChatArgs).not.toBeNull();
|
expect(lastSendChatArgs).not.toBeNull();
|
||||||
const args = lastSendChatArgs!;
|
const args = lastSendChatArgs as NonNullable<typeof lastSendChatArgs>;
|
||||||
expect(args.messages).toHaveLength(3);
|
expect(args.messages).toHaveLength(3);
|
||||||
expect(args.messages[0]).toEqual({
|
expect(args.messages[0]).toEqual({
|
||||||
role: "user",
|
role: "user",
|
||||||
@@ -1344,7 +1350,7 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", ()
|
|||||||
|
|
||||||
expect(lastSendChatArgs).not.toBeNull();
|
expect(lastSendChatArgs).not.toBeNull();
|
||||||
expect(
|
expect(
|
||||||
(lastSendChatArgs!.config as Record<string, unknown>).session_id,
|
(lastSendChatArgs?.config as Record<string, unknown>).session_id,
|
||||||
).toBe("persisted-session-xyz");
|
).toBe("persisted-session-xyz");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -1387,3 +1393,57 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", ()
|
|||||||
expect(localStorage.getItem(otherKey)).toBe("other-session");
|
expect(localStorage.getItem(otherKey)).toBe("other-session");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("File reference expansion (Story 269 AC4)", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
capturedWsHandlers = null;
|
||||||
|
lastSendChatArgs = null;
|
||||||
|
setupMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("includes file contents as context when message contains @file reference", async () => {
|
||||||
|
mockedApi.readFile.mockResolvedValue('fn main() { println!("hello"); }');
|
||||||
|
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Send a message...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { value: "explain @src/main.rs" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
|
||||||
|
const sentMessages = (
|
||||||
|
lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
|
||||||
|
).messages;
|
||||||
|
const userMsg = sentMessages[sentMessages.length - 1];
|
||||||
|
expect(userMsg.content).toContain("explain @src/main.rs");
|
||||||
|
expect(userMsg.content).toContain("[File: src/main.rs]");
|
||||||
|
expect(userMsg.content).toContain("fn main()");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("sends message without modification when no @file references are present", async () => {
|
||||||
|
render(<Chat projectPath="/tmp/project" onCloseProject={vi.fn()} />);
|
||||||
|
await waitFor(() => expect(capturedWsHandlers).not.toBeNull());
|
||||||
|
|
||||||
|
const input = screen.getByPlaceholderText("Send a message...");
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(input, { target: { value: "hello world" } });
|
||||||
|
});
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(input, { key: "Enter", shiftKey: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(lastSendChatArgs).not.toBeNull());
|
||||||
|
const sentMessages = (
|
||||||
|
lastSendChatArgs as NonNullable<typeof lastSendChatArgs>
|
||||||
|
).messages;
|
||||||
|
const userMsg = sentMessages[sentMessages.length - 1];
|
||||||
|
expect(userMsg.content).toBe("hello world");
|
||||||
|
expect(mockedApi.readFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -554,7 +554,26 @@ export function Chat({ projectPath, onCloseProject }: ChatProps) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userMsg: Message = { role: "user", content: messageText };
|
// Expand @file references: append file contents as context
|
||||||
|
const fileRefs = [...messageText.matchAll(/(^|[\s\n])@([^\s@]+)/g)].map(
|
||||||
|
(m) => m[2],
|
||||||
|
);
|
||||||
|
let expandedText = messageText;
|
||||||
|
if (fileRefs.length > 0) {
|
||||||
|
const expansions = await Promise.allSettled(
|
||||||
|
fileRefs.map(async (ref) => {
|
||||||
|
const contents = await api.readFile(ref);
|
||||||
|
return { ref, contents };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
for (const result of expansions) {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
expandedText += `\n\n[File: ${result.value.ref}]\n\`\`\`\n${result.value.contents}\n\`\`\``;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const userMsg: Message = { role: "user", content: expandedText };
|
||||||
const newHistory = [...messages, userMsg];
|
const newHistory = [...messages, userMsg];
|
||||||
|
|
||||||
setMessages(newHistory);
|
setMessages(newHistory);
|
||||||
|
|||||||
194
frontend/src/components/ChatInputFilePicker.test.tsx
Normal file
194
frontend/src/components/ChatInputFilePicker.test.tsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { api } from "../api/client";
|
||||||
|
import { ChatInput } from "./ChatInput";
|
||||||
|
|
||||||
|
vi.mock("../api/client", () => ({
|
||||||
|
api: {
|
||||||
|
listProjectFiles: vi.fn(),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedListProjectFiles = vi.mocked(api.listProjectFiles);
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
loading: false,
|
||||||
|
queuedMessages: [],
|
||||||
|
onSubmit: vi.fn(),
|
||||||
|
onCancel: vi.fn(),
|
||||||
|
onRemoveQueuedMessage: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockedListProjectFiles.mockResolvedValue([
|
||||||
|
"src/main.rs",
|
||||||
|
"src/lib.rs",
|
||||||
|
"frontend/index.html",
|
||||||
|
"README.md",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File picker overlay (Story 269 AC1)", () => {
|
||||||
|
it("shows file picker overlay when @ is typed", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not show file picker overlay for text without @", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "hello world" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File picker fuzzy matching (Story 269 AC2)", () => {
|
||||||
|
it("filters files by query typed after @", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@main" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// main.rs should be visible, README.md should not
|
||||||
|
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("README.md")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows all files when @ is typed with no query", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// All 4 files should be visible
|
||||||
|
expect(screen.getByText("src/main.rs")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("src/lib.rs")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("README.md")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File picker selection (Story 269 AC3)", () => {
|
||||||
|
it("clicking a file inserts @path into the message", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-item-0")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.click(screen.getByTestId("file-picker-item-0"));
|
||||||
|
});
|
||||||
|
|
||||||
|
// Picker should be dismissed and the file reference inserted
|
||||||
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toMatch(/^@\S+/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Enter key selects highlighted file and inserts it into message", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@main" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||||
|
expect((textarea as HTMLTextAreaElement).value).toContain("@src/main.rs");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("File picker dismiss (Story 269 AC5)", () => {
|
||||||
|
it("Escape key dismisses the file picker", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Escape" });
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("file-picker-overlay")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Multiple @ references (Story 269 AC6)", () => {
|
||||||
|
it("typing @ after a completed reference triggers picker again", async () => {
|
||||||
|
render(<ChatInput {...defaultProps} />);
|
||||||
|
const textarea = screen.getByPlaceholderText("Send a message...");
|
||||||
|
|
||||||
|
// First reference
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.change(textarea, { target: { value: "@main" } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Select file
|
||||||
|
await act(async () => {
|
||||||
|
fireEvent.keyDown(textarea, { key: "Enter" });
|
||||||
|
});
|
||||||
|
|
||||||
|
// Type a second @
|
||||||
|
await act(async () => {
|
||||||
|
const current = (textarea as HTMLTextAreaElement).value;
|
||||||
|
fireEvent.change(textarea, { target: { value: `${current} @` } });
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByTestId("file-picker-overlay")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -37,6 +37,7 @@ const DEFAULT_CONTENT = {
|
|||||||
content: "# Big Title\n\nSome content here.",
|
content: "# Big Title\n\nSome content here.",
|
||||||
stage: "current",
|
stage: "current",
|
||||||
name: "Big Title Story",
|
name: "Big Title Story",
|
||||||
|
agent: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sampleTestResults: TestResultsResponse = {
|
const sampleTestResults: TestResultsResponse = {
|
||||||
@@ -436,6 +437,60 @@ describe("WorkItemDetailPanel - Agent Logs", () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe("WorkItemDetailPanel - Assigned Agent", () => {
|
||||||
|
it("shows assigned agent name when agent front matter field is set", async () => {
|
||||||
|
mockedGetWorkItemContent.mockResolvedValue({
|
||||||
|
...DEFAULT_CONTENT,
|
||||||
|
agent: "coder-opus",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="271_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
|
||||||
|
expect(agentEl).toHaveTextContent("coder-opus");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("omits assigned agent field when no agent is set in front matter", async () => {
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="271_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
await screen.findByTestId("detail-panel-content");
|
||||||
|
expect(
|
||||||
|
screen.queryByTestId("detail-panel-assigned-agent"),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the specific agent name not just 'assigned'", async () => {
|
||||||
|
mockedGetWorkItemContent.mockResolvedValue({
|
||||||
|
...DEFAULT_CONTENT,
|
||||||
|
agent: "coder-haiku",
|
||||||
|
});
|
||||||
|
|
||||||
|
render(
|
||||||
|
<WorkItemDetailPanel
|
||||||
|
storyId="271_story_test"
|
||||||
|
pipelineVersion={0}
|
||||||
|
onClose={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const agentEl = await screen.findByTestId("detail-panel-assigned-agent");
|
||||||
|
expect(agentEl).toHaveTextContent("coder-haiku");
|
||||||
|
expect(agentEl).not.toHaveTextContent("assigned");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("WorkItemDetailPanel - Test Results", () => {
|
describe("WorkItemDetailPanel - Test Results", () => {
|
||||||
it("shows empty test results message when no results exist", async () => {
|
it("shows empty test results message when no results exist", async () => {
|
||||||
mockedGetTestResults.mockResolvedValue(null);
|
mockedGetTestResults.mockResolvedValue(null);
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ export function WorkItemDetailPanel({
|
|||||||
const [content, setContent] = useState<string | null>(null);
|
const [content, setContent] = useState<string | null>(null);
|
||||||
const [stage, setStage] = useState<string>("");
|
const [stage, setStage] = useState<string>("");
|
||||||
const [name, setName] = useState<string | null>(null);
|
const [name, setName] = useState<string | null>(null);
|
||||||
|
const [assignedAgent, setAssignedAgent] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
|
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
|
||||||
@@ -133,6 +134,7 @@ export function WorkItemDetailPanel({
|
|||||||
setContent(data.content);
|
setContent(data.content);
|
||||||
setStage(data.stage);
|
setStage(data.stage);
|
||||||
setName(data.name);
|
setName(data.name);
|
||||||
|
setAssignedAgent(data.agent);
|
||||||
})
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
setError(err instanceof Error ? err.message : "Failed to load content");
|
setError(err instanceof Error ? err.message : "Failed to load content");
|
||||||
@@ -278,6 +280,14 @@ export function WorkItemDetailPanel({
|
|||||||
{stageLabel}
|
{stageLabel}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{assignedAgent ? (
|
||||||
|
<div
|
||||||
|
data-testid="detail-panel-assigned-agent"
|
||||||
|
style={{ fontSize: "0.75em", color: "#888" }}
|
||||||
|
>
|
||||||
|
Agent: {assignedAgent}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ struct WorkItemContentResponse {
|
|||||||
content: String,
|
content: String,
|
||||||
stage: String,
|
stage: String,
|
||||||
name: Option<String>,
|
name: Option<String>,
|
||||||
|
agent: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A single test case result for the OpenAPI response.
|
/// A single test case result for the OpenAPI response.
|
||||||
@@ -354,13 +355,14 @@ impl AgentsApi {
|
|||||||
if file_path.exists() {
|
if file_path.exists() {
|
||||||
let content = std::fs::read_to_string(&file_path)
|
let content = std::fs::read_to_string(&file_path)
|
||||||
.map_err(|e| bad_request(format!("Failed to read work item: {e}")))?;
|
.map_err(|e| bad_request(format!("Failed to read work item: {e}")))?;
|
||||||
let name = crate::io::story_metadata::parse_front_matter(&content)
|
let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
|
||||||
.ok()
|
let name = metadata.as_ref().and_then(|m| m.name.clone());
|
||||||
.and_then(|m| m.name);
|
let agent = metadata.and_then(|m| m.agent);
|
||||||
return Ok(Json(WorkItemContentResponse {
|
return Ok(Json(WorkItemContentResponse {
|
||||||
content,
|
content,
|
||||||
stage: stage_name.to_string(),
|
stage: stage_name.to_string(),
|
||||||
name,
|
name,
|
||||||
|
agent,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -401,4 +401,5 @@ mod tests {
|
|||||||
let result = api.list_directory(payload).await;
|
let result = api.list_directory(payload).await;
|
||||||
assert!(result.is_err());
|
assert!(result.is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user