Skip to content

Commit 5ce830c

Browse files
Show pending summary state
Render a centered generating state for empty enhanced notes before summary text streams.
1 parent 14c55ed commit 5ce830c

3 files changed

Lines changed: 151 additions & 30 deletions

File tree

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { cleanup, render, screen } from "@testing-library/react";
2+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3+
4+
import { Enhanced } from "./index";
5+
6+
import type { LLMConnectionStatus } from "~/ai/hooks";
7+
8+
const hoisted = vi.hoisted(() => ({
9+
task: {
10+
status: "idle",
11+
error: undefined as Error | undefined,
12+
streamedText: "",
13+
currentStep: undefined as unknown,
14+
isGenerating: false,
15+
},
16+
llmStatus: {
17+
status: "success",
18+
providerId: "hyprnote",
19+
isHosted: true,
20+
} as LLMConnectionStatus,
21+
content: "",
22+
}));
23+
24+
vi.mock("streamdown", () => ({
25+
Streamdown: ({ children }: { children: string }) => <div>{children}</div>,
26+
}));
27+
28+
vi.mock("@hypr/ui/components/ui/spinner", () => ({
29+
Spinner: () => <span data-testid="spinner" />,
30+
}));
31+
32+
vi.mock("~/ai/hooks", () => ({
33+
useAITaskTask: () => ({
34+
status: hoisted.task.status,
35+
error: hoisted.task.error,
36+
streamedText: hoisted.task.streamedText,
37+
currentStep: hoisted.task.currentStep,
38+
isGenerating: hoisted.task.isGenerating,
39+
}),
40+
useLLMConnectionStatus: () => hoisted.llmStatus,
41+
}));
42+
43+
vi.mock("~/store/tinybase/store/main", () => ({
44+
STORE_ID: "main",
45+
UI: {
46+
useCell: () => hoisted.content,
47+
},
48+
}));
49+
50+
vi.mock("./config-error", () => ({
51+
ConfigError: () => <div>Config error</div>,
52+
}));
53+
54+
vi.mock("./editor", () => ({
55+
EnhancedEditor: () => <div>Enhanced editor</div>,
56+
}));
57+
58+
vi.mock("./enhance-error", () => ({
59+
EnhanceError: () => <div>Enhance error</div>,
60+
}));
61+
62+
describe("Enhanced", () => {
63+
afterEach(() => {
64+
cleanup();
65+
});
66+
67+
beforeEach(() => {
68+
hoisted.task = {
69+
status: "idle",
70+
error: undefined,
71+
streamedText: "",
72+
currentStep: undefined,
73+
isGenerating: false,
74+
};
75+
hoisted.llmStatus = {
76+
status: "success",
77+
providerId: "hyprnote",
78+
isHosted: true,
79+
};
80+
hoisted.content = "";
81+
});
82+
83+
it("shows an interim summary state while an empty note is waiting to stream", () => {
84+
render(<Enhanced sessionId="session-1" enhancedNoteId="note-1" />);
85+
86+
expect(screen.getByRole("status").textContent).toContain(
87+
"Preparing summary...",
88+
);
89+
expect(screen.getByRole("status").textContent).toContain(
90+
"Tip: The Anarlog team loves our users!",
91+
);
92+
expect(screen.queryByText("Enhanced editor")).toBeNull();
93+
});
94+
95+
it("keeps config errors ahead of the empty interim state", () => {
96+
hoisted.llmStatus = { status: "pending", reason: "missing_provider" };
97+
98+
render(<Enhanced sessionId="session-1" enhancedNoteId="note-1" />);
99+
100+
expect(screen.getByText("Config error")).not.toBeNull();
101+
expect(screen.queryByRole("status")).toBeNull();
102+
});
103+
104+
it("renders the editor when the enhanced note already has content", () => {
105+
hoisted.content = JSON.stringify({
106+
type: "doc",
107+
content: [{ type: "paragraph", content: [{ type: "text", text: "Hi" }] }],
108+
});
109+
110+
render(<Enhanced sessionId="session-1" enhancedNoteId="note-1" />);
111+
112+
expect(screen.getByText("Enhanced editor")).not.toBeNull();
113+
expect(screen.queryByRole("status")).toBeNull();
114+
});
115+
});

