Skip to content

Commit 23ae7ed

Browse files
author
Yann Leflour
committed
Add chat
1 parent 84e368c commit 23ae7ed

File tree

10 files changed

+267
-229
lines changed

10 files changed

+267
-229
lines changed
Lines changed: 14 additions & 142 deletions
Original file line numberDiff line numberDiff line change
@@ -1,144 +1,14 @@
1-
import { useSnapshot } from "valtio";
2-
import RMarkdown from "react-markdown";
3-
import remarkGfm from "remark-gfm";
4-
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
5-
import { a11yDark } from "react-syntax-highlighter/dist/esm/styles/prism";
61
import {
72
FormEvent,
83
KeyboardEvent,
9-
useCallback,
4+
Suspense,
105
useEffect,
116
useRef,
127
useState,
138
} from "react";
14-
import { ArrowDownCircleFill, ArrowUpSquareFill } from "react-bootstrap-icons";
15-
16-
const Mardown = ({ children }: { children: string }) => (
17-
<RMarkdown
18-
remarkPlugins={[remarkGfm]}
19-
components={{
20-
code: Code,
21-
}}
22-
>
23-
{children}
24-
</RMarkdown>
25-
);
26-
27-
const Code = (
28-
props: React.DetailedHTMLProps<
29-
React.HTMLAttributes<HTMLElement>,
30-
HTMLElement
31-
>,
32-
) => {
33-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
34-
const { children, className, ref, ...rest } = props;
35-
const match = /language-(\w+)/.exec(className || "");
36-
37-
if (!match) {
38-
return (
39-
<div className="p-3">
40-
<code {...rest}>{children}</code>
41-
</div>
42-
);
43-
}
44-
45-
return (
46-
<div>
47-
<SyntaxHighlighter
48-
{...rest}
49-
PreTag="div"
50-
customStyle={{ margin: 0 }}
51-
language={match[1]}
52-
style={a11yDark}
53-
>
54-
{String(children).replace(/\n$/, "")}
55-
</SyntaxHighlighter>
56-
</div>
57-
);
58-
};
59-
60-
const History = ({ history }: { history: any[] }) => {
61-
const scrollRef = useRef<HTMLDivElement>(null);
62-
const [displayScrollButton, setDisplayScrollButton] = useState(false);
63-
64-
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
65-
const element = e.currentTarget;
66-
if (element.scrollTop + element.clientHeight < element.scrollHeight) {
67-
setDisplayScrollButton(true);
68-
} else {
69-
setDisplayScrollButton(false);
70-
}
71-
};
72-
73-
const scrollToBottom = useCallback(
74-
(smooth = true) => {
75-
if (scrollRef.current) {
76-
scrollRef.current.scrollTo({
77-
top: scrollRef.current.scrollHeight,
78-
behavior: smooth ? "smooth" : "instant",
79-
});
80-
}
81-
},
82-
[scrollRef],
83-
);
84-
85-
// Start at bottom on mount
86-
useEffect(() => {
87-
scrollToBottom(false);
88-
}, []);
89-
90-
// Scroll to bottom when history changes
91-
useEffect(() => {
92-
scrollToBottom();
93-
}, [history, scrollToBottom]);
94-
95-
return (
96-
<div
97-
className="scroll relative flex flex-1 flex-col overflow-y-scroll px-3"
98-
onScroll={onScroll}
99-
ref={scrollRef}
100-
>
101-
{history.map((item, index) => {
102-
switch (item.type) {
103-
case "system":
104-
return (
105-
<div key={index} className="pt-4 text-lg font-bold">
106-
<div className="sticky top-0 flex items-center gap-2 bg-white">
107-
<div className="text-l text-primary">Assistant</div>
108-
<div className="flex-1 border-b-2" />
109-
</div>
110-
<div className="prose p-2">
111-
<Mardown>{item.message}</Mardown>
112-
</div>
113-
</div>
114-
);
115-
case "user":
116-
return (
117-
<div key={index} className="pt-4 text-lg font-bold">
118-
<div className="sticky top-0 flex items-center gap-2 bg-white">
119-
<div className="text-l text-secondary">You</div>
120-
<div className="flex-1 border-b-2" />
121-
</div>
122-
<div className="prose p-2">
123-
<Mardown>{item.message}</Mardown>
124-
</div>
125-
</div>
126-
);
127-
}
128-
})}
129-
{displayScrollButton && (
130-
<div className="sticky bottom-2 flex w-full justify-center ">
131-
<button
132-
onClick={() => scrollToBottom()}
133-
className="opacity-40 hover:opacity-100"
134-
>
135-
<ArrowDownCircleFill size="2rem" />
136-
</button>
137-
</div>
138-
)}
139-
</div>
140-
);
141-
};
9+
import { ArrowUpSquareFill } from "react-bootstrap-icons";
10+
import { useAssistant } from "./use-assistant";
11+
import { History } from "./History";
14212

