Skip to content
This repository was archived by the owner on Sep 3, 2026. It is now read-only.

Commit f293147

Browse files
author
outlndrr
committed
feat(ui): add saved session browser
UI needs to reopen prior conversations and inspect stored\nmessages. Expose a session list endpoint and accept\nnon-UUID ids in browser client.
1 parent 5aa4431 commit f293147

7 files changed

Lines changed: 621 additions & 160 deletions

File tree

src/lummy_agent/transport/api_json.gleam

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,12 @@ pub fn snapshot_json(snapshot: session_actor.Snapshot) -> json.Json {
492492
])
493493
}
494494

495+
pub fn sessions_json(sessions: List(domain_session.Session)) -> json.Json {
496+
json.object([
497+
#("sessions", json.array(from: sessions, of: session_json)),
498+
])
499+
}
500+
495501
pub fn session_json(session: domain_session.Session) -> json.Json {
496502
json.object([
497503
#("session_id", json.string(domain_id.session_id_value(session.session_id))),
@@ -505,6 +511,10 @@ pub fn session_json(session: domain_session.Session) -> json.Json {
505511
#("updated_at", json.string(session.updated_at)),
506512
#("status", json.string(domain_session.status_name(session.status))),
507513
#("config_snapshot", json.string(session.config_snapshot)),
514+
#("active_run_id", case session.active_run_id {
515+
Some(run_id) -> json.string(domain_id.run_id_value(run_id))
516+
None -> json.null()
517+
}),
508518
])
509519
}
510520

src/lummy_agent/transport/http_server.gleam

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -225,6 +225,9 @@ pub fn handle(
225225
http.Post, ["api", "v1", "providers", "openai-compatible", "verify"] ->
226226
handle_verify_openai_compatible(config, request)
227227

228+
http.Get, ["api", "v1", "sessions"] ->
229+
handle_list_sessions(dependencies.repositories)
230+
228231
http.Post, ["api", "v1", "sessions"] ->
229232
handle_create_session(request, manager)
230233

@@ -313,6 +316,13 @@ pub fn handle(
313316
}
314317
}
315318