apps/desktop/src/session/components/note-input/enhanced/index.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,8 +53,13 @@ export const Enhanced = forwardRef<
5353
);
5454
}
5555

56-
if (status === "generating") {
57-
return <StreamingView enhancedNoteId={enhancedNoteId} />;
56+
if (status === "generating" || (!hasContent && status === "idle")) {
57+
return (
58+
<StreamingView
59+
enhancedNoteId={enhancedNoteId}
60+
pending={status === "idle"}
61+
/>
62+
);
5863
}
5964

6065
return (

apps/desktop/src/session/components/note-input/enhanced/streaming.tsx

Lines changed: 29 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useEffect, useState } from "react";
21
import { Streamdown } from "streamdown";
32

3+
import { Spinner } from "@hypr/ui/components/ui/spinner";
44
import { cn } from "@hypr/utils";
55

66
import { streamdownComponents } from "../../streamdown";
@@ -9,34 +9,54 @@ import { useAITaskTask } from "~/ai/hooks";
99
import { createTaskId } from "~/store/zustand/ai-task/task-configs";
1010
import { type TaskStepInfo } from "~/store/zustand/ai-task/tasks";
1111

12-
export function StreamingView({ enhancedNoteId }: { enhancedNoteId: string }) {
12+
export function StreamingView({
13+
enhancedNoteId,
14+
pending = false,
15+
}: {
16+
enhancedNoteId: string;
17+
pending?: boolean;
18+
}) {
1319
const taskId = createTaskId(enhancedNoteId, "enhance");
1420
const { streamedText, currentStep, isGenerating } = useAITaskTask(
1521
taskId,
1622
"enhance",
1723
);
1824

1925
const step = currentStep as TaskStepInfo<"enhance"> | undefined;
20-
const hasContent = streamedText.length > 0;
26+
const hasContent = streamedText.trim().length > 0;
2127
let statusText: string | null = null;
2228
if (isGenerating && !hasContent) {
2329
if (step?.type === "analyzing") {
24-
statusText = "Analyzing structure...";
30+
statusText = "Analyzing transcript...";
2531
} else if (step?.type === "generating") {
26-
statusText = "Generating...";
32+
statusText = "Writing summary...";
2733
} else if (step?.type === "retrying") {
2834
statusText = `Retrying (attempt ${step.attempt})...`;
2935
} else {
30-
statusText = "Loading...";
36+
statusText = "Preparing summary...";
3137
}
38+
} else if (pending && !hasContent) {
39+
statusText = "Preparing summary...";
3240
}
3341

3442
return (
3543
<div className="pb-2">
3644
{statusText ? (
37-
<div className="flex flex-col gap-1">
38-
<p className="text-muted-foreground text-sm">{statusText}</p>
39-
<RotatingTip />
45+
<div
46+
role="status"
47+
aria-live="polite"
48+
className="flex min-h-[260px] flex-col items-center justify-center gap-2 text-center"
49+
>
50+
<Spinner size={16} />
51+
<div className="flex flex-col gap-1">
52+
<p className="text-foreground text-sm font-medium">{statusText}</p>
53+
<p className="text-muted-foreground text-xs">
54+
The summary will appear here as soon as it starts streaming.
55+
</p>
56+
<p className="text-muted-foreground text-xs">
57+
Tip: The Anarlog team loves our users!
58+
</p>
59+
</div>
4060
</div>
4161
) : (
4262
<div className="flex flex-col gap-1">
@@ -53,22 +73,3 @@ export function StreamingView({ enhancedNoteId }: { enhancedNoteId: string }) {
5373
</div>
5474
);
5575
}
56-
57-
const TIPS = ["The Anarlog team loves our users!"];
58-
59-
function RotatingTip() {
60-
const [index, setIndex] = useState(() =>
61-
Math.floor(Math.random() * TIPS.length),
62-
);
63-
64-
useEffect(() => {
65-
const id = setInterval(() => {
66-
setIndex((i) => (i + 1) % TIPS.length);
67-
}, 5_000);
68-
return () => clearInterval(id);
69-
}, []);
70-
71-
return (
72-
<p className="text-muted-foreground pl-3 text-xs">└ Tip: {TIPS[index]}</p>
73-
);
74-
}

0 commit comments

Comments
 (0)