hide chat window with expand/collapse button and key bindings

This commit is contained in:
tobiadefami
2024-12-27 04:11:07 +01:00
parent 085b7330f5
commit a73e4cae0e
12 changed files with 523 additions and 49 deletions

View File

@@ -57,5 +57,5 @@ npm run electron-start
- [ ] Other kinds of operations (hide rows where, hide columns where, sort table by attribute, etc.)
- [ ] Support for multiple sheets (or select sheet to use on import)
- [ ] Make more of the top toolbar buttons work (first check out of the box hands on table functionality)
- [ ] Keybindings / hotkeys
- [ ] Hide chat window with expand / collapse button
- [] Keybindings / hotkeys
- [x] Hide chat window with expand / collapse button

21
components.json Normal file
View File

@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "tailwind.config.js",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}

View File

@@ -36,3 +36,14 @@ body {
.chat-message-container::-webkit-scrollbar-thumb:hover {
background: #555;
}
.handsontable {
z-index: 1 !important;
}
.handsontable .wtHolder {
z-index: 1 !important;
}
.handsontable .htCore {
z-index: 1 !important;
}

View File

@@ -7,11 +7,12 @@ import {
SpreadsheetProvider,
useSpreadsheet,
} from "@/context/SpreadsheetContext";
import { MessageCircle } from "lucide-react";
const Spreadsheet = dynamic(() => import("@/components/Spreadsheet"), {
ssr: false,
loading: () => (
<div className="flex-1 h-[70vh] flex items-center justify-center bg-gray-50 border rounded-lg">
<div className="flex-1 h-full flex items-center justify-center bg-gray-50 border rounded-lg">
<div className="text-gray-500">Loading spreadsheet...</div>
</div>
),
@@ -30,8 +31,33 @@ const SpreadsheetApp = () => {
const [isElectron, setIsElectron] = useState(false);
const [electronAPIAvailable, setElectronAPIAvailable] = useState(false);
const [chatHistory, setChatHistory] = useState<ChatMessage[]>([]);
const [isChatOpen, setIsChatOpen] = useState(false);
// Load chat history from localStorage on mount
// Keyboard shortcut handler
useEffect(() => {
const handleKeyPress = (e: KeyboardEvent) => {
if (e.ctrlKey && e.shiftKey && e.key === "?") {
setIsChatOpen((prev) => !prev);
}
};
window.addEventListener("keydown", handleKeyPress);
return () => window.removeEventListener("keydown", handleKeyPress);
}, []);
// Load chat state
useEffect(() => {
const savedState = localStorage.getItem("chatOpen");
if (savedState) {
setIsChatOpen(JSON.parse(savedState));
}
}, []);
// Save chat state
useEffect(() => {
localStorage.setItem("chatOpen", JSON.stringify(isChatOpen));
}, [isChatOpen]);
// Load chat history
useEffect(() => {
const savedHistory = localStorage.getItem("chatHistory");
if (savedHistory) {
@@ -50,13 +76,13 @@ const SpreadsheetApp = () => {
}
}, []);
// Save chat history to localStorage when it changes
// Save chat history
useEffect(() => {
localStorage.setItem("chatHistory", JSON.stringify(chatHistory));
}, [chatHistory]);
// Check Electron availability
useEffect(() => {
// Check for Electron API availability after component mounts
const checkElectronAvailability = () => {
const hasElectronAPI = typeof window !== "undefined" && !!window.electron;
setElectronAPIAvailable(hasElectronAPI);
@@ -65,7 +91,7 @@ const SpreadsheetApp = () => {
if (hasElectronAPI) {
console.log("Electron environment detected!");
window.electron
.invoke("test-connection", { test: true })
?.invoke("test-connection", { test: true })
.then((result) => console.log("Electron test successful:", result))
.catch((err) => console.error("Electron test failed:", err));
} else {
@@ -81,7 +107,6 @@ const SpreadsheetApp = () => {
isElectron,
hasElectronAPI: electronAPIAvailable,
});
try {
let updates;
@@ -153,16 +178,26 @@ const SpreadsheetApp = () => {
};
return (
<main className="min-h-screen p-8 bg-gray-50">
<div className="max-w-[1800px] mx-auto">
<main className="h-screen w-screen bg-gray-50 overflow-hidden">
<div className="h-full p-4 flex flex-col">
<h1 className="text-2xl font-bold mb-4 text-gray-800">
Magic Spreadsheet
</h1>
<div className="flex gap-4">
<div className="flex-1 bg-white rounded-lg shadow-sm overflow-hidden">
<div className="flex gap-4 flex-1 relative">
<div className="flex-1 bg-white rounded-lg shadow-sm">
<Spreadsheet onDataChange={handleDataChange} />
</div>
<div className="w-96">
{/* Updated ChatBox container */}
<div
className={`fixed right-4 top-[5.5rem] bottom-4 w-96 transition-transform duration-300 transform ${
isChatOpen ? "translate-x-0" : "translate-x-full"
}`}
style={{
backgroundColor: "white",
boxShadow: "0 0 10px rgba(0, 0, 0, 0.1)",
zIndex: 9999,
}}
>
<ChatBox
onSend={handleSend}
chatHistory={chatHistory}
@@ -171,6 +206,13 @@ const SpreadsheetApp = () => {
</div>
</div>
</div>
<button
onClick={() => setIsChatOpen((prev) => !prev)}
className="fixed bottom-4 right-4 p-3 rounded-full shadow-lg bg-blue-500 hover:bg-blue-600 text-white transition-colors z-50"
title="Toggle Chat (Ctrl+Shift+/)"
>
<MessageCircle size={24} />
</button>
</main>
);
};

View File

@@ -1,5 +1,6 @@
import { useState } from "react";
import { CellUpdate } from "@/types/api";
import { Send, Trash2, Loader2 } from "lucide-react";
interface ChatMessage {
id: string;
@@ -21,7 +22,6 @@ const ChatBox = ({ onSend, chatHistory, clearHistory }: ChatBoxProps) => {
const handleSend = async () => {
if (message.trim()) {
setIsLoading(true);
try {
await onSend(message);
} catch (error) {
@@ -40,59 +40,81 @@ const ChatBox = ({ onSend, chatHistory, clearHistory }: ChatBoxProps) => {
};
return (
<div className="flex flex-col h-[calc(100vh-8rem)] bg-white rounded-lg shadow-sm border">
{/* Chat Header */}
<div className="p-4 border-b flex justify-between items-center">
<h2 className="font-semibold text-gray-800">Chat History</h2>
<div className="flex flex-col h-full bg-white rounded-lg shadow-lg border border-gray-200">
{/* Header */}
<div className="px-4 py-3 border-b border-gray-200 flex justify-between items-center bg-white rounded-t-lg z-50">
<div>
<h2 className="font-semibold text-gray-800">AI Assistant</h2>
<p className="text-xs text-gray-500">
Ask me about spreadsheet formulas
</p>
</div>
<button
onClick={clearHistory}
className="text-sm px-2 py-1 text-red-500 hover:bg-red-50 rounded transition-colors"
className="p-2 text-gray-500 hover:text-red-500 hover:bg-red-50 rounded-full transition-colors"
title="Clear chat history"
>
Clear History
<Trash2 size={18} />
</button>
</div>
{/* Chat History */}
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{chatHistory.map((chat) => (
<div key={chat.id} className="space-y-2">
<div className="flex items-start gap-2">
<div className="bg-blue-50 rounded-lg p-3 max-w-[80%]">
<p className="text-sm text-gray-800">{chat.text}</p>
<span className="text-xs text-gray-500">
{new Date(chat.timestamp).toLocaleTimeString()}
</span>
</div>
</div>
<div className="flex items-start gap-2 justify-end">
<div className="bg-gray-50 rounded-lg p-3 max-w-[80%]">
<pre className="text-sm text-gray-800 whitespace-pre-wrap">
{chat.response}
</pre>
</div>
</div>
<div className="flex-1 overflow-y-auto p-4 space-y-4 bg-gray-50">
{chatHistory.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-gray-500 space-y-2">
<p className="text-sm">No messages yet</p>
<p className="text-xs text-center">
Try asking me to create formulas or analyze your data
</p>
</div>
))}
) : (
chatHistory.map((chat) => (
<div key={chat.id} className="space-y-2">
{/* User Message */}
<div className="flex justify-end">
<div className="bg-blue-500 text-white rounded-2xl rounded-tr-sm px-4 py-2 max-w-[80%] shadow-sm">
<p className="text-sm">{chat.text}</p>
<span className="text-xs opacity-75 mt-1 block">
{new Date(chat.timestamp).toLocaleTimeString()}
</span>
</div>
</div>
{/* AI Response */}
<div className="flex justify-start">
<div className="bg-white rounded-2xl rounded-tl-sm px-4 py-2 max-w-[80%] shadow-sm border border-gray-200">
<pre className="text-sm text-gray-800 whitespace-pre-wrap font-mono">
{chat.response}
</pre>
</div>
</div>
</div>
))
)}
</div>
{/* Input Area */}
<div className="p-4 border-t">
<div className="flex gap-2">
<div className="p-4 bg-white border-t border-gray-200 rounded-b-lg">
<div className="flex gap-2 items-center">
<input
type="text"
value={message}
onChange={(e) => setMessage(e.target.value)}
onKeyDown={handleKeyDown}
className="flex-1 p-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Ask the LLM to create a formula..."
className="flex-1 px-4 py-2 border border-gray-200 rounded-full focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent text-sm"
placeholder="Type your message..."
disabled={isLoading}
/>
<button
onClick={handleSend}
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors disabled:bg-gray-400"
disabled={isLoading}
disabled={isLoading || !message.trim()}
className="p-2 bg-blue-500 text-white rounded-full hover:bg-blue-600 transition-colors disabled:bg-gray-300 disabled:cursor-not-allowed"
title="Send message"
>
{isLoading ? "..." : "Send"}
{isLoading ? (
<Loader2 size={20} className="animate-spin" />
) : (
<Send size={20} />
)}
</button>
</div>
</div>

View File

@@ -0,0 +1,9 @@
const KeyboardShortcutHint = () => {
return (
<div className="fixed bottom-20 right-4 bg-gray-800 text-white text-sm px-3 py-2 rounded-lg opacity-0 transition-opacity duration-200 group-hover:opacity-100">
Press Ctrl+Shift+/ to toggle chat
</div>
);
};
export default KeyboardShortcutHint;

View File

@@ -128,6 +128,15 @@ const Spreadsheet = ({ onDataChange, initialData }: SpreadsheetProps) => {
useEffect(() => {
if (spreadsheetRef.current && !hotInstanceRef.current) {
try {
const config = getInitialConfig(currentData);
config.afterChange = (changes: any) => {
if (changes) {
const currentData = hotInstanceRef.current?.getData();
if (currentData && onDataChange) {
onDataChange(currentData);
}
}
};
hotInstanceRef.current = new Handsontable(
spreadsheetRef.current,
getInitialConfig(currentData),
@@ -152,7 +161,7 @@ const Spreadsheet = ({ onDataChange, initialData }: SpreadsheetProps) => {
}, [currentData]);
return (
<div className="flex flex-col w-full h-full bg-white">
<div className="h-full flex flex-col">
<SpreadsheetToolbar
onImport={handleImport}
onExport={handleExport}
@@ -205,7 +214,7 @@ const Spreadsheet = ({ onDataChange, initialData }: SpreadsheetProps) => {
onSearch={() => setShowSearch(true)}
onSort={() => dataHandlers.handleSort(hotInstanceRef.current)}
/>
<div className="relative flex-1 min-h-0">
<div className="relative flex-1">
{showSearch && (
<SearchBox
onSearch={handleSearch}

View File

@@ -0,0 +1,57 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default:
"bg-primary text-primary-foreground shadow hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2",
sm: "h-8 rounded-md px-3 text-xs",
lg: "h-10 rounded-md px-8",
icon: "h-9 w-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -11,7 +11,7 @@ export const getInitialConfig = (data: any[][]): GridSettings => {
rowHeaders: true,
colHeaders: true,
width: "100%",
height: "70vh",
height: "100%",
licenseKey: "non-commercial-and-evaluation",
formulas: {
engine: hyperformulaInstance,
@@ -36,5 +36,7 @@ export const getInitialConfig = (data: any[][]): GridSettings => {
const cellProperties: any = {};
return cellProperties;
},
observeDomVisibility: true,
observeChanges: true,
};
};

View File

@@ -0,0 +1,16 @@
export const themes: any = {
default: {
name: "default",
className: "default-theme",
},
dark: {
name: "dark",
className: "dark-theme",
},
custom: {
name: "custom",
className: "custom-theme",
},
} as const;
export type ThemeType = keyof typeof themes;

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,279 @@
.handsontable.default-theme {
background: #ffffff;
}
.handsontable.default-theme th {
background-color: #f3f4f6;
color: #374151;
border-color: #e5e7eb;
}
.handsontable.default-theme td {
background-color: #ffffff;
color: #1f2937;
border-color: #e5e7eb;
}
.handsontable.default-theme .currentRow {
background-color: #f9fafb;
}
.handsontable.default-theme .currentCol {
background-color: #f9fafb;
}
/* Dark Theme */
.handsontable.dark-theme {
background: #1f2937;
}
.handsontable.dark-theme th {
background-color: #374151;
color: #e5e7eb;
border-color: #4b5563;
}
.handsontable.dark-theme td {
background-color: #1f2937;
color: #f9fafb;
border-color: #4b5563;
}
.handsontable.dark-theme .currentRow {
background-color: #374151;
}
.handsontable.dark-theme .currentCol {
background-color: #374151;
}
.handsontable.dark-theme .handsontable-row-header,
.handsontable.dark-theme .handsontable-col-header {
background-color: #374151;
color: #e5e7eb;
}
/* Custom Theme (Blue) */
.handsontable.custom-theme {
background: #f0f9ff;
}
.handsontable.custom-theme th {
background-color: #0ea5e9;
color: white;
border-color: #7dd3fc;
}
.handsontable.custom-theme td {
background-color: #ffffff;
color: #0c4a6e;
border-color: #7dd3fc;
}
.handsontable.custom-theme .currentRow {
background-color: #f0f9ff;
}
.handsontable.custom-theme .currentCol {
background-color: #f0f9ff;
}
/* Common Styles */
.handsontable {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
"Helvetica Neue", Arial, sans-serif;
font-size: 14px;
}
/* Selection Styles */
.handsontable .area {
background-color: #0ea5e9 !important;
opacity: 0.1;
}
.handsontable .area.corner {
background-color: #0ea5e9 !important;
opacity: 0.2;
}
/* Text Formatting */
.handsontable td strong,
.handsontable td .bold {
font-weight: bold !important;
}
.handsontable td em,
.handsontable td .italic {
font-style: italic !important;
}
.handsontable td u,
.handsontable td .underline {
text-decoration: underline !important;
}
/* Cell Alignment */
.handsontable td.htLeft {
text-align: left !important;
}
.handsontable td.htCenter {
text-align: center !important;
}
.handsontable td.htRight {
text-align: right !important;
}
/* Search Highlighting */
.handsontable td.search-highlight {
background-color: rgba(255, 255, 0, 0.5) !important;
}
/* Header Highlighting */
.handsontable th.ht__highlight {
background-color: #0ea5e9 !important;
color: white !important;
}
/* Active Cell */
.handsontable td.current {
border: 2px solid #0ea5e9 !important;
}
/* Invalid Cell */
.handsontable td.htInvalid {
background-color: #fee2e2 !important;
}
/* Read-only Cell */
.handsontable td.htReadOnly {
background-color: #f3f4f6 !important;
}
/* Merged Cells */
.handsontable td.htMergedCell {
background-color: #f9fafb !important;
}
/* Context Menu */
.handsontable .htContextMenu {
background: #ffffff;
border: 1px solid #e5e7eb;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.handsontable.dark-theme .htContextMenu {
background: #374151;
border: 1px solid #4b5563;
color: #f9fafb;
}
.handsontable .htContextMenu .ht_master .htCore td {
padding: 8px 12px;
border: none;
}
.handsontable .htContextMenu .ht_master .htCore td:hover {
background: #f3f4f6;
}
.handsontable.dark-theme .htContextMenu .ht_master .htCore td:hover {
background: #4b5563;
}
/* Scrollbars */
.handsontable ::-webkit-scrollbar {
width: 8px;
height: 8px;
}
.handsontable ::-webkit-scrollbar-track {
background: #f3f4f6;
}
.handsontable.dark-theme ::-webkit-scrollbar-track {
background: #374151;
}
.handsontable ::-webkit-scrollbar-thumb {
background: #cbd5e1;
border-radius: 4px;
}
.handsontable.dark-theme ::-webkit-scrollbar-thumb {
background: #4b5563;
}
.handsontable ::-webkit-scrollbar-thumb:hover {
background: #94a3b8;
}
.handsontable.dark-theme ::-webkit-scrollbar-thumb:hover {
background: #6b7280;
}
/* Comments */
.handsontable .htCommentCell:after {
border-color: #ef4444 transparent transparent #ef4444 !important;
}
/* Column Resize Handle */
.handsontable .manualColumnResizer:hover,
.handsontable .manualColumnResizer.active {
background-color: #0ea5e9 !important;
}
/* Row Resize Handle */
.handsontable .manualRowResizer:hover,
.handsontable .manualRowResizer.active {
background-color: #0ea5e9 !important;
}
/* Focus Border */
.handsontable .wtBorder.current {
background-color: #0ea5e9 !important;
}
.handsontable .wtBorder.area {
background-color: #0ea5e9 !important;
opacity: 0.2;
}
/* Selection Border */
.handsontable .wtBorder.selection {
background-color: #0ea5e9 !important;
}
/* Fill Handle */
.handsontable .wtBorder.corner {
background-color: #0ea5e9 !important;
}
/* Autocomplete */
.handsontableEditor.autocompleteEditor {
background: #ffffff;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.handsontableEditor.autocompleteEditor .ht_master .htCore td {
padding: 8px 12px;
border: none;
}
.handsontableEditor.autocompleteEditor .ht_master .htCore td:hover {
background: #f3f4f6;
}
.handsontable.dark-theme .handsontableEditor.autocompleteEditor {
background: #374151;
color: #f9fafb;
}
.handsontable.dark-theme
.handsontableEditor.autocompleteEditor
.ht_master
.htCore
td:hover {
background: #4b5563;
}