Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions desktop/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand Down
7 changes: 5 additions & 2 deletions desktop/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -638,7 +638,7 @@ export default function App() {
};

return (
<main className="bg-background flex h-screen w-screen overflow-hidden">
<main className="bg-background flex h-full min-h-0 w-full min-w-0 overflow-hidden">
<a
href="#main-content"
className="bg-background text-foreground fixed top-2 left-2 z-[60] -translate-y-16 rounded-md border px-3 py-2 text-sm shadow-sm transition-transform focus:translate-y-0"
Expand Down Expand Up @@ -851,7 +851,10 @@ export default function App() {
)}

{page === "settings" && (
<div className="min-h-0 flex-1 overflow-auto">
<div
className="soca-scrollbar min-h-0 flex-1 overscroll-contain overflow-y-auto"
data-testid="settings-scroll-region"
>
<PageBody>
<Suspense fallback={<PageLoading />}>
<SettingsPanel
Expand Down
9 changes: 4 additions & 5 deletions desktop/src/components/ChatPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ export function ChatPage({
}: ChatPageProps) {
const hasTurns = conversation.turns.length > 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 &&
Expand Down Expand Up @@ -186,9 +186,8 @@ export function ChatPage({
/>
</div>
<div className="bg-background z-20 flex shrink-0 flex-col gap-2 px-6 pb-5">
{/* 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 && (
<div className="text-muted-foreground mx-auto flex w-full max-w-2xl items-center gap-2 text-xs">
<ThinkingOrb state={orbState} size={20} aria-hidden />
Expand Down
145 changes: 129 additions & 16 deletions desktop/src/components/KnowledgePanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,20 @@ function IndexProgress({ job }: { job: IndexJob }) {
</span>
)}
</div>
<div className="bg-secondary h-1.5 w-full overflow-hidden rounded-full">
<div
className="bg-secondary h-1.5 w-full overflow-hidden rounded-full"
role="progressbar"
aria-label={
job.action === "model"
? "Tiến trình tải retrieval model"
: "Tiến trình dựng chỉ mục"
}
aria-valuemin={0}
aria-valuemax={fraction === null ? undefined : 100}
aria-valuenow={
fraction === null ? undefined : Math.round(fraction * 100)
}
>
<div
className={cn(
"bg-primary h-full rounded-full",
Expand Down Expand Up @@ -255,6 +268,9 @@ function MemorySection({
}: Omit<KnowledgePanelProps, "onInit" | "onIndex">) {
const trace = knowledge.memoryTrace;
const proposals = knowledge.proposals;
const compactionRunning =
knowledge.compaction?.status === "accepted" ||
knowledge.compaction?.status === "running";

return (
<Section
Expand All @@ -274,10 +290,10 @@ function MemorySection({
<Button
size="sm"
variant="outline"
disabled={!connected}
disabled={!connected || compactionRunning}
onClick={onCompact}
>
Nén
{compactionRunning ? "Đang nén…" : "Nén"}
</Button>
</>
}
Expand All @@ -298,6 +314,23 @@ function MemorySection({
</div>
)}

{knowledge.compaction !== null && (
<div
className="border-border bg-muted/30 rounded-lg border px-3 py-2.5 text-sm"
role="status"
aria-live="polite"
>
<p className="font-medium">
{compactionRunning
? "Đang nén bộ nhớ phiên"
: "Tác vụ nén gần nhất"}
</p>
<p className="text-muted-foreground mt-1 text-xs leading-5">
{knowledge.compaction.detail ?? knowledge.compaction.status}
</p>
</div>
)}

{knowledge.memory?.summary !== undefined &&
knowledge.memory.summary !== "" && (
<Field label="Tóm tắt phiên">
Expand Down Expand Up @@ -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<KnowledgeDetail, null>;
Expand All @@ -453,41 +498,57 @@ 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",
icon: Database,
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",
},
];

Expand Down Expand Up @@ -523,6 +584,58 @@ function KnowledgeOverview({ props }: { props: KnowledgePanelProps }) {
))}
</div>

{activeJob !== null && (
<div
className="border-border bg-card rounded-xl border px-4 py-3"
role="status"
aria-live="polite"
data-testid="knowledge-active-operation"
>
<div className="mb-2 flex items-center justify-between gap-3">
<p className="text-sm font-medium">
{activeJob.action === "model"
? "Đang chuẩn bị retrieval model"
: "Đang dựng chỉ mục"}
</p>
<Badge variant="outline">Đang chạy</Badge>
</div>
<IndexProgress job={activeJob} />
</div>
)}

{operationIssue !== null && (
<div
className="border-destructive/30 bg-destructive/5 rounded-xl border px-4 py-3"
role="alert"
data-testid="knowledge-operation-error"
>
<p className="text-sm font-medium">
{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"}
</p>
<p className="text-muted-foreground mt-1 text-xs leading-5">
{operationIssue.detail}
{operationIssue.errorCode !== null
? ` · ${operationIssue.errorCode}`
: ""}
</p>
</div>
)}

{compactionRunning && (
<p
className="border-border bg-card rounded-xl border px-4 py-3 text-sm"
role="status"
aria-live="polite"
>
Đ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.
</p>
)}

<Dialog
open={detail !== null}
onOpenChange={(open) => !open && setDetail(null)}
Expand Down
6 changes: 3 additions & 3 deletions desktop/src/components/VoiceMode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
>
<div
className={cn(
"relative flex min-h-0 flex-col items-center justify-center px-6",
immersive ? "flex-1 gap-8 py-10" : "shrink-0 gap-4 py-7",
immersive ? "flex-1 gap-6 py-6" : "shrink-0 gap-4 py-7",
)}
data-voice-presentation={presentation}
>
Expand Down Expand Up @@ -332,7 +332,7 @@ export function VoiceMode({
</div>
)}

<div className="shrink-0 px-6 pb-8">
<div className="shrink-0 px-6 pb-[max(1.5rem,env(safe-area-inset-bottom))]">
{!immersive && (
<SessionContext
session={session}
Expand Down
28 changes: 26 additions & 2 deletions desktop/src/components/VoiceOrb.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, expect, it } from "vitest";
// @vitest-environment jsdom

import { perceptualVoiceLevel } from "./VoiceOrb";
import { cleanup, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it } from "vitest";

import { initialVoice } from "@/engine/voice";

import { perceptualVoiceLevel, VoiceOrb } from "./VoiceOrb";

afterEach(cleanup);

describe("perceptualVoiceLevel", () => {
it("keeps recorder silence visually still", () => {
Expand All @@ -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(
<VoiceOrb
voice={{ ...initialVoice, phase: "listening", levels: [0.003] }}
ready
presentation="immersive"
/>,
);

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");
});
});
5 changes: 4 additions & 1 deletion desktop/src/components/VoiceOrb.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,7 +95,10 @@ export function VoiceOrb({ voice, ready, checking = false, presentation }: Voice
data-voice-orb-mode={mode}
data-voice-orb-presentation={presentation}
>
<img className="voice-orb__aura" src={voiceOrbAsset} alt="" aria-hidden="true" />
{/* 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. */}
<span className="voice-orb__aura" aria-hidden="true" />
<div className="voice-orb__motion">
<img className="voice-orb__core" src={voiceOrbAsset} alt="" aria-hidden="true" />
</div>
Expand Down
Loading
Loading