feat(rhema-code): add the colors setting with four palettes

The stored `colors` key names a palette — rhema (the default,
Jadeite), aquamarine, black-and-white, or dracula — and the
Appearance page of /settings cycles it live, like the theme and
icon toggles beside it. Each palette carries a dark and a light
appearance under the existing `theme` key; dracula's light side is
derived, the official palette ships only dark.

The appearance decision moves off token comparison: Theme now
answers is_dark() from the ground/text luminance, so palettes
beyond the original two get the right per-profile fallback tones.
This commit is contained in:
vhaudiquet committed 2026-10-17 04:28:31 +08:00
commit 81da26b955
159 files changed
+50628

No files matched your search

+316
View File
@@ -0,0 +1,316 @@
<script setup lang="ts">
// The session inventory: the dispatch keys a human converses with,
// interactive first. Clicking selects the session the chat mirrors.
// Displayed names drop the `interactive:` prefix — the full key stays
// in the tooltip. Creating is intent only: the name flows up, the
// shell answers, and the form closes when the new key lands in the
// inventory.
import { SESSION_NAME_PATTERN } from '~/composables/useClaw'
import type { ClawSession } from '~/composables/useClaw'
const props = defineProps<{
sessions: ClawSession[]
activeKey: string
loading: boolean
identity?: { name: string | null; tagline: string | null; mark: string | null; initials: string | null } | null
avatarUrl?: string | null
}>()
const emit = defineEmits<{ select: [key: string]; refresh: []; create: [name: string] }>()
interface Group {
label: string
rows: { key: string; name: string; session: ClawSession }[]
}
const groups = computed<Group[]>(() => {
const byKey = (a: ClawSession, b: ClawSession) => a.key.localeCompare(b.key)
// `phone:<device>:<topic>` keys carry the phone client's status
// reporting (battery, network, dnd), not conversation; the inventory
// hides them rather than the API narrowing.
const conversable = props.sessions.filter((session) => !session.key.startsWith('phone:'))
const interactive = conversable
.filter((session) => session.key.startsWith('interactive:'))
.sort(byKey)
const others = conversable
.filter((session) => !session.key.startsWith('interactive:'))
.sort(byKey)
const row = (session: ClawSession) => ({
key: session.key,
name: session.key.replace(/^interactive:/, ''),
session,
})
const groups: Group[] = []
if (interactive.length > 0)
groups.push({ label: 'interactive', rows: interactive.map(row) })
if (others.length > 0) groups.push({ label: 'other keys', rows: others.map(row) })
return groups
})
const shownCount = computed(() =>
groups.value.reduce((total, group) => total + group.rows.length, 0),
)
function stateTone(state: string): 'ok' | 'warn' | 'idle' {
if (state === 'leased') return 'ok'
if (state === 'reacquire') return 'warn'
return 'idle'
}
// --- new sessions -----------------------------------------------------
const creating = ref(false)
const nameDraft = ref('')
const nameInput = ref<HTMLInputElement | null>(null)
/** The name this form is waiting on; the create is in flight. */
const pendingCreate = ref<string | null>(null)
const nameValid = computed(() => SESSION_NAME_PATTERN.test(nameDraft.value))
function openCreate(): void {
creating.value = true
nameDraft.value = ''
void nextTick(() => nameInput.value?.focus())
}
function closeCreate(): void {
creating.value = false
nameDraft.value = ''
pendingCreate.value = null
}
function submitCreate(): void {
if (!nameValid.value) return
pendingCreate.value = nameDraft.value
emit('create', nameDraft.value)
}
// The form closes when the claw answers with the session in the
// inventory; a failed create answers nothing, and the form stays.
watch(
() => props.sessions,
(sessions) => {
if (
pendingCreate.value !== null &&
sessions.some((session) => session.key === `interactive:${pendingCreate.value}`)
) {
closeCreate()
}
},
)
</script>
<template>
<aside class="sidebar">
<header class="agent">
<AgentAvatar
:avatar-url="avatarUrl"
:initials="identity?.initials ?? identity?.name?.slice(0, 1)"
:size="34"
/>
<div class="agent-text">
<strong>{{ identity?.name?.trim() || 'rhema' }}</strong>
<span>{{ identity?.tagline?.trim() || 'the spoken word' }}</span>
</div>
</header>
<header class="head">
<span class="label">Sessions</span>
<span class="count">{{ shownCount }}</span>
<button class="ghost" title="new session" :disabled="creating" @click="creating ? closeCreate() : openCreate()">
+
</button>
<button class="ghost" title="refresh sessions" :disabled="loading" @click="$emit('refresh')">
↻
</button>
</header>
<form v-if="creating" class="new-session" @submit.prevent="submitCreate">
<input
ref="nameInput"
v-model="nameDraft"
type="text"
class="mono"
placeholder="session name"
autocomplete="off"
maxlength="64"
/>
<div class="new-actions">
<button type="button" class="ghost" @click="closeCreate">cancel</button>
<button type="submit" class="primary" :disabled="!nameValid">create</button>
</div>
<p v-if="nameDraft !== '' && !nameValid" class="new-note">
letters, digits, dots, dashes, underscores — up to 64.
</p>
</form>
<p v-if="shownCount === 0" class="empty">
{{ loading ? 'loading…' : 'no sessions yet; send the first message' }}
</p>
<div v-for="group in groups" :key="group.label" class="group">
<span class="label">{{ group.label }}</span>
<button
v-for="row in group.rows"
:key="row.key"
class="entry"
:class="{ active: row.key === activeKey }"
:title="row.key"
@click="$emit('select', row.key)"
>
<StatusDot :tone="stateTone(row.session.state)" />
<span class="name">{{ row.name }}</span>
<span v-if="row.session.inbox_depth > 0" class="depth" title="inbox depth">
{{ row.session.inbox_depth }}
</span>
</button>
</div>
</aside>
</template>
<style scoped>
.sidebar {
display: flex;
flex-direction: column;
min-height: 0;
width: 240px;
flex: none;
padding: 14px 10px;
border-right: 1px solid var(--border);
background: var(--surface);
overflow-y: auto;
}
.agent {
display: flex;
align-items: center;
gap: 10px;
padding: 0 8px 12px;
border-bottom: 1px solid var(--border);
margin-bottom: 10px;
}
.agent-text {
display: flex;
flex-direction: column;
min-width: 0;
}
.agent-text strong {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-text span {
color: var(--text3);
font-size: 11.5px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.head {
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 10px;
}
.count {
flex: 1;
color: var(--text3);
font-size: 12px;
}
.head .ghost {
padding: 0 6px;
}
.new-session {
display: flex;
flex-direction: column;
gap: 6px;
padding: 0 8px 10px;
}
.new-session input {
width: 100%;
font-size: 12.5px;
}
.new-actions {
display: flex;
justify-content: flex-end;
gap: 6px;
}
.new-actions button {
padding: 3px 10px;
font-size: 12px;
}
.new-note {
margin: 0;
color: var(--text3);
font-size: 11.5px;
}
.empty {
margin: 4px 8px;
color: var(--text3);
}
.group {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 14px;
}
.group .label {
padding: 0 8px 4px;
}
.entry {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
text-align: left;
background: none;
border: none;
border-radius: 7px;
padding: 7px 10px;
color: var(--text2);
}
.entry:hover:not(:disabled) {
background: var(--elevated);
color: var(--text);
}
.entry.active {
background: color-mix(in srgb, var(--accent) 17%, transparent);
color: var(--accent-text);
font-weight: 600;
}
.name {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.depth {
flex: none;
min-width: 18px;
text-align: center;
font-size: 11px;
font-family: var(--mono);
color: var(--info);
border: 1px solid var(--border-strong);
border-radius: 999px;
padding: 0 5px;
}
</style>