14313
interface InputProps {
14414
onSubmit: (message: string) => void;
@@ -199,17 +69,19 @@ const Input = ({ onSubmit }: InputProps) => {
19969
);
20070
};
20171

202-
export const Chat = () => {
203-
const historySnap = useSnapshot(state);
72+
export const Chat = () => (
73+
<Suspense fallback={<span>waiting...</span>}>
74+
<ChatSuspense />
75+
</Suspense>
76+
);
77+
78+
const ChatSuspense = () => {
79+
const state = useAssistant();
20480

20581
return (
20682
<div className="flex h-full max-h-full flex-col gap-3 bg-white">
207-
<History history={historySnap.history} />
208-
<Input
209-
onSubmit={(data) => {
210-
state.history.push({ type: "user", message: data });
211-
}}
212-
/>
83+
<History history={state.history} />
84+
<Input onSubmit={state.sendMessage} />
21385
</div>
21486
);
21587
};

ui-sketcher-webview/src/chat/History.stories.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ export const Done: Story = {
9999
{
100100
id: "2",
101101
role: "system",
102-
state: "thinking",
102+
state: "done",
103103
steps: [
104104
{
105105
type: "tools",

ui-sketcher-webview/src/chat/History.tsx

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import RMarkdown from "react-markdown";
44
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
55
import { a11yDark } from "react-syntax-highlighter/dist/esm/styles/prism";
66
import remarkGfm from "remark-gfm";
7+
import { DeepReadOnly } from "../ts.utils";
8+
import { StateIndicator } from "./SystemStateIndicator";
79
import { ToolsStep } from "./ToolsStep";
810
import { Message } from "./chat.types";
911

@@ -51,7 +53,7 @@ const Code = (
5153
);
5254
};
5355

54-
export const History = ({ history }: { history: Message[] }) => {
56+
export const History = ({ history }: { history: DeepReadOnly<Message[]> }) => {
5557
const scrollRef = useRef<HTMLDivElement>(null);
5658
const [displayScrollButton, setDisplayScrollButton] = useState(false);
5759

@@ -84,7 +86,7 @@ export const History = ({ history }: { history: Message[] }) => {
8486
// Scroll to bottom when history changes
8587
useEffect(() => {
8688
scrollToBottom();
87-
}, [history, scrollToBottom]);
89+
}, [history?.length, scrollToBottom]);
8890

8991
return (
9092
<div
@@ -96,10 +98,11 @@ export const History = ({ history }: { history: Message[] }) => {
9698
switch (item.role) {
9799
case "system":
98100
return (
99-
<div key={index} className="pt-4 text-lg font-bold">
101+
<div key={index} className="pt-4 text-lg">
100102
<div className="sticky top-0 flex items-center gap-2 bg-white">
101-
<div className="text-l text-primary">Assistant</div>
102-
<div className="flex-1 border-b-2" />
103+
<div className="text-l divider divider-start w-full text-primary">
104+
Assistant <StateIndicator state={item.state} />
105+
</div>
103106
</div>
104107
<div className="flex flex-col gap-2">
105108
{item.steps.map((step, i) => {
@@ -122,10 +125,11 @@ export const History = ({ history }: { history: Message[] }) => {
122125
);
123126
case "user":
124127
return (
125-
<div key={index} className="pt-4 text-lg font-bold">
128+
<div key={index} className="pt-4 text-lg">
126129
<div className="sticky top-0 flex items-center gap-2 bg-white">
127-
<div className="text-l text-secondary">You</div>
128-
<div className="flex-1 border-b-2" />
130+
<div className="text-l divider divider-start w-full text-secondary">
131+
You
132+
</div>
129133
</div>
130134
<div className="prose p-2">
131135
<Mardown>{item.content}</Mardown>
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { Meta, StoryObj } from "@storybook/react";
2+
3+
import { StateIndicator } from "./SystemStateIndicator";
4+
5+
const meta: Meta<typeof StateIndicator> = {
6+
component: StateIndicator,
7+
};
8+
9+
export default meta;
10+
type Story = StoryObj<typeof StateIndicator>;
11+
12+
export const Thinking: Story = {
13+
args: {
14+
state: "thinking",
15+
},
16+
};
17+
18+
export const Cancelled: Story = {
19+
args: {
20+
state: "cancelled",
21+
},
22+
};
23+
24+
export const Cancelling: Story = {
25+
args: {
26+
state: "cancelling",
27+
},
28+
};
29+
30+
export const Done: Story = {
31+
args: {
32+
state: "done",
33+
},
34+
};
35+
36+
export const Expired: Story = {
37+
args: {
38+
state: "expired",
39+
},
40+
};
41+
42+
export const Failed: Story = {
43+
args: {
44+
state: "failed",
45+
},
46+
};
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { never } from "../ts.utils";
2+
import { SystemMessage } from "./chat.types";
3+
import {
4+
InfoCircleFill,
5+
CheckCircleFill,
6+
ExclamationCircleFill,
7+
} from "react-bootstrap-icons";
8+
9+
export const StateIndicator = ({
10+
state,
11+
}: {
12+
state: SystemMessage["state"];
13+
}) => {
14+
switch (state) {
15+
case "thinking":
16+
return (
17+
<div className="flex w-fit items-center gap-2 rounded-sm bg-slate-300 px-2 py-1">
18+
<span className="loading loading-dots loading-sm"></span>
19+
<span>Thinking</span>
20+
</div>
21+
);
22+
case "cancelled":
23+
return (
24+
<div className="flex w-fit items-center gap-2 rounded-sm bg-error px-2 py-1 text-error">
25+
<InfoCircleFill />
26+
<span>Cancelled</span>
27+
</div>
28+
);
29+
case "cancelling":
30+
return (
31+
<div className="flex w-fit items-center gap-2 rounded-sm bg-warning px-2 py-1 text-warning">
32+
<span className="loading loading-dots loading-sm"></span>
33+
<span>Cancelling</span>
34+
</div>
35+
);
36+
case "done":
37+
return (
38+
<div className="flex w-fit items-center gap-2 rounded-sm bg-success px-2 py-1 text-success">
39+
<CheckCircleFill />
40+
<span>Done</span>
41+
</div>
42+
);
43+
case "expired":
44+
return (
45+
<div className="flex w-fit items-center gap-2 rounded-sm bg-error px-2 py-1 text-error">
46+
<ExclamationCircleFill />
47+
<span>Expired</span>
48+
</div>
49+
);
50+
case "failed":
51+
return (
52+
<div className="flex w-fit items-center gap-2 rounded-sm bg-error px-2 py-1 text-error">
53+
<ExclamationCircleFill />
54+
<span>Failed</span>
55+
</div>
56+
);
57+
default:
58+
never(state);
59+
}
60+
};

ui-sketcher-webview/src/chat/ToolsStep.tsx

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,10 @@ import { ToolStep } from "./chat.types";
22
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
33
import { a11yDark } from "react-syntax-highlighter/dist/esm/styles/prism";
44
import { CheckCircle, XCircle } from "react-bootstrap-icons";
5+
import { DeepReadOnly } from "../ts.utils";
56

67
interface ToolsStepProps {
7-
step: ToolStep;
8+
step: DeepReadOnly<ToolStep>;
89
index: number;
910
}
1011

@@ -13,11 +14,8 @@ export const ToolsStep = ({ step, index }: ToolsStepProps) => {
1314
<div className="flex flex-col gap-2">
1415
<div className="flex gap-2 align-middle">
1516
<span className="font-bold">{index + 1}. Executing tools</span>
16-
{step.status === "running" ? (
17-
<span className="loading loading-bars loading-sm" />
18-
) : null}
1917
</div>
20-
<div className="join join-vertical w-full">
18+
<div className="join join-vertical w-full pl-6">
2119
{step.tools.map((tool, ti) => (
2220
<Tool key={tool.id} tool={tool} index={ti} />
2321
))}

0 commit comments

Comments
 (0)