diff --git a/desktop/src/App.test.tsx b/desktop/src/App.test.tsx index 033672c..abe26a4 100644 --- a/desktop/src/App.test.tsx +++ b/desktop/src/App.test.tsx @@ -180,6 +180,60 @@ beforeEach(() => { afterEach(cleanup); describe("desktop session lifecycle", () => { + it("keeps the shell and settings scroller bound to the live viewport", async () => { + const user = userEvent.setup(); + await renderReadyApp(); + + const shell = document.querySelector("main"); + expect(shell?.classList.contains("h-full")).toBe(true); + expect(shell?.classList.contains("h-screen")).toBe(false); + + await user.click(screen.getByRole("button", { name: "Cài đặt" })); + const scroller = await screen.findByTestId("settings-scroll-region"); + expect(scroller.classList.contains("overflow-y-auto")).toBe(true); + expect(scroller.classList.contains("min-h-0")).toBe(true); + }); + + it("keeps index progress on Knowledge instead of presenting Chat as busy", async () => { + const user = userEvent.setup(); + await renderReadyApp(); + + emit(EVENT_CHANNEL, { + event: "status", + knowledge_vault: { + path: "/tmp/soca-vault", + initialized: true, + }, + knowledge_index: null, + }); + emit(EVENT_CHANNEL, { + event: "knowledge_setup", + action: "index", + status: "running", + vault: "/tmp/soca-vault", + detail: "Đang tạo embedding…", + phase: "embedding", + completed_chunks: 25, + total_chunks: 100, + reused_chunks: 5, + embedded_chunks: 20, + }); + + await act(async () => { + await new Promise((resolve) => window.setTimeout(resolve, 120)); + }); + expect(screen.queryByText("Indexing")).toBeNull(); + + await user.click(screen.getByRole("button", { name: "Kiến thức" })); + + const operation = await screen.findByTestId("knowledge-active-operation"); + expect(operation.textContent).toContain("Đang dựng chỉ mục"); + const progress = screen.getByRole("progressbar", { + name: "Tiến trình dựng chỉ mục", + }); + expect(progress.getAttribute("aria-valuenow")).toBe("25"); + }); + it("remembers an explicit desktop sidebar collapse without touching session data", async () => { await renderReadyApp(); diff --git a/desktop/src/App.tsx b/desktop/src/App.tsx index 11ed21a..72b6998 100644 --- a/desktop/src/App.tsx +++ b/desktop/src/App.tsx @@ -638,7 +638,7 @@ export default function App() { }; return ( -
+
+
}> 0; const lastTurn = conversation.turns[conversation.turns.length - 1]; - // A turn that has not produced text yet renders its own orb inline; anything - // else that keeps the orb off `breathing` is background work. + // A turn that has not produced text yet renders its own orb inline. The + // shared activity reducer intentionally excludes Knowledge maintenance. const liveTurnShowsOrb = lastTurn !== undefined && lastTurn.finalText === null && @@ -186,9 +186,8 @@ export function ChatPage({ />
- {/* Work that happens outside a turn — an index build, a memory - compaction — has no message to sit inside, so the orb reports it - here. A live turn shows its own orb in the transcript. */} + {/* A live turn shows its own orb in the transcript. Any other + conversation activity stays beside the composer. */} {busyOutsideTurn && (
diff --git a/desktop/src/components/KnowledgePanel.tsx b/desktop/src/components/KnowledgePanel.tsx index ad98f11..6156b8c 100644 --- a/desktop/src/components/KnowledgePanel.tsx +++ b/desktop/src/components/KnowledgePanel.tsx @@ -83,7 +83,20 @@ function IndexProgress({ job }: { job: IndexJob }) { )}
-
+
) { const trace = knowledge.memoryTrace; const proposals = knowledge.proposals; + const compactionRunning = + knowledge.compaction?.status === "accepted" || + knowledge.compaction?.status === "running"; return (
- Nén + {compactionRunning ? "Đang nén…" : "Nén"} } @@ -298,6 +314,23 @@ function MemorySection({
)} + {knowledge.compaction !== null && ( +
+

+ {compactionRunning + ? "Đang nén bộ nhớ phiên" + : "Tác vụ nén gần nhất"} +

+

+ {knowledge.compaction.detail ?? knowledge.compaction.status} +

+
+ )} + {knowledge.memory?.summary !== undefined && knowledge.memory.summary !== "" && ( @@ -440,6 +473,18 @@ function KnowledgeOverview({ props }: { props: KnowledgePanelProps }) { const initialized = knowledge.vault?.initialized === true; const indexed = knowledge.index !== null; const running = knowledgeSetupRunning(knowledge.indexJob); + const activeJob = running ? knowledge.indexJob : null; + const operationIssue = + knowledge.indexJob?.status === "failed" || + knowledge.indexJob?.status === "busy" + ? knowledge.indexJob + : null; + const vaultIssue = operationIssue?.action === "init"; + const indexIssue = + operationIssue?.action === "model" || operationIssue?.action === "index"; + const compactionRunning = + knowledge.compaction?.status === "accepted" || + knowledge.compaction?.status === "running"; const cards: Array<{ id: Exclude; @@ -453,11 +498,19 @@ function KnowledgeOverview({ props }: { props: KnowledgePanelProps }) { id: "vault", icon: FolderOpen, title: "Vault", - status: initialized ? "Sẵn sàng" : "Chưa tạo", + status: vaultIssue + ? "Cần xử lý" + : initialized + ? "Sẵn sàng" + : "Chưa tạo", description: initialized ? "Tài liệu Markdown được quản lý tại một thư mục riêng." : "Tạo cấu trúc vault trước khi thêm tài liệu.", - tone: initialized ? "bg-emerald-400" : "bg-amber-400", + tone: vaultIssue + ? "bg-destructive" + : initialized + ? "bg-emerald-400" + : "bg-amber-400", }, { id: "index", @@ -465,29 +518,37 @@ function KnowledgeOverview({ props }: { props: KnowledgePanelProps }) { title: "Chỉ mục", status: running ? "Đang xử lý" - : indexed - ? `${knowledge.index?.documents ?? 0} tài liệu` - : "Chưa dựng", + : indexIssue + ? "Cần xử lý" + : indexed + ? `${knowledge.index?.documents ?? 0} tài liệu` + : "Chưa dựng", description: indexed ? "Dùng để tìm đoạn tài liệu phù hợp khi trả lời." : "Chỉ cần dựng sau khi vault đã sẵn sàng.", tone: running ? "bg-amber-400 animate-pulse" - : indexed - ? "bg-cyan-400" - : "bg-muted-foreground/50", + : indexIssue + ? "bg-destructive" + : indexed + ? "bg-cyan-400" + : "bg-muted-foreground/50", }, { id: "memory", icon: BookOpen, title: "Bộ nhớ phiên", status: - knowledge.memoryTrace?.recentTurnCount !== null && - knowledge.memoryTrace?.recentTurnCount !== undefined - ? `${knowledge.memoryTrace.recentTurnCount} lượt gần đây` - : "Chưa có tóm tắt", + compactionRunning + ? "Đang nén" + : knowledge.memoryTrace?.recentTurnCount !== null && + knowledge.memoryTrace?.recentTurnCount !== undefined + ? `${knowledge.memoryTrace.recentTurnCount} lượt gần đây` + : "Chưa có tóm tắt", description: "Tóm tắt làm việc và đề xuất cần duyệt của phiên hiện tại.", - tone: "bg-violet-400", + tone: compactionRunning + ? "bg-amber-400 animate-pulse" + : "bg-violet-400", }, ]; @@ -523,6 +584,58 @@ function KnowledgeOverview({ props }: { props: KnowledgePanelProps }) { ))}
+ {activeJob !== null && ( +
+
+

+ {activeJob.action === "model" + ? "Đang chuẩn bị retrieval model" + : "Đang dựng chỉ mục"} +

+ Đang chạy +
+ +
+ )} + + {operationIssue !== null && ( +
+

+ {operationIssue.action === "init" + ? "Không thể tạo cấu trúc vault" + : operationIssue.action === "model" + ? "Không thể chuẩn bị retrieval model" + : "Không thể dựng chỉ mục"} +

+

+ {operationIssue.detail} + {operationIssue.errorCode !== null + ? ` · ${operationIssue.errorCode}` + : ""} +

+
+ )} + + {compactionRunning && ( +

+ Đang nén bộ nhớ phiên. Bạn có thể tiếp tục xem tài liệu trong lúc tác + vụ chạy. +

+ )} + !open && setDetail(null)} diff --git a/desktop/src/components/VoiceMode.tsx b/desktop/src/components/VoiceMode.tsx index 840f241..275ec1e 100644 --- a/desktop/src/components/VoiceMode.tsx +++ b/desktop/src/components/VoiceMode.tsx @@ -191,14 +191,14 @@ export function VoiceMode({ // Fills the page area. The sidebar and top bar are outside it, so // settings, the engine health dot and the restart button stay one click // away mid-call — this screen used to cover the window and hide them. - className="bg-background flex h-full w-full flex-col" + className="bg-background flex h-full min-h-0 w-full flex-col overflow-hidden" role="region" aria-label="Chế độ thoại" >
@@ -332,7 +332,7 @@ export function VoiceMode({
)} -
+
{!immersive && ( { it("keeps recorder silence visually still", () => { @@ -20,3 +27,20 @@ describe("perceptualVoiceLevel", () => { expect(perceptualVoiceLevel(1)).toBe(1); }); }); + +describe("VoiceOrb surface", () => { + it("renders one clipped core image and a non-image halo", () => { + render( + , + ); + + const orb = screen.getByTestId("voice-orb"); + expect(orb.querySelectorAll("img")).toHaveLength(1); + expect(orb.querySelector(".voice-orb__aura")?.tagName).toBe("SPAN"); + expect(orb.style.getPropertyValue("--voice-orb-level")).not.toBe("0"); + }); +}); diff --git a/desktop/src/components/VoiceOrb.tsx b/desktop/src/components/VoiceOrb.tsx index ae99526..decb4a1 100644 --- a/desktop/src/components/VoiceOrb.tsx +++ b/desktop/src/components/VoiceOrb.tsx @@ -95,7 +95,10 @@ export function VoiceOrb({ voice, ready, checking = false, presentation }: Voice data-voice-orb-mode={mode} data-voice-orb-presentation={presentation} > - + {/* A CSS halo avoids asking WebKit to blur the full square PNG surface. + That compositor path exposed the bitmap's rectangular layer in dark + mode even though the source pixels themselves are transparent. */} +