fix(web): merge chat status bar into single row and switch task on archive (#460)

This commit is contained in:
Carlos Florêncio
2026-03-24 23:22:20 +00:00
committed by GitHub
parent 8f0939d650
commit df70eee749
8 changed files with 347 additions and 20 deletions
+8
View File
@@ -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 {
+27
View File
@@ -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(
+14
View File
@@ -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 });
}
+243
View File
@@ -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);
});
});
+25
View File
@@ -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],
);
}