Compare commits
10 Commits
55133751cb
...
dc7678d184
| Author | SHA1 | Date | |
|---|---|---|---|
| dc7678d184 | |||
| ee19de5f94 | |||
| 455d304885 | |||
| 91260c8e30 | |||
| 68c5024334 | |||
| b4e9d25b9f | |||
| a432ac976b | |||
| 3109ac4c22 | |||
| 7c2e5ca9a9 | |||
| d384465f2e |
BIN
src-tauri/icons/icon.png
Normal file
BIN
src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 100 KiB |
17
src-tauri/src/commands/conversation.rs
Normal file
17
src-tauri/src/commands/conversation.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
use crate::models::Conversation;
|
||||
use crate::state::{AppState, BASE_URL};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_conversations(
|
||||
state: tauri::State<'_, AppState>,
|
||||
workspace_id: String,
|
||||
) -> Result<Vec<Conversation>, String> {
|
||||
state
|
||||
.authed_get(&format!("{}/conversations?workspace_id={}", BASE_URL, workspace_id))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
47
src-tauri/src/commands/message.rs
Normal file
47
src-tauri/src/commands/message.rs
Normal file
@@ -0,0 +1,47 @@
|
||||
use crate::models::Message;
|
||||
use crate::state::{AppState, BASE_URL};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_messages(
|
||||
state: tauri::State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
) -> Result<Vec<Message>, String> {
|
||||
state
|
||||
.authed_get(&format!("{}/conversations/{}/messages", BASE_URL, conversation_id))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_message(
|
||||
state: tauri::State<'_, AppState>,
|
||||
conversation_id: String,
|
||||
content: String,
|
||||
) -> Result<Message, String> {
|
||||
let user_id = state
|
||||
.user_id
|
||||
.lock()
|
||||
.unwrap()
|
||||
.clone()
|
||||
.ok_or_else(|| "Not logged in".to_string())?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"conversation_id": conversation_id,
|
||||
"user_id": user_id,
|
||||
"content": content,
|
||||
});
|
||||
|
||||
state
|
||||
.authed_post(&format!("{}/messages", BASE_URL))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
4
src-tauri/src/commands/mod.rs
Normal file
4
src-tauri/src/commands/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
||||
pub mod conversation;
|
||||
pub mod message;
|
||||
pub mod user;
|
||||
pub mod workspace;
|
||||
42
src-tauri/src/commands/user.rs
Normal file
42
src-tauri/src/commands/user.rs
Normal file
@@ -0,0 +1,42 @@
|
||||
use crate::models::{AuthTokenResponse, User};
|
||||
use crate::state::{AppState, BASE_URL};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn login(state: tauri::State<'_, AppState>, email: String) -> Result<User, String> {
|
||||
let users: Vec<User> = state
|
||||
.client
|
||||
.get(format!("{}/users", BASE_URL))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let user = users
|
||||
.into_iter()
|
||||
.find(|u| u.email == email)
|
||||
.ok_or_else(|| "User not found".to_string())?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"user_id": user.id,
|
||||
"kind": "refresh_token",
|
||||
"expires_at": "2027-01-01T00:00:00Z"
|
||||
});
|
||||
|
||||
let token_resp: AuthTokenResponse = state
|
||||
.client
|
||||
.post(format!("{}/auth-tokens", BASE_URL))
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
*state.token.lock().unwrap() = Some(token_resp.token);
|
||||
*state.user_id.lock().unwrap() = Some(user.id.clone());
|
||||
|
||||
Ok(user)
|
||||
}
|
||||
14
src-tauri/src/commands/workspace.rs
Normal file
14
src-tauri/src/commands/workspace.rs
Normal file
@@ -0,0 +1,14 @@
|
||||
use crate::models::Workspace;
|
||||
use crate::state::{AppState, BASE_URL};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn list_workspaces(state: tauri::State<'_, AppState>) -> Result<Vec<Workspace>, String> {
|
||||
state
|
||||
.authed_get(&format!("{}/workspaces", BASE_URL))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
@@ -1,11 +1,20 @@
|
||||
mod commands;
|
||||
mod models;
|
||||
mod state;
|
||||
|
||||
use state::AppState;
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.manage(AppState::new())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::user::login,
|
||||
commands::workspace::list_workspaces,
|
||||
commands::conversation::list_conversations,
|
||||
commands::message::list_messages,
|
||||
commands::message::send_message,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
}
|
||||
|
||||
@@ -16,4 +16,21 @@ impl AppState {
|
||||
user_id: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authed_get(&self, url: &str) -> reqwest::RequestBuilder {
|
||||
let builder = self.client.get(url);
|
||||
// ponytail: omit header when no token — server may not require auth yet
|
||||
match self.token.lock().unwrap().clone() {
|
||||
Some(t) => builder.bearer_auth(t),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn authed_post(&self, url: &str) -> reqwest::RequestBuilder {
|
||||
let builder = self.client.post(url);
|
||||
match self.token.lock().unwrap().clone() {
|
||||
Some(t) => builder.bearer_auth(t),
|
||||
None => builder,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
14
src/App.tsx
Normal file
14
src/App.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Routes, Route } from "react-router-dom";
|
||||
import LoginPage from "./pages/LoginPage";
|
||||
import WorkspacePage from "./pages/WorkspacePage";
|
||||
import ChatPage from "./pages/ChatPage";
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/" element={<LoginPage />} />
|
||||
<Route path="/workspaces" element={<WorkspacePage />} />
|
||||
<Route path="/workspaces/:workspaceId" element={<ChatPage />} />
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
47
src/components/ConversationList.tsx
Normal file
47
src/components/ConversationList.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface Conversation {
|
||||
id: string;
|
||||
name: string | null;
|
||||
kind: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
workspaceId: string;
|
||||
onSelect: (id: string) => void;
|
||||
selectedId: string | null;
|
||||
}
|
||||
|
||||
export default function ConversationList({ workspaceId, onSelect, selectedId }: Props) {
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
invoke<Conversation[]>("list_conversations", { workspaceId })
|
||||
.then(setConversations)
|
||||
.catch((e) => setError(String(e)));
|
||||
}, [workspaceId]);
|
||||
|
||||
return (
|
||||
<div style={{ width: 220, borderRight: "1px solid #ccc", overflowY: "auto" }}>
|
||||
<div style={{ padding: "12px 16px", fontWeight: "bold", borderBottom: "1px solid #ccc" }}>
|
||||
Conversations
|
||||
</div>
|
||||
{error && <p style={{ color: "red", padding: 8 }}>{error}</p>}
|
||||
{conversations.map((c) => (
|
||||
<div
|
||||
key={c.id}
|
||||
onClick={() => onSelect(c.id)}
|
||||
style={{
|
||||
padding: "10px 16px",
|
||||
cursor: "pointer",
|
||||
background: selectedId === c.id ? "#e8f0fe" : "transparent",
|
||||
}}
|
||||
>
|
||||
{c.name ?? `(${c.kind})`}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
37
src/components/MessageInput.tsx
Normal file
37
src/components/MessageInput.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface Props {
|
||||
conversationId: string;
|
||||
onSent: () => void;
|
||||
}
|
||||
|
||||
export default function MessageInput({ conversationId, onSent }: Props) {
|
||||
const [content, setContent] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function handleSend() {
|
||||
if (!content.trim()) return;
|
||||
try {
|
||||
await invoke("send_message", { conversationId, content });
|
||||
setContent("");
|
||||
setError(null);
|
||||
onSent();
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ padding: 16, borderTop: "1px solid #ccc" }}>
|
||||
{error && <p style={{ color: "red", margin: "0 0 8px" }}>{error}</p>}
|
||||
<input
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||
placeholder="Type a message and press Enter…"
|
||||
style={{ width: "100%", padding: 10, boxSizing: "border-box", fontSize: 14 }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
src/components/MessageList.tsx
Normal file
62
src/components/MessageList.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
interface Message {
|
||||
id: string;
|
||||
user_id: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
conversationId: string;
|
||||
refreshKey: number;
|
||||
}
|
||||
|
||||
export default function MessageList({ conversationId, refreshKey }: Props) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const prevLengthRef = useRef(0);
|
||||
|
||||
// Fix 3: cancellation flag prevents stale responses from overwriting new conversation
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const load = () =>
|
||||
invoke<Message[]>("list_messages", { conversationId })
|
||||
.then((msgs) => {
|
||||
if (!cancelled) setMessages(msgs);
|
||||
})
|
||||
.catch((e) => { if (!cancelled) setError(String(e)); });
|
||||
|
||||
load();
|
||||
// ponytail: polling every 3s — replace with WebSocket when server supports it
|
||||
const id = setInterval(load, 3000);
|
||||
return () => { cancelled = true; clearInterval(id); prevLengthRef.current = 0; };
|
||||
// Fix 2: refreshKey in deps triggers immediate reload after send
|
||||
}, [conversationId, refreshKey]);
|
||||
|
||||
// Fix 4: scroll only when message count increases, not on every poll
|
||||
useEffect(() => {
|
||||
if (messages.length > prevLengthRef.current) {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
prevLengthRef.current = messages.length;
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
return (
|
||||
<div style={{ flex: 1, overflowY: "auto", padding: 16 }}>
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
{messages.map((m) => (
|
||||
<div key={m.id} style={{ marginBottom: 12 }}>
|
||||
<strong style={{ marginRight: 8 }}>{m.user_id}</strong>
|
||||
<span style={{ color: "#888", fontSize: 12, marginRight: 8 }}>
|
||||
{new Date(m.created_at).toLocaleTimeString()}
|
||||
</span>
|
||||
<div>{m.content}</div>
|
||||
</div>
|
||||
))}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import { BrowserRouter } from "react-router-dom";
|
||||
import App from "./App";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")!).render(
|
||||
<React.StrictMode>
|
||||
<div>Loading…</div>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
36
src/pages/ChatPage.tsx
Normal file
36
src/pages/ChatPage.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { useState } from "react";
|
||||
import { useParams } from "react-router-dom";
|
||||
import ConversationList from "../components/ConversationList";
|
||||
import MessageList from "../components/MessageList";
|
||||
import MessageInput from "../components/MessageInput";
|
||||
|
||||
export default function ChatPage() {
|
||||
const { workspaceId } = useParams<{ workspaceId: string }>();
|
||||
const [conversationId, setConversationId] = useState<string | null>(null);
|
||||
const [refreshKey, setRefreshKey] = useState(0);
|
||||
|
||||
return (
|
||||
<div style={{ display: "flex", height: "100vh" }}>
|
||||
<ConversationList
|
||||
workspaceId={workspaceId!}
|
||||
onSelect={setConversationId}
|
||||
selectedId={conversationId}
|
||||
/>
|
||||
<div style={{ flex: 1, display: "flex", flexDirection: "column" }}>
|
||||
{conversationId ? (
|
||||
<>
|
||||
<MessageList conversationId={conversationId} refreshKey={refreshKey} />
|
||||
<MessageInput
|
||||
conversationId={conversationId}
|
||||
onSent={() => setRefreshKey((k) => k + 1)}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ flex: 1, display: "flex", alignItems: "center", justifyContent: "center", color: "#888" }}>
|
||||
Select a conversation
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
41
src/pages/LoginPage.tsx
Normal file
41
src/pages/LoginPage.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
export default function LoginPage() {
|
||||
const [email, setEmail] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const navigate = useNavigate();
|
||||
|
||||
async function handleLogin() {
|
||||
setError(null);
|
||||
setLoading(true);
|
||||
try {
|
||||
await invoke("login", { email });
|
||||
navigate("/workspaces");
|
||||
} catch (e) {
|
||||
setError(String(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 400, margin: "100px auto", padding: 24 }}>
|
||||
<h1>Rustification</h1>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleLogin()}
|
||||
placeholder="Email address"
|
||||
style={{ display: "block", width: "100%", marginBottom: 12, padding: 8, boxSizing: "border-box" }}
|
||||
/>
|
||||
<button onClick={handleLogin} disabled={loading || !email}>
|
||||
{loading ? "Logging in…" : "Login"}
|
||||
</button>
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
39
src/pages/WorkspacePage.tsx
Normal file
39
src/pages/WorkspacePage.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface Workspace {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
}
|
||||
|
||||
export default function WorkspacePage() {
|
||||
const [workspaces, setWorkspaces] = useState<Workspace[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
invoke<Workspace[]>("list_workspaces")
|
||||
.then(setWorkspaces)
|
||||
.catch((e) => setError(String(e)));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: 600, margin: "60px auto", padding: 24 }}>
|
||||
<h1>Workspaces</h1>
|
||||
{error && <p style={{ color: "red" }}>{error}</p>}
|
||||
{workspaces.map((ws) => (
|
||||
<div
|
||||
key={ws.id}
|
||||
onClick={() => navigate(`/workspaces/${ws.id}`)}
|
||||
style={{ padding: 16, border: "1px solid #ccc", marginBottom: 8, cursor: "pointer", borderRadius: 4 }}
|
||||
>
|
||||
<strong>{ws.name}</strong>
|
||||
<span style={{ marginLeft: 8, color: "#888" }}>/{ws.slug}</span>
|
||||
</div>
|
||||
))}
|
||||
{workspaces.length === 0 && !error && <p>No workspaces found.</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user