Phase 5: Session-Verwaltung + permanente Konversationen

- session.rs: Neues Modul mit 7 Tauri-Commands (CRUD, Resume, aktive Session)
- db.rs: Sessions-Tabelle + CRUD-Methoden (bleiben bis User sie löscht)
- claude.rs: Session-ID und Token/Kosten automatisch in DB speichern
- SessionList.svelte: Sidebar mit Session-Liste, Erstellen, Fortsetzen, Löschen
- +page.svelte: 4-Panel Layout (Sessions | Chat | Aktivität | Agents)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Eddy 2026-04-13 19:11:17 +02:00
parent 532c91c605
commit f101661016
6 changed files with 716 additions and 14 deletions

View file

@ -7,6 +7,8 @@ use std::process::{Command, Stdio};
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Emitter, Manager}; use tauri::{AppHandle, Emitter, Manager};
use crate::db;
/// Status eines Agents /// Status eines Agents
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentStatus { pub struct AgentStatus {
@ -176,6 +178,30 @@ fn handle_bridge_message(app: &AppHandle, msg: BridgeMessage) {
let _ = app.emit("claude-text", &payload); let _ = app.emit("claude-text", &payload);
} }
"result" => { "result" => {
// Session-ID aus Result extrahieren und in DB speichern
if let Some(sid) = payload.get("session_id").and_then(|v| v.as_str()) {
if let Some(db_state) = app.try_state::<Arc<Mutex<db::Database>>>() {
let db_lock = db_state.lock().unwrap();
if let Ok(Some(active_id)) = db_lock.get_setting("active_session_id") {
if !active_id.is_empty() {
if let Ok(Some(mut session)) = db_lock.get_session(&active_id) {
session.claude_session_id = Some(sid.to_string());
session.message_count += 1;
if let Some(cost) = payload.get("cost").and_then(|v| v.as_f64()) {
session.cost_usd += cost;
}
if let Some(tin) = payload.get("tokens").and_then(|t| t.get("input")).and_then(|v| v.as_i64()) {
session.token_input += tin;
}
if let Some(tout) = payload.get("tokens").and_then(|t| t.get("output")).and_then(|v| v.as_i64()) {
session.token_output += tout;
}
let _ = db_lock.update_session(&session);
}
}
}
}
}
let _ = app.emit("claude-result", &payload); let _ = app.emit("claude-result", &payload);
} }
"all-stopped" => { "all-stopped" => {

View file

@ -10,6 +10,23 @@ use crate::audit::{AuditAction, AuditCategory, AuditEntry, AuditStats};
use crate::guard::{Permission, PermissionAction, PermissionType}; use crate::guard::{Permission, PermissionAction, PermissionType};
use crate::memory::{ContextCategory, MemoryEntry, Pattern}; use crate::memory::{ContextCategory, MemoryEntry, Pattern};
/// Eine Claude-Session
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct Session {
pub id: String,
pub claude_session_id: Option<String>,
pub title: String,
pub working_dir: Option<String>,
pub message_count: i64,
pub token_input: i64,
pub token_output: i64,
pub cost_usd: f64,
pub status: String,
pub created_at: String,
pub updated_at: String,
pub last_message: Option<String>,
}
/// Datenbank-Wrapper /// Datenbank-Wrapper
pub struct Database { pub struct Database {
conn: Connection, conn: Connection,
@ -100,6 +117,24 @@ impl Database {
updated_at TEXT NOT NULL updated_at TEXT NOT NULL
); );
-- Sessions (Claude-Konversationen)
CREATE TABLE IF NOT EXISTS sessions (
id TEXT PRIMARY KEY,
claude_session_id TEXT,
title TEXT NOT NULL,
working_dir TEXT,
message_count INTEGER DEFAULT 0,
token_input INTEGER DEFAULT 0,
token_output INTEGER DEFAULT 0,
cost_usd REAL DEFAULT 0,
status TEXT NOT NULL DEFAULT 'active',
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_message TEXT
);
CREATE INDEX IF NOT EXISTS idx_sessions_updated ON sessions(updated_at DESC);
CREATE INDEX IF NOT EXISTS idx_sessions_status ON sessions(status);
-- Einstellungen (Key-Value) -- Einstellungen (Key-Value)
CREATE TABLE IF NOT EXISTS settings ( CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
@ -344,6 +379,119 @@ impl Database {
Ok(patterns) Ok(patterns)
} }
// ============ Sessions ============
/// Erstellt eine neue Session
pub fn create_session(&self, session: &Session) -> SqlResult<()> {
self.conn.execute(
"INSERT INTO sessions (id, claude_session_id, title, working_dir, message_count, token_input, token_output, cost_usd, status, created_at, updated_at, last_message)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
params![
session.id,
session.claude_session_id,
session.title,
session.working_dir,
session.message_count,
session.token_input,
session.token_output,
session.cost_usd,
session.status,
session.created_at,
session.updated_at,
session.last_message,
],
)?;
Ok(())
}
/// Aktualisiert eine Session
pub fn update_session(&self, session: &Session) -> SqlResult<()> {
self.conn.execute(
"UPDATE sessions SET claude_session_id = ?2, title = ?3, message_count = ?4,
token_input = ?5, token_output = ?6, cost_usd = ?7, status = ?8,
updated_at = ?9, last_message = ?10
WHERE id = ?1",
params![
session.id,
session.claude_session_id,
session.title,
session.message_count,
session.token_input,
session.token_output,
session.cost_usd,
session.status,
chrono::Local::now().to_rfc3339(),
session.last_message,
],
)?;
Ok(())
}
/// Lädt alle Sessions (neueste zuerst)
pub fn load_sessions(&self, limit: usize) -> SqlResult<Vec<Session>> {
let mut stmt = self.conn.prepare(
"SELECT id, claude_session_id, title, working_dir, message_count,
token_input, token_output, cost_usd, status, created_at, updated_at, last_message
FROM sessions ORDER BY updated_at DESC LIMIT ?1"
)?;
let sessions = stmt.query_map(params![limit as i64], |row| {
Ok(Session {
id: row.get(0)?,
claude_session_id: row.get(1)?,
title: row.get(2)?,
working_dir: row.get(3)?,
message_count: row.get(4)?,
token_input: row.get(5)?,
token_output: row.get(6)?,
cost_usd: row.get(7)?,
status: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
last_message: row.get(11)?,
})
})?.collect::<SqlResult<Vec<_>>>()?;
Ok(sessions)
}
/// Holt eine Session nach ID
pub fn get_session(&self, id: &str) -> SqlResult<Option<Session>> {
let result = self.conn.query_row(
"SELECT id, claude_session_id, title, working_dir, message_count,
token_input, token_output, cost_usd, status, created_at, updated_at, last_message
FROM sessions WHERE id = ?1",
params![id],
|row| {
Ok(Session {
id: row.get(0)?,
claude_session_id: row.get(1)?,
title: row.get(2)?,
working_dir: row.get(3)?,
message_count: row.get(4)?,
token_input: row.get(5)?,
token_output: row.get(6)?,
cost_usd: row.get(7)?,
status: row.get(8)?,
created_at: row.get(9)?,
updated_at: row.get(10)?,
last_message: row.get(11)?,
})
},
);
match result {
Ok(s) => Ok(Some(s)),
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
Err(e) => Err(e),
}
}
/// Löscht eine Session
pub fn delete_session(&self, id: &str) -> SqlResult<()> {
self.conn.execute("DELETE FROM sessions WHERE id = ?1", params![id])?;
Ok(())
}
// ============ Settings ============ // ============ Settings ============
/// Speichert eine Einstellung /// Speichert eine Einstellung

View file

@ -9,6 +9,7 @@ mod claude;
mod db; mod db;
mod guard; mod guard;
mod memory; mod memory;
mod session;
/// Initialisiert die App /// Initialisiert die App
#[cfg_attr(mobile, tauri::mobile_entry_point)] #[cfg_attr(mobile, tauri::mobile_entry_point)]
@ -40,6 +41,15 @@ pub fn run() {
// Datenbank // Datenbank
db::init_database, db::init_database,
db::get_db_stats, db::get_db_stats,
// Sessions
session::create_session,
session::update_session,
session::list_sessions,
session::get_session,
session::delete_session,
session::resume_session,
session::get_active_session,
session::set_claude_session_id,
]) ])
.setup(|app| { .setup(|app| {
let handle = app.handle().clone(); let handle = app.handle().clone();

155
src-tauri/src/session.rs Normal file
View file

@ -0,0 +1,155 @@
// Claude Desktop — Session-Verwaltung
// Sessions bleiben permanent gespeichert bis der User sie löscht
use std::sync::{Arc, Mutex};
use tauri::{AppHandle, Manager};
use crate::db::{self, Session};
// ============ Tauri Commands ============
/// Neue Session erstellen
#[tauri::command]
pub async fn create_session(
app: AppHandle,
title: String,
working_dir: Option<String>,
) -> Result<Session, String> {
let session = Session {
id: uuid::Uuid::new_v4().to_string(),
claude_session_id: None,
title,
working_dir,
message_count: 0,
token_input: 0,
token_output: 0,
cost_usd: 0.0,
status: "active".to_string(),
created_at: chrono::Local::now().to_rfc3339(),
updated_at: chrono::Local::now().to_rfc3339(),
last_message: None,
};
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.create_session(&session).map_err(|e| e.to_string())?;
// Als aktive Session speichern
db.set_setting("active_session_id", &session.id).map_err(|e| e.to_string())?;
println!("📝 Neue Session: {} ({})", session.title, session.id);
Ok(session)
}
/// Session aktualisieren (nach Nachrichten, Token-Update, etc.)
#[tauri::command]
pub async fn update_session(
app: AppHandle,
session: Session,
) -> Result<(), String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.update_session(&session).map_err(|e| e.to_string())
}
/// Alle Sessions laden
#[tauri::command]
pub async fn list_sessions(
app: AppHandle,
limit: Option<usize>,
) -> Result<Vec<Session>, String> {
let limit = limit.unwrap_or(50);
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.load_sessions(limit).map_err(|e| e.to_string())
}
/// Session nach ID laden
#[tauri::command]
pub async fn get_session(
app: AppHandle,
id: String,
) -> Result<Option<Session>, String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.get_session(&id).map_err(|e| e.to_string())
}
/// Session löschen
#[tauri::command]
pub async fn delete_session(
app: AppHandle,
id: String,
) -> Result<(), String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
db.delete_session(&id).map_err(|e| e.to_string())?;
// Falls es die aktive Session war, Setting löschen
if let Ok(Some(active_id)) = db.get_setting("active_session_id") {
if active_id == id {
let _ = db.set_setting("active_session_id", "");
}
}
println!("🗑️ Session gelöscht: {}", id);
Ok(())
}
/// Session fortsetzen — setzt die aktive Session und gibt die claude_session_id zurück
#[tauri::command]
pub async fn resume_session(
app: AppHandle,
id: String,
) -> Result<Session, String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
let session = db.get_session(&id)
.map_err(|e| e.to_string())?
.ok_or_else(|| format!("Session {} nicht gefunden", id))?;
// Als aktive Session setzen
db.set_setting("active_session_id", &session.id).map_err(|e| e.to_string())?;
println!("▶️ Session fortgesetzt: {} (claude: {:?})", session.title, session.claude_session_id);
Ok(session)
}
/// Aktive Session holen (nach App-Start)
#[tauri::command]
pub async fn get_active_session(
app: AppHandle,
) -> Result<Option<Session>, String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
if let Ok(Some(id)) = db.get_setting("active_session_id") {
if !id.is_empty() {
return db.get_session(&id).map_err(|e| e.to_string());
}
}
Ok(None)
}
/// Claude Session-ID speichern (kommt von der Bridge nach erstem Request)
#[tauri::command]
pub async fn set_claude_session_id(
app: AppHandle,
session_id: String,
claude_session_id: String,
) -> Result<(), String> {
let state = app.state::<Arc<Mutex<db::Database>>>();
let db = state.lock().unwrap();
if let Ok(Some(mut session)) = db.get_session(&session_id) {
session.claude_session_id = Some(claude_session_id.clone());
db.update_session(&session).map_err(|e| e.to_string())?;
println!("🔗 Claude Session-ID gesetzt: {}{}", session_id, claude_session_id);
}
Ok(())
}

