10 Commits

Author SHA1 Message Date
dc7678d184 chore: add app icon 2026-08-12 18:11:43 +02:00
ee19de5f94 fix: reset scroll ref on conversation change
prevLengthRef.current was never zeroed when conversationId changed,
so the first batch of messages in a new (shorter) conversation would
not trigger scrollIntoView. Reset it in the polling useEffect cleanup.
2026-08-12 18:03:19 +02:00
455d304885 fix: address final review findings
- Finding 1: add AppState::authed_get/authed_post helpers that attach
  Bearer token when present; all 4 commands now use them
- Finding 2: MessageInput accepts onSent callback, calls it after
  successful send; ChatPage passes refreshKey bump as onSent
- Finding 3: MessageList useEffect uses cancellation flag so stale
  in-flight responses cannot overwrite a newly selected conversation
- Finding 4: scrollIntoView moved to separate effect keyed on
  messages.length so scroll only fires when new messages arrive
2026-08-12 17:58:02 +02:00
91260c8e30 feat: implement chat page with conversation list, message view, and polling 2026-08-12 17:51:00 +02:00
68c5024334 feat: implement workspace list page 2026-08-12 17:47:50 +02:00
b4e9d25b9f feat: implement login page 2026-08-12 17:44:21 +02:00
a432ac976b feat: add React app shell with routing 2026-08-12 17:27:40 +02:00
3109ac4c22 feat: add message Tauri commands 2026-08-12 17:21:44 +02:00
7c2e5ca9a9 feat: add workspace and conversation Tauri commands
Implement list_workspaces and list_conversations commands to enable
frontend access to workspace and conversation data via Tauri IPC.

- Add workspace.rs with list_workspaces command (GET /workspaces)
- Add conversation.rs with list_conversations command (GET /conversations?workspace_id=)
- Register both commands in lib.rs invoke_handler
- Update commands/mod.rs to expose new modules
2026-08-12 17:04:35 +02:00
d384465f2e feat: add login Tauri command 2026-08-12 16:59:55 +02:00
16 changed files with 431 additions and 1 deletions

BIN
src-tauri/icons/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

View 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())
}

View 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())
}

View File

@@ -0,0 +1,4 @@
pub mod conversation;
pub mod message;
pub mod user;
pub mod workspace;

View 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)
}

View 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())
}

View File

@@ -1,11 +1,20 @@
mod commands;
mod models; mod models;
mod state; mod state;
use state::AppState; use state::AppState;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() { pub fn run() {
tauri::Builder::default() tauri::Builder::default()
.manage(AppState::new()) .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!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
} }

View File

@@ -16,4 +16,21 @@ impl AppState {
user_id: Mutex::new(None), 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
View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -1,8 +1,12 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<div>Loading…</div> <BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode> </React.StrictMode>
); );

36
src/pages/ChatPage.tsx Normal file
View 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
View 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>
);
}

View 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>
);
}