319+
fn handle_list_sessions(repositories: repository.Repositories) -> wisp.Response {
320+
case repository.list_sessions(repositories.sessions) {
321+
Ok(sessions) -> json_response(api_json.sessions_json(sessions), 200)
322+
Error(error) -> error_response(error)
323+
}
324+
}
325+
316326
fn handle_create_session(
317327
request: wisp.Request,
318328
manager: session_manager.Handle,
@@ -1222,7 +1232,7 @@ fn ui_index_html() -> String {
12221232
" * { box-sizing: border-box; }",
12231233
" body { margin: 0; background: #0b1020; color: #e5e7eb; }",
12241234
" .page { min-height: 100vh; padding: 24px; }",
1225-
" .panel { max-width: 1400px; margin: 0 auto; background: #11172a; border: 1px solid #24304b; border-radius: 16px; padding: 20px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); }",
1235+
" .panel { max-width: 1600px; margin: 0 auto; background: #11172a; border: 1px solid #24304b; border-radius: 16px; padding: 20px; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35); }",
12261236
" .hero { display: flex; gap: 16px; justify-content: space-between; align-items: flex-start; margin-bottom: 20px; }",
12271237
" .hero h1 { margin: 0 0 8px 0; font-size: 28px; }",
12281238
" .hero-actions { display: flex; gap: 12px; flex-wrap: wrap; }",
@@ -1236,8 +1246,20 @@ fn ui_index_html() -> String {
12361246
" .textarea { resize: vertical; min-height: 140px; }",
12371247
" .button { border: 0; border-radius: 12px; background: #2563eb; color: white; padding: 10px 16px; font: inherit; cursor: pointer; }",
12381248
" .button:disabled { opacity: 0.45; cursor: not-allowed; }",
1239-
" .split-layout { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(320px, 1fr); gap: 16px; align-items: start; }",
1240-
" .chat-pane, .debug-pane { min-width: 0; }",
1249+
" .workspace { display: grid; grid-template-columns: minmax(260px, 320px) minmax(0, 1.6fr) minmax(320px, 1fr); gap: 16px; align-items: start; }",
1250+
" .sessions-pane, .chat-pane, .debug-pane { min-width: 0; }",
1251+
" .sessions-panel, .debug-panel { background: #0f172a; border: 1px solid #24304b; border-radius: 12px; padding: 14px; }",
1252+
" .sessions-panel h2, .debug-panel h2 { margin: 0 0 8px 0; }",
1253+
" .session-list { display: grid; gap: 10px; max-height: 70vh; overflow-y: auto; padding-right: 4px; }",
1254+
" .session-item { width: 100%; text-align: left; border: 1px solid #24304b; border-radius: 12px; background: #111827; color: inherit; padding: 12px; display: grid; gap: 10px; cursor: pointer; }",
1255+
" .session-item.current { border-color: #3b82f6; background: #172554; }",
1256+
" .session-item:disabled { opacity: 0.7; cursor: not-allowed; }",
1257+
" .session-item-head { display: flex; justify-content: space-between; gap: 12px; align-items: center; }",
1258+
" .session-id { display: block; white-space: pre-wrap; word-break: break-word; color: #dbeafe; }",
1259+
" .session-item-meta { display: flex; flex-direction: column; gap: 4px; }",
1260+
" .session-item-tags { display: flex; gap: 8px; flex-wrap: wrap; }",
1261+
" .badge { display: inline-flex; align-items: center; border-radius: 999px; border: 1px solid #334155; padding: 2px 8px; font-size: 12px; color: #cbd5e1; }",
1262+
" .badge.accent { border-color: #3b82f6; color: #bfdbfe; }",
12411263
" .messages { display: grid; gap: 12px; margin-bottom: 16px; min-height: 240px; max-height: 52vh; overflow-y: auto; padding-right: 4px; }",
12421264
" .message { border-radius: 14px; border: 1px solid #24304b; padding: 12px 14px; }",
12431265
" .message.user { background: #172554; }",
@@ -1249,13 +1271,12 @@ fn ui_index_html() -> String {
12491271
" .composer { display: grid; gap: 12px; }",
12501272
" .composer-actions { display: flex; gap: 12px; justify-content: space-between; align-items: center; flex-wrap: wrap; }",
12511273
" .hint { color: #94a3b8; font-size: 13px; }",
1252-
" .debug-panel { background: #0f172a; border: 1px solid #24304b; border-radius: 12px; padding: 14px; }",
1253-
" .debug-panel h2 { margin: 0 0 8px 0; }",
12541274
" .debug-log { display: grid; gap: 10px; max-height: 70vh; overflow-y: auto; padding-right: 4px; }",
12551275
" .debug-entry { border: 1px solid #24304b; border-radius: 10px; padding: 10px; background: #111827; }",
12561276
" .debug-message { margin: 6px 0 0 0; white-space: pre-wrap; word-break: break-word; font-size: 13px; }",
12571277
" .empty { border: 1px dashed #334155; border-radius: 12px; padding: 18px; color: #94a3b8; text-align: center; }",
1258-
" @media (max-width: 980px) { .split-layout { grid-template-columns: 1fr; } }",
1278+
" @media (max-width: 1280px) { .workspace { grid-template-columns: minmax(240px, 300px) minmax(0, 1fr); } .debug-pane { grid-column: 1 / -1; } }",
1279+
" @media (max-width: 980px) { .workspace { grid-template-columns: 1fr; } }",
12591280
" @media (max-width: 720px) { .page { padding: 12px; } .panel { padding: 16px; } .hero { flex-direction: column; } }",
12601281
" </style>",
12611282
" </head>",

test/lummy_agent_http_api_test.gleam

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,33 @@ pub fn create_session_route_generates_uuid_session_id_test() {
7070
assert session.channel_identity == "local-user"
7171
}
7272

73+
pub fn list_sessions_route_test() {
74+
let runtime = runtime("http-list-sessions")
75+
76+
let _ =
77+
simulate.request(http.Post, "/api/v1/sessions")
78+
|> simulate.json_body(create_session_body())
79+
|> http_server.handle(runtime.config, runtime.dependencies, _)
80+
81+
let _ =
82+
simulate.request(http.Post, "/api/v1/sessions")
83+
|> simulate.json_body(create_session_body_for(
84+
session_id: "session-http-2",
85+
user_id: "user-http-2",
86+
channel_identity: "browser-user",
87+
created_at: "2026-04-16T00:00:10Z",
88+
))
89+
|> http_server.handle(runtime.config, runtime.dependencies, _)
90+
91+
let response =
92+
simulate.request(http.Get, "/api/v1/sessions")
93+
|> http_server.handle(runtime.config, runtime.dependencies, _)
94+
95+
assert response.status == 200
96+
assert decode_session_count(response) == 2
97+
assert decode_session_ids(response) == ["session-http-1", "session-http-2"]
98+
}
99+
73100
pub fn openrouter_verify_route_without_key_test() {
74101
let runtime = runtime("http-openrouter-verify")
75102

@@ -801,12 +828,26 @@ fn reset_db(name: String) -> String {
801828
}
802829

803830
fn create_session_body() -> json.Json {
831+
create_session_body_for(
832+
session_id: "session-http-1",
833+
user_id: "user-http-1",
834+
channel_identity: "local-user",
835+
created_at: "2026-04-16T00:00:00Z",
836+
)
837+
}
838+
839+
fn create_session_body_for(
840+
session_id session_id: String,
841+
user_id user_id: String,
842+
channel_identity channel_identity: String,
843+
created_at created_at: String,
844+
) -> json.Json {
804845
json.object([
805-
#("session_id", json.string("session-http-1")),
806-
#("user_id", json.string("user-http-1")),
846+
#("session_id", json.string(session_id)),
847+
#("user_id", json.string(user_id)),
807848
#("channel_type", json.string("api")),
808-
#("channel_identity", json.string("local-user")),
809-
#("created_at", json.string("2026-04-16T00:00:00Z")),
849+
#("channel_identity", json.string(channel_identity)),
850+
#("created_at", json.string(created_at)),
810851
#("config_snapshot", json.string("{}")),
811852
])
812853
}
@@ -966,6 +1007,31 @@ fn decode_int_field(response, field: String) -> Int {
9661007
value
9671008
}
9681009

1010+
fn decode_session_count(response) -> Int {
1011+
let decoder = {
1012+
use sessions <- decode.field("sessions", decode.list(of: decode.dynamic))
1013+
decode.success(list.length(sessions))
1014+
}
1015+
1016+
let assert Ok(value) =
1017+
json.parse(from: simulate.read_body(response), using: decoder)
1018+
value
1019+
}
1020+
1021+
fn decode_session_ids(response) -> List(String) {
1022+
let decoder = {
1023+
use sessions <- decode.field(
1024+
"sessions",
1025+
decode.list(of: session_id_field_decoder()),
1026+
)
1027+
decode.success(sessions)
1028+
}
1029+
1030+
let assert Ok(value) =
1031+
json.parse(from: simulate.read_body(response), using: decoder)
1032+
value
1033+
}
1034+
9691035
fn decode_message_count(response) -> Int {
9701036
let decoder = {
9711037
use messages <- decode.field("messages", decode.list(of: decode.dynamic))
@@ -1143,6 +1209,13 @@ fn decode_active_run_id(response) -> String {
11431209
value
11441210
}
11451211

1212+
fn session_id_field_decoder() {
1213+
{
1214+
use session_id <- decode.field("session_id", decode.string)
1215+
decode.success(session_id)
1216+
}
1217+
}
1218+
11461219
fn message_role_decoder() {
11471220
{
11481221
use role <- decode.field("role", decode.string)

ui/README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ http://localhost:4000/ui
2828
## Current scope
2929

3030
- provider fixed to `openrouter`
31-
- creates fresh API session in browser
31+
- loads saved sessions from backend and selects latest by default
32+
- can create fresh API session in browser
33+
- shows session list sidebar for reopening prior conversations
3234
- appends user message
3335
- starts `/api/v1/sessions/:id/agent-runs`
3436
- opens live session SSE stream from `/api/v1/sessions/:id/events`
@@ -37,4 +39,4 @@ http://localhost:4000/ui
3739

3840
Not yet:
3941
- auth
40-
- reconnect to prior browser session
42+
- session search/filter

0 commit comments

Comments
 (0)