diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 684d276..64575fd 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -9,6 +9,7 @@ import { SkillsPanel } from "./components/skills/SkillsPanel.js";
import { EventTimeline } from "./components/timeline/EventTimeline.js";
import { ApprovalDialog } from "./components/approvals/ApprovalDialog.js";
import { ModelSettings, type ModelView } from "./components/settings/ModelSettings.js";
+import { CompletionAnnouncer } from "./components/common/CompletionAnnouncer.js";
import { HarnessClientStore } from "./store/harness-client-store.js";
type AppStore = HarnessRuntimeStore & {
@@ -60,6 +61,10 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
const busy = Object.values(snapshot.runs).some((run) => run.status === "running");
const approvalPending = Object.values(snapshot.approvals).some((approval) => approval.status === "pending");
const pendingApproval = Object.values(snapshot.approvals).find((approval) => approval.status === "pending");
+ const latestAssistantStatus = [...snapshot.messageOrder]
+ .reverse()
+ .map((messageId) => snapshot.messages[messageId])
+ .find((message) => message?.role === "assistant")?.status;
useEffect(() => {
if (!autoStart) return undefined;
@@ -127,6 +132,7 @@ export function App({ store: suppliedStore, autoStart = true, modelConfigured: s
});
}}
/>
+
);
}
diff --git a/apps/web/src/components/common/CompletionAnnouncer.tsx b/apps/web/src/components/common/CompletionAnnouncer.tsx
new file mode 100644
index 0000000..b388232
--- /dev/null
+++ b/apps/web/src/components/common/CompletionAnnouncer.tsx
@@ -0,0 +1,5 @@
+import type { MessageStatus } from "../../store/reducer.js";
+
+export function CompletionAnnouncer({ status }: { status: MessageStatus | undefined }) {
+ return
{status === "complete" ? "回答已完成" : ""}
;
+}
diff --git a/apps/web/src/components/layout/AppShell.tsx b/apps/web/src/components/layout/AppShell.tsx
index 1dcf058..d3c9f9d 100644
--- a/apps/web/src/components/layout/AppShell.tsx
+++ b/apps/web/src/components/layout/AppShell.tsx
@@ -1,5 +1,5 @@
import { Activity, MessageSquare, PanelsTopLeft } from "lucide-react";
-import { useEffect, useState, type ReactNode } from "react";
+import { useEffect, useState, type KeyboardEvent, type ReactNode } from "react";
import { TopBar, type TopBarProps } from "../topbar/TopBar.js";
type WorkspaceTab = "chat" | "skills" | "events";
@@ -17,13 +17,25 @@ export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarPro
return () => media.removeEventListener("change", update);
}, []);
+ const selectAdjacentTab = (current: WorkspaceTab, event: KeyboardEvent) => {
+ if (event.key !== "ArrowLeft" && event.key !== "ArrowRight" && event.key !== "Home" && event.key !== "End") return;
+ event.preventDefault();
+ const order: WorkspaceTab[] = ["chat", "skills", "events"];
+ const currentIndex = order.indexOf(current);
+ const targetIndex = event.key === "Home" ? 0 : event.key === "End" ? order.length - 1 : (currentIndex + (event.key === "ArrowRight" ? 1 : -1) + order.length) % order.length;
+ const target = order[targetIndex];
+ if (target === undefined) return;
+ setActiveTab(target);
+ document.getElementById(`${target}-tab`)?.focus();
+ };
+
return (
{skills}
@@ -34,9 +46,15 @@ export function AppShell({ topBar, skills, chat, timeline }: { topBar: TopBarPro
);
}
-function TabButton({ id, active, onSelect, children }: { id: WorkspaceTab; active: boolean; onSelect: (tab: WorkspaceTab) => void; children: ReactNode }) {
+function TabButton({ id, active, onSelect, onKeyDown, children }: {
+ id: WorkspaceTab;
+ active: boolean;
+ onSelect: (tab: WorkspaceTab) => void;
+ onKeyDown: (tab: WorkspaceTab, event: KeyboardEvent
) => void;
+ children: ReactNode;
+}) {
return (
-