Skip to content

Commit 461d125

Browse files
committed
chat context indicator fix for pending state
Signed-off-by: Yujong Lee <yujonglee.dev@gmail.com>
1 parent dece86a commit 461d125

File tree

16 files changed

+354
-257
lines changed

16 files changed

+354
-257
lines changed

apps/desktop/src/chat/components/view.tsx renamed to apps/desktop/src/chat/components/chat-panel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { cn } from "@hypr/utils";
55
import { ChatBody } from "./body";
66
import { ChatContent } from "./content";
77
import { ChatHeader } from "./header";
8-
import { ChatSession } from "./session";
8+
import { ChatSession } from "./session-provider";
99

1010
import { useLanguageModel } from "~/ai/hooks";
1111
import {

apps/desktop/src/chat/components/content.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@ import { ContextBar } from "./context-bar";
55
import { ChatMessageInput, type McpIndicator } from "./input";
66

77
import type { useLanguageModel } from "~/ai/hooks";
8-
import type { ContextEntity, ContextRef } from "~/chat/context/entities";
8+
import type { ContextRef } from "~/chat/context/entities";
9+
import type { DisplayEntity } from "~/chat/context/use-chat-context-pipeline";
910
import type { HyprUIMessage } from "~/chat/types";
1011

1112
export function ChatContent({
@@ -40,7 +41,7 @@ export function ChatContent({
4041
sendMessage: (message: HyprUIMessage) => void,
4142
contextRefs?: ContextRef[],
4243
) => void;
43-
contextEntities: ContextEntity[];
44+
contextEntities: DisplayEntity[];
4445
pendingRefs: ContextRef[];
4546
onRemoveContextEntity?: (key: string) => void;
4647
onAddContextEntity?: (ref: ContextRef) => void;

apps/desktop/src/chat/components/context-bar.tsx

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ import {
1313
} from "@hypr/ui/components/ui/tooltip";
1414
import { cn } from "@hypr/utils";
1515

16-
import type { ContextEntity, ContextRef } from "~/chat/context/entities";
16+
import type { ContextRef } from "~/chat/context/entities";
1717
import { type ContextChipProps, renderChip } from "~/chat/context/registry";
18+
import type { DisplayEntity } from "~/chat/context/use-chat-context-pipeline";
1819
import { useSearchEngine } from "~/search/contexts/engine";
1920
import { useTabs } from "~/store/zustand/tabs";
2021

@@ -60,9 +61,11 @@ function useOverflow(
6061
function ContextChip({
6162
chip,
6263
onRemove,
64+
pending,
6365
}: {
6466
chip: ContextChipProps;
6567
onRemove?: (key: string) => void;
68+
pending?: boolean;
6669
}) {
6770
const Icon = chip.icon;
6871
const openNew = useTabs((state) => state.openNew);
@@ -80,7 +83,10 @@ function ContextChip({
8083
<span
8184
onClick={handleClick}
8285
className={cn([
83-
"group max-w-48 min-w-0 rounded-md bg-neutral-500/10 px-1.5 py-0.5 text-xs text-neutral-600",
86+
"group max-w-48 min-w-0 rounded-md px-1.5 py-0.5 text-xs",
87+
pending
88+
? "bg-neutral-500/5 text-neutral-400"
89+
: "bg-white text-neutral-600 shadow-xs",
8490
"inline-flex shrink items-center gap-1",
8591
isClickable
8692
? "cursor-pointer hover:bg-neutral-500/20"
@@ -182,13 +188,21 @@ export function ContextBar({
182188
onRemoveEntity,
183189
onAddEntity,
184190
}: {
185-
entities: ContextEntity[];
191+
entities: DisplayEntity[];
186192
onRemoveEntity?: (key: string) => void;
187193
onAddEntity?: (ref: ContextRef) => void;
188194
}) {
189195
const chips = useMemo(
190196
() =>
191-
entities.map(renderChip).filter((c): c is ContextChipProps => c !== null),
197+
entities
198+
.map((entity) => ({
199+
chip: renderChip(entity),
200+
pending: entity.pending,
201+
}))
202+
.filter(
203+
(c): c is { chip: ContextChipProps; pending: boolean } =>
204+
c.chip !== null,
205+
),
192206
[entities],
193207
);
194208

@@ -201,50 +215,59 @@ export function ContextBar({
201215
setExpanded(false);
202216
}, [chips.length]);
203217

204-
if (chips.length === 0 && !onAddEntity) return null;
218+
if (chips.length === 0 && !onAddEntity) {
219+
return null;
220+
}
205221

206222
return (
207223
<div className="mx-2 rounded-t-xl border-t border-r border-l border-neutral-200 bg-neutral-100">
208-
<div className="flex items-start gap-1.5 px-2.5 py-2">
209-
<div
210-
ref={chipsRef}
211-
className={cn([
212-
"flex min-w-0 flex-1 flex-wrap items-center gap-1.5",
213-
!expanded && "max-h-[22px] overflow-hidden",
214-
])}
215-
>
216-
{chips.map((chip) => (
217-
<ContextChip key={chip.key} chip={chip} onRemove={onRemoveEntity} />
218-
))}
219-
</div>
224+
<div className="flex flex-col gap-1 px-2.5 py-2">
225+
<div className="flex items-start gap-1.5">
226+
<div
227+
ref={chipsRef}
228+
className={cn([
229+
"flex min-w-0 flex-1 flex-wrap items-center gap-1.5",
230+
!expanded && "max-h-[22px] overflow-hidden",
231+
])}
232+
>
233+
{chips.map(({ chip, pending }) => (
234+
<ContextChip
235+
key={chip.key}
236+
chip={chip}
237+
onRemove={onRemoveEntity}
238+
pending={pending}
239+
/>
240+
))}
241+
</div>
220242

221-
<div className="flex shrink-0 items-center gap-1.5">
222-
{(hasOverflow || expanded) && (
223-
<button
224-
type="button"
225-
onClick={() => setExpanded((v) => !v)}
226-
className="inline-flex items-center gap-0.5 rounded-md bg-neutral-500/10 px-1 py-0.5 text-xs text-neutral-400 transition-colors hover:bg-neutral-500/20 hover:text-neutral-600"
227-
>
228-
{!expanded && hiddenCount > 0 && <span>+{hiddenCount}</span>}
229-
<ChevronDownIcon
230-
className={cn(["size-3.5", expanded && "rotate-180"])}
243+
<div className="flex shrink-0 items-center gap-1.5">
244+
{(hasOverflow || expanded) && (
245+
<button
246+
type="button"
247+
onClick={() => setExpanded((v) => !v)}
248+
className="inline-flex items-center gap-0.5 rounded-md bg-neutral-500/10 px-1 py-0.5 text-xs text-neutral-400 transition-colors hover:bg-neutral-500/20 hover:text-neutral-600"
249+
>
250+
{!expanded && hiddenCount > 0 && <span>+{hiddenCount}</span>}
251+
<ChevronDownIcon
252+
className={cn(["size-3.5", expanded && "rotate-180"])}
253+
/>
254+
</button>
255+
)}
256+
{onAddEntity && (
257+
<AddSessionButton
258+
onAdd={(sessionId) => {
259+
onAddEntity({
260+
kind: "session",
261+
key: `session:manual:${sessionId}`,
262+
source: "manual",
263+
sessionId,
264+
});
265+
}}
266+
open={pickerOpen}
267+
onOpenChange={setPickerOpen}
231268
/>
232-
</button>
233-
)}
234-
{onAddEntity && (
235-
<AddSessionButton
236-
onAdd={(sessionId) => {
237-
onAddEntity({
238-
kind: "session",
239-
key: `session:manual:${sessionId}`,
240-
source: "manual",
241-
sessionId,
242-
});
243-
}}
244-
open={pickerOpen}
245-
onOpenChange={setPickerOpen}
246-
/>
247-
)}
269+
)}
270+
</div>
248271
</div>
249272
</div>
250273
</div>
File renamed without changes.

apps/desktop/src/chat/components/persistent-chat.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { useHotkeys } from "react-hotkeys-hook";
44

55
import { cn } from "@hypr/utils";
66

7-
import { ChatView } from "./view";
7+
import { ChatView } from "./chat-panel";
88

99
import { useShell } from "~/contexts/shell";
1010

@@ -96,8 +96,8 @@ export function PersistentChatPanel({
9696
return (
9797
<div
9898
className={cn([
99-
"fixed z-[100]",
100-
!isVisible && "!hidden",
99+
"fixed z-100",
100+
!isVisible && "hidden!",
101101
isPanel && "pointer-events-none",
102102
])}
103103
style={

apps/desktop/src/chat/components/session.tsx renamed to apps/desktop/src/chat/components/session-provider.tsx

Lines changed: 8 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -10,17 +10,15 @@ import {
1010
useState,
1111
} from "react";
1212

13-
import { commands as templateCommands } from "@hypr/plugin-template";
14-
15-
import { useLanguageModel } from "~/ai/hooks";
16-
import type { ContextEntity, ContextRef } from "~/chat/context/entities";
17-
import { hydrateSessionContextFromFs } from "~/chat/context/session-context-hydrator";
18-
import { useChatContextPipeline } from "~/chat/context/use-chat-context-pipeline";
13+
import type { ContextRef } from "~/chat/context/entities";
14+
import {
15+
type DisplayEntity,
16+
useChatContextPipeline,
17+
} from "~/chat/context/use-chat-context-pipeline";
1918
import { useCreateChatMessage } from "~/chat/store/useCreateChatMessage";
20-
import { CONTEXT_TEXT_FIELD } from "~/chat/tools";
21-
import { CustomChatTransport } from "~/chat/transport";
19+
import { stripEphemeralToolContext } from "~/chat/tools/strip-ephemeral-tool-context";
20+
import { useTransport } from "~/chat/transport/use-transport";
2221
import type { HyprUIMessage } from "~/chat/types";
23-
import { useToolRegistry } from "~/contexts/tool";
2422
import { id } from "~/shared/utils";
2523
import * as main from "~/store/tinybase/store/main";
2624

@@ -42,44 +40,14 @@ interface ChatSessionProps {
4240
stop: () => void;
4341
status: ChatStatus;
4442
error?: Error;
45-
contextEntities: ContextEntity[];
43+
contextEntities: DisplayEntity[];
4644
pendingRefs: ContextRef[];
4745
onRemoveContextEntity: (key: string) => void;
4846
onAddContextEntity: (ref: ContextRef) => void;
4947
isSystemPromptReady: boolean;
5048
}) => ReactNode;
5149
}
5250

53-
function isRecord(value: unknown): value is Record<string, unknown> {
54-
return typeof value === "object" && value !== null;
55-
}
56-
57-
function stripEphemeralToolContext(
58-
parts: HyprUIMessage["parts"],
59-
): HyprUIMessage["parts"] {
60-
let changed = false;
61-
const sanitized = parts.map((part) => {
62-
if (
63-
!isRecord(part) ||
64-
part.type !== "tool-search_sessions" ||
65-
part.state !== "output-available" ||
66-
!isRecord(part.output) ||
67-
!(CONTEXT_TEXT_FIELD in part.output)
68-
) {
69-
return part;
70-
}
71-
72-
changed = true;
73-
const { contextText: _contextText, ...restOutput } = part.output;
74-
return {
75-
...part,
76-
output: restOutput,
77-
};
78-
});
79-
80-
return changed ? sanitized : parts;
81-
}
82-
8351
export function ChatSession({
8452
sessionId,
8553
chatGroupId,
@@ -277,99 +245,3 @@ export function ChatSession({
277245
</div>
278246
);
279247
}
280-
281-
function useTransport(
282-
modelOverride?: LanguageModel,
283-
extraTools?: ToolSet,
284-
systemPromptOverride?: string,
285-
store?: ReturnType<typeof main.UI.useStore>,
286-
) {
287-
const registry = useToolRegistry();
288-
const configuredModel = useLanguageModel("chat");
289-
const model = modelOverride ?? configuredModel;
290-
const language = main.UI.useValue("ai_language", main.STORE_ID) ?? "en";
291-
const [systemPrompt, setSystemPrompt] = useState<string | undefined>();
292-
293-
useEffect(() => {
294-
if (systemPromptOverride) {
295-
setSystemPrompt(systemPromptOverride);
296-
return;
297-
}
298-
299-
let stale = false;
300-
301-
templateCommands
302-
.render({
303-
chatSystem: {
304-
language,
305-
},
306-
})
307-
.then((result) => {
308-
if (stale) {
309-
return;
310-
}
311-
312-
if (result.status === "ok") {
313-
setSystemPrompt(result.data);
314-
} else {
315-
setSystemPrompt("");
316-
}
317-
})
318-
.catch((error) => {
319-
console.error(error);
320-
if (!stale) {
321-
setSystemPrompt("");
322-
}
323-
});
324-
325-
return () => {
326-
stale = true;
327-
};
328-
}, [language, systemPromptOverride]);
329-
330-
const effectiveSystemPrompt = systemPromptOverride ?? systemPrompt;
331-
const isSystemPromptReady =
332-
typeof systemPromptOverride === "string" || systemPrompt !== undefined;
333-
334-
const tools = useMemo(() => {
335-
const localTools = registry.getTools("chat-general");
336-
337-
if (extraTools && import.meta.env.DEV) {
338-
for (const key of Object.keys(extraTools)) {
339-
if (key in localTools) {
340-
console.warn(
341-
`[ChatSession] Tool name collision: "${key}" exists in both local registry and extraTools. extraTools will take precedence.`,
342-
);
343-
}
344-
}
345-
}
346-
347-
return {
348-
...localTools,
349-
...extraTools,
350-
};
351-
}, [registry, extraTools]);
352-
353-
const transport = useMemo(() => {
354-
if (!model) {
355-
return null;
356-
}
357-
358-
return new CustomChatTransport(
359-
model,
360-
tools,
361-
effectiveSystemPrompt,
362-
async (ref) => {
363-
if (!store) {
364-
return null;
365-
}
366-
return hydrateSessionContextFromFs(store, ref.sessionId);
367-
},
368-
);
369-
}, [model, tools, effectiveSystemPrompt, store]);
370-
371-
return {
372-
transport,
373-
isSystemPromptReady,
374-
};
375-
}

apps/desktop/src/chat/context/entities.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,12 @@ function isRecord(value: unknown): value is Record<string, unknown> {
77
return typeof value === "object" && value !== null;
88
}
99

10-
export type ContextEntitySource = "tool" | "manual" | "auto-current";
10+
export const CONTEXT_ENTITY_SOURCES = [
11+
"tool",
12+
"manual",
13+
"auto-current",
14+
] as const;
15+
export type ContextEntitySource = (typeof CONTEXT_ENTITY_SOURCES)[number];
1116

1217
export type ContextRef = {
1318
kind: "session";

0 commit comments

Comments
 (0)