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;
|
||||
stage: string;
|
||||
name: string | null;
|
||||
agent: string | null;
|
||||
}
|
||||
|
||||
export interface TestCaseResult {
|
||||
|
||||
@@ -38,6 +38,8 @@ vi.mock("../api/client", () => {
|
||||
setModelPreference: vi.fn(),
|
||||
cancelChat: vi.fn(),
|
||||
setAnthropicApiKey: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
listProjectFiles: vi.fn(),
|
||||
};
|
||||
class ChatWebSocket {
|
||||
connect(handlers: WsHandlers) {
|
||||
@@ -60,6 +62,8 @@ const mockedApi = {
|
||||
setModelPreference: vi.mocked(api.setModelPreference),
|
||||
cancelChat: vi.mocked(api.cancelChat),
|
||||
setAnthropicApiKey: vi.mocked(api.setAnthropicApiKey),
|
||||
readFile: vi.mocked(api.readFile),
|
||||
listProjectFiles: vi.mocked(api.listProjectFiles),
|
||||
};
|
||||
|
||||
function setupMocks() {
|
||||
@@ -68,6 +72,8 @@ function setupMocks() {
|
||||
mockedApi.getAnthropicModels.mockResolvedValue([]);
|
||||
mockedApi.getModelPreference.mockResolvedValue(null);
|
||||
mockedApi.setModelPreference.mockResolvedValue(true);
|
||||
mockedApi.readFile.mockResolvedValue("");
|
||||
mockedApi.listProjectFiles.mockResolvedValue([]);
|
||||
mockedApi.cancelChat.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
|
||||
expect(lastSendChatArgs).not.toBeNull();
|
||||
const args = lastSendChatArgs!;
|
||||
const args = lastSendChatArgs as NonNullable<typeof lastSendChatArgs>;
|
||||
expect(args.messages).toHaveLength(3);
|
||||
expect(args.messages[0]).toEqual({
|
||||
role: "user",
|
||||
@@ -1344,7 +1350,7 @@ describe("Bug 264: Claude Code session ID persisted across browser refresh", ()
|
||||
|
||||
expect(lastSendChatArgs).not.toBeNull();
|
||||
expect(
|
||||
(lastSendChatArgs!.config as Record<string, unknown>).session_id,
|
||||
(lastSendChatArgs?.config as Record<string, unknown>).session_id,
|
||||
).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");
|
||||
});
|
||||
});
|
||||
|
||||
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];
|
||||
|
||||
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.",
|
||||
stage: "current",
|
||||
name: "Big Title Story",
|
||||
agent: null,
|
||||
};
|
||||
|
||||
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", () => {
|
||||
it("shows empty test results message when no results exist", async () => {
|
||||
mockedGetTestResults.mockResolvedValue(null);
|
||||
|
||||
@@ -113,6 +113,7 @@ export function WorkItemDetailPanel({
|
||||
const [content, setContent] = useState<string | null>(null);
|
||||
const [stage, setStage] = useState<string>("");
|
||||
const [name, setName] = useState<string | null>(null);
|
||||
const [assignedAgent, setAssignedAgent] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [agentInfo, setAgentInfo] = useState<AgentInfo | null>(null);
|
||||
@@ -133,6 +134,7 @@ export function WorkItemDetailPanel({
|
||||
setContent(data.content);
|
||||
setStage(data.stage);
|
||||
setName(data.name);
|
||||
setAssignedAgent(data.agent);
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
setError(err instanceof Error ? err.message : "Failed to load content");
|
||||
@@ -278,6 +280,14 @@ export function WorkItemDetailPanel({
|
||||
{stageLabel}
|
||||
</div>
|
||||
)}
|
||||
{assignedAgent ? (
|
||||
<div
|
||||
data-testid="detail-panel-assigned-agent"
|
||||
style={{ fontSize: "0.75em", color: "#888" }}
|
||||
>
|
||||
Agent: {assignedAgent}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -68,6 +68,7 @@ struct WorkItemContentResponse {
|
||||
content: String,
|
||||
stage: String,
|
||||
name: Option<String>,
|
||||
agent: Option<String>,
|
||||
}
|
||||
|
||||
/// A single test case result for the OpenAPI response.
|
||||
@@ -354,13 +355,14 @@ impl AgentsApi {
|
||||
if file_path.exists() {
|
||||
let content = std::fs::read_to_string(&file_path)
|
||||
.map_err(|e| bad_request(format!("Failed to read work item: {e}")))?;
|
||||
let name = crate::io::story_metadata::parse_front_matter(&content)
|
||||
.ok()
|
||||
.and_then(|m| m.name);
|
||||
let metadata = crate::io::story_metadata::parse_front_matter(&content).ok();
|
||||
let name = metadata.as_ref().and_then(|m| m.name.clone());
|
||||
let agent = metadata.and_then(|m| m.agent);
|
||||
return Ok(Json(WorkItemContentResponse {
|
||||
content,
|
||||
stage: stage_name.to_string(),
|
||||
name,
|
||||
agent,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -401,4 +401,5 @@ mod tests {
|
||||
let result = api.list_directory(payload).await;
|
||||
assert!(result.is_err());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user