Skip to content

Commit 023edb5

Browse files
feat(ui): chat visualization + minimap rail (#203)
* feat(ui): display agent status in session sidebar - Update SessionView to accept sessionStatusById prop - Render status pill in session list sidebar - Pass status from App component to SessionView - Fix type errors in demo-state mocks * feat(ui): add chat visualization enhancements - Add 'flow' animation for new tasks, artifacts, and files moving to sidebar - Add minimalist jump rail to navigate between Agent and User messages - Add copy button to message bubbles - Add OnePlus-inspired colors for User interactions * feat(ui): implement fine-grained message minimap rail - Replaces simple jump buttons with a minimap rail showing individual message lines - Uses #EB0029 for user messages and theme-inverse for agent messages - Implements active state highlighting and hover expansion effects - Adds click-to-scroll navigation for each message line * style(ui): redesign minimap rail to horizontal bars with premium feel - Change lines from vertical to horizontal dashes - Remove rail border for cleaner look - Unify line thickness (2px default, 3px active) - Enhance active state with expansion and glow effect - Improve clickability with expanded hit areas - Use #EB0029 for user messages and theme-inverse for agent * style(ui): hide default scrollbar in chat view - Add no-scrollbar utility class and CSS rules - Ensure clean look with only the custom minimap rail visible * style(ui): increase minimap line width for better visibility - Default width: 2.5 (was 1.5) - Active width: 4 (was 3) - Hover width: 3.5 (was 2.5) - Keeps the same premium thin height * chore(pr): add minimap rail screenshots * fix(ui): harden minimap and copy interactions - debounce minimap line updates with raf scheduler - clean up timeouts and rafs on unmount - add retry logic for artifact flyout source lookup - ensure minimap markers are keyboard accessible * feat: unify slash command menu with input bar (#202) * feat: adjust session command list * chore: bump version to 0.3.3 * feat: keep users oriented during workspace switches (#204) * chore: bump version to 0.3.4 * feat: window sidecar (#205) * feat: enable windows sidecar bundling * fix: run sidecar prep from repo root * fix: run sidecar prep via workspace filter * refactor(ui): simplify session view with soft pill design and decomposed components --------- Co-authored-by: ben <ben@prologe.io>
1 parent 471ff35 commit 023edb5

File tree

10 files changed

+1119
-801
lines changed

10 files changed

+1119
-801
lines changed
94.8 KB
Loading
55.7 KB
Loading

packages/app/src/app/app.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2002,6 +2002,7 @@ export default function App() {
20022002
listAgents={listAgents}
20032003
setSessionAgent={setSessionAgent}
20042004
saveSession={saveSessionExport}
2005+
sessionStatusById={activeSessionStatusById()}
20052006
onTryNotionPrompt={() => {
20062007
setPrompt("setup my crm");
20072008
setTryNotionPromptVisible(false);
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { Show, createSignal, onMount } from "solid-js";
2+
import { Check, FileText, Folder } from "lucide-solid";
3+
4+
export type FlyoutProps = {
5+
item: {
6+
id: string;
7+
rect: { top: number; left: number; width: number; height: number };
8+
targetRect: { top: number; left: number; width: number; height: number };
9+
label: string;
10+
icon: "file" | "check" | "folder";
11+
};
12+
};
13+
14+
export default function FlyoutItem(props: FlyoutProps) {
15+
const [active, setActive] = createSignal(false);
16+
onMount(() => {
17+
requestAnimationFrame(() => {
18+
requestAnimationFrame(() => {
19+
setActive(true);
20+
});
21+
});
22+
});
23+
24+
return (
25+
<div
26+
class="fixed z-[100] pointer-events-none transition-all duration-1000 ease-[cubic-bezier(0.16,1,0.3,1)] flex items-center gap-2 px-3 py-2 rounded-xl bg-gray-12 text-gray-1 shadow-xl border border-gray-11/20"
27+
style={{
28+
top: `${props.item.rect.top}px`,
29+
left: `${props.item.rect.left}px`,
30+
transform: active()
31+
? `translate(${props.item.targetRect.left - props.item.rect.left}px, ${
32+
props.item.targetRect.top - props.item.rect.top
33+
}px) scale(0.3)`
34+
: "translate(0, 0) scale(1)",
35+
opacity: active() ? 0 : 1,
36+
}}
37+
>
38+
<Show when={props.item.icon === "check"}>
39+
<Check size={14} />
40+
</Show>
41+
<Show when={props.item.icon === "file"}>
42+
<FileText size={14} />
43+
</Show>
44+
<Show when={props.item.icon === "folder"}>
45+
<Folder size={14} />
46+
</Show>
47+
<span class="text-xs font-medium truncate max-w-[120px]">{props.item.label}</span>
48+
</div>
49+
);
50+
}
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js";
2+
import { ArrowRight, Zap } from "lucide-solid";
3+
4+
export type CommandItem = {
5+
id: string;
6+
description: string;
7+
};
8+
9+
export type ComposerProps = {
10+
prompt: string;
11+
setPrompt: (value: string) => void;
12+
busy: boolean;
13+
onSend: () => void;
14+
commandMatches: CommandItem[];
15+
onRunCommand: (commandId: string) => void;
16+
selectedModelLabel: string;
17+
onModelClick: () => void;
18+
showNotionBanner: boolean;
19+
onNotionBannerClick: () => void;
20+
toast: string | null;
21+
};
22+
23+
export default function Composer(props: ComposerProps) {
24+
let textareaRef: HTMLTextAreaElement | undefined;
25+
const [commandIndex, setCommandIndex] = createSignal(0);
26+
27+
const commandMenuOpen = createMemo(() => {
28+
return props.prompt.startsWith("/") && !props.busy;
29+
});
30+
31+
const syncHeight = () => {
32+
if (!textareaRef) return;
33+
textareaRef.style.height = "auto";
34+
const nextHeight = Math.min(textareaRef.scrollHeight, 160);
35+
textareaRef.style.height = `${nextHeight}px`;
36+
textareaRef.style.overflowY = textareaRef.scrollHeight > 160 ? "auto" : "hidden";
37+
};
38+
39+
createEffect(() => {
40+
props.prompt;
41+
syncHeight();
42+
});
43+
44+
createEffect(() => {
45+
if (commandMenuOpen()) {
46+
setCommandIndex(0);
47+
}
48+
});
49+
50+
const handleKeyDown = (event: KeyboardEvent) => {
51+
if (event.key === "Enter" && event.shiftKey) return;
52+
if (event.isComposing && event.key !== "Enter") return;
53+
54+
if (commandMenuOpen()) {
55+
const matches = props.commandMatches;
56+
if (event.key === "Enter") {
57+
event.preventDefault();
58+
const active = matches[commandIndex()] ?? matches[0];
59+
if (active) {
60+
props.onRunCommand(active.id);
61+
}
62+
return;
63+
}
64+
if (event.key === "ArrowDown") {
65+
event.preventDefault();
66+
setCommandIndex((i) => Math.min(i + 1, matches.length - 1));
67+
return;
68+
}
69+
if (event.key === "ArrowUp") {
70+
event.preventDefault();
71+
setCommandIndex((i) => Math.max(i - 1, 0));
72+
return;
73+
}
74+
if (event.key === "Escape") {
75+
event.preventDefault();
76+
props.setPrompt("");
77+
return;
78+
}
79+
if (event.key === "Tab") {
80+
event.preventDefault();
81+
// maybe autocomplete?
82+
const active = matches[commandIndex()] ?? matches[0];
83+
if (active) {
84+
props.onRunCommand(active.id);
85+
}
86+
return;
87+
}
88+
}
89+
90+
if (event.key === "Enter") {
91+
event.preventDefault();
92+
props.onSend();
93+
}
94+
};
95+
96+
createEffect(() => {
97+
const handler = () => {
98+
textareaRef?.focus();
99+
};
100+
window.addEventListener("openwork:focusPrompt", handler);
101+
onCleanup(() => window.removeEventListener("openwork:focusPrompt", handler));
102+
});
103+
104+
return (
105+
<div class="p-4 border-t border-gray-6 bg-gray-1 sticky bottom-0 z-20">
106+
<div class="max-w-2xl mx-auto">
107+
<div
108+
class={`bg-gray-2 border border-gray-6 rounded-3xl overflow-visible transition-all shadow-2xl relative group/input ${
109+
commandMenuOpen()
110+
? "rounded-t-none border-t-transparent"
111+
: "focus-within:ring-1 focus-within:ring-gray-7"
112+
}`}
113+
>
114+
<Show when={commandMenuOpen()}>
115+
<div class="absolute bottom-full left-[-1px] right-[-1px] z-30">
116+
<div class="rounded-t-3xl border border-gray-6 border-b-0 bg-gray-2 shadow-2xl overflow-hidden">
117+
<div class="px-4 pt-3 pb-2 text-[10px] font-semibold uppercase tracking-[0.2em] text-gray-8 border-b border-gray-6/30 bg-gray-2">
118+
Commands
119+
</div>
120+
<div class="space-y-1 p-2 bg-gray-2">
121+
<Show
122+
when={props.commandMatches.length}
123+
fallback={
124+
<div class="px-3 py-2 text-xs text-gray-9">No commands found.</div>
125+
}
126+
>
127+
<For each={props.commandMatches}>
128+
{(command, idx) => (
129+
<button
130+
type="button"
131+
class={`w-full flex items-start gap-3 rounded-xl px-3 py-2 text-left transition-colors ${
132+
idx() === commandIndex()
133+
? "bg-gray-12/10 text-gray-12"
134+
: "text-gray-11 hover:bg-gray-12/5"
135+
}`}
136+
onMouseDown={(e) => {
137+
e.preventDefault();
138+
props.onRunCommand(command.id);
139+
}}
140+
onMouseEnter={() => setCommandIndex(idx())}
141+
>
142+
<div class="text-xs font-semibold text-gray-12">/{command.id}</div>
143+
<div class="text-[11px] text-gray-9">{command.description}</div>
144+
</button>
145+
)}
146+
</For>
147+
</Show>
148+
</div>
149+
</div>
150+
</div>
151+
</Show>
152+
153+
<button
154+
type="button"
155+
class="absolute top-3 left-4 flex items-center gap-1.5 text-[10px] font-bold text-gray-7 hover:text-gray-11 transition-colors uppercase tracking-widest z-10"
156+
onClick={props.onModelClick}
157+
disabled={props.busy}
158+
>
159+
<Zap size={10} class="text-gray-7 group-hover:text-amber-11 transition-colors" />
160+
<span>{props.selectedModelLabel}</span>
161+
</button>
162+
163+
<div class="p-3 pt-8 pb-3 px-4">
164+
<Show when={props.showNotionBanner}>
165+
<button
166+
type="button"
167+
class="w-full mb-2 flex items-center justify-between gap-3 rounded-xl border border-green-7/20 bg-green-7/10 px-3 py-2 text-left text-sm text-green-12 transition-colors hover:bg-green-7/15"
168+
onClick={props.onNotionBannerClick}
169+
>
170+
<span>Try it now: set up my CRM in Notion</span>
171+
<span class="text-xs text-green-12 font-medium">Insert prompt</span>
172+
</button>
173+
</Show>
174+
175+
<div class="relative">
176+
<Show when={props.toast}>
177+
<div class="absolute bottom-full right-0 mb-2 z-30 rounded-xl border border-gray-6 bg-gray-1/90 px-3 py-2 text-xs text-gray-11 shadow-lg backdrop-blur-md">
178+
{props.toast}
179+
</div>
180+
</Show>
181+
182+
<div class="relative flex items-end gap-3">
183+
<textarea
184+
ref={textareaRef}
185+
rows={1}
186+
disabled={props.busy}
187+
value={props.prompt}
188+
onInput={(e) => props.setPrompt(e.currentTarget.value)}
189+
onKeyDown={handleKeyDown}
190+
placeholder="Ask OpenWork..."
191+
class="flex-1 bg-transparent border-none p-0 text-gray-12 placeholder-gray-6 focus:ring-0 text-[15px] leading-relaxed resize-none min-h-[24px]"
192+
/>
193+
194+
<button
195+
disabled={!props.prompt.trim() || props.busy}
196+
onClick={props.onSend}
197+
class="p-2 bg-gray-12 text-gray-1 rounded-xl hover:scale-105 active:scale-95 transition-all disabled:opacity-0 disabled:scale-75 shadow-lg shrink-0 flex items-center justify-center"
198+
title="Run"
199+
>
200+
<ArrowRight size={18} />
201+
</button>
202+
</div>
203+
</div>
204+
</div>
205+
</div>
206+
</div>
207+
</div>
208+
);
209+
}

0 commit comments

Comments
 (0)