From ce0d8afe69ed6e25751dfeaa40647edda58d166b Mon Sep 17 00:00:00 2001 From: Richard Zink Date: Sat, 14 Nov 2026 23:31:52 +0100 Subject: [PATCH] Chat bubbles, icons for chats, more vivid and distinct colour tags - Bubbles: own messages right-aligned in their own colour, agent answers left. - Chat icons (22, validated on the server), shown in list, top bar, project page. - Colour tags: eight fixed hues (new: pink) with theme-matched lightness and the highest chroma sRGB allows; stronger background tint. Co-Authored-By: Claude Sonnet 5.5 --- core/src/types.rs | 11 ++- server/src/api.rs | 12 ++- server/src/jobs.rs | 1 + server/src/runner.rs | 1 + server/src/telegram.rs | 1 + web/src/components/ChatView.tsx | 12 +-- web/src/components/ColorSwatches.tsx | 37 +++++++- web/src/components/ProjectPage.tsx | 5 +- web/src/components/Sidebar.tsx | 10 ++- web/src/i18n/de.ts | 2 + web/src/i18n/en.ts | 2 + web/src/styles.css | 121 +++++++++++++++++++++------ web/src/types.ts | 2 + 13 files changed, 180 insertions(+), 37 deletions(-) diff --git a/core/src/types.rs b/core/src/types.rs index 46866b8..61b7356 100644 --- a/core/src/types.rs +++ b/core/src/types.rs @@ -193,10 +193,19 @@ pub struct Session { /// Colour tag of the chat (a palette name, see `SESSION_COLORS`); the client maps it to the theme. #[serde(default, skip_serializing_if = "Option::is_none")] pub color: Option, + /// Icon of the chat (a name from `SESSION_ICONS`), shown in the list and the top bar. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub icon: Option, } /// Names of the colour tags: the client draws them with the colours of the active theme. -pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua", "blue", "purple"]; +pub const SESSION_COLORS: &[&str] = &["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"]; + +/// Icons a chat can get (names of the client's icon set). +pub const SESSION_ICONS: &[&str] = &[ + "robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock", + "users", "toolbox", "plug", "scroll", "eye", "template", +]; impl Session { pub fn clean_room(&self, default: bool) -> bool { diff --git a/server/src/api.rs b/server/src/api.rs index 91fbe10..fcf7372 100644 --- a/server/src/api.rs +++ b/server/src/api.rs @@ -647,6 +647,7 @@ async fn create_session(Extension(user): Extension, State(app): State, /// Colour tag ("" removes it). color: Option, + /// Icon ("" removes it). + icon: Option, } async fn update_session(Extension(user): Extension, State(app): State, Path(id): Path, Json(body): Json) -> ApiResult { @@ -957,6 +960,9 @@ async fn update_session(Extension(user): Extension, State(app): State, State(app): State, State(app): Stat let s = writable(&app, &user, &id)?; let data = json!({ "model": s.model, "agent": s.agent, "permission_mode": s.permission_mode, - "color": s.color, "instructions": s.instructions, "folder": s.folder, + "color": s.color, "icon": s.icon, "instructions": s.instructions, "folder": s.folder, }); app.store.set_project_defaults(&s.cwd, Some(&data))?; Ok(Json(data)) @@ -2145,6 +2154,7 @@ async fn job_assistant(Extension(user): Extension, State(app): State, job: &Job, trigger: &str, payload: Option< telegram_quiet: true, summary: String::new(), color: None, + icon: None, }; let session = app.save_session(session)?; app.store.job_started(&job.id, &session.id, now)?; diff --git a/server/src/runner.rs b/server/src/runner.rs index 1abbdf3..773260a 100644 --- a/server/src/runner.rs +++ b/server/src/runner.rs @@ -899,6 +899,7 @@ fn create_child(app: &Arc, ctx: &ToolCtx, agent_name: &str, description: &s telegram_quiet: false, summary: String::new(), color: None, + icon: None, }; let child = app.save_session(child)?; let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); diff --git a/server/src/telegram.rs b/server/src/telegram.rs index 8788917..0bd2c87 100644 --- a/server/src/telegram.rs +++ b/server/src/telegram.rs @@ -543,6 +543,7 @@ async fn new_session(app: &Arc, u: &User, bot: &Bot, chat: i64, arg: &str, telegram_quiet: false, summary: String::new(), color: None, + icon: None, })?; update(app, &u.id, |st| st.session = Some(s.id.clone())); bot.send(chat, &format!("{} {} · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?; diff --git a/web/src/components/ChatView.tsx b/web/src/components/ChatView.tsx index 90c0173..6b88bf6 100644 --- a/web/src/components/ChatView.tsx +++ b/web/src/components/ChatView.tsx @@ -26,7 +26,7 @@ import { ShellsChip } from "./ShellsPanel"; import { TodoList, ToolCard } from "./ToolCard"; import { confirmDialog, promptDialog, toast } from "../dialogs"; import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; -import { ColorSwatches } from "./ColorSwatches"; +import { ColorSwatches, IconPicker } from "./ColorSwatches"; import { ProjectPermissions } from "./ProjectPermissions"; import { OutboxBar } from "./OutboxBar"; import { addToOutbox, isOffline } from "../outbox"; @@ -374,7 +374,7 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa ); } return ( -
+
)} {draft && ( -
+
p.type === "text" && p.text.trim()) ? "bubble agent" : ""}`}>
)} diff --git a/web/src/components/ColorSwatches.tsx b/web/src/components/ColorSwatches.tsx index cacb88e..fe47af7 100644 --- a/web/src/components/ColorSwatches.tsx +++ b/web/src/components/ColorSwatches.tsx @@ -1,9 +1,16 @@ import { useT, type Key } from "../i18n"; +import { Icon } from "./Icon"; /** Colour tags of chats: names, drawn in the colours of the active theme (see `[data-color]` in styles.css). */ -export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue", "purple"] as const; +export const SESSION_COLORS = ["red", "orange", "yellow", "green", "aqua", "blue", "purple", "pink"] as const; -/** Row of colour dots; the selected one is ringed, a second click (or ✕) removes the tag. */ +/** Icons a chat can get (must match SESSION_ICONS on the server). */ +export const SESSION_ICONS = [ + "robot", "brain", "terminal", "wrench", "gear", "chart", "shield", "image", "music", "cube", "file-text", "folder", "search", "sparkle", "clock", "lock", + "users", "toolbox", "plug", "scroll", "eye", "template", +] as const; + +/** Row of colour dots; the selected one is ringed, a second click removes the tag. */ export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) { const t = useT(); return ( @@ -27,3 +34,29 @@ export function ColorSwatches({ value, onChange }: { value?: string; onChange: (
); } + +/** Grid of icons for a chat (in the colour of the chat); a second click removes the icon. */ +export function IconPicker({ value, color, onChange }: { value?: string; color?: string; onChange: (icon: string) => void }) { + const t = useT(); + return ( +
+ {SESSION_ICONS.map((i) => ( + + ))} +
+ ); +} diff --git a/web/src/components/ProjectPage.tsx b/web/src/components/ProjectPage.tsx index 0b0a41b..6152ef0 100644 --- a/web/src/components/ProjectPage.tsx +++ b/web/src/components/ProjectPage.tsx @@ -96,7 +96,10 @@ export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }
  • (e.preventDefault(), navigate(`/s/${s.id}`))}>
    - {s.title || t("session.untitled")} + + {s.icon && } + {s.title || t("session.untitled")} + {fmtRelative(s.updated_at)} diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 7db3523..c4f9785 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -1,4 +1,4 @@ -import { ColorSwatches } from "./ColorSwatches"; +import { ColorSwatches, IconPicker } from "./ColorSwatches"; import { Icon } from "./Icon"; import { StatusLeds } from "./StatusLeds"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; @@ -26,7 +26,7 @@ function groupKey(ms: number): Key { const GROUP_BY = "codecraft.sidebarGroupBy"; const COLLAPSED = "codecraft.sidebarCollapsed"; -const organise = (id: string, body: Partial> & { folder?: string; color?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message)); +const organise = (id: string, body: Partial> & { folder?: string; color?: string; icon?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message)); /** One session in the list, with a menu to pin, file or archive it. */ function SessionRow({ s, active, attention, busy, folders }: { s: Session; active: boolean; attention: boolean; busy: boolean; folders: string[] }) { @@ -57,7 +57,10 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ )} (setMenu(false), organise(s.id, { color }))} /> + (setMenu(false), organise(s.id, { icon }))} /> diff --git a/web/src/i18n/de.ts b/web/src/i18n/de.ts index 33d78eb..dc98af8 100644 --- a/web/src/i18n/de.ts +++ b/web/src/i18n/de.ts @@ -400,6 +400,8 @@ export const de: Messages = { "color.aqua": "Türkis", "color.blue": "Blau", "color.purple": "Violett", + "color.pink": "Pink", + "icon.label": "Symbol dieses Chats", "claude.title": "Claude Code", "claude.intro": "Chats mit einem „Claude Code“-Modell führt die Claude-Code-CLI auf dem Server aus – mit deinem Claude-Abo oder Anthropic-Console-Konto. Einmal hier anmelden; Freigaben, Push und Sandbox funktionieren wie gewohnt.", "claude.notInstalled": "Claude Code ist auf dem Server nicht installiert.", diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 95fb5e5..77d6b88 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -400,6 +400,8 @@ export const en = { "color.aqua": "Teal", "color.blue": "Blue", "color.purple": "Purple", + "color.pink": "Pink", + "icon.label": "Icon of this chat", "claude.title": "Claude Code", "claude.intro": "Chats with a \"Claude Code\" model are run by the Claude Code CLI on the server – with your Claude subscription or Anthropic Console account. Sign in here once; approvals, push and the sandbox work as usual.", "claude.notInstalled": "Claude Code is not installed on the server.", diff --git a/web/src/styles.css b/web/src/styles.css index 702f029..fdcbe53 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -547,15 +547,45 @@ button.session-group:hover { border: 1px solid var(--bg2); line-height: 0; } +/* Chat bubbles: the agent's answers on the left, your messages on the right in another colour. */ .msg-user { - background: var(--bg1); - border: 1px solid var(--bg2); - border-radius: var(--radius); - padding: 8px 12px; + background: color-mix(in srgb, var(--accent) 26%, var(--bg1)); + border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent); + border-radius: 18px 4px 18px 18px; + color: var(--fg0); + padding: 9px 14px; white-space: pre-wrap; word-break: break-word; position: relative; } +.msg.user-msg { + width: fit-content; + max-width: min(88%, 780px); + margin-left: auto; +} +.msg.bubble.agent { + width: fit-content; + max-width: 100%; + padding: 10px 14px; + background: var(--bg1); + border: 1px solid var(--bg2); + border-radius: 4px 18px 18px 18px; +} +.msg.bubble.agent > :last-child { + margin-bottom: 0; +} +.chat-pane[data-color] .msg-user { + background: color-mix(in srgb, var(--tag) 38%, var(--bg1)); + border-color: color-mix(in srgb, var(--tag) 70%, transparent); +} +.chat-pane[data-color] .msg.bubble.agent { + background: color-mix(in srgb, var(--tag) 7%, var(--bg1)); +} +@media (max-width: 700px) { + .msg.user-msg { + max-width: 94%; + } +} /* User messages are Markdown too; the line breaks come from keepLineBreaks. */ .msg-user .markdown { white-space: normal; @@ -4687,41 +4717,64 @@ span.chat-tab > button { } /* --- colour tags of chats ------------------------------------------------------ - * The tag is a name; the colour comes from the active theme (--red, --accent, …), - * the background is mixed into --bg0, so every theme (light or dark) stays readable. */ -[data-color="red"] { --tag: var(--red); } -[data-color="orange"] { --tag: var(--accent2, var(--red)); } -[data-color="yellow"] { --tag: var(--accent, var(--green)); } -[data-color="green"] { --tag: var(--green); } -[data-color="aqua"] { --tag: var(--aqua); } -[data-color="blue"] { --tag: var(--blue); } -[data-color="purple"] { --tag: var(--purple); } + * The tag is a name. Eight clearly separated hues, drawn with a lightness that fits the theme + * (light or dark). Browsers without relative colours use the theme's own colours. + * Backgrounds are mixed into --bg0, so text stays readable. */ +[data-color="red"] { --tag: var(--red); --tag-h: 18; --tag-c: 0.14; } +[data-color="orange"] { --tag: var(--accent2, var(--red)); --tag-h: 57; --tag-c: 0.14; } +[data-color="yellow"] { --tag: var(--accent, var(--green)); --tag-h: 104; --tag-c: 0.12; } +[data-color="green"] { --tag: var(--green); --tag-h: 147; --tag-c: 0.17; } +[data-color="aqua"] { --tag: var(--aqua); --tag-h: 190; --tag-c: 0.1; } +[data-color="blue"] { --tag: var(--blue); --tag-h: 250; --tag-c: 0.12; } +[data-color="purple"] { --tag: var(--purple); --tag-h: 297; --tag-c: 0.135; } +[data-color="pink"] { --tag: var(--red); --tag-h: 338; --tag-c: 0.17; } +@supports (color: oklch(from red l c h)) { + /* Lightness 0.76 on dark and 0.60 on light themes (from the theme's background), chroma per hue + * as high as sRGB allows at both – vivid and still clearly different from each other. */ + [data-color] { + --tag: oklch(from var(--bg0) calc(0.6 + 0.16 * clamp(0, (0.5 - l) * 20, 1)) var(--tag-c) var(--tag-h)); + } +} .chat-pane[data-color] { - background: color-mix(in srgb, var(--tag) 9%, var(--bg0)); + background: color-mix(in srgb, var(--tag) 16%, var(--bg0)); +} +.chat-pane[data-color] .composer-wrap { + background: color-mix(in srgb, var(--tag) 16%, var(--bg0)); } .topbar[data-color] { - border-bottom: 2px solid var(--tag); - background: color-mix(in srgb, var(--tag) 14%, var(--bg0)); + border-bottom: 3px solid var(--tag); + background: color-mix(in srgb, var(--tag) 28%, var(--bg0)); } .session-item[data-color] { - box-shadow: inset 3px 0 0 var(--tag); + box-shadow: inset 4px 0 0 var(--tag); } .color-badge { - padding: 0 7px; + padding: 0 8px; border-radius: 999px; color: var(--fg0); - background: color-mix(in srgb, var(--tag) 30%, var(--bg1)); - border: 1px solid color-mix(in srgb, var(--tag) 60%, transparent); + background: color-mix(in srgb, var(--tag) 42%, var(--bg1)); + border: 1px solid color-mix(in srgb, var(--tag) 75%, transparent); +} +.chat-icon { + margin-right: 5px; + vertical-align: -2px; +} +[data-color] .chat-icon { + color: var(--tag); +} +.color-badge .chat-icon { + color: var(--fg0); } .color-swatches { display: flex; + flex-wrap: wrap; gap: 8px; padding: 6px 10px; } .color-swatches .swatch { - width: 20px; - height: 20px; + width: 22px; + height: 22px; padding: 0; border-radius: 50%; background: var(--tag); @@ -4731,8 +4784,28 @@ span.chat-tab > button { border-color: var(--fg0); box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag); } -.chat-pane[data-color] .composer-wrap { - background: color-mix(in srgb, var(--tag) 9%, var(--bg0)); +.icon-picker { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(32px, 1fr)); + gap: 4px; + padding: 4px 10px 8px; + max-width: 280px; +} +.icon-choice { + padding: 5px; + line-height: 0; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--fg2); +} +.icon-choice:hover { + background: var(--bg2); +} +.icon-choice.on { + color: var(--tag, var(--accent)); + border-color: var(--tag, var(--accent)); + background: var(--bg2); } /* --- check badge of a turn ------------------------------------------------------ */ diff --git a/web/src/types.ts b/web/src/types.ts index 222a93f..de25447 100644 --- a/web/src/types.ts +++ b/web/src/types.ts @@ -63,6 +63,8 @@ export interface Session { summary?: string; /** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */ color?: string; + /** Icon name of the chat (from the picker). */ + icon?: string; todos: Todo[] | null; last_usage?: Usage | null; busy?: boolean;