fix(web): merge chat status bar into single row and switch task on archive (#460)
This commit is contained in:
@@ -65,6 +65,14 @@ func (e *emitter) completeTool(id acp.ToolCallId, output any) {
|
||||
})
|
||||
}
|
||||
|
||||
// plan sends an ACP Plan update with the provided entries.
|
||||
func (e *emitter) plan(entries []acp.PlanEntry) {
|
||||
_ = e.conn.SessionUpdate(e.ctx, acp.SessionNotification{
|
||||
SessionId: e.sid,
|
||||
Update: acp.UpdatePlan(entries...),
|
||||
})
|
||||
}
|
||||
|
||||
// requestPermission asks the client for permission to proceed with a tool call.
|
||||
// Returns true if permission was granted, false otherwise.
|
||||
func (e *emitter) requestPermission(toolCallID acp.ToolCallId, title string, kind acp.ToolKind, input any) bool {
|
||||
|
||||
@@ -47,6 +47,9 @@ func executeCommand(e *emitter, fullPrompt, line string) {
|
||||
case strings.HasPrefix(line, "e2e:mcp:"):
|
||||
executeMCPCommand(e, fullPrompt, line)
|
||||
|
||||
case strings.HasPrefix(line, "e2e:plan("):
|
||||
executePlanCommand(e, line)
|
||||
|
||||
case strings.HasPrefix(line, "e2e:tool_use("):
|
||||
executeSimulatedToolUse(e, line)
|
||||
|
||||
@@ -100,6 +103,30 @@ func executeMCPCommand(e *emitter, fullPrompt, line string) {
|
||||
}
|
||||
}
|
||||
|
||||
// executePlanCommand emits an ACP Plan update.
|
||||
// Format: e2e:plan([{"content":"task","status":"in_progress"},{"content":"task2","status":"pending"}])
|
||||
func executePlanCommand(e *emitter, line string) {
|
||||
argsStr := extractParenContent(line, "e2e:plan(")
|
||||
var rawEntries []struct {
|
||||
Content string `json:"content"`
|
||||
Status string `json:"status"`
|
||||
Priority string `json:"priority,omitempty"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(argsStr), &rawEntries); err != nil {
|
||||
e.text(fmt.Sprintf("Script error: bad plan JSON: %v", err))
|
||||
return
|
||||
}
|
||||
entries := make([]acp.PlanEntry, len(rawEntries))
|
||||
for i, r := range rawEntries {
|
||||
entries[i] = acp.PlanEntry{
|
||||
Content: r.Content,
|
||||
Status: acp.PlanEntryStatus(r.Status),
|
||||
Priority: acp.PlanEntryPriority(r.Priority),
|
||||
}
|
||||
}
|
||||
e.plan(entries)
|
||||
}
|
||||
|
||||
// executeSimulatedToolUse emits a simulated tool call start (no real execution).
|
||||
// Format: e2e:tool_use("name", {"key":"value"})
|
||||
func executeSimulatedToolUse(e *emitter, line string) {
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
formatPlanCommentsAsMarkdown,
|
||||
} from "@/lib/state/slices/comments/format";
|
||||
import { setChatDraftContent } from "@/lib/local-storage";
|
||||
import { useTaskActions } from "@/hooks/use-task-actions";
|
||||
import { useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useToast } from "@/components/toast-provider";
|
||||
import type { DiffComment } from "@/lib/diff/types";
|
||||
import type { useChatPanelState } from "./use-chat-panel-state";
|
||||
@@ -254,28 +254,32 @@ After completing the implementation, provide a summary of what was done.
|
||||
function PRMergedBanner({ taskId }: { taskId: string }) {
|
||||
const taskPR = useAppStore((state) => state.taskPRs.byTaskId[taskId]);
|
||||
const [dismissed, setDismissed] = useState(false);
|
||||
const { archiveTaskById } = useTaskActions();
|
||||
const archiveAndSwitch = useArchiveAndSwitchTask();
|
||||
const { toast } = useToast();
|
||||
|
||||
const handleArchive = useCallback(async () => {
|
||||
try {
|
||||
await archiveTaskById(taskId);
|
||||
await archiveAndSwitch(taskId);
|
||||
toast({ description: "Task archived" });
|
||||
} catch {
|
||||
toast({ description: "Failed to archive task", variant: "error" });
|
||||
}
|
||||
}, [taskId, archiveTaskById, toast]);
|
||||
}, [taskId, archiveAndSwitch, toast]);
|
||||
|
||||
if (taskPR?.state !== "merged" || dismissed) return null;
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 mx-1 mb-1 px-3 py-1.5 text-xs rounded-md bg-purple-500/10 text-purple-600 dark:text-purple-400">
|
||||
<div
|
||||
data-testid="pr-merged-banner"
|
||||
className="flex flex-1 items-center gap-2 rounded-md bg-purple-500/10 px-2 py-1 text-purple-600 dark:text-purple-400"
|
||||
>
|
||||
<IconGitMerge className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="flex-1">
|
||||
PR #{taskPR.pr_number} has been merged. You can archive this task.
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="pr-merged-archive-button"
|
||||
onClick={handleArchive}
|
||||
className="underline underline-offset-2 hover:text-purple-700 dark:hover:text-purple-300 cursor-pointer"
|
||||
>
|
||||
@@ -299,12 +303,22 @@ type TodoDisplayItem = {
|
||||
status?: "pending" | "in_progress" | "completed" | "failed";
|
||||
};
|
||||
|
||||
function SessionStatusBar({ todoItems }: { todoItems: TodoDisplayItem[] }) {
|
||||
function ChatStatusBar({
|
||||
todoItems,
|
||||
taskId,
|
||||
}: {
|
||||
todoItems: TodoDisplayItem[];
|
||||
taskId: string | null;
|
||||
}) {
|
||||
const showTodos = todoItems.length > 0;
|
||||
if (!showTodos) return null;
|
||||
// PRMergedBanner returns null internally when not applicable
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 px-3 py-1 text-xs text-muted-foreground">
|
||||
<TodoIndicator todos={todoItems} />
|
||||
<div
|
||||
data-testid="chat-status-bar"
|
||||
className="flex items-center gap-1.5 py-1 text-xs text-muted-foreground"
|
||||
>
|
||||
{showTodos && <TodoIndicator todos={todoItems} />}
|
||||
{taskId && <PRMergedBanner key={taskId} taskId={taskId} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -387,8 +401,7 @@ export function ChatInputArea({
|
||||
);
|
||||
return (
|
||||
<div className="bg-card flex-shrink-0 px-2 pb-2 pt-1">
|
||||
<SessionStatusBar todoItems={todoItems} />
|
||||
{taskId && <PRMergedBanner key={taskId} taskId={taskId} />}
|
||||
<ChatStatusBar todoItems={todoItems} taskId={taskId} />
|
||||
<ChatInputContainer
|
||||
ref={chatInputRef}
|
||||
key={clarificationKey}
|
||||
|
||||
@@ -72,6 +72,7 @@ export function TodoIndicator({ todos }: TodoIndicatorProps) {
|
||||
<HoverCardTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="todo-indicator"
|
||||
className={cn(
|
||||
"flex items-center gap-1.5 px-2 py-0.5 text-xs transition-colors rounded cursor-pointer",
|
||||
allComplete
|
||||
|
||||
@@ -15,7 +15,7 @@ import { SHORTCUTS } from "@/lib/keyboard/constants";
|
||||
import type { CommandItem } from "@/lib/commands/types";
|
||||
import { replaceSessionUrl } from "@/lib/links";
|
||||
import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots";
|
||||
import { useTaskActions } from "@/hooks/use-task-actions";
|
||||
import { useTaskActions, useArchiveAndSwitchTask } from "@/hooks/use-task-actions";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
import { performLayoutSwitch, useDockviewStore } from "@/lib/state/dockview-store";
|
||||
import { INTENT_PR_REVIEW } from "@/lib/state/layout-manager";
|
||||
@@ -256,7 +256,8 @@ function useSidebarActions(store: StoreApi) {
|
||||
const setActiveSession = useAppStore((state) => state.setActiveSession);
|
||||
const [deletingTaskId, setDeletingTaskId] = useState<string | null>(null);
|
||||
const [preparingTaskId, setPreparingTaskId] = useState<string | null>(null);
|
||||
const { deleteTaskById, archiveTaskById, renameTaskById } = useTaskActions();
|
||||
const { deleteTaskById, renameTaskById } = useTaskActions();
|
||||
const archiveAndSwitch = useArchiveAndSwitchTask({ useLayoutSwitch: true });
|
||||
const { removeTaskFromBoard, loadTaskSessionsForTask } = useTaskRemoval({
|
||||
store,
|
||||
useLayoutSwitch: true,
|
||||
@@ -302,18 +303,13 @@ function useSidebarActions(store: StoreApi) {
|
||||
|
||||
const handleArchiveTask = useCallback(
|
||||
async (taskId: string) => {
|
||||
// Capture active state before the async API call — the WS "task.updated"
|
||||
// handler removes the archived task from kanbans before removeTaskFromBoard runs.
|
||||
const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } =
|
||||
store.getState().tasks;
|
||||
try {
|
||||
await archiveTaskById(taskId);
|
||||
await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId });
|
||||
await archiveAndSwitch(taskId);
|
||||
} catch (error) {
|
||||
console.error("Failed to archive task:", error);
|
||||
}
|
||||
},
|
||||
[archiveTaskById, removeTaskFromBoard, store],
|
||||
[archiveAndSwitch],
|
||||
);
|
||||
|
||||
const handleDeleteTask = useCallback(
|
||||
|
||||
@@ -41,6 +41,20 @@ export class SessionPage {
|
||||
return this.page.getByTestId(`port-forward-row-${port}`);
|
||||
}
|
||||
|
||||
// Chat status bar locators
|
||||
chatStatusBar() {
|
||||
return this.page.getByTestId("chat-status-bar");
|
||||
}
|
||||
prMergedBanner() {
|
||||
return this.page.getByTestId("pr-merged-banner");
|
||||
}
|
||||
prMergedArchiveButton() {
|
||||
return this.page.getByTestId("pr-merged-archive-button");
|
||||
}
|
||||
todoIndicator() {
|
||||
return this.page.getByTestId("todo-indicator");
|
||||
}
|
||||
|
||||
async waitForLoad(timeout = 15_000) {
|
||||
await this.chat.waitFor({ state: "visible", timeout });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { test, expect } from "../fixtures/test-base";
|
||||
import { KanbanPage } from "../pages/kanban-page";
|
||||
import { SessionPage } from "../pages/session-page";
|
||||
|
||||
test.describe("Chat status bar", () => {
|
||||
test("shows todo indicator alone when no PR is associated", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
// e2e:plan emits an ACP Plan update with todo entries
|
||||
await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Todo Only Task",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: [
|
||||
'e2e:plan([{"content":"Review code","status":"in_progress"},{"content":"Run tests","status":"pending"}])',
|
||||
'e2e:message("todo only response")',
|
||||
].join("\n"),
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle("Todo Only Task");
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await expect(session.chat.getByText("todo only response").last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Todo indicator should be visible, PR banner should not
|
||||
const statusBar = session.chatStatusBar();
|
||||
await expect(statusBar).toBeVisible({ timeout: 10_000 });
|
||||
await expect(statusBar.getByTestId("todo-indicator")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(statusBar.getByTestId("pr-merged-banner")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("shows PR merged banner alone when no todos exist", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"PR Banner Only Task",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: 'e2e:message("pr banner only response")',
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
task_id: task.id,
|
||||
owner: "test-org",
|
||||
repo: "test-repo",
|
||||
pr_number: 101,
|
||||
pr_url: "https://github.com/test-org/test-repo/pull/101",
|
||||
pr_title: "Test PR",
|
||||
head_branch: "feature/test",
|
||||
base_branch: "main",
|
||||
author_login: "test-user",
|
||||
state: "merged",
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle("PR Banner Only Task");
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await expect(session.chat.getByText("pr banner only response").last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// PR merged banner should be visible, todo indicator should not
|
||||
const statusBar = session.chatStatusBar();
|
||||
await expect(statusBar).toBeVisible({ timeout: 10_000 });
|
||||
await expect(statusBar.getByTestId("pr-merged-banner")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(statusBar.getByTestId("pr-merged-banner")).toContainText(
|
||||
"PR #101 has been merged",
|
||||
);
|
||||
await expect(statusBar.getByTestId("todo-indicator")).not.toBeVisible();
|
||||
});
|
||||
|
||||
test("shows both todo indicator and PR banner on the same row", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
}) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const task = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Both Indicators Task",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: [
|
||||
'e2e:plan([{"content":"Review code","status":"completed"},{"content":"Run tests","status":"in_progress"}])',
|
||||
'e2e:message("both indicators response")',
|
||||
].join("\n"),
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
task_id: task.id,
|
||||
owner: "test-org",
|
||||
repo: "test-repo",
|
||||
pr_number: 202,
|
||||
pr_url: "https://github.com/test-org/test-repo/pull/202",
|
||||
pr_title: "Both Test PR",
|
||||
head_branch: "feature/both",
|
||||
base_branch: "main",
|
||||
author_login: "test-user",
|
||||
state: "merged",
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const card = kanban.taskCardByTitle("Both Indicators Task");
|
||||
await expect(card).toBeVisible({ timeout: 30_000 });
|
||||
await card.click();
|
||||
await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await expect(session.chat.getByText("both indicators response").last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
// Both should be visible inside the same status bar container (same row)
|
||||
const statusBar = session.chatStatusBar();
|
||||
await expect(statusBar).toBeVisible({ timeout: 10_000 });
|
||||
await expect(statusBar.getByTestId("todo-indicator")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(statusBar.getByTestId("pr-merged-banner")).toBeVisible({ timeout: 10_000 });
|
||||
await expect(statusBar.getByTestId("pr-merged-banner")).toContainText(
|
||||
"PR #202 has been merged",
|
||||
);
|
||||
});
|
||||
|
||||
test("archive via PR banner switches to next task", async ({ testPage, apiClient, seedData }) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const taskA = await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Archive Banner Task A",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: 'e2e:message("archive banner alpha response")',
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await apiClient.createTaskWithAgent(
|
||||
seedData.workspaceId,
|
||||
"Archive Banner Task B",
|
||||
seedData.agentProfileId,
|
||||
{
|
||||
description: 'e2e:message("archive banner beta response")',
|
||||
workflow_id: seedData.workflowId,
|
||||
workflow_step_id: seedData.startStepId,
|
||||
repository_ids: [seedData.repositoryId],
|
||||
},
|
||||
);
|
||||
|
||||
await apiClient.mockGitHubAssociateTaskPR({
|
||||
task_id: taskA.id,
|
||||
owner: "test-org",
|
||||
repo: "test-repo",
|
||||
pr_number: 303,
|
||||
pr_url: "https://github.com/test-org/test-repo/pull/303",
|
||||
pr_title: "Archive Test PR",
|
||||
head_branch: "feature/archive",
|
||||
base_branch: "main",
|
||||
author_login: "test-user",
|
||||
state: "merged",
|
||||
});
|
||||
|
||||
const kanban = new KanbanPage(testPage);
|
||||
await kanban.goto();
|
||||
|
||||
const cardA = kanban.taskCardByTitle("Archive Banner Task A");
|
||||
await expect(cardA).toBeVisible({ timeout: 30_000 });
|
||||
await cardA.click();
|
||||
await expect(testPage).toHaveURL(/\/s\//, { timeout: 15_000 });
|
||||
|
||||
const session = new SessionPage(testPage);
|
||||
await session.waitForLoad();
|
||||
|
||||
await expect(session.chat.getByText("archive banner alpha response").last()).toBeVisible({
|
||||
timeout: 30_000,
|
||||
});
|
||||
|
||||
await expect(session.prMergedBanner()).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const urlBeforeArchive = testPage.url();
|
||||
|
||||
// Click archive in the PR merged banner
|
||||
await session.prMergedArchiveButton().click();
|
||||
|
||||
// Should switch to task B
|
||||
await expect(session.taskInSidebar("Archive Banner Task A")).not.toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
await expect(session.taskInSidebar("Archive Banner Task B")).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
await expect(session.chat.getByText("archive banner beta response").last()).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
await expect(testPage).toHaveURL(/\/s\//, { timeout: 10_000 });
|
||||
expect(testPage.url()).not.toBe(urlBeforeArchive);
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useCallback } from "react";
|
||||
import { archiveTask, deleteTask, moveTask, updateTask } from "@/lib/api";
|
||||
import { useAppStoreApi } from "@/components/state-provider";
|
||||
import { useTaskRemoval } from "@/hooks/use-task-removal";
|
||||
|
||||
type MovePayload = { workflow_id: string; workflow_step_id: string; position: number };
|
||||
|
||||
@@ -22,3 +24,26 @@ export function useTaskActions() {
|
||||
|
||||
return { moveTaskById, deleteTaskById, archiveTaskById, renameTaskById };
|
||||
}
|
||||
|
||||
/**
|
||||
* Archives a task and switches to the next available task.
|
||||
* Shared between the PR merged banner and the sidebar archive action.
|
||||
*/
|
||||
export function useArchiveAndSwitchTask(opts?: { useLayoutSwitch?: boolean }) {
|
||||
const store = useAppStoreApi();
|
||||
const { archiveTaskById } = useTaskActions();
|
||||
const { removeTaskFromBoard } = useTaskRemoval({
|
||||
store,
|
||||
useLayoutSwitch: opts?.useLayoutSwitch,
|
||||
});
|
||||
|
||||
return useCallback(
|
||||
async (taskId: string) => {
|
||||
const { activeTaskId: wasActiveTaskId, activeSessionId: wasActiveSessionId } =
|
||||
store.getState().tasks;
|
||||
await archiveTaskById(taskId);
|
||||
await removeTaskFromBoard(taskId, { wasActiveTaskId, wasActiveSessionId });
|
||||
},
|
||||
[archiveTaskById, removeTaskFromBoard, store],
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user