From 69e7717e6fc2ed7bc42ddef8087e349c8eba5ce3 Mon Sep 17 00:00:00 2001 From: PerishCode Date: Thu, 11 Jun 2026 14:13:44 +0800 Subject: [PATCH] web: ship session title edits and shell polish --- .github/workflows/guard.yml | 23 +- .runseal/scripts/browser.sh | 365 ++++++++++++++++++ .runseal/wrappers/browser.seal | 2 + .runseal/wrappers/init.seal | 1 + AGENTS.md | 73 +++- DESIGN.md | 161 +++++++- README.md | 3 + apps/client/soma/web/src/App.tsx | 27 +- .../soma/web/src/components/ChatHeader.tsx | 74 +++- .../soma/web/src/components/ChatShell.tsx | 6 +- .../soma/web/src/components/Composer.tsx | 3 +- .../soma/web/src/components/SessionRail.tsx | 4 +- apps/server/soma/crates/santi-api/src/main.rs | 32 +- .../soma/crates/santi-core/src/model.rs | 5 + .../soma/crates/santi-core/src/service.rs | 10 +- .../soma/crates/santi-core/src/store.rs | 23 ++ .../soma/crates/santi-core/tests/store.rs | 19 + packages/components/package.json | 1 + .../components/src/atoms/AppRoot/AppRoot.scss | 5 + packages/components/src/atoms/Grid/Grid.scss | 1 + packages/components/src/atoms/Grid/Grid.tsx | 2 +- .../components/src/atoms/Input/Input.scss | 32 ++ packages/components/src/atoms/Input/Input.tsx | 23 ++ packages/components/src/atoms/Pane/Pane.scss | 24 ++ packages/components/src/atoms/Pane/Pane.tsx | 3 + packages/components/src/icons/Icon/Icon.scss | 20 + packages/components/src/icons/Icon/Icon.tsx | 34 ++ packages/components/src/icons/PlusIcon.tsx | 7 + packages/components/src/icons/SendIcon.tsx | 7 + packages/components/src/index.ts | 4 + packages/components/src/theme/default.scss | 4 + packages/contracts/openapi.json | 67 ++++ packages/contracts/src/openapi.ts | 60 +++ packages/hooks/src/index.tsx | 33 ++ packages/mqueue/src/index.ts | 14 + packages/mqueue/src/types.ts | 3 + pnpm-lock.yaml | 12 + 37 files changed, 1143 insertions(+), 44 deletions(-) create mode 100644 .runseal/scripts/browser.sh create mode 100644 .runseal/wrappers/browser.seal create mode 100644 packages/components/src/atoms/Input/Input.scss create mode 100644 packages/components/src/atoms/Input/Input.tsx create mode 100644 packages/components/src/icons/Icon/Icon.scss create mode 100644 packages/components/src/icons/Icon/Icon.tsx create mode 100644 packages/components/src/icons/PlusIcon.tsx create mode 100644 packages/components/src/icons/SendIcon.tsx diff --git a/.github/workflows/guard.yml b/.github/workflows/guard.yml index db0522e..ba78d9c 100644 --- a/.github/workflows/guard.yml +++ b/.github/workflows/guard.yml @@ -60,8 +60,29 @@ jobs: sh -n "$hooks/pre-commit" sh -n "$hooks/commit-msg" - rust: + rust-pr: + name: rust (ubuntu-latest) + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + + - name: Format + run: cargo fmt --all --check + + - name: Clippy + run: cargo clippy --locked --workspace --all-targets -- -D warnings + + - name: Test + run: cargo test --locked --workspace + + rust-matrix: name: rust (${{ matrix.os }}) + if: github.event_name != 'pull_request' strategy: fail-fast: false matrix: diff --git a/.runseal/scripts/browser.sh b/.runseal/scripts/browser.sh new file mode 100644 index 0000000..e85608c --- /dev/null +++ b/.runseal/scripts/browser.sh @@ -0,0 +1,365 @@ +#!/usr/bin/env bash +set -u + +print() { + printf '%s\n' "$1" +} + +error() { + printf '%s\n' "$1" >&2 +} + +fail() { + error "$1" + exit 1 +} + +usage() { + print "Usage: runseal :browser [options]" + print "" + print "Browser working-surface helpers for Playwright + sidecar." + print "" + print "Subcommands:" + print " check inspect sidecar/web/session state and recommend next action" + print " reset converge Playwright sessions and browser processes back to empty" + print " recover restore the routine browser working surface after runtime drift" + print "" + print "Shared options:" + print " --session session name (default: mini-stim)" + print " --browser browser for open/recover (default: chromium)" + print " --url explicit target url" + print "" + print "Recover options:" + print " --restart-sidecar force a sidecar restart before browser recovery" + print " --force-open always open a fresh session instead of reload/goto recovery" +} + +require_tool() { + if command -v "$1" >/dev/null 2>&1; then + : + else + fail "browser: missing required tool: $1" + fi +} + +sidecar_status_output() { + sidecar status --config sidecar.toml +} + +current_namespace() { + sidecar_status_output | sed -n 's/^namespace: //p' | head -n 1 +} + +current_port() { + local namespace + namespace=$(current_namespace) + if [ -z "$namespace" ]; then + return 1 + fi + local port_file + port_file="$root/.tmp/sidecar/$namespace/client/web.port" + if [ -f "$port_file" ]; then + cat "$port_file" + else + return 1 + fi +} + +current_url() { + if [ -n "$explicit_url" ]; then + print "$explicit_url" + return 0 + fi + local port + port=$(current_port) || return 1 + print "http://127.0.0.1:$port" +} + +web_ready() { + local url + url=$(current_url) || return 1 + curl -sf --output /dev/null "$url/" +} + +wait_web_ready() { + local attempt=0 + while [ "$attempt" -lt 30 ]; do + if web_ready; then + return 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + return 1 +} + +session_list_json() { + playwright-cli --json list +} + +session_exists() { + session_list_json | rg "\"name\": \"$session_name\"" -q +} + +session_browser_type() { + session_list_json | sed -n "/\"name\": \"$session_name\"/,/}/ s/.*\"browserType\": \"\\([^\"]*\\)\".*/\\1/p" | head -n 1 +} + +playwright_process_lines() { + pgrep -af '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome --disable-field-trial-config|Google Chrome for Testing --disable-field-trial-config' || true +} + +playwright_process_count() { + local lines + lines=$(playwright_process_lines) + if [ -z "$lines" ]; then + print "0" + else + printf '%s\n' "$lines" | wc -l | tr -d ' ' + fi +} + +wait_sessions_empty() { + local attempt=0 + while [ "$attempt" -lt 10 ]; do + if session_list_json | rg '"browsers": \[\]' -q; then + return 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + return 1 +} + +wait_processes_empty() { + local attempt=0 + while [ "$attempt" -lt 10 ]; do + if [ "$(playwright_process_count)" = "0" ]; then + return 0 + fi + attempt=$((attempt + 1)) + sleep 1 + done + return 1 +} + +run_check() { + local status_output namespace runtime_state server_state client_state port url + local web_state session_state browser_type playwright_state recommended_action eval_output + + status_output=$(sidecar_status_output) + namespace=$(printf '%s\n' "$status_output" | sed -n 's/^namespace: //p' | head -n 1) + runtime_state=$(printf '%s\n' "$status_output" | sed -n 's/^runtime: \([^ ]*\).*/\1/p' | head -n 1) + server_state=$(printf '%s\n' "$status_output" | sed -n 's/^- server: \([^ ]*\).*/\1/p' | head -n 1) + client_state=$(printf '%s\n' "$status_output" | sed -n 's/^- client: \([^ ]*\).*/\1/p' | head -n 1) + port=$(current_port || true) + url=$(current_url || true) + + if web_ready; then + web_state=ready + else + web_state=not-ready + fi + + if session_exists; then + session_state=open + browser_type=$(session_browser_type) + if eval_output=$(playwright-cli -s="$session_name" eval 'location.href'); then + playwright_state=usable + else + playwright_state=stale + fi + else + session_state=missing + browser_type= + playwright_state=missing + fi + + recommended_action=none + if [ "$web_state" != ready ] || [ "$playwright_state" = stale ] || [ "$session_state" = missing ]; then + recommended_action=recover + fi + + print "sidecar.namespace=${namespace:-unknown}" + print "sidecar.runtime=${runtime_state:-unknown}" + print "sidecar.server=${server_state:-unknown}" + print "sidecar.client=${client_state:-unknown}" + print "web.port=${port:-unknown}" + print "web.url=${url:-unknown}" + print "web.state=$web_state" + print "playwright.session=$session_name" + print "playwright.state=$playwright_state" + print "playwright.browser=${browser_type:-unknown}" + print "playwright.process_count=$(playwright_process_count)" + print "recommended_action=$recommended_action" +} + +run_reset() { + local sessions_ok=false + local procs_ok=false + local remaining_sessions remaining_processes + + playwright-cli close-all >/dev/null 2>&1 || true + if wait_sessions_empty && wait_processes_empty; then + print "result=clean" + print "remaining_sessions=0" + print "remaining_processes=0" + return 0 + fi + + playwright-cli kill-all >/dev/null 2>&1 || true + + if wait_sessions_empty; then + sessions_ok=true + fi + if wait_processes_empty; then + procs_ok=true + fi + + remaining_sessions=$(session_list_json | sed -n 's/.*"name": "\([^"]*\)".*/\1/p' | wc -l | tr -d ' ') + remaining_processes=$(playwright_process_count) + + if [ "$sessions_ok" = true ] && [ "$procs_ok" = true ]; then + print "result=clean" + print "remaining_sessions=0" + print "remaining_processes=0" + return 0 + fi + + print "result=partial" + print "remaining_sessions=$remaining_sessions" + print "remaining_processes=$remaining_processes" + return 1 +} + +run_recover() { + local url mode actual_browser + url=$(current_url) || fail "browser: unable to resolve target url" + + if [ "$restart_sidecar" = true ]; then + sidecar stop --config sidecar.toml >/dev/null 2>&1 || true + sidecar start --config sidecar.toml >/dev/null 2>&1 || fail "browser: failed to start sidecar" + fi + + wait_web_ready || fail "browser: web surface did not become ready" + + if [ "$force_open" = true ]; then + playwright-cli -s="$session_name" open "$url" --browser="$browser_name" --headed >/dev/null 2>&1 || fail "browser: open failed" + mode=opened + else + if session_exists; then + if playwright-cli -s="$session_name" reload >/dev/null 2>&1; then + mode=reloaded + elif playwright-cli -s="$session_name" goto "$url" >/dev/null 2>&1; then + mode=goto + else + playwright-cli -s="$session_name" open "$url" --browser="$browser_name" --headed >/dev/null 2>&1 || fail "browser: recovery open failed" + mode=opened + fi + else + playwright-cli -s="$session_name" open "$url" --browser="$browser_name" --headed >/dev/null 2>&1 || fail "browser: open failed" + mode=opened + fi + fi + + playwright-cli -s="$session_name" eval 'document.title' >/dev/null 2>&1 || fail "browser: recovered session is not interactive" + + actual_browser=$(session_browser_type) + print "result=recovered" + print "mode=$mode" + print "session=$session_name" + print "browser=${actual_browser:-unknown}" + print "url=$url" +} + +root=$(git rev-parse --show-toplevel) +require_tool bash +require_tool sidecar +require_tool playwright-cli +require_tool curl +require_tool sed +require_tool rg +require_tool pgrep + +if [ "$#" -lt 1 ]; then + usage + exit 1 +fi + +subcommand=$1 +shift + +session_name=mini-stim +browser_name=chromium +explicit_url= +restart_sidecar=false +force_open=false + +while [ "$#" -gt 0 ]; do + case "$1" in + --session) + if [ "$#" -lt 2 ]; then fail "missing value for --session"; fi + session_name=$2 + shift 2 + ;; + --session=*) + session_name=${1#--session=} + shift + ;; + --browser) + if [ "$#" -lt 2 ]; then fail "missing value for --browser"; fi + browser_name=$2 + shift 2 + ;; + --browser=*) + browser_name=${1#--browser=} + shift + ;; + --url) + if [ "$#" -lt 2 ]; then fail "missing value for --url"; fi + explicit_url=$2 + shift 2 + ;; + --url=*) + explicit_url=${1#--url=} + shift + ;; + --restart-sidecar) + restart_sidecar=true + shift + ;; + --force-open) + force_open=true + shift + ;; + -h|--help|help) + usage + exit 0 + ;; + --) + shift + break + ;; + *) + fail "unknown option: $1" + ;; + esac +done + +case "$subcommand" in + help|-h|--help) + usage + ;; + check) + run_check + ;; + reset) + run_reset + ;; + recover) + run_recover + ;; + *) + fail "unknown subcommand: $subcommand" + ;; +esac diff --git a/.runseal/wrappers/browser.seal b/.runseal/wrappers/browser.seal new file mode 100644 index 0000000..af93edd --- /dev/null +++ b/.runseal/wrappers/browser.seal @@ -0,0 +1,2 @@ +root=$(git rev-parse --show-toplevel) +bash "$root/.runseal/scripts/browser.sh" "$@" diff --git a/.runseal/wrappers/init.seal b/.runseal/wrappers/init.seal index 7f7e36d..00423b9 100644 --- a/.runseal/wrappers/init.seal +++ b/.runseal/wrappers/init.seal @@ -150,6 +150,7 @@ require_path apps/client/soma/web/package.json require_path packages/components/package.json require_path packages/contracts/package.json require_path .runseal/wrappers/codegen.seal +require_path .runseal/wrappers/browser.seal require_path .runseal/wrappers/init.seal require_path .runseal/wrappers/pr.seal require_path .runseal/hooks/pre-commit diff --git a/AGENTS.md b/AGENTS.md index e95efb5..83b9d78 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,7 +154,41 @@ the named session is unusable. restart-first, or new-session-first. `working-surface check` is the standard phrase for cold-starting or revalidating -the local runtime/browser surface before discussing concrete edits. It means: +the local runtime/browser surface before discussing concrete edits. + +Use the runseal browser wrappers for low-confidence browser-surface operations: + +- `runseal :browser check` + - inspect sidecar/web/session truth and recommend `none` or `recover` +- `runseal :browser reset` + - converge Playwright session/browser state back to empty +- `runseal :browser recover` + - recover the routine browser session after runtime drift or restart + - defaults to `--browser chromium`, which resolves to + `chrome-for-testing` and is the preferred routine automation channel + +Use raw `playwright-cli` for stable page-level operations such as `open`, +`goto`, `reload`, `snapshot`, `click`, `fill`, and `eval`. + +Routine browser recovery should prefer `runseal :browser recover` over ad hoc +cleanup/reopen command sequences when the surface is stale but not fundamentally +broken, because the wrapper already bakes in the currently validated ordering: + +- wait for sidecar/web readiness before `reload` +- fall back from `reload` to `goto` to `open` +- treat `close-all` / `kill-all` cleanup as a convergence process rather than + as an instant state transition + +For the current validated `mini-stim` hot path, a sidecar-only restart is not +automatically a browser-recovery event. + +- If `sidecar` has just been restarted but the routine session still exists and + `runseal :browser check` reports `playwright.state=usable`, prefer a direct + `playwright-cli -s=mini-stim reload`. +- Use `runseal :browser recover` only when the routine session is missing, + stale, or the page can no longer be refreshed back to the target surface. + +`working-surface check` maps to the `runseal :browser` layer. It means: - verify the installed `playwright-cli` command surface before using browser session commands when there is any sign of version drift @@ -168,12 +202,8 @@ the local runtime/browser surface before discussing concrete edits. It means: Normal local web startup path: ```bash -playwright-cli --version -sidecar status --config sidecar.toml -sidecar stop --config sidecar.toml # only if runtime/cells are unhealthy -sidecar start --config sidecar.toml # only if runtime/cells are unhealthy -cat .tmp/sidecar//client/web.port -playwright-cli -s=mini-stim open http://127.0.0.1: --headed +runseal :browser check +runseal :browser recover playwright-cli -s=mini-stim snapshot ``` @@ -186,8 +216,7 @@ Normal `playwright-cli` shutdown path: ```bash playwright-cli -s=mini-stim close # stop the routine session cleanly -playwright-cli close-all # stop all sessions when broader cleanup is intended -playwright-cli kill-all # only for stale/zombie browser processes +runseal :browser reset # converge session/process state when cleanup matters playwright-cli -s= delete-data # only after close, and only if session data should be removed ``` @@ -219,6 +248,11 @@ The frontend design-system asset model is explicit: - `packages/components/src/atoms` owns business-blind primitives, low-level layout/control capabilities, token consumption, and SCSS for those primitives. +- `packages/components/src/icons` + owns the business-blind symbol system. Icons are a first-class asset layer, + not a sub-type of atoms. This layer owns the controlled icon set, the shared + icon wrapper, and any cold-start third-party icon integration behind local + exports. - `packages/components/src/patterns` owns business-blind but higher-level hard-coded composition templates. A pattern is not a page component and not a product concept; it is a reusable @@ -231,6 +265,15 @@ The frontend design-system asset model is explicit: Treat `patterns` as a first-class asset layer, not as a documentation-only idea and not as an accidental pile of "slightly larger atoms". +Treat `icons` the same way. + +- Icons are symbol assets, not layout primitives and not product components. +- Cold-starting from a mature external icon set is acceptable, but the codebase + should consume icons through the local `icons` layer rather than importing the + third-party package directly throughout atoms or `web`. +- Icon names should stay controlled by the local export surface, even when the + underlying glyphs come from a third-party set. + - A pattern must stay business-blind. It may encode structural relationships, surface layering, spacing rhythm, fixed-vs-fluid layout logic, label/status clustering, and similar reusable @@ -266,6 +309,7 @@ idea and not as an accidental pile of "slightly larger atoms". re-implementing it in `web`. - When deciding where a UI change belongs, use this ladder: - token issue -> theme/tokens + - symbol issue -> `icons` - primitive capability issue -> `atoms` - recurring high-constraint composition issue -> `patterns` - product semantics / content assembly issue -> `web` @@ -325,6 +369,17 @@ Current conventions: - `SANTI_DB=.tmp/manual.sqlite cargo run -p mini-stim-server-soma -- serve` - `cargo run -p mini-stim-client-soma -- dev` +Routine browser flows use `playwright-cli` as the default browser tool for this +repository. Prefer the named `mini-stim` session and the existing working +surface before considering any alternate browser layer. + +- `runseal :browser check` +- `runseal :browser recover` +- `playwright-cli -s=mini-stim snapshot` +- `playwright-cli -s=mini-stim reload` +- `playwright-cli -s=mini-stim open http://127.0.0.1: --headed` +- `playwright-cli -s=mini-stim close` + ## Environment `.env` is local and ignored by git. Required OpenAI settings: diff --git a/DESIGN.md b/DESIGN.md index 54c01ad..0d38678 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -101,9 +101,10 @@ Patterns are not: The intended hierarchy is: 1. tokens -2. atoms -3. patterns -4. product components +2. icons +3. atoms +4. patterns +5. product components Patterns sit above atoms because they express recommended composition logic. Patterns sit below product components because they must not encode product @@ -124,7 +125,36 @@ It should not express: Those are product-semantic usages of more general patterns. -## 5. Pattern lifecycle +## 5. Icon layer + +`mini-stim` also recognizes `icons` as a first-class design-system asset layer. + +Icons are: + +- business-blind +- controlled through a local export surface +- allowed to cold-start from a mature external set +- responsible for symbol consistency rather than layout or product semantics + +Icons are not: + +- atoms +- patterns +- page-local SVG fragments +- direct product semantics + +The default rule is: + +- consume icons through `packages/components/src/icons` +- do not scatter direct third-party icon imports through atoms or `web` +- keep icon naming and replacement under local control even when the glyph + source is external + +Cold-starting from a mature icon set is acceptable. +Long-term icon language may still evolve through replacement, curation, or +custom additions. + +## 6. Pattern lifecycle Patterns are design assets, not sacred artifacts. @@ -152,7 +182,42 @@ The default storage rule is: If a pattern is stable enough to trust, the preferred outcome is to encode it in `packages/components`. -## 6. Current aesthetic direction +## 7. Computed geometry + +Not every useful design token should be authored as an isolated final value. + +When a visual layer starts showing proportional tension, the preferred fix is +often to introduce a small set of base values plus explicit derivation rules, +instead of hand-tuning multiple final tokens independently. + +The intended progression is: + +1. base atomic values +2. computed relationships +3. semantic layer tokens +4. component consumption + +This matters most when values are visually interdependent, such as: + +- shell gap +- shell radius +- shell shadow +- shell padding + +These values should read as one geometric language, not as unrelated numbers +that happened to be chosen near each other. + +Current shell-level rule: + +- shell layout is the first formal landing point for computed geometry tokens +- shell gap, shell radius, shell padding, and shell shadow may be derived from + lower-level atomic tokens +- when shell density changes, adjust the computed relationship first before + introducing page-local overrides +- expand this approach to other layers only when repeated proportional issues + show that the relationship is stable enough to formalize + +## 8. Current aesthetic direction The current direction is: @@ -172,7 +237,7 @@ The closest useful inspiration pattern is: The wrong takeaway from editorial references is warm paper nostalgia. The right takeaway is restraint, hierarchy, and accent discipline. -## 7. Color philosophy +## 9. Color philosophy ### Base rule @@ -229,7 +294,7 @@ Do not drift into: Warm accents may exist, but the workspace itself should remain clear and clean. -## 8. Surface hierarchy +## 10. Surface hierarchy The product should read as a stack of clear working layers: @@ -253,7 +318,7 @@ Not by: Shadows should be minimal. Borders and tonal separation do most of the work. -## 9. Typography roles +## 11. Typography roles Typography has distinct jobs. @@ -299,7 +364,7 @@ Use for: Mono is not a theme. It is a utility lane. -## 10. Component translation rules +## 12. Component translation rules Visual language must land in the component system, not in page-local styling. @@ -334,6 +399,22 @@ Atoms should answer questions like: They should not answer recurring high-level composition questions that already have a stable structural solution. +### Icons + +Icons own: + +- sanctioned glyphs +- their shared rendering wrapper +- size/stroke defaults +- controlled naming + +They do not own: + +- page-level layout +- button/input interaction +- product semantics +- arbitrary unreviewed symbol drift + ### Patterns Patterns are business-blind hard-coded composition templates. @@ -375,7 +456,7 @@ when those rules already belong in atoms or patterns. If `web` appears to need CSS, assume atom expressiveness is missing. Add or refine atom/pattern capability instead of patching page-local styles. -## 11. Pattern index and provisional storage +## 13. Pattern index and provisional storage This file also acts as the pattern index. @@ -420,9 +501,47 @@ Current index: ### Provisional patterns -None currently tracked. - -## 12. Message styling rules +#### Icon system usage constraints + +- status: provisional +- scope: + - icon stroke weight policy + - icon size-scale policy + - outline-only vs filled/outline mixed usage + - icon-only vs icon+label usage boundaries +- current judgment: + - the icon layer now exists and is cold-started from `lucide-react` + - real usage coverage is still too small to justify hard global rules for + these questions + - premature certainty here would likely create decorative or arbitrary system + constraints rather than durable guidance +- why not yet hard-coded: + - the current product surface does not yet exercise enough icon cases across + atoms, patterns, and product components + - the repository needs more real icon usage before narrowing the long-term + style and interaction policy +- promotion trigger: + - revisit once icons appear across a broader set of controls, status lanes, + and reusable patterns + - only hard-code rules that continue to hold across multiple validated usage + contexts + +### Hard-coded icons + +Current cold-start icon surface: + +- `Icon` +- `PlusIcon` +- `SendIcon` + +Current policy: + +- glyphs are sourced from `lucide-react` +- the local `icons` layer is the only sanctioned integration point +- future icon review may rename, replace, or expand this set without exposing + raw third-party imports as the system contract + +## 14. Message styling rules Messages should be visually distinct by role, but still belong to one family. @@ -448,7 +567,7 @@ Messages should be visually distinct by role, but still belong to one family. - should feel structured and inspectable - code/payload areas should remain mono and neutral enough for scanning -## 13. Motion and interaction +## 15. Motion and interaction Motion should be sparse and useful. @@ -466,7 +585,7 @@ Do not use motion for: The interface should feel responsive, not animated. -## 14. Background and atmosphere +## 16. Background and atmosphere The product may have a small amount of atmosphere, but it must stay behind the work. @@ -486,7 +605,7 @@ Not allowed: If a background effect is noticeable before the content is noticeable, it is too strong. -## 15. Borrowing from external references +## 17. Borrowing from external references When studying another project, extract only stable principles such as: @@ -511,14 +630,14 @@ The question is: "What visual rule from that project is durable enough to become a token, atom, or pattern capability here?" -## 16. Review heuristics +## 18. Review heuristics A visual change is likely correct when: - readability improves - hierarchy gets clearer - fewer colors carry more meaning -- the change can be explained through tokens, atoms, or patterns +- the change can be explained through tokens, icons, atoms, or patterns - another screen could reuse the same rule A visual change is likely wrong when: @@ -531,7 +650,7 @@ A visual change is likely wrong when: - it leaves a recurring high-constraint local structure trapped in page-level JSX instead of promoting it to a reusable pattern -## 17. Near-term direction for mini-stim +## 19. Near-term direction for mini-stim Near-term refinement should focus on: @@ -540,6 +659,7 @@ Near-term refinement should focus on: - clearer meta typography - improved transcript readability - stronger atom capabilities for shell/layout/surfaces +- a better controlled icon surface now that the cold-start icon layer exists - extracting the first true business-blind hard-coded patterns from recurring chat/workspace structures @@ -550,12 +670,13 @@ Not on: - dark mode expansion before the light mode language is stable - high-brand marketing aesthetics inside the working chat surface -## 18. Maintenance rule +## 20. Maintenance rule When `mini-stim` gains new visual capabilities, update this file if the change alters: - the aesthetic direction +- the icon-layer policy - the pattern philosophy or layer boundary - the interpretation of accent usage - the allowed surface hierarchy diff --git a/README.md b/README.md index 31edaea..e4758f9 100644 --- a/README.md +++ b/README.md @@ -124,6 +124,9 @@ Useful commands: ```sh runseal :init runseal :init --check +runseal :browser check +runseal :browser recover +runseal :browser reset runseal :pr --dry-run cargo fmt --all --check flavor check --root . --config flavor.toml diff --git a/apps/client/soma/web/src/App.tsx b/apps/client/soma/web/src/App.tsx index 736c464..c6827ee 100644 --- a/apps/client/soma/web/src/App.tsx +++ b/apps/client/soma/web/src/App.tsx @@ -1,6 +1,7 @@ import { useMemo, useState } from "react"; import { AppRoot, Grid, GridItem } from "@mini-stim/components"; import { + useDebouncedValue, useMessageConnection, useSelectedSessionId, useSessionActions, @@ -25,11 +26,17 @@ export function App() { const [error, setError] = useState(null); const busy = pending > 0; + const debouncedBusy = useDebouncedValue(busy, { debounceMs: 150 }); const visibleError = error ?? sessionError?.message ?? null; + const debouncedConnection = useDebouncedValue(connection, { debounceMs: 150 }); const selectedTitle = useMemo(() => { const selected = sessions.find((session) => session.id === selectedSessionId); return selected ? sessionLabel(selected) : "New session"; }, [selectedSessionId, sessions]); + const selectedSession = useMemo( + () => sessions.find((session) => session.id === selectedSessionId) ?? null, + [selectedSessionId, sessions], + ); function createNewSession() { setError(null); @@ -67,9 +74,21 @@ export function App() { } } + function updateTitle(title: string | null) { + if (!selectedSessionId) { + return; + } + setError(null); + try { + actions.updateTitle(selectedSessionId, title); + } catch (caught) { + setError(caught instanceof Error ? caught.message : String(caught)); + } + } + return ( - + diff --git a/apps/client/soma/web/src/components/ChatHeader.tsx b/apps/client/soma/web/src/components/ChatHeader.tsx index 6573700..48aa81d 100644 --- a/apps/client/soma/web/src/components/ChatHeader.tsx +++ b/apps/client/soma/web/src/components/ChatHeader.tsx @@ -1,6 +1,9 @@ +import { useEffect, useState } from "react"; + import { Badge, - Heading, + Button, + Input, Inline, Pane, Stack, @@ -10,17 +13,75 @@ import { export function ChatHeader(props: { busy: boolean; connection: string; + onTitleCommit: (title: string | null) => void; selectedSessionId: string | null; title: string; + titleValue: string | null; }) { + const [draft, setDraft] = useState(props.title); + const [editing, setEditing] = useState(false); + + useEffect(() => { + if (!editing) { + setDraft(props.title); + } + }, [editing, props.title]); + + function commit() { + if (!props.selectedSessionId) { + setEditing(false); + return; + } + const normalized = normalizeTitle(draft); + setEditing(false); + if (normalized === normalizeTitle(props.titleValue ?? null)) { + setDraft(props.title); + return; + } + props.onTitleCommit(normalized); + } + return ( SESSION - - {props.title} - + {editing ? ( + setDraft(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + event.currentTarget.blur(); + } + if (event.key === "Escape") { + setEditing(false); + setDraft(props.title); + } + }} + /> + ) : ( + + )} @@ -33,3 +94,8 @@ export function ChatHeader(props: { ); } + +function normalizeTitle(value: string | null) { + const trimmed = value?.trim() ?? ""; + return trimmed ? trimmed : null; +} diff --git a/apps/client/soma/web/src/components/ChatShell.tsx b/apps/client/soma/web/src/components/ChatShell.tsx index 542c15c..991fd46 100644 --- a/apps/client/soma/web/src/components/ChatShell.tsx +++ b/apps/client/soma/web/src/components/ChatShell.tsx @@ -10,19 +10,23 @@ export function ChatShell(props: { error: string | null; onDraftChange: (value: string) => void; onSend: () => void; + onTitleCommit: (title: string | null) => void; selectedSessionId: string | null; title: string; + titleValue: string | null; timeline: Parameters[0]["timeline"]; draft: string; }) { return ( - + diff --git a/apps/client/soma/web/src/components/Composer.tsx b/apps/client/soma/web/src/components/Composer.tsx index b0f7db5..ac93160 100644 --- a/apps/client/soma/web/src/components/Composer.tsx +++ b/apps/client/soma/web/src/components/Composer.tsx @@ -1,4 +1,4 @@ -import { Button, FieldActionLayout, TextArea } from "@mini-stim/components"; +import { Button, FieldActionLayout, SendIcon, TextArea } from "@mini-stim/components"; export function Composer(props: { disabled?: boolean; @@ -22,6 +22,7 @@ export function Composer(props: { size="lg" disabled={props.disabled || !props.value.trim()} > + Send )} diff --git a/apps/client/soma/web/src/components/SessionRail.tsx b/apps/client/soma/web/src/components/SessionRail.tsx index 37e7fd5..6ba1e6b 100644 --- a/apps/client/soma/web/src/components/SessionRail.tsx +++ b/apps/client/soma/web/src/components/SessionRail.tsx @@ -5,6 +5,7 @@ import { Heading, Inline, Pane, + PlusIcon, ScrollArea, Stack, Text, @@ -19,7 +20,7 @@ export function SessionRail(props: { sessions: Session[]; }) { return ( - + @@ -33,6 +34,7 @@ export function SessionRail(props: { disabled={props.busy} onClick={props.onCreate} > + New diff --git a/apps/server/soma/crates/santi-api/src/main.rs b/apps/server/soma/crates/santi-api/src/main.rs index a1911b7..60c65ef 100644 --- a/apps/server/soma/crates/santi-api/src/main.rs +++ b/apps/server/soma/crates/santi-api/src/main.rs @@ -14,7 +14,7 @@ use futures_core::Stream; use santi_core::{ CreateSessionResponse, ErrorResponse, HealthResponse, SantiService, SantiServiceConfig, SantiStreamEvent, SantiStreamPayload, SendSessionRequest, SendSessionResponse, Session, - SessionDetail, SessionRuntimeSnapshot, prefixed_id, timestamp_now, + SessionDetail, SessionRuntimeSnapshot, UpdateSessionRequest, prefixed_id, timestamp_now, }; use santi_provider::{OpenAIProvider, OpenAIProviderConfig}; use tower_http::{ @@ -118,7 +118,10 @@ fn router(service: SantiService) -> Router { .route("/api/v1/health", get(health)) .route("/api/v1/openapi.json", get(openapi)) .route("/api/v1/sessions", post(create_session).get(list_sessions)) - .route("/api/v1/sessions/{session_id}", get(get_session)) + .route( + "/api/v1/sessions/{session_id}", + get(get_session).patch(update_session), + ) .route("/api/v1/sessions/{session_id}/messages", get(list_messages)) .route("/api/v1/sessions/{session_id}/events", get(session_events)) .route("/api/v1/sessions/{session_id}/send", post(send_session)) @@ -197,6 +200,29 @@ async fn get_session( .ok_or_else(|| ApiError::not_found("session not found")) } +#[utoipa::path( + patch, + path = "/api/v1/sessions/{session_id}", + params(("session_id" = String, Path)), + request_body = UpdateSessionRequest, + responses( + (status = 200, body = Session), + (status = 404, body = ErrorResponse), + (status = 500, body = ErrorResponse) + ) +)] +async fn update_session( + State(service): State, + Path(session_id): Path, + Json(request): Json, +) -> Result, ApiError> { + service + .update_session(&session_id, request) + .map_err(ApiError::internal)? + .map(Json) + .ok_or_else(|| ApiError::not_found("session not found")) +} + #[utoipa::path( get, path = "/api/v1/sessions/{session_id}/messages", @@ -362,6 +388,7 @@ impl IntoResponse for ApiError { create_session, list_sessions, get_session, + update_session, list_messages, send_session, runtime_snapshot @@ -375,6 +402,7 @@ impl IntoResponse for ApiError { Session, SessionDetail, SessionRuntimeSnapshot, + UpdateSessionRequest, santi_core::ActorType, santi_core::Compact, santi_core::Message, diff --git a/apps/server/soma/crates/santi-core/src/model.rs b/apps/server/soma/crates/santi-core/src/model.rs index e347289..10b76c9 100644 --- a/apps/server/soma/crates/santi-core/src/model.rs +++ b/apps/server/soma/crates/santi-core/src/model.rs @@ -257,6 +257,11 @@ pub struct SessionDetail { pub messages: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] +pub struct UpdateSessionRequest { + pub title: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct SendSessionRequest { pub content: Vec, diff --git a/apps/server/soma/crates/santi-core/src/service.rs b/apps/server/soma/crates/santi-core/src/service.rs index fa548c0..2396114 100644 --- a/apps/server/soma/crates/santi-core/src/service.rs +++ b/apps/server/soma/crates/santi-core/src/service.rs @@ -14,7 +14,7 @@ use crate::service_prompt::{ use crate::{ ActorType, CreateSessionResponse, MessageContent, MessageState, SantiStore, SantiStreamEvent, SantiStreamPayload, SendSessionRequest, SendSessionResponse, Session, SessionDetail, - SessionRuntimeSnapshot, prefixed_id, timestamp_now, + SessionRuntimeSnapshot, UpdateSessionRequest, prefixed_id, timestamp_now, }; #[derive(Clone)] @@ -71,6 +71,14 @@ impl SantiService { })) } + pub fn update_session( + &self, + session_id: &str, + request: UpdateSessionRequest, + ) -> Result, String> { + self.store.update_session_title(session_id, request.title) + } + pub fn runtime_snapshot( &self, session_id: &str, diff --git a/apps/server/soma/crates/santi-core/src/store.rs b/apps/server/soma/crates/santi-core/src/store.rs index cebfc4f..d35c1eb 100644 --- a/apps/server/soma/crates/santi-core/src/store.rs +++ b/apps/server/soma/crates/santi-core/src/store.rs @@ -160,6 +160,24 @@ impl SantiStore { session_by_id(&conn, session_id) } + pub fn update_session_title( + &self, + session_id: &str, + title: Option, + ) -> Result, String> { + let conn = self.conn.lock().unwrap(); + if session_by_id(&conn, session_id)?.is_none() { + return Ok(None); + } + let now = timestamp_now(); + conn.execute( + "UPDATE sessions SET title = ?2, updated_at = ?3 WHERE id = ?1", + params![session_id, normalize_session_title(title), now], + ) + .map_err(|error| error.to_string())?; + session_by_id(&conn, session_id) + } + pub fn session_messages(&self, session_id: &str) -> Result, String> { let conn = self.conn.lock().unwrap(); session_messages(&conn, session_id) @@ -345,6 +363,11 @@ fn session_title(content: &MessageContent) -> Option { .split_whitespace() .collect::>() .join(" "); + normalize_session_title(Some(title)) +} + +fn normalize_session_title(title: Option) -> Option { + let title = title?; let trimmed = title.trim(); if trimmed.is_empty() { return None; diff --git a/apps/server/soma/crates/santi-core/tests/store.rs b/apps/server/soma/crates/santi-core/tests/store.rs index 2213cda..3e95268 100644 --- a/apps/server/soma/crates/santi-core/tests/store.rs +++ b/apps/server/soma/crates/santi-core/tests/store.rs @@ -101,3 +101,22 @@ fn titles_from_first_message() { .expect("session exists"); assert_eq!(session.title.as_deref(), Some(title)); } + +#[test] +fn trims_session_title() { + let temp = tempfile::tempdir().expect("temp dir"); + let store = SantiStore::open(temp.path().join("santi.sqlite")).expect("open store"); + let session = store.create_session().expect("create session"); + + let session = store + .update_session_title(&session.id, Some(" renamed title ".to_string())) + .expect("update title") + .expect("session exists"); + assert_eq!(session.title.as_deref(), Some("renamed title")); + + let session = store + .update_session_title(&session.id, Some(" ".to_string())) + .expect("clear title") + .expect("session exists"); + assert_eq!(session.title, None); +} diff --git a/packages/components/package.json b/packages/components/package.json index 0de2f03..0696afe 100644 --- a/packages/components/package.json +++ b/packages/components/package.json @@ -11,6 +11,7 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { + "lucide-react": "^1.17.0", "react": "^19.2.7" }, "devDependencies": { diff --git a/packages/components/src/atoms/AppRoot/AppRoot.scss b/packages/components/src/atoms/AppRoot/AppRoot.scss index be6890d..9576150 100644 --- a/packages/components/src/atoms/AppRoot/AppRoot.scss +++ b/packages/components/src/atoms/AppRoot/AppRoot.scss @@ -4,8 +4,13 @@ height: 100dvh; min-width: 0; min-height: 0; + padding: var(--ms-shell-padding); background: var(--ms-color-canvas); color: var(--ms-color-text); overflow: hidden; overscroll-behavior: none; + + @media (max-width: 48rem) { + padding: var(--ms-space-2); + } } diff --git a/packages/components/src/atoms/Grid/Grid.scss b/packages/components/src/atoms/Grid/Grid.scss index c9b693b..3254992 100644 --- a/packages/components/src/atoms/Grid/Grid.scss +++ b/packages/components/src/atoms/Grid/Grid.scss @@ -1,5 +1,6 @@ $grid-gaps: ( "none": 0, + "shell": var(--ms-shell-gap), "sm": var(--ms-space-3), "md": var(--ms-space-4), ); diff --git a/packages/components/src/atoms/Grid/Grid.tsx b/packages/components/src/atoms/Grid/Grid.tsx index 186777e..e5f3250 100644 --- a/packages/components/src/atoms/Grid/Grid.tsx +++ b/packages/components/src/atoms/Grid/Grid.tsx @@ -4,7 +4,7 @@ import { cx } from "../../internal/cx"; import "./Grid.scss"; type GridProps = ComponentPropsWithoutRef<"div"> & { - gap?: "none" | "sm" | "md"; + gap?: "none" | "shell" | "sm" | "md"; grow?: boolean; template?: "sidebar-main"; }; diff --git a/packages/components/src/atoms/Input/Input.scss b/packages/components/src/atoms/Input/Input.scss new file mode 100644 index 0000000..c8309bc --- /dev/null +++ b/packages/components/src/atoms/Input/Input.scss @@ -0,0 +1,32 @@ +@use "../../theme/mixins" as *; + +.msInput { + display: block; + width: 100%; + min-width: 0; + min-height: 2.5rem; + border: 1px solid var(--ms-color-input-border); + border-radius: var(--ms-radius-lg); + background: var(--ms-color-input-bg); + color: var(--ms-color-text-strong); + padding: 0 var(--ms-space-3); + @include transition(background-color, border-color, box-shadow); + + @include control-disabled(0.7); + + @include focus-ring("&:focus"); + + &:focus { + background: var(--ms-color-input-focus-bg); + } + + &--variant-title { + min-height: 2rem; + border-color: var(--ms-color-dock-border); + background: var(--ms-color-panel-raised); + padding-inline: var(--ms-space-3); + font-size: 1.375rem; + font-weight: 650; + letter-spacing: -0.01em; + } +} diff --git a/packages/components/src/atoms/Input/Input.tsx b/packages/components/src/atoms/Input/Input.tsx new file mode 100644 index 0000000..aed0146 --- /dev/null +++ b/packages/components/src/atoms/Input/Input.tsx @@ -0,0 +1,23 @@ +import type { InputHTMLAttributes } from "react"; + +import { cx } from "../../internal/cx"; +import "./Input.scss"; + +type InputProps = InputHTMLAttributes & { + variant?: "default" | "title"; +}; + +export function Input({ + className, + type = "text", + variant = "default", + ...props +}: InputProps) { + return ( + + ); +} diff --git a/packages/components/src/atoms/Pane/Pane.scss b/packages/components/src/atoms/Pane/Pane.scss index 4d0ad3e..fe660f9 100644 --- a/packages/components/src/atoms/Pane/Pane.scss +++ b/packages/components/src/atoms/Pane/Pane.scss @@ -30,6 +30,7 @@ $pane-borders: ( .msPane { min-width: 0; + min-height: 0; &--grow { display: flex; @@ -37,6 +38,29 @@ $pane-borders: ( min-height: 0; } + &--chrome-panel { + position: relative; + border-radius: var(--ms-shell-radius); + box-shadow: var(--ms-shell-shadow); + overflow: hidden; + isolation: isolate; + + &::before { + content: ""; + position: absolute; + inset: 0; + background: + linear-gradient(180deg, rgba(255, 255, 255, 0.56), rgba(255, 255, 255, 0) 4rem); + pointer-events: none; + z-index: 0; + } + + > * { + position: relative; + z-index: 1; + } + } + @each $name, $value in $pane-tones { &--tone-#{$name} { background: $value; diff --git a/packages/components/src/atoms/Pane/Pane.tsx b/packages/components/src/atoms/Pane/Pane.tsx index 8ee4967..e854b40 100644 --- a/packages/components/src/atoms/Pane/Pane.tsx +++ b/packages/components/src/atoms/Pane/Pane.tsx @@ -5,6 +5,7 @@ import "./Pane.scss"; type PaneProps = ComponentPropsWithoutRef<"div"> & { border?: "none" | "right" | "top" | "bottom" | "around"; + chrome?: "none" | "panel"; grow?: boolean; padding?: "none" | "sm" | "md" | "lg" | "xl"; tone?: "canvas" | "panel" | "raised" | "subtle"; @@ -13,6 +14,7 @@ type PaneProps = ComponentPropsWithoutRef<"div"> & { export function Pane({ border = "none", className, + chrome = "none", grow = false, padding = "none", tone = "canvas", @@ -24,6 +26,7 @@ export function Pane({ className={cx( "msPane", `msPane--border-${border}`, + `msPane--chrome-${chrome}`, `msPane--padding-${padding}`, `msPane--tone-${tone}`, grow && "msPane--grow", diff --git a/packages/components/src/icons/Icon/Icon.scss b/packages/components/src/icons/Icon/Icon.scss new file mode 100644 index 0000000..decd9fb --- /dev/null +++ b/packages/components/src/icons/Icon/Icon.scss @@ -0,0 +1,20 @@ +.msIcon { + display: block; + flex: none; + color: currentColor; + + &--size-sm { + width: 1rem; + height: 1rem; + } + + &--size-md { + width: 1.125rem; + height: 1.125rem; + } + + &--size-lg { + width: 1.25rem; + height: 1.25rem; + } +} diff --git a/packages/components/src/icons/Icon/Icon.tsx b/packages/components/src/icons/Icon/Icon.tsx new file mode 100644 index 0000000..5f327e0 --- /dev/null +++ b/packages/components/src/icons/Icon/Icon.tsx @@ -0,0 +1,34 @@ +import type { SVGProps } from "react"; +import type { LucideIcon } from "lucide-react"; + +import { cx } from "../../internal/cx"; +import "./Icon.scss"; + +export type IconProps = Omit, "color"> & { + decorative?: boolean; + glyph: LucideIcon; + size?: "sm" | "md" | "lg"; + strokeWidth?: number; + title?: string; +}; + +export function Icon({ + className, + decorative = true, + glyph: Glyph, + size = "md", + strokeWidth = 1.9, + title, + ...props +}: IconProps) { + return ( + + ); +} diff --git a/packages/components/src/icons/PlusIcon.tsx b/packages/components/src/icons/PlusIcon.tsx new file mode 100644 index 0000000..e94b815 --- /dev/null +++ b/packages/components/src/icons/PlusIcon.tsx @@ -0,0 +1,7 @@ +import { Plus } from "lucide-react"; + +import { Icon, type IconProps } from "./Icon/Icon"; + +export function PlusIcon(props: Omit) { + return ; +} diff --git a/packages/components/src/icons/SendIcon.tsx b/packages/components/src/icons/SendIcon.tsx new file mode 100644 index 0000000..b6a9fe7 --- /dev/null +++ b/packages/components/src/icons/SendIcon.tsx @@ -0,0 +1,7 @@ +import { SendHorizontal } from "lucide-react"; + +import { Icon, type IconProps } from "./Icon/Icon"; + +export function SendIcon(props: Omit) { + return ; +} diff --git a/packages/components/src/index.ts b/packages/components/src/index.ts index c42405e..5e2c65a 100644 --- a/packages/components/src/index.ts +++ b/packages/components/src/index.ts @@ -6,11 +6,15 @@ export { FieldActionLayout } from "./patterns/FieldActionLayout/FieldActionLayou export { Grid, GridItem } from "./atoms/Grid/Grid"; export { GridRows } from "./atoms/GridRows/GridRows"; export { Heading } from "./atoms/Heading/Heading"; +export { Icon } from "./icons/Icon/Icon"; export { IconButton } from "./atoms/IconButton/IconButton"; +export { Input } from "./atoms/Input/Input"; export { Inline } from "./atoms/Inline/Inline"; export { Notice } from "./atoms/Notice/Notice"; export { Pane } from "./atoms/Pane/Pane"; +export { PlusIcon } from "./icons/PlusIcon"; export { ScrollArea } from "./atoms/ScrollArea/ScrollArea"; +export { SendIcon } from "./icons/SendIcon"; export { Stack } from "./atoms/Stack/Stack"; export { Surface } from "./atoms/Surface/Surface"; export { Text } from "./atoms/Text/Text"; diff --git a/packages/components/src/theme/default.scss b/packages/components/src/theme/default.scss index 01f682e..d440014 100644 --- a/packages/components/src/theme/default.scss +++ b/packages/components/src/theme/default.scss @@ -9,6 +9,10 @@ color: var(--ms-color-text); background: var(--ms-color-canvas); font-family: var(--ms-font-family-sans); + --ms-shell-padding: var(--ms-space-3); + --ms-shell-gap: calc(var(--ms-space-3) * 0.8); + --ms-shell-radius: calc(var(--ms-radius-xl) - var(--ms-space-1)); + --ms-shell-shadow: var(--ms-shadow-shell); } * { diff --git a/packages/contracts/openapi.json b/packages/contracts/openapi.json index f60086e..4ed097f 100644 --- a/packages/contracts/openapi.json +++ b/packages/contracts/openapi.json @@ -131,6 +131,62 @@ } } } + }, + "patch": { + "tags": [], + "operationId": "update_session", + "parameters": [ + { + "name": "session_id", + "in": "path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSessionRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Session" + } + } + } + }, + "404": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } } }, "/api/v1/sessions/{session_id}/messages": { @@ -955,6 +1011,17 @@ "session_send", "system" ] + }, + "UpdateSessionRequest": { + "type": "object", + "properties": { + "title": { + "type": [ + "string", + "null" + ] + } + } } } } diff --git a/packages/contracts/src/openapi.ts b/packages/contracts/src/openapi.ts index e84fc6f..ff5db3a 100644 --- a/packages/contracts/src/openapi.ts +++ b/packages/contracts/src/openapi.ts @@ -209,6 +209,11 @@ export interface SessionRuntimeSnapshot { turns: Turn[]; } +export interface UpdateSessionRequest { + /** @nullable */ + title?: string | null; +} + export type healthResponse200 = { data: HealthResponse; status: 200; @@ -375,6 +380,61 @@ export const getSession = async ( } as getSessionResponse; }; +export type updateSessionResponse200 = { + data: Session; + status: 200; +}; + +export type updateSessionResponse404 = { + data: ErrorResponse; + status: 404; +}; + +export type updateSessionResponse500 = { + data: ErrorResponse; + status: 500; +}; + +export type updateSessionResponseSuccess = updateSessionResponse200 & { + headers: Headers; +}; +export type updateSessionResponseError = ( + | updateSessionResponse404 + | updateSessionResponse500 +) & { + headers: Headers; +}; + +export type updateSessionResponse = + | updateSessionResponseSuccess + | updateSessionResponseError; + +export const getUpdateSessionUrl = (sessionId: string) => { + return `/api/v1/sessions/${sessionId}`; +}; + +export const updateSession = async ( + sessionId: string, + updateSessionRequest: UpdateSessionRequest, + options?: RequestInit, +): Promise => { + const res = await fetch(getUpdateSessionUrl(sessionId), { + ...options, + method: "PATCH", + headers: { "Content-Type": "application/json", ...options?.headers }, + body: JSON.stringify(updateSessionRequest), + }); + + const body = [204, 205, 304].includes(res.status) ? null : await res.text(); + + const data: updateSessionResponse["data"] = body ? JSON.parse(body) : {}; + return { + data, + status: res.status, + headers: res.headers, + } as updateSessionResponse; +}; + export type listMessagesResponse200 = { data: SessionMessage[]; status: 200; diff --git a/packages/hooks/src/index.tsx b/packages/hooks/src/index.tsx index 4d8bc45..2230f20 100644 --- a/packages/hooks/src/index.tsx +++ b/packages/hooks/src/index.tsx @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, + useState, useSyncExternalStore, type ReactNode, } from "react"; @@ -48,6 +49,7 @@ interface SessionActions { select(sessionId: string | null): PubAck; selectAndGet(sessionId: string): PubAck[]; send(input: { sessionId?: string | null; content: MessagePart[] }): PubAck; + updateTitle(sessionId: string, title: string | null): PubAck; } const SantiMqueueContext = createContext(null); @@ -136,6 +138,31 @@ export function useSessionError(): MqueueError | null { return useSessionProjection().error; } +export function useDebouncedValue( + value: T, + options: { + debounceMs: number; + equality?: (left: T, right: T) => boolean; + }, +): T { + const { debounceMs, equality = defaultEquality } = options; + const [debouncedValue, setDebouncedValue] = useState(value); + + useEffect(() => { + if (equality(value, debouncedValue)) { + return; + } + + const timeoutId = setTimeout(() => { + setDebouncedValue((current) => (equality(value, current) ? current : value)); + }, debounceMs); + + return () => clearTimeout(timeoutId); + }, [debouncedValue, debounceMs, equality, value]); + + return debouncedValue; +} + export function useMessageConnection(sessionId?: string | null): MessageConnectionState { const mqueue = useSantiMqueue(); const selectedSessionId = useSelectedSessionId(); @@ -192,6 +219,8 @@ export function useSessionActions(): SessionActions { ], send: (input: { sessionId?: string | null; content: MessagePart[] }) => mqueue.session.pub("send", input), + updateTitle: (sessionId: string, title: string | null) => + mqueue.session.pub("update", { sessionId, title }), }), [mqueue], ); @@ -206,3 +235,7 @@ function installBrowserMqueue(target?: Window & SantiWindow): SantiMqueue { } return installSantiMqueue(resolvedTarget); } + +function defaultEquality(left: T, right: T) { + return left === right; +} diff --git a/packages/mqueue/src/index.ts b/packages/mqueue/src/index.ts index ed07a28..cbb5784 100644 --- a/packages/mqueue/src/index.ts +++ b/packages/mqueue/src/index.ts @@ -5,6 +5,7 @@ import { listSessions, runtimeSnapshot, sendSession, + updateSession, type CreateSessionResponse, type SendSessionResponse, type Session, @@ -263,6 +264,19 @@ function createMqueueCore(target: Window): SantiMqueue { dispatchSession(target, sessionEvent(action, "committed", detail, "http")); return; } + case "update": { + const updatePayload = payload as SessionPayloads["update"]; + const session = expectStatus( + await updateSession(updatePayload.sessionId, { title: updatePayload.title }), + 200, + ) as Session; + upsertSession(session); + if (state.selectedSessionId === session.id) { + state.messages = state.messagesBySessionId[session.id] ?? []; + } + dispatchSession(target, sessionEvent(action, "committed", { session }, "http")); + return; + } case "messages": { const messagesPayload = payload as SessionPayloads["messages"]; const messages = expectStatus( diff --git a/packages/mqueue/src/types.ts b/packages/mqueue/src/types.ts index 63a7184..fe6ceb0 100644 --- a/packages/mqueue/src/types.ts +++ b/packages/mqueue/src/types.ts @@ -5,6 +5,7 @@ import type { SessionRuntimeSnapshot, ToolCall, ToolResult, + UpdateSessionRequest, } from "@mini-stim/contracts"; export type { @@ -22,6 +23,7 @@ export type SessionAction = | "messages" | "runtime" | "select" + | "update" | "send"; export type SessionPhase = @@ -39,6 +41,7 @@ export interface SessionPayloads { runtime: { sessionId: string }; select: { sessionId: string | null }; send: { sessionId?: string | null; content: MessagePart[] }; + update: { sessionId: string; title: UpdateSessionRequest["title"] }; } export interface MqueueError { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7772eb1..19d0329 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -57,6 +57,9 @@ importers: packages/components: dependencies: + lucide-react: + specifier: ^1.17.0 + version: 1.17.0(react@19.2.7) react: specifier: ^19.2.7 version: 19.2.7 @@ -862,6 +865,11 @@ packages: resolution: {integrity: sha512-XT9ewWAC43tiAV7xDAPflMkG0qOPn2QjHqlgX8FOqmWa/rxnyYDulF9T0F7tRy1u+TVTmK/M//6VIOye+2zDXg==} engines: {node: '>=20'} + lucide-react@1.17.0: + resolution: {integrity: sha512-9FA9evdox/JQL5PT57fdA1x/yg8T7knJ98+zjTL3UfKza6pflQUUh3XtaQIHKvnsJw1lmsEyHVlt5jchYxOQ5w==} + peerDependencies: + react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + lunr@2.3.9: resolution: {integrity: sha512-zTU3DaZaF3Rt9rhN3uBMGQD3dD2/vFQqnvZCDv4dl5iOzq2IZQqTxu90r4E5J+nP70J3ilqVCrbho2eWaeW8Ow==} @@ -1777,6 +1785,10 @@ snapshots: dependencies: p-locate: 6.0.0 + lucide-react@1.17.0(react@19.2.7): + dependencies: + react: 19.2.7 + lunr@2.3.9: {} markdown-it@14.2.0: