diff --git a/.gitignore b/.gitignore
index 3e7e175..75620fe 100644
--- a/.gitignore
+++ b/.gitignore
@@ -12,9 +12,11 @@
# Logs
*.log
-# Keycloak runtime data (but keep realm-export.json)
+# Keycloak runtime data (but keep config and theme)
keycloak/*
!keycloak/realm-export.json
+!keycloak/themes/
+!keycloak/themes/**
# Node modules
node_modules/
diff --git a/Cargo.lock b/Cargo.lock
index a105900..57bf95e 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -760,9 +760,11 @@ dependencies = [
name = "dashboard"
version = "0.1.0"
dependencies = [
+ "async-stream",
"async-stripe",
"axum",
"base64 0.22.1",
+ "bytes",
"chrono",
"dioxus",
"dioxus-cli-config",
@@ -774,6 +776,7 @@ dependencies = [
"maud",
"mongodb",
"petname",
+ "pulldown-cmark",
"rand 0.10.0",
"reqwest 0.13.2",
"scraper",
@@ -784,10 +787,12 @@ dependencies = [
"thiserror 2.0.18",
"time",
"tokio",
+ "tokio-stream",
"tower-http",
"tower-sessions",
"tracing",
"url",
+ "wasm-bindgen",
"web-sys",
]
@@ -1127,7 +1132,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
- "wasm-streams",
+ "wasm-streams 0.4.2",
"web-sys",
"xxhash-rust",
]
@@ -1531,7 +1536,7 @@ dependencies = [
"tracing",
"wasm-bindgen",
"wasm-bindgen-futures",
- "wasm-streams",
+ "wasm-streams 0.4.2",
"web-sys",
]
@@ -3297,6 +3302,24 @@ dependencies = [
"psl-types",
]
+[[package]]
+name = "pulldown-cmark"
+version = "0.12.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14"
+dependencies = [
+ "bitflags",
+ "memchr",
+ "pulldown-cmark-escape",
+ "unicase",
+]
+
+[[package]]
+name = "pulldown-cmark-escape"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae"
+
[[package]]
name = "quinn"
version = "0.11.9"
@@ -3573,7 +3596,7 @@ dependencies = [
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
- "wasm-streams",
+ "wasm-streams 0.4.2",
"web-sys",
"webpki-roots 1.0.6",
]
@@ -3588,6 +3611,7 @@ dependencies = [
"bytes",
"encoding_rs",
"futures-core",
+ "futures-util",
"h2 0.4.13",
"http 1.4.0",
"http-body 1.0.1",
@@ -3610,12 +3634,14 @@ dependencies = [
"sync_wrapper",
"tokio",
"tokio-rustls 0.26.4",
+ "tokio-util",
"tower",
"tower-http",
"tower-service",
"url",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "wasm-streams 0.5.0",
"web-sys",
]
@@ -5147,6 +5173,19 @@ dependencies = [
"web-sys",
]
+[[package]]
+name = "wasm-streams"
+version = "0.5.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb"
+dependencies = [
+ "futures-util",
+ "js-sys",
+ "wasm-bindgen",
+ "wasm-bindgen-futures",
+ "web-sys",
+]
+
[[package]]
name = "wasmparser"
version = "0.244.0"
diff --git a/Cargo.toml b/Cargo.toml
index 2064284..8caa25c 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -36,7 +36,7 @@ mongodb = { version = "3.2", default-features = false, features = [
"compat-3-0-0",
], optional = true }
futures = { version = "0.3.31", default-features = false }
-reqwest = { version = "0.13", optional = true, features = ["json", "form"] }
+reqwest = { version = "0.13", optional = true, features = ["json", "form", "stream"] }
tower-sessions = { version = "0.15", default-features = false, features = [
"axum-core",
"memory-store",
@@ -61,11 +61,14 @@ secrecy = { version = "0.10", default-features = false, optional = true }
serde_json = { version = "1.0.133", default-features = false }
maud = { version = "0.27", default-features = false }
url = { version = "2.5.4", default-features = false, optional = true }
+wasm-bindgen = { version = "0.2", optional = true }
web-sys = { version = "0.3", optional = true, features = [
"Clipboard",
"Document",
"Element",
+ "EventSource",
"HtmlElement",
+ "MessageEvent",
"Navigator",
"Storage",
"Window",
@@ -81,10 +84,14 @@ dioxus-free-icons = { version = "0.10", features = [
sha2 = { version = "0.10.9", default-features = false, optional = true }
base64 = { version = "0.22.1", default-features = false, optional = true }
scraper = { version = "0.22", default-features = false, optional = true }
+pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
+tokio-stream = { version = "0.1", optional = true, features = ["sync"] }
+async-stream = { version = "0.3", optional = true }
+bytes = { version = "1", optional = true }
[features]
# default = ["web"]
-web = ["dioxus/web", "dep:reqwest", "dep:web-sys"]
+web = ["dioxus/web", "dep:reqwest", "dep:web-sys", "dep:wasm-bindgen"]
server = [
"dioxus/server",
"dep:axum",
@@ -100,6 +107,9 @@ server = [
"dep:scraper",
"dep:secrecy",
"dep:petname",
+ "dep:tokio-stream",
+ "dep:async-stream",
+ "dep:bytes",
]
[[bin]]
diff --git a/assets/main.css b/assets/main.css
index c283179..50b37fc 100644
--- a/assets/main.css
+++ b/assets/main.css
@@ -215,6 +215,31 @@ h6 {
color: var(--accent);
}
+.sidebar-legal {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 6px;
+ margin-top: 4px;
+}
+
+.legal-link {
+ font-size: 11px;
+ color: var(--text-dimmest);
+ text-decoration: none;
+ transition: color 0.15s ease;
+}
+
+.legal-link:hover {
+ color: var(--text-secondary);
+}
+
+.legal-sep {
+ font-size: 10px;
+ color: var(--text-dimmest);
+ opacity: 0.5;
+}
+
.sidebar-version {
font-size: 11px;
color: var(--text-dimmest);
@@ -1884,6 +1909,44 @@ h6 {
color: var(--accent);
}
+/* -- Chat Action Bar -- */
+.chat-action-bar {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ padding: 4px 24px 0;
+ background-color: var(--bg-sidebar);
+}
+
+.chat-action-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 5px;
+ padding: 4px 10px;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: 6px;
+ color: var(--text-secondary);
+ font-size: 12px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.chat-action-btn:hover:not(:disabled) {
+ color: var(--text-primary);
+ background-color: var(--bg-card);
+ border-color: var(--border-secondary);
+}
+
+.chat-action-btn:disabled {
+ opacity: 0.35;
+ cursor: not-allowed;
+}
+
+.chat-action-label {
+ font-family: 'Inter', sans-serif;
+}
+
/* -- Chat Input Bar -- */
.chat-input-bar {
display: flex;
@@ -1918,6 +1981,289 @@ h6 {
padding: 10px 20px;
}
+/* -- Chat Model Selector Bar -- */
+.chat-model-bar {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ padding: 10px 24px;
+ border-bottom: 1px solid var(--border-primary);
+ background-color: var(--bg-sidebar);
+}
+
+.chat-model-label {
+ font-size: 13px;
+ font-weight: 500;
+ color: var(--text-secondary);
+}
+
+.chat-model-select {
+ padding: 6px 12px;
+ background-color: var(--bg-card);
+ border: 1px solid var(--border-secondary);
+ border-radius: 6px;
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: 'Inter', sans-serif;
+ outline: none;
+ cursor: pointer;
+ min-width: 160px;
+}
+
+.chat-model-select:focus {
+ border-color: var(--accent);
+}
+
+/* -- Chat Namespace Headers -- */
+.chat-namespace-header {
+ font-size: 11px;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--text-faint);
+ padding: 12px 12px 4px;
+}
+
+/* -- Chat Session Item Layout -- */
+.chat-session-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ position: relative;
+}
+
+.chat-session-info {
+ flex: 1;
+ min-width: 0;
+ display: flex;
+ flex-direction: column;
+}
+
+.chat-session-title {
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.chat-session-actions {
+ display: none;
+ gap: 4px;
+ flex-shrink: 0;
+}
+
+.chat-session-item:hover .chat-session-actions {
+ display: flex;
+}
+
+.btn-icon-sm {
+ width: 24px;
+ height: 24px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: none;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ color: var(--text-faint);
+ transition: all 0.15s ease;
+ padding: 0;
+}
+
+.btn-icon-sm:hover {
+ background-color: var(--bg-surface);
+ color: var(--text-primary);
+}
+
+.btn-icon-danger:hover {
+ color: #ef4444;
+}
+
+/* -- Inline Rename -- */
+.chat-session-rename-input {
+ width: 100%;
+ padding: 8px 10px;
+ background-color: var(--bg-card);
+ border: 1px solid var(--accent);
+ border-radius: 6px;
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: 'Inter', sans-serif;
+ outline: none;
+}
+
+/* -- Chat Message List -- */
+.chat-message-list {
+ flex: 1;
+ overflow-y: auto;
+ padding: 24px 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+}
+
+/* -- Chat Empty Hint -- */
+.chat-empty-hint {
+ font-size: 13px;
+ color: var(--text-faint);
+ padding: 8px 12px;
+}
+
+/* -- Thinking Indicator -- */
+.chat-bubble--thinking {
+ background-color: transparent;
+ border: none;
+ padding: 8px 0;
+}
+
+.chat-thinking {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--text-faint);
+ font-size: 14px;
+}
+
+.chat-thinking-text {
+ opacity: 0.7;
+}
+
+.chat-thinking-dots {
+ display: flex;
+ gap: 4px;
+}
+
+.chat-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background-color: var(--accent);
+ animation: dot-pulse 1.4s ease-in-out infinite;
+}
+
+.chat-dot:nth-child(2) {
+ animation-delay: 0.2s;
+}
+
+.chat-dot:nth-child(3) {
+ animation-delay: 0.4s;
+}
+
+@keyframes dot-pulse {
+ 0%, 80%, 100% { opacity: 0.3; transform: scale(0.8); }
+ 40% { opacity: 1; transform: scale(1); }
+}
+
+/* -- Streaming Bubble -- */
+.chat-bubble--streaming {
+ border: 1px solid var(--accent);
+ border-style: dashed;
+}
+
+.chat-streaming-cursor {
+ display: inline-block;
+ width: 8px;
+ height: 16px;
+ background-color: var(--accent);
+ margin-left: 2px;
+ animation: blink-cursor 1s steps(2) infinite;
+ vertical-align: text-bottom;
+}
+
+@keyframes blink-cursor {
+ 0%, 100% { opacity: 1; }
+ 50% { opacity: 0; }
+}
+
+/* -- Chat Prose (Markdown in Assistant Bubbles) -- */
+.chat-prose {
+ white-space: normal;
+}
+
+.chat-prose p {
+ margin: 0 0 12px;
+}
+
+.chat-prose p:last-child {
+ margin-bottom: 0;
+}
+
+.chat-prose pre {
+ background-color: rgba(0, 0, 0, 0.3);
+ border-radius: 8px;
+ padding: 12px 16px;
+ overflow-x: auto;
+ margin: 8px 0;
+ font-size: 13px;
+ line-height: 1.5;
+}
+
+.chat-prose code {
+ font-family: 'JetBrains Mono', 'Fira Code', monospace;
+ font-size: 13px;
+}
+
+.chat-prose :not(pre) > code {
+ background-color: rgba(145, 164, 210, 0.15);
+ padding: 2px 6px;
+ border-radius: 4px;
+ font-size: 12px;
+}
+
+.chat-prose ul,
+.chat-prose ol {
+ padding-left: 20px;
+ margin: 8px 0;
+}
+
+.chat-prose li {
+ margin-bottom: 4px;
+}
+
+.chat-prose blockquote {
+ border-left: 3px solid var(--accent);
+ padding-left: 12px;
+ color: var(--text-secondary);
+ margin: 8px 0;
+ font-style: italic;
+}
+
+.chat-prose table {
+ width: 100%;
+ border-collapse: collapse;
+ margin: 8px 0;
+ font-size: 13px;
+}
+
+.chat-prose th,
+.chat-prose td {
+ border: 1px solid var(--border-secondary);
+ padding: 8px;
+ text-align: left;
+}
+
+.chat-prose th {
+ background-color: rgba(145, 164, 210, 0.1);
+ font-weight: 600;
+}
+
+.chat-prose a {
+ color: var(--accent);
+ text-decoration: underline;
+}
+
+.chat-prose h1,
+.chat-prose h2,
+.chat-prose h3 {
+ font-family: 'Space Grotesk', sans-serif;
+ margin: 16px 0 8px;
+ color: var(--text-heading);
+}
+
+.chat-prose h1 { font-size: 20px; }
+.chat-prose h2 { font-size: 17px; }
+.chat-prose h3 { font-size: 15px; }
+
/* ===== Tools Page ===== */
.tools-page {
max-width: 1200px;
diff --git a/assets/tailwind.css b/assets/tailwind.css
index 173cc7e..ab4799a 100644
--- a/assets/tailwind.css
+++ b/assets/tailwind.css
@@ -162,6 +162,59 @@
}
}
@layer utilities {
+ .diff {
+ @layer daisyui.l1.l2.l3 {
+ position: relative;
+ display: grid;
+ width: 100%;
+ overflow: hidden;
+ webkit-user-select: none;
+ user-select: none;
+ grid-template-rows: 1fr 1.8rem 1fr;
+ direction: ltr;
+ container-type: inline-size;
+ grid-template-columns: auto 1fr;
+ &:focus-visible, &:has(.diff-item-1:focus-visible) {
+ outline-style: var(--tw-outline-style);
+ outline-width: 2px;
+ outline-offset: 1px;
+ outline-color: var(--color-base-content);
+ }
+ &:focus-visible {
+ outline-style: var(--tw-outline-style);
+ outline-width: 2px;
+ outline-offset: 1px;
+ outline-color: var(--color-base-content);
+ .diff-resizer {
+ min-width: 95cqi;
+ max-width: 95cqi;
+ }
+ }
+ &:has(.diff-item-1:focus-visible) {
+ outline-style: var(--tw-outline-style);
+ outline-width: 2px;
+ outline-offset: 1px;
+ .diff-resizer {
+ min-width: 5cqi;
+ max-width: 5cqi;
+ }
+ }
+ @supports (-webkit-overflow-scrolling: touch) and (overflow: -webkit-paged-x) {
+ &:focus {
+ .diff-resizer {
+ min-width: 5cqi;
+ max-width: 5cqi;
+ }
+ }
+ &:has(.diff-item-1:focus) {
+ .diff-resizer {
+ min-width: 95cqi;
+ max-width: 95cqi;
+ }
+ }
+ }
+ }
+ }
.modal {
@layer daisyui.l1.l2.l3 {
pointer-events: none;
@@ -1383,6 +1436,81 @@
padding: calc(0.25rem * 4);
}
}
+ .textarea {
+ @layer daisyui.l1.l2.l3 {
+ border: var(--border) solid #0000;
+ min-height: calc(0.25rem * 20);
+ flex-shrink: 1;
+ appearance: none;
+ border-radius: var(--radius-field);
+ background-color: var(--color-base-100);
+ padding-block: calc(0.25rem * 2);
+ vertical-align: middle;
+ width: clamp(3rem, 20rem, 100%);
+ padding-inline-start: 0.75rem;
+ padding-inline-end: 0.75rem;
+ font-size: max(var(--font-size, 0.875rem), 0.875rem);
+ touch-action: manipulation;
+ border-color: var(--input-color);
+ box-shadow: 0 1px var(--input-color) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000) inset, 0 -1px oklch(100% 0 0 / calc(var(--depth) * 0.1)) inset;
+ }
+ --input-color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ --input-color: color-mix(in oklab, var(--color-base-content) 20%, #0000);
+ }
+ textarea {
+ appearance: none;
+ background-color: transparent;
+ border: none;
+ &:focus, &:focus-within {
+ --tw-outline-style: none;
+ outline-style: none;
+ @media (forced-colors: active) {
+ outline: 2px solid transparent;
+ outline-offset: 2px;
+ }
+ }
+ }
+ &:focus, &:focus-within {
+ --input-color: var(--color-base-content);
+ box-shadow: 0 1px var(--input-color);
+ @supports (color: color-mix(in lab, red, red)) {
+ box-shadow: 0 1px color-mix(in oklab, var(--input-color) calc(var(--depth) * 10%), #0000);
+ }
+ outline: 2px solid var(--input-color);
+ outline-offset: 2px;
+ isolation: isolate;
+ }
+ @media (pointer: coarse) {
+ @supports (-webkit-touch-callout: none) {
+ &:focus, &:focus-within {
+ --font-size: 1rem;
+ }
+ }
+ }
+ &:has(> textarea[disabled]), &:is(:disabled, [disabled]) {
+ cursor: not-allowed;
+ border-color: var(--color-base-200);
+ background-color: var(--color-base-200);
+ color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-base-content) 40%, transparent);
+ }
+ &::placeholder {
+ color: var(--color-base-content);
+ @supports (color: color-mix(in lab, red, red)) {
+ color: color-mix(in oklab, var(--color-base-content) 20%, transparent);
+ }
+ }
+ box-shadow: none;
+ }
+ &:has(> textarea[disabled]) > textarea[disabled] {
+ cursor: not-allowed;
+ }
+ }
+ }
.stack {
@layer daisyui.l1.l2.l3 {
display: inline-grid;
@@ -1680,9 +1808,6 @@
font-weight: 600;
}
}
- .block {
- display: block;
- }
.grid {
display: grid;
}
@@ -1724,6 +1849,14 @@
border-color: currentColor;
}
}
+ .glass {
+ border: none;
+ backdrop-filter: blur(var(--glass-blur, 40px));
+ background-color: #0000;
+ background-image: linear-gradient( 135deg, oklch(100% 0 0 / var(--glass-opacity, 30%)) 0%, oklch(0% 0 0 / 0%) 100% ), linear-gradient( var(--glass-reflect-degree, 100deg), oklch(100% 0 0 / var(--glass-reflect-opacity, 5%)) 25%, oklch(0% 0 0 / 0%) 25% );
+ box-shadow: 0 0 0 1px oklch(100% 0 0 / var(--glass-border-opacity, 20%)) inset, 0 0 0 2px oklch(0% 0 0 / 5%);
+ text-shadow: 0 1px oklch(0% 0 0 / var(--glass-text-shadow-opacity, 5%));
+ }
.p-6 {
padding: calc(var(--spacing) * 6);
}
diff --git a/docker-compose.yml b/docker-compose.yml
index 28a4111..7194306 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -15,6 +15,7 @@ services:
- --import-realm
volumes:
- ./keycloak/realm-export.json:/opt/keycloak/data/import/realm-export.json:ro
+ - ./keycloak/themes/certifai:/opt/keycloak/themes/certifai:ro
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8080/health/ready"]
interval: 10s
diff --git a/keycloak/realm-export.json b/keycloak/realm-export.json
index 4b1c52a..7e3aa42 100644
--- a/keycloak/realm-export.json
+++ b/keycloak/realm-export.json
@@ -9,6 +9,7 @@
"loginWithEmailAllowed": true,
"duplicateEmailsAllowed": false,
"resetPasswordAllowed": true,
+ "loginTheme": "certifai",
"editUsernameAllowed": false,
"bruteForceProtected": true,
"permanentLockout": false,
diff --git a/keycloak/themes/certifai/login/resources/css/login.css b/keycloak/themes/certifai/login/resources/css/login.css
new file mode 100644
index 0000000..c8123a2
--- /dev/null
+++ b/keycloak/themes/certifai/login/resources/css/login.css
@@ -0,0 +1,583 @@
+/* CERTifAI Keycloak Login Theme
+ * Overrides PatternFly v4 / legacy Keycloak classes to match the dashboard.
+ *
+ * Actual page structure (Keycloak 26 with parent=keycloak):
+ * html.login-pf > body
+ * div.login-pf-page
+ * div#kc-header.login-pf-page-header
+ * div#kc-header-wrapper
+ * div.card-pf
+ * header.login-pf-header > h1#kc-page-title
+ * div#kc-content > div#kc-content-wrapper
+ * form#kc-form-login
+ * .form-group (email)
+ * .form-group (password + .pf-c-input-group)
+ * .form-group.login-pf-settings (forgot pwd)
+ * .form-group #kc-form-buttons (submit: input#kc-login.pf-c-button.pf-m-primary)
+ * div#kc-info.login-pf-signup (register link)
+ *
+ * Classes used: pf-c-* (PF v4), login-pf-*, card-pf, form-group
+ */
+
+/* ===== Google Fonts ===== */
+@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;600;700&display=swap');
+
+/* ===== CSS Variables ===== */
+:root {
+ --cai-bg-body: #0f1116;
+ --cai-bg-card: #1a1d26;
+ --cai-bg-surface: #1e222d;
+ --cai-bg-input: #12141a;
+ --cai-text-primary: #e2e8f0;
+ --cai-text-heading: #f1f5f9;
+ --cai-text-muted: #8892a8;
+ --cai-text-faint: #5a6478;
+ --cai-border-primary: #1e222d;
+ --cai-border-secondary: #2a2f3d;
+ --cai-accent: #91a4d2;
+ --cai-accent-secondary: #6d85c6;
+ --cai-brand-indigo: #4B3FE0;
+ --cai-brand-teal: #38B2AC;
+ --cai-error: #f87171;
+ --cai-success: #4ade80;
+}
+
+/* ===== Animations ===== */
+
+/* Slow-moving ambient gradient behind the page */
+@keyframes ambientShift {
+ 0% { background-position: 0% 0%; }
+ 25% { background-position: 100% 50%; }
+ 50% { background-position: 50% 100%; }
+ 75% { background-position: 0% 50%; }
+ 100% { background-position: 0% 0%; }
+}
+
+/* Subtle glow pulse on the card */
+@keyframes cardGlow {
+ 0%, 100% { box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 60px rgba(75, 63, 224, 0.04); }
+ 50% { box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3), 0 0 80px rgba(56, 178, 172, 0.06); }
+}
+
+/* Gentle float for the logo */
+@keyframes logoFloat {
+ 0%, 100% { transform: translateY(0); }
+ 50% { transform: translateY(-4px); }
+}
+
+/* Gradient shimmer on the button */
+@keyframes buttonShimmer {
+ 0% { background-position: 0% 50%; }
+ 50% { background-position: 100% 50%; }
+ 100% { background-position: 0% 50%; }
+}
+
+/* ===== Base Page ===== */
+html.login-pf {
+ background-color: var(--cai-bg-body) !important;
+}
+
+html.login-pf body {
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif !important;
+ background:
+ radial-gradient(ellipse at 20% 20%, rgba(75, 63, 224, 0.07) 0%, transparent 50%),
+ radial-gradient(ellipse at 80% 80%, rgba(56, 178, 172, 0.05) 0%, transparent 50%),
+ radial-gradient(ellipse at 50% 50%, rgba(109, 133, 198, 0.03) 0%, transparent 70%),
+ var(--cai-bg-body) !important;
+ background-size: 200% 200%, 200% 200%, 100% 100%, 100% 100% !important;
+ animation: ambientShift 20s ease-in-out infinite !important;
+ color: var(--cai-text-primary) !important;
+ min-height: 100vh;
+}
+
+/* ===== Page Layout ===== */
+.login-pf-page {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 100vh;
+ padding: 40px 24px;
+ position: relative;
+}
+
+/* ===== Header (Logo + Realm Name) ===== */
+#kc-header.login-pf-page-header {
+ background: transparent !important;
+ background-image: none !important;
+ padding: 0 0 32px !important;
+ text-align: center;
+ max-width: 440px;
+ width: 100%;
+ margin: 0;
+}
+
+#kc-header-wrapper {
+ font-family: 'Space Grotesk', sans-serif !important;
+ font-size: 28px !important;
+ font-weight: 700 !important;
+ color: var(--cai-text-heading) !important;
+ letter-spacing: -0.02em;
+ text-transform: none !important;
+ padding: 0 !important;
+}
+
+/* Logo via ::before pseudo-element */
+#kc-header-wrapper::before {
+ content: '';
+ display: block;
+ width: 64px;
+ height: 64px;
+ margin: 0 auto 16px;
+ background-image: url('../img/logo.svg');
+ background-size: contain;
+ background-repeat: no-repeat;
+ background-position: center;
+ animation: logoFloat 4s ease-in-out infinite;
+ filter: drop-shadow(0 0 12px rgba(75, 63, 224, 0.3));
+}
+
+/* ===== Login Card ===== */
+.card-pf {
+ background-color: var(--cai-bg-card) !important;
+ border: 1px solid var(--cai-border-secondary) !important;
+ border-radius: 12px !important;
+ max-width: 440px;
+ width: 100%;
+ padding: 32px !important;
+ margin: 0 !important;
+ animation: cardGlow 6s ease-in-out infinite;
+ position: relative;
+ overflow: hidden;
+}
+
+/* Subtle gradient border effect on the card via ::before overlay */
+.card-pf::before {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ right: 0;
+ height: 2px;
+ background: linear-gradient(
+ 90deg,
+ transparent,
+ var(--cai-brand-indigo),
+ var(--cai-brand-teal),
+ var(--cai-accent-secondary),
+ transparent
+ );
+ opacity: 0.5;
+}
+
+/* ===== Card Header (Sign In Title) ===== */
+.login-pf-header {
+ border-bottom: none !important;
+ padding: 0 0 24px !important;
+ margin: 0 !important;
+}
+
+#kc-page-title {
+ font-family: 'Space Grotesk', sans-serif !important;
+ font-size: 22px !important;
+ font-weight: 600 !important;
+ color: var(--cai-text-heading) !important;
+ text-align: center;
+ margin: 0 !important;
+}
+
+/* ===== Form Groups ===== */
+.form-group {
+ margin-bottom: 20px !important;
+}
+
+/* ===== Labels ===== */
+.pf-c-form__label,
+.pf-c-form__label-text,
+.login-pf-page .form-group label,
+.card-pf label {
+ font-family: 'Inter', sans-serif !important;
+ font-size: 13px !important;
+ font-weight: 500 !important;
+ color: var(--cai-text-muted) !important;
+ margin-bottom: 6px !important;
+ display: block;
+}
+
+/* ===== Text Inputs ===== */
+.pf-c-form-control,
+.login-pf-page .form-control,
+.card-pf input[type="text"],
+.card-pf input[type="password"],
+.card-pf input[type="email"] {
+ background-color: var(--cai-bg-input) !important;
+ border: 1px solid var(--cai-border-secondary) !important;
+ border-radius: 8px !important;
+ color: var(--cai-text-primary) !important;
+ font-family: 'Inter', sans-serif !important;
+ font-size: 14px !important;
+ padding: 10px 14px !important;
+ height: auto !important;
+ line-height: 1.5 !important;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease !important;
+ box-shadow: none !important;
+ outline: none !important;
+}
+
+.pf-c-form-control:focus,
+.pf-c-form-control:focus-within,
+.card-pf input[type="text"]:focus,
+.card-pf input[type="password"]:focus,
+.card-pf input[type="email"]:focus {
+ border-color: var(--cai-accent) !important;
+ box-shadow: 0 0 0 1px var(--cai-accent), 0 0 12px rgba(145, 164, 210, 0.1) !important;
+ outline: none !important;
+}
+
+.pf-c-form-control::placeholder,
+.card-pf input::placeholder {
+ color: var(--cai-text-faint) !important;
+}
+
+/* Override browser autofill yellow background */
+input:-webkit-autofill,
+input:-webkit-autofill:hover,
+input:-webkit-autofill:focus,
+input:-webkit-autofill:active {
+ -webkit-box-shadow: 0 0 0 9999px var(--cai-bg-input) inset !important;
+ -webkit-text-fill-color: var(--cai-text-primary) !important;
+ caret-color: var(--cai-text-primary) !important;
+ transition: background-color 5000s ease-in-out 0s !important;
+ background-color: var(--cai-bg-input) !important;
+ color: var(--cai-text-primary) !important;
+}
+
+/* Firefox autofill override */
+input:autofill {
+ background-color: var(--cai-bg-input) !important;
+ color: var(--cai-text-primary) !important;
+ border-color: var(--cai-border-secondary) !important;
+}
+
+/* Additional specificity for autofill inside input-group */
+.pf-c-input-group input:-webkit-autofill,
+.card-pf input:-webkit-autofill,
+.form-group input:-webkit-autofill,
+#username:-webkit-autofill,
+#password:-webkit-autofill {
+ -webkit-box-shadow: 0 0 0 9999px var(--cai-bg-input) inset !important;
+ -webkit-text-fill-color: var(--cai-text-primary) !important;
+ background-color: var(--cai-bg-input) !important;
+}
+
+/* ===== Password Input Group ===== */
+/* FIX: The .pf-c-input-group has white bg from PF4, causing white corners
+ * behind the rounded child elements. Set transparent + matching border-radius. */
+.pf-c-input-group {
+ display: flex !important;
+ align-items: stretch !important;
+ background-color: transparent !important;
+ background: transparent !important;
+ border-radius: 8px !important;
+ overflow: hidden !important;
+}
+
+.pf-c-input-group > .pf-c-form-control,
+.pf-c-input-group > input.pf-c-form-control,
+.pf-c-input-group > input[type="password"],
+#password {
+ border-radius: 8px 0 0 8px !important;
+ border-right: none !important;
+ flex: 1;
+}
+
+/* Password visibility toggle */
+.pf-c-button.pf-m-control,
+.pf-c-input-group > .pf-c-button.pf-m-control {
+ background-color: var(--cai-bg-surface) !important;
+ color: var(--cai-text-muted) !important;
+ border-top: 1px solid var(--cai-border-secondary) !important;
+ border-right: 1px solid var(--cai-border-secondary) !important;
+ border-bottom: 1px solid var(--cai-border-secondary) !important;
+ border-left: 1px solid var(--cai-border-primary) !important;
+ border-radius: 0 8px 8px 0 !important;
+ padding: 0 14px !important;
+ transition: color 0.2s ease, background-color 0.2s ease !important;
+ line-height: 1 !important;
+ display: flex !important;
+ align-items: center !important;
+ justify-content: center !important;
+}
+
+.pf-c-button.pf-m-control:hover,
+.pf-c-input-group > .pf-c-button.pf-m-control:hover {
+ color: var(--cai-accent) !important;
+ background-color: rgba(145, 164, 210, 0.08) !important;
+}
+
+.pf-c-button.pf-m-control:focus,
+.pf-c-input-group > .pf-c-button.pf-m-control:focus {
+ box-shadow: none !important;
+ outline: none !important;
+}
+
+/* ===== Primary Button (Sign In) ===== */
+.pf-c-button.pf-m-primary,
+input.pf-c-button.pf-m-primary,
+#kc-login {
+ background: linear-gradient(135deg,
+ var(--cai-accent),
+ var(--cai-accent-secondary),
+ var(--cai-brand-indigo),
+ var(--cai-accent-secondary),
+ var(--cai-accent)) !important;
+ background-size: 300% 100% !important;
+ animation: buttonShimmer 6s ease-in-out infinite !important;
+ border: none !important;
+ border-radius: 8px !important;
+ color: #0a0c10 !important;
+ font-family: 'Inter', sans-serif !important;
+ font-size: 14px !important;
+ font-weight: 600 !important;
+ padding: 12px 20px !important;
+ cursor: pointer !important;
+ transition: opacity 0.15s ease, box-shadow 0.2s ease !important;
+ text-shadow: none !important;
+ box-shadow: 0 2px 12px rgba(109, 133, 198, 0.2) !important;
+ width: 100%;
+ text-align: center;
+}
+
+.pf-c-button.pf-m-primary:hover,
+input.pf-c-button.pf-m-primary:hover,
+#kc-login:hover {
+ opacity: 0.95;
+ box-shadow: 0 4px 20px rgba(109, 133, 198, 0.35) !important;
+}
+
+.pf-c-button.pf-m-primary:focus,
+#kc-login:focus {
+ box-shadow: 0 0 0 2px var(--cai-accent), 0 4px 20px rgba(109, 133, 198, 0.3) !important;
+ outline: none !important;
+}
+
+/* ===== Links ===== */
+.login-pf-page a,
+.card-pf a {
+ color: var(--cai-accent) !important;
+ text-decoration: none !important;
+ transition: color 0.15s ease !important;
+}
+
+.login-pf-page a:hover,
+.card-pf a:hover {
+ color: var(--cai-accent-secondary) !important;
+ text-decoration: none !important;
+}
+
+/* Forgot Password link */
+.login-pf-settings {
+ text-align: right;
+ margin-bottom: 24px !important;
+}
+
+.login-pf-settings a {
+ font-size: 13px !important;
+}
+
+/* ===== Registration / Info Section ===== */
+#kc-info.login-pf-signup {
+ background-color: var(--cai-bg-surface) !important;
+ border-top: 1px solid var(--cai-border-primary) !important;
+ padding: 16px 32px !important;
+ margin: 0 -32px -32px !important;
+ border-radius: 0 0 12px 12px !important;
+ text-align: center;
+}
+
+#kc-info-wrapper,
+#kc-registration {
+ font-size: 14px !important;
+ color: var(--cai-text-muted) !important;
+}
+
+#kc-registration span {
+ color: var(--cai-text-muted) !important;
+}
+
+/* ===== Alert / Error Messages ===== */
+.alert,
+.pf-c-alert {
+ background-color: var(--cai-bg-surface) !important;
+ border: 1px solid var(--cai-border-secondary) !important;
+ border-radius: 8px !important;
+ color: var(--cai-text-primary) !important;
+ padding: 12px 16px !important;
+ margin-bottom: 16px !important;
+ font-size: 14px !important;
+}
+
+.alert-error,
+.alert-warning,
+.pf-c-alert.pf-m-danger,
+.pf-c-alert.pf-m-warning {
+ border-color: var(--cai-error) !important;
+}
+
+.alert-error .kc-feedback-text,
+.pf-c-alert .pf-c-alert__title {
+ color: var(--cai-text-primary) !important;
+}
+
+.alert-success {
+ border-color: var(--cai-success) !important;
+}
+
+/* ===== Checkboxes (Remember Me) ===== */
+.pf-c-check,
+.login-pf-page .checkbox {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+.pf-c-check__label,
+.login-pf-page .checkbox label {
+ font-size: 13px !important;
+ color: var(--cai-text-muted) !important;
+ cursor: pointer;
+}
+
+.pf-c-check__input,
+.login-pf-page input[type="checkbox"] {
+ accent-color: var(--cai-accent);
+ width: 16px;
+ height: 16px;
+}
+
+/* ===== Select / Dropdown ===== */
+.card-pf select,
+.login-pf-page select {
+ background-color: var(--cai-bg-input) !important;
+ border: 1px solid var(--cai-border-secondary) !important;
+ border-radius: 8px !important;
+ color: var(--cai-text-primary) !important;
+ padding: 10px 14px !important;
+ font-family: 'Inter', sans-serif !important;
+ font-size: 14px !important;
+}
+
+/* ===== Social Login / Identity Providers ===== */
+#kc-social-providers {
+ margin-top: 20px;
+ padding-top: 20px;
+ border-top: 1px solid var(--cai-border-primary);
+}
+
+#kc-social-providers ul {
+ list-style: none;
+ padding: 0;
+ margin: 0;
+}
+
+#kc-social-providers li {
+ margin-bottom: 8px;
+}
+
+#kc-social-providers a,
+#kc-social-providers .pf-c-button {
+ background-color: var(--cai-bg-surface) !important;
+ border: 1px solid var(--cai-border-secondary) !important;
+ border-radius: 8px !important;
+ color: var(--cai-text-primary) !important;
+ padding: 10px 16px !important;
+ display: block;
+ text-align: center;
+ font-size: 14px !important;
+ font-weight: 500 !important;
+ transition: border-color 0.15s ease !important;
+}
+
+#kc-social-providers a:hover,
+#kc-social-providers .pf-c-button:hover {
+ border-color: var(--cai-accent) !important;
+}
+
+/* ===== Form Buttons Row ===== */
+#kc-form-buttons {
+ margin-top: 8px !important;
+}
+
+#kc-form-options {
+ margin-bottom: 4px;
+}
+
+/* ===== Tooltip ===== */
+.kc-tooltip-text {
+ background-color: var(--cai-bg-surface) !important;
+ color: var(--cai-text-primary) !important;
+ border: 1px solid var(--cai-border-secondary) !important;
+ border-radius: 8px !important;
+ font-size: 13px !important;
+}
+
+/* ===== Scrollbar ===== */
+::-webkit-scrollbar {
+ width: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: var(--cai-bg-body);
+}
+
+::-webkit-scrollbar-thumb {
+ background: var(--cai-border-secondary);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: var(--cai-text-faint);
+}
+
+/* ===== Responsive ===== */
+@media (max-width: 768px) {
+ .login-pf-page {
+ padding: 24px 16px;
+ }
+
+ .card-pf {
+ padding: 24px !important;
+ }
+
+ #kc-header-wrapper {
+ font-size: 24px !important;
+ }
+
+ #kc-header-wrapper::before {
+ width: 48px;
+ height: 48px;
+ }
+
+ #kc-info.login-pf-signup {
+ margin: 0 -24px -24px !important;
+ padding: 16px 24px !important;
+ }
+}
+
+/* ===== Override PatternFly background images ===== */
+.login-pf-page .login-pf-page-header,
+.login-pf body {
+ background-image: none !important;
+}
+
+/* Remove any PF4 container-fluid stretching */
+.container-fluid {
+ padding: 0 !important;
+ max-width: none !important;
+}
+
+/* Ensure the card doesn't stretch full width */
+.login-pf-page > .card-pf {
+ max-width: 440px;
+ margin: 0 auto !important;
+}
diff --git a/keycloak/themes/certifai/login/resources/img/logo.svg b/keycloak/themes/certifai/login/resources/img/logo.svg
new file mode 100644
index 0000000..ac16408
--- /dev/null
+++ b/keycloak/themes/certifai/login/resources/img/logo.svg
@@ -0,0 +1,25 @@
+
diff --git a/keycloak/themes/certifai/login/theme.properties b/keycloak/themes/certifai/login/theme.properties
new file mode 100644
index 0000000..bb5f523
--- /dev/null
+++ b/keycloak/themes/certifai/login/theme.properties
@@ -0,0 +1,3 @@
+parent=keycloak
+import=common/keycloak
+styles=css/login.css
diff --git a/src/components/chat_action_bar.rs b/src/components/chat_action_bar.rs
new file mode 100644
index 0000000..09e9d48
--- /dev/null
+++ b/src/components/chat_action_bar.rs
@@ -0,0 +1,65 @@
+use dioxus::prelude::*;
+use dioxus_free_icons::icons::fa_solid_icons::{FaCopy, FaPenToSquare, FaShareNodes};
+
+/// Action bar displayed above the chat input with copy, share, and edit buttons.
+///
+/// Only visible when there is at least one message in the conversation.
+///
+/// # Arguments
+///
+/// * `on_copy` - Copies the last assistant response to the clipboard
+/// * `on_share` - Copies the full conversation as text to the clipboard
+/// * `on_edit` - Places the last user message back in the input for editing
+/// * `has_messages` - Whether any messages exist (hides the bar when empty)
+/// * `has_assistant_message` - Whether an assistant message exists (disables copy if not)
+/// * `has_user_message` - Whether a user message exists (disables edit if not)
+#[component]
+pub fn ChatActionBar(
+ on_copy: EventHandler<()>,
+ on_share: EventHandler<()>,
+ on_edit: EventHandler<()>,
+ has_messages: bool,
+ has_assistant_message: bool,
+ has_user_message: bool,
+) -> Element {
+ if !has_messages {
+ return rsx! {};
+ }
+
+ rsx! {
+ div { class: "chat-action-bar",
+ button {
+ class: "chat-action-btn",
+ disabled: !has_assistant_message,
+ title: "Copy last response",
+ onclick: move |_| on_copy.call(()),
+ dioxus_free_icons::Icon {
+ icon: FaCopy,
+ width: 14, height: 14,
+ }
+ span { class: "chat-action-label", "Copy" }
+ }
+ button {
+ class: "chat-action-btn",
+ title: "Copy conversation",
+ onclick: move |_| on_share.call(()),
+ dioxus_free_icons::Icon {
+ icon: FaShareNodes,
+ width: 14, height: 14,
+ }
+ span { class: "chat-action-label", "Share" }
+ }
+ button {
+ class: "chat-action-btn",
+ disabled: !has_user_message,
+ title: "Edit last message",
+ onclick: move |_| on_edit.call(()),
+ dioxus_free_icons::Icon {
+ icon: FaPenToSquare,
+ width: 14, height: 14,
+ }
+ span { class: "chat-action-label", "Edit" }
+ }
+ }
+ }
+}
diff --git a/src/components/chat_bubble.rs b/src/components/chat_bubble.rs
index c6e022e..ad103a2 100644
--- a/src/components/chat_bubble.rs
+++ b/src/components/chat_bubble.rs
@@ -1,34 +1,82 @@
use crate::models::{ChatMessage, ChatRole};
use dioxus::prelude::*;
+/// Render markdown content to HTML using `pulldown-cmark`.
+///
+/// # Arguments
+///
+/// * `md` - Raw markdown string
+///
+/// # Returns
+///
+/// HTML string suitable for `dangerous_inner_html`
+fn markdown_to_html(md: &str) -> String {
+ use pulldown_cmark::{Options, Parser};
+
+ let mut opts = Options::empty();
+ opts.insert(Options::ENABLE_TABLES);
+ opts.insert(Options::ENABLE_STRIKETHROUGH);
+ opts.insert(Options::ENABLE_TASKLISTS);
+
+ let parser = Parser::new_ext(md, opts);
+ let mut html = String::with_capacity(md.len() * 2);
+ pulldown_cmark::html::push_html(&mut html, parser);
+ html
+}
+
/// Renders a single chat message bubble with role-based styling.
///
-/// User messages are right-aligned; assistant messages are left-aligned.
+/// User messages are displayed as plain text, right-aligned.
+/// Assistant messages are rendered as markdown with `pulldown-cmark`.
+/// System messages are hidden from the UI.
///
/// # Arguments
///
/// * `message` - The chat message to render
#[component]
pub fn ChatBubble(message: ChatMessage) -> Element {
+ // System messages are not rendered in the UI
+ if message.role == ChatRole::System {
+ return rsx! {};
+ }
+
let bubble_class = match message.role {
ChatRole::User => "chat-bubble chat-bubble--user",
ChatRole::Assistant => "chat-bubble chat-bubble--assistant",
- ChatRole::System => "chat-bubble chat-bubble--system",
+ ChatRole::System => unreachable!(),
};
let role_label = match message.role {
ChatRole::User => "You",
ChatRole::Assistant => "Assistant",
- ChatRole::System => "System",
+ ChatRole::System => unreachable!(),
};
+ // Format timestamp for display (show time only if today)
+ let display_time = if message.timestamp.len() >= 16 {
+ // Extract HH:MM from ISO 8601
+ message.timestamp[11..16].to_string()
+ } else {
+ message.timestamp.clone()
+ };
+
+ let is_assistant = message.role == ChatRole::Assistant;
+
rsx! {
div { class: "{bubble_class}",
div { class: "chat-bubble-header",
span { class: "chat-bubble-role", "{role_label}" }
- span { class: "chat-bubble-time", "{message.timestamp}" }
+ span { class: "chat-bubble-time", "{display_time}" }
+ }
+ if is_assistant {
+ // Render markdown for assistant messages
+ div {
+ class: "chat-bubble-content chat-prose",
+ dangerous_inner_html: "{markdown_to_html(&message.content)}",
+ }
+ } else {
+ div { class: "chat-bubble-content", "{message.content}" }
}
- div { class: "chat-bubble-content", "{message.content}" }
if !message.attachments.is_empty() {
div { class: "chat-bubble-attachments",
for att in &message.attachments {
@@ -39,3 +87,45 @@ pub fn ChatBubble(message: ChatMessage) -> Element {
}
}
}
+
+/// Renders a streaming assistant message bubble.
+///
+/// While waiting for tokens, shows a "Thinking..." indicator with
+/// a pulsing dot animation. Once tokens arrive, renders them as
+/// markdown with a blinking cursor.
+///
+/// # Arguments
+///
+/// * `content` - The accumulated streaming content so far
+#[component]
+pub fn StreamingBubble(content: String) -> Element {
+ if content.is_empty() {
+ // Thinking state -- no tokens yet
+ rsx! {
+ div { class: "chat-bubble chat-bubble--assistant chat-bubble--thinking",
+ div { class: "chat-thinking",
+ span { class: "chat-thinking-dots",
+ span { class: "chat-dot" }
+ span { class: "chat-dot" }
+ span { class: "chat-dot" }
+ }
+ span { class: "chat-thinking-text", "Thinking..." }
+ }
+ }
+ }
+ } else {
+ let html = markdown_to_html(&content);
+ rsx! {
+ div { class: "chat-bubble chat-bubble--assistant chat-bubble--streaming",
+ div { class: "chat-bubble-header",
+ span { class: "chat-bubble-role", "Assistant" }
+ }
+ div {
+ class: "chat-bubble-content chat-prose",
+ dangerous_inner_html: "{html}",
+ }
+ span { class: "chat-streaming-cursor" }
+ }
+ }
+ }
+}
diff --git a/src/components/chat_input_bar.rs b/src/components/chat_input_bar.rs
new file mode 100644
index 0000000..44b0bae
--- /dev/null
+++ b/src/components/chat_input_bar.rs
@@ -0,0 +1,69 @@
+use dioxus::prelude::*;
+
+/// Chat input bar with a textarea and send button.
+///
+/// Enter sends the message; Shift+Enter inserts a newline.
+/// The input is disabled during streaming.
+///
+/// # Arguments
+///
+/// * `input_text` - Two-way bound input text signal
+/// * `on_send` - Callback fired with the message text when sent
+/// * `is_streaming` - Whether to disable the input (streaming in progress)
+#[component]
+pub fn ChatInputBar(
+ input_text: Signal,
+ on_send: EventHandler,
+ is_streaming: bool,
+) -> Element {
+ let mut input = input_text;
+
+ rsx! {
+ div { class: "chat-input-bar",
+ textarea {
+ class: "chat-input",
+ placeholder: "Type a message...",
+ disabled: is_streaming,
+ rows: "1",
+ value: "{input}",
+ oninput: move |e: Event| {
+ input.set(e.value());
+ },
+ onkeypress: move |e: Event| {
+ // Enter sends, Shift+Enter adds newline
+ if e.key() == Key::Enter && !e.modifiers().shift() {
+ e.prevent_default();
+ let text = input.read().trim().to_string();
+ if !text.is_empty() {
+ on_send.call(text);
+ input.set(String::new());
+ }
+ }
+ },
+ }
+ button {
+ class: "btn-primary chat-send-btn",
+ disabled: is_streaming || input.read().trim().is_empty(),
+ onclick: move |_| {
+ let text = input.read().trim().to_string();
+ if !text.is_empty() {
+ on_send.call(text);
+ input.set(String::new());
+ }
+ },
+ if is_streaming {
+ // Stop icon during streaming
+ dioxus_free_icons::Icon {
+ icon: dioxus_free_icons::icons::fa_solid_icons::FaStop,
+ width: 16, height: 16,
+ }
+ } else {
+ dioxus_free_icons::Icon {
+ icon: dioxus_free_icons::icons::fa_solid_icons::FaPaperPlane,
+ width: 16, height: 16,
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/chat_message_list.rs b/src/components/chat_message_list.rs
new file mode 100644
index 0000000..f4c6991
--- /dev/null
+++ b/src/components/chat_message_list.rs
@@ -0,0 +1,38 @@
+use crate::components::{ChatBubble, StreamingBubble};
+use crate::models::ChatMessage;
+use dioxus::prelude::*;
+
+/// Scrollable message list that renders all messages in a chat session.
+///
+/// Auto-scrolls to the bottom when new messages arrive or during streaming.
+/// Shows a streaming bubble with a blinking cursor when `is_streaming` is true.
+///
+/// # Arguments
+///
+/// * `messages` - All loaded messages for the current session
+/// * `streaming_content` - Accumulated content from the SSE stream
+/// * `is_streaming` - Whether a response is currently streaming
+#[component]
+pub fn ChatMessageList(
+ messages: Vec,
+ streaming_content: String,
+ is_streaming: bool,
+) -> Element {
+ rsx! {
+ div {
+ class: "chat-message-list",
+ id: "chat-message-list",
+ if messages.is_empty() && !is_streaming {
+ div { class: "chat-empty",
+ p { "Send a message to start the conversation." }
+ }
+ }
+ for msg in &messages {
+ ChatBubble { key: "{msg.id}", message: msg.clone() }
+ }
+ if is_streaming {
+ StreamingBubble { content: streaming_content }
+ }
+ }
+ }
+}
diff --git a/src/components/chat_model_selector.rs b/src/components/chat_model_selector.rs
new file mode 100644
index 0000000..f49adb5
--- /dev/null
+++ b/src/components/chat_model_selector.rs
@@ -0,0 +1,42 @@
+use dioxus::prelude::*;
+
+/// Dropdown bar for selecting the LLM model for the current chat session.
+///
+/// Displays the currently selected model and a list of available models
+/// from the Ollama instance. Fires `on_change` when the user selects
+/// a different model.
+///
+/// # Arguments
+///
+/// * `selected_model` - The currently active model ID
+/// * `available_models` - List of model names from Ollama
+/// * `on_change` - Callback fired with the new model name
+#[component]
+pub fn ChatModelSelector(
+ selected_model: String,
+ available_models: Vec,
+ on_change: EventHandler,
+) -> Element {
+ rsx! {
+ div { class: "chat-model-bar",
+ label { class: "chat-model-label", "Model:" }
+ select {
+ class: "chat-model-select",
+ value: "{selected_model}",
+ onchange: move |e: Event| {
+ on_change.call(e.value());
+ },
+ for model in &available_models {
+ option {
+ value: "{model}",
+ selected: *model == selected_model,
+ "{model}"
+ }
+ }
+ if available_models.is_empty() {
+ option { disabled: true, "No models available" }
+ }
+ }
+ }
+ }
+}
diff --git a/src/components/chat_sidebar.rs b/src/components/chat_sidebar.rs
new file mode 100644
index 0000000..f0a32c4
--- /dev/null
+++ b/src/components/chat_sidebar.rs
@@ -0,0 +1,226 @@
+use crate::models::{ChatNamespace, ChatSession};
+use dioxus::prelude::*;
+
+/// Chat sidebar displaying grouped session list with actions.
+///
+/// Sessions are split into "News Chats" and "General" sections.
+/// Each session item shows the title and relative date, with
+/// rename and delete actions on hover.
+///
+/// # Arguments
+///
+/// * `sessions` - All chat sessions for the user
+/// * `active_session_id` - Currently selected session ID (highlighted)
+/// * `on_select` - Callback when a session is clicked
+/// * `on_new` - Callback to create a new chat session
+/// * `on_rename` - Callback with `(session_id, new_title)`
+/// * `on_delete` - Callback with `session_id`
+#[component]
+pub fn ChatSidebar(
+ sessions: Vec,
+ active_session_id: Option,
+ on_select: EventHandler,
+ on_new: EventHandler<()>,
+ on_rename: EventHandler<(String, String)>,
+ on_delete: EventHandler,
+) -> Element {
+ // Split sessions by namespace
+ let news_sessions: Vec<&ChatSession> = sessions
+ .iter()
+ .filter(|s| s.namespace == ChatNamespace::News)
+ .collect();
+ let general_sessions: Vec<&ChatSession> = sessions
+ .iter()
+ .filter(|s| s.namespace == ChatNamespace::General)
+ .collect();
+
+ // Signal for inline rename state: Option<(session_id, current_value)>
+ let rename_state: Signal