View file

@ -0,0 +1,344 @@
<script lang="ts">
import { onMount } from 'svelte';
import { invoke } from '@tauri-apps/api/core';
import { messages, clearAll, isProcessing } from '$lib/stores/app';
interface Session {
id: string;
claude_session_id: string | null;
title: string;
working_dir: string | null;
message_count: number;
token_input: number;
token_output: number;
cost_usd: number;
status: string;
created_at: string;
updated_at: string;
last_message: string | null;
}
let sessions: Session[] = [];
let activeSessionId: string | null = null;
let loading = true;
let showNewForm = false;
let newTitle = '';
async function loadSessions() {
try {
sessions = await invoke('list_sessions', { limit: 50 });
const active: Session | null = await invoke('get_active_session');
activeSessionId = active?.id || null;
} catch (err) {
console.error('Fehler beim Laden der Sessions:', err);
}
loading = false;
}
onMount(() => {
loadSessions();
});
async function createSession() {
const title = newTitle.trim() || `Session ${new Date().toLocaleDateString('de-DE')}`;
try {
const session: Session = await invoke('create_session', {
title,
workingDir: null,
});
activeSessionId = session.id;
clearAll();
newTitle = '';
showNewForm = false;
await loadSessions();
} catch (err) {
console.error('Fehler:', err);
}
}
async function resumeSession(id: string) {
if (id === activeSessionId) return;
try {
const session: Session = await invoke('resume_session', { id });
activeSessionId = session.id;
clearAll();
// TODO: Nachrichten aus Session-Historie laden
} catch (err) {
console.error('Fehler:', err);
}
}
async function deleteSession(id: string) {
try {
await invoke('delete_session', { id });
if (activeSessionId === id) {
activeSessionId = null;
clearAll();
}
await loadSessions();
} catch (err) {
console.error('Fehler:', err);
}
}
function formatDate(dateStr: string): string {
const date = new Date(dateStr);
const now = new Date();
const diff = now.getTime() - date.getTime();
if (diff < 60000) return 'gerade eben';
if (diff < 3600000) return `vor ${Math.floor(diff / 60000)} Min`;
if (diff < 86400000) return `vor ${Math.floor(diff / 3600000)} Std`;
if (diff < 172800000) return 'gestern';
return date.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
function formatCost(usd: number): string {
if (usd === 0) return '';
return `$${usd.toFixed(3)}`;
}
</script>
<div class="session-list">
<div class="session-header">
<h2>💬 Sessions</h2>
<button class="btn-new" on:click={() => showNewForm = !showNewForm}>
{showNewForm ? '✕' : '+ Neu'}
</button>
</div>
{#if showNewForm}
<div class="new-form">
<input
type="text"
bind:value={newTitle}
placeholder="Session-Titel (optional)"
on:keydown={(e) => e.key === 'Enter' && createSession()}
/>
<button class="btn-create" on:click={createSession}>Erstellen</button>
</div>
{/if}
{#if loading}
<div class="loading">Lade Sessions...</div>
{:else if sessions.length === 0}
<div class="empty">
<p>Keine Sessions vorhanden.</p>
<button class="btn-first" on:click={() => { showNewForm = true; }}>
Erste Session starten
</button>
</div>
{:else}
<div class="sessions">
{#each sessions as session}
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="session-item"
class:active={session.id === activeSessionId}
on:click={() => resumeSession(session.id)}
>
<div class="session-main">
<span class="session-status">
{#if session.id === activeSessionId}
🟢
{:else if session.claude_session_id}
⏸️
{:else}
{/if}
</span>
<div class="session-info">
<span class="session-title">{session.title}</span>
<span class="session-meta">
{session.message_count} Nachrichten
{#if session.cost_usd > 0}
· {formatCost(session.cost_usd)}
{/if}
</span>
</div>
</div>
<div class="session-right">
<span class="session-time">{formatDate(session.updated_at)}</span>
<button
class="btn-delete"
on:click|stopPropagation={() => deleteSession(session.id)}
title="Session löschen"
>
🗑️
</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.session-list {
display: flex;
flex-direction: column;
height: 100%;
border-right: 1px solid var(--border);
background: var(--bg-primary);
}
.session-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.session-header h2 {
font-size: 0.85rem;
font-weight: 600;
}
.btn-new {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--accent);
color: white;
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 600;
}
.btn-new:hover {
background: var(--accent-hover);
}
/* Neues Session Formular */
.new-form {
display: flex;
gap: var(--spacing-xs);
padding: var(--spacing-sm);
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
}
.new-form input {
flex: 1;
font-size: 0.75rem;
padding: var(--spacing-xs) var(--spacing-sm);
}
.btn-create {
padding: var(--spacing-xs) var(--spacing-sm);
background: var(--success);
color: white;
border-radius: var(--radius-sm);
font-size: 0.7rem;
font-weight: 600;
}
.loading, .empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--text-secondary);
font-size: 0.8rem;
padding: var(--spacing-md);
text-align: center;
}
.btn-first {
margin-top: var(--spacing-sm);
padding: var(--spacing-sm) var(--spacing-md);
background: var(--accent);
color: white;
border-radius: var(--radius-md);
font-size: 0.8rem;
}
/* Session-Items */
.sessions {
flex: 1;
overflow-y: auto;
}
.session-item {
width: 100%;
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--spacing-sm) var(--spacing-md);
text-align: left;
border-bottom: 1px solid var(--bg-tertiary);
transition: background 0.15s ease;
cursor: pointer;
}
.session-item:hover {
background: var(--bg-hover);
}
.session-item.active {
background: var(--bg-tertiary);
border-left: 3px solid var(--accent);
}
.session-main {
display: flex;
align-items: center;
gap: var(--spacing-sm);
flex: 1;
min-width: 0;
}
.session-status {
font-size: 0.6rem;
flex-shrink: 0;
}
.session-info {
display: flex;
flex-direction: column;
min-width: 0;
}
.session-title {
font-size: 0.8rem;
font-weight: 500;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.session-meta {
font-size: 0.65rem;
color: var(--text-secondary);
}
.session-right {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 2px;
flex-shrink: 0;
}
.session-time {
font-size: 0.6rem;
color: var(--text-secondary);
}
.btn-delete {
font-size: 0.65rem;
opacity: 0;
padding: 2px;
transition: opacity 0.15s;
}
.session-item:hover .btn-delete {
opacity: 0.5;
}
.btn-delete:hover {
opacity: 1 !important;
}
</style>

View file

@ -1,4 +1,5 @@
<script lang="ts"> <script lang="ts">
import SessionList from '$lib/components/SessionList.svelte';
import ChatPanel from '$lib/components/ChatPanel.svelte'; import ChatPanel from '$lib/components/ChatPanel.svelte';
import ActivityPanel from '$lib/components/ActivityPanel.svelte'; import ActivityPanel from '$lib/components/ActivityPanel.svelte';
import AgentView from '$lib/components/AgentView.svelte'; import AgentView from '$lib/components/AgentView.svelte';
@ -25,12 +26,17 @@
</script> </script>
<div class="panels"> <div class="panels">
<!-- Linkes Panel: Chat --> <!-- Session-Sidebar (links) -->
<aside class="panel panel-sessions">
<SessionList />
</aside>
<!-- Chat (Mitte-Links) -->
<section class="panel panel-chat"> <section class="panel panel-chat">
<ChatPanel /> <ChatPanel />
</section> </section>
<!-- Mittleres Panel: Aktivität / Memory / Audit --> <!-- Aktivität / Memory / Audit (Mitte-Rechts) -->
<section class="panel panel-activity"> <section class="panel panel-activity">
<div class="panel-tabs"> <div class="panel-tabs">
{#each middleTabs as tab} {#each middleTabs as tab}
@ -54,7 +60,7 @@
</div> </div>
</section> </section>
<!-- Rechtes Panel: Agents / Guard-Rails --> <!-- Agents / Guard-Rails (rechts) -->
<section class="panel panel-details"> <section class="panel panel-details">
<div class="panel-tabs"> <div class="panel-tabs">
{#each rightTabs as tab} {#each rightTabs as tab}
@ -80,10 +86,10 @@
<style> <style>
.panels { .panels {
display: grid; display: grid;
grid-template-columns: 1fr 1fr 1fr; grid-template-columns: 220px 1fr 1fr 1fr;
gap: 1px; gap: 1px;
height: 100%; height: 100%;
background: var(--bg-tertiary); background: var(--border);
} }
.panel { .panel {
@ -93,28 +99,32 @@
overflow: hidden; overflow: hidden;
} }
.panel-sessions {
min-width: 180px;
}
.panel-tabs { .panel-tabs {
display: flex; display: flex;
background: var(--bg-secondary); background: var(--bg-secondary);
border-bottom: 1px solid var(--bg-tertiary); border-bottom: 1px solid var(--border);
} }
.tab { .tab {
flex: 1; flex: 1;
padding: var(--spacing-sm) var(--spacing-md); padding: var(--spacing-sm) var(--spacing-sm);
font-size: 0.8rem; font-size: 0.75rem;
color: var(--text-secondary); color: var(--text-secondary);
background: transparent; background: transparent;
border: none; border: none;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.15s ease;
white-space: nowrap; white-space: nowrap;
} }
.tab:hover { .tab:hover {
color: var(--text-primary); color: var(--text-primary);
background: var(--bg-tertiary); background: var(--bg-hover);
} }
.tab.active { .tab.active {
@ -128,21 +138,30 @@
} }
/* Responsive */ /* Responsive */
@media (max-width: 1200px) { @media (max-width: 1400px) {
.panels { .panels {
grid-template-columns: 1fr 1fr; grid-template-columns: 200px 1fr 1fr;
} }
.panel-details { .panel-details {
display: none; display: none;
} }
} }
@media (max-width: 800px) { @media (max-width: 1000px) {
.panels { .panels {
grid-template-columns: 1fr; grid-template-columns: 180px 1fr;
} }
.panel-activity { .panel-activity {
display: none; display: none;
} }
} }
@media (max-width: 700px) {
.panels {
grid-template-columns: 1fr;
}
.panel-sessions {
display: none;
}
}
</style> </style>