Skip to content

Commit 33cd11c

Browse files
authored
feat: add mobile omnibar for persistent AI chat (#109)
- Add MobileOmnibar component that stays sticky at bottom on mobile - Creates AI cells at end of notebook when submitted with auto-execution - Clean design with subtle bot icon, 'vibe it' placeholder, and purple send button - Neutral gray styling with only send button as focus element - Adds bottom padding to notebook content to prevent overlap - Mobile-only feature, hidden on desktop - Includes keyboard shortcut support (Ctrl+Enter to send) Addresses conversation flow issues on mobile where users lack keyboard shortcuts for smooth notebook interaction.
1 parent b07477f commit 33cd11c

File tree

2 files changed

+121
-1
lines changed

2 files changed

+121
-1
lines changed
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
import React, { useState } from "react";
2+
import { useStore } from "@livestore/react";
3+
import { events, CellData, tables } from "@runt/schema";
4+
import { queryDb } from "@livestore/livestore";
5+
import { Button } from "@/components/ui/button";
6+
import { Textarea } from "@/components/ui/textarea";
7+
import { Bot, Send } from "lucide-react";
8+
9+
interface MobileOmnibarProps {
10+
onCellAdded?: () => void;
11+
}
12+
13+
export const MobileOmnibar: React.FC<MobileOmnibarProps> = ({
14+
onCellAdded,
15+
}) => {
16+
const { store } = useStore();
17+
const [input, setInput] = useState("");
18+
const [isSubmitting, setIsSubmitting] = useState(false);
19+
20+
// Get current cells to calculate position
21+
const cells = store.useQuery(queryDb(tables.cells.select())) as CellData[];
22+
23+
const handleSubmit = async () => {
24+
if (!input.trim() || isSubmitting) return;
25+
26+
setIsSubmitting(true);
27+
28+
try {
29+
// Create AI cell at the bottom of the notebook
30+
const cellId = crypto.randomUUID();
31+
const queueId = crypto.randomUUID();
32+
33+
// Calculate position at end
34+
const newPosition =
35+
Math.max(...cells.map((c: CellData) => c.position), -1) + 1;
36+
37+
// Create the cell
38+
store.commit(
39+
events.cellCreated({
40+
id: cellId,
41+
position: newPosition,
42+
cellType: "ai",
43+
createdBy: "current-user",
44+
})
45+
);
46+
47+
// Set the source
48+
store.commit(
49+
events.cellSourceChanged({
50+
id: cellId,
51+
source: input.trim(),
52+
modifiedBy: "current-user",
53+
})
54+
);
55+
56+
// Clear input and notify parent
57+
setInput("");
58+
onCellAdded?.();
59+
60+
// Auto-execute the AI cell
61+
store.commit(
62+
events.executionRequested({
63+
queueId,
64+
cellId,
65+
executionCount: 1,
66+
requestedBy: "current-user",
67+
priority: 1,
68+
})
69+
);
70+
} catch (error) {
71+
console.error("Failed to create AI cell:", error);
72+
} finally {
73+
setIsSubmitting(false);
74+
}
75+
};
76+
77+
const handleKeyDown = (e: React.KeyboardEvent) => {
78+
// Submit on Ctrl+Enter or Cmd+Enter
79+
if ((e.ctrlKey || e.metaKey) && e.key === "Enter") {
80+
e.preventDefault();
81+
handleSubmit();
82+
}
83+
};
84+
85+
return (
86+
<div className="fixed right-0 bottom-0 left-0 z-40 border-t border-gray-200 bg-white p-3 shadow-lg sm:hidden">
87+
<div className="flex items-end gap-2">
88+
<div className="relative flex-1">
89+
<Textarea
90+
value={input}
91+
onChange={(e) => setInput(e.target.value)}
92+
onKeyDown={handleKeyDown}
93+
placeholder="vibe it"
94+
className="max-h-[8rem] min-h-[2.5rem] resize-none border-gray-200 bg-gray-50 pl-7 placeholder:text-gray-400 focus:border-gray-300 focus:ring-gray-300/20"
95+
disabled={isSubmitting}
96+
/>
97+
<div className="absolute top-1/2 left-2 flex -translate-y-1/2 items-center">
98+
<Bot className="h-3 w-3 text-gray-400" />
99+
</div>
100+
</div>
101+
<Button
102+
onClick={handleSubmit}
103+
disabled={!input.trim() || isSubmitting}
104+
className="h-10 w-10 flex-shrink-0 bg-purple-600 p-0 text-white hover:bg-purple-700"
105+
size="sm"
106+
>
107+
{isSubmitting ? (
108+
<div className="h-4 w-4 animate-spin rounded-full border border-white border-t-transparent" />
109+
) : (
110+
<Send className="h-4 w-4" />
111+
)}
112+
</Button>
113+
</div>
114+
</div>
115+
);
116+
};

src/components/notebook/NotebookViewer.tsx

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ const LazyDebugPanel = React.lazy(() =>
3434

3535
// Import prefetch utilities
3636
import { prefetchOutputsAdaptive } from "../../util/prefetch.js";
37+
import { MobileOmnibar } from "./MobileOmnibar.js";
3738

3839
interface NotebookViewerProps {
3940
notebookId: string;
@@ -679,7 +680,7 @@ export const NotebookViewer: React.FC<NotebookViewerProps> = ({
679680
</div>
680681

681682
<div
682-
className={`w-full px-0 py-3 ${debugMode ? "px-4" : "sm:mx-auto sm:max-w-4xl sm:p-4"}`}
683+
className={`w-full px-0 py-3 pb-24 ${debugMode ? "px-4" : "sm:mx-auto sm:max-w-4xl sm:p-4 sm:pb-4"}`}
683684
>
684685
{/* Keyboard Shortcuts Help - Desktop only */}
685686
{sortedCells.length > 0 && (
@@ -852,6 +853,9 @@ export const NotebookViewer: React.FC<NotebookViewerProps> = ({
852853
/>
853854
</Suspense>
854855
)}
856+
857+
{/* Mobile Omnibar - sticky at bottom on mobile */}
858+
<MobileOmnibar />
855859
</div>
856860
</div>
857861
);

0 commit comments

Comments
 (0)