Compare commits

..
Author SHA1 Message Date
Richard Zink 44dd6978d4 Release v202611-00027 2026-11-14 23:32:10 +01:00
Richard ZinkandClaude Sonnet 5.5 8a90f83db5 Changelog: bubbles, chat icons, colour tags
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-14 23:32:03 +01:00
Richard ZinkandClaude Sonnet 5.5 ce0d8afe69 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 <noreply@anthropic.com>
2026-11-14 23:31:52 +01:00
16 changed files with 201 additions and 38 deletions

No files matched your search

+16
View File
@@ -3,6 +3,22 @@
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/); All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
until 1.0, minor versions may contain breaking changes. until 1.0, minor versions may contain breaking changes.
## [v202611-00027] – 2026-11-14
### Added
- **Chat bubbles:** your messages on the right in their own colour (the chat's colour tag if it
has one), the agent's answers on the left; tool-only steps and system notes stay without bubble.
- **Icons for chats:** pick one of 22 icons in the ⋯ menu (chat and session list); it shows in the
list, the top bar and the project page, in the chat's colour, and is part of the project default.
### Changed
- **Colour tags are more vivid and more distinct:** eight fixed hues (new: pink), drawn with a
lightness that fits the theme (light/dark) and the highest chroma sRGB allows, so no two tags
look alike; backgrounds are tinted more strongly. Browsers without relative colours keep the
theme's own colours.
## [v202611-00026] – 2026-11-14 ## [v202611-00026] – 2026-11-14
### Added ### Added
+1 -1
View File
@@ -11,7 +11,7 @@
</p> </p>
<p align="center"> <p align="center">
<img alt="Version v202611-00026" src="https://img.shields.io/badge/version-v202611--00026-d69e2e?style=flat-square"> <img alt="Version v202611-00027" src="https://img.shields.io/badge/version-v202611--00027-d69e2e?style=flat-square">
<img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square"> <img alt="License: MIT" src="https://img.shields.io/badge/license-MIT-689d6a?style=flat-square">
<img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust"> <img alt="Rust backend" src="https://img.shields.io/badge/backend-Rust-b7410e?style=flat-square&logo=rust">
<img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react"> <img alt="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
+10 -1
View File
@@ -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. /// 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")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub color: Option<String>, pub color: Option<String>,
/// 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<String>,
} }
/// Names of the colour tags: the client draws them with the colours of the active theme. /// 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 { impl Session {
pub fn clean_room(&self, default: bool) -> bool { pub fn clean_room(&self, default: bool) -> bool {
+11 -1
View File
@@ -647,6 +647,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
telegram_quiet: true, telegram_quiet: true,
summary: String::new(), summary: String::new(),
color: dstr("color").filter(|c| codecraft_core::types::SESSION_COLORS.contains(&c.as_str())), color: dstr("color").filter(|c| codecraft_core::types::SESSION_COLORS.contains(&c.as_str())),
icon: dstr("icon").filter(|c| codecraft_core::types::SESSION_ICONS.contains(&c.as_str())),
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
@@ -942,6 +943,8 @@ struct UpdateSession {
telegram_quiet: Option<bool>, telegram_quiet: Option<bool>,
/// Colour tag ("" removes it). /// Colour tag ("" removes it).
color: Option<String>, color: Option<String>,
/// Icon ("" removes it).
icon: Option<String>,
} }
async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult { async fn update_session(Extension(user): Extension<User>, State(app): State<AppState>, Path(id): Path<String>, Json(body): Json<UpdateSession>) -> ApiResult {
@@ -957,6 +960,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if body.color.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_COLORS.contains(&c)) { if body.color.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_COLORS.contains(&c)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid colour".into())); return Err(ApiError(StatusCode::BAD_REQUEST, "invalid colour".into()));
} }
if body.icon.as_deref().is_some_and(|c| !c.is_empty() && !codecraft_core::types::SESSION_ICONS.contains(&c)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid icon".into()));
}
if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) { if body.permission_mode.as_deref().is_some_and(|p| !crate::permission::MODES.contains(&p)) {
return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into())); return Err(ApiError(StatusCode::BAD_REQUEST, "invalid permission mode".into()));
} }
@@ -999,6 +1005,9 @@ async fn update_session(Extension(user): Extension<User>, State(app): State<AppS
if let Some(c) = body.color { if let Some(c) = body.color {
s.color = (!c.is_empty()).then_some(c); s.color = (!c.is_empty()).then_some(c);
} }
if let Some(i) = body.icon {
s.icon = (!i.is_empty()).then_some(i);
}
})?; })?;
Ok(Json(json!(s))) Ok(Json(json!(s)))
} }
@@ -1454,7 +1463,7 @@ async fn put_project_defaults(Extension(user): Extension<User>, State(app): Stat
let s = writable(&app, &user, &id)?; let s = writable(&app, &user, &id)?;
let data = json!({ let data = json!({
"model": s.model, "agent": s.agent, "permission_mode": s.permission_mode, "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))?; app.store.set_project_defaults(&s.cwd, Some(&data))?;
Ok(Json(data)) Ok(Json(data))
@@ -2145,6 +2154,7 @@ async fn job_assistant(Extension(user): Extension<User>, State(app): State<AppSt
telegram_quiet: true, telegram_quiet: true,
summary: String::new(), summary: String::new(),
color: None, color: None,
icon: None,
}; };
let s = app.save_session(s)?; let s = app.save_session(s)?;
st.assistant_session = Some(s.id.clone()); st.assistant_session = Some(s.id.clone());
+1
View File
@@ -323,6 +323,7 @@ pub async fn run_with(app: &Arc<App>, job: &Job, trigger: &str, payload: Option<
telegram_quiet: true, telegram_quiet: true,
summary: String::new(), summary: String::new(),
color: None, color: None,
icon: None,
}; };
let session = app.save_session(session)?; let session = app.save_session(session)?;
app.store.job_started(&job.id, &session.id, now)?; app.store.job_started(&job.id, &session.id, now)?;
+1
View File
@@ -899,6 +899,7 @@ fn create_child(app: &Arc<App>, ctx: &ToolCtx, agent_name: &str, description: &s
telegram_quiet: false, telegram_quiet: false,
summary: String::new(), summary: String::new(),
color: None, color: None,
icon: None,
}; };
let child = app.save_session(child)?; let child = app.save_session(child)?;
let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]); let msg = Message::new(Role::User, vec![Part::Text { text: prompt }]);
+1
View File
@@ -543,6 +543,7 @@ async fn new_session(app: &Arc<App>, u: &User, bot: &Bot, chat: i64, arg: &str,
telegram_quiet: false, telegram_quiet: false,
summary: String::new(), summary: String::new(),
color: None, color: None,
icon: None,
})?; })?;
update(app, &u.id, |st| st.session = Some(s.id.clone())); update(app, &u.id, |st| st.session = Some(s.id.clone()));
bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?; bot.send(chat, &format!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
+7 -5
View File
@@ -26,7 +26,7 @@ import { ShellsChip } from "./ShellsPanel";
import { TodoList, ToolCard } from "./ToolCard"; import { TodoList, ToolCard } from "./ToolCard";
import { confirmDialog, promptDialog, toast } from "../dialogs"; import { confirmDialog, promptDialog, toast } from "../dialogs";
import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs"; import { ChatTabBar, ChatTabPanes, useChatTabs } from "./ChatTabs";
import { ColorSwatches } from "./ColorSwatches"; import { ColorSwatches, IconPicker } from "./ColorSwatches";
import { ProjectPermissions } from "./ProjectPermissions"; import { ProjectPermissions } from "./ProjectPermissions";
import { OutboxBar } from "./OutboxBar"; import { OutboxBar } from "./OutboxBar";
import { addToOutbox, isOffline } from "../outbox"; import { addToOutbox, isOffline } from "../outbox";
@@ -374,7 +374,7 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa
); );
} }
return ( return (
<div className="msg work-block" data-mid={answer?.id}> <div className={`msg work-block ${answerText ? "bubble agent" : ""}`} data-mid={answer?.id}>
<button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}> <button className="work-head" onClick={() => setOpen(true)} aria-expanded={false}>
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })} <Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>} {files.size > 0 && <span> · {t("chat.workFiles", { n: files.size })}</span>}
@@ -423,7 +423,7 @@ const AssistantOrNote = memo(function AssistantOrNote({
const cls = `msg ${highlight === m.id ? "highlight" : ""}`; const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
if (m.role === "assistant") { if (m.role === "assistant") {
return ( return (
<div className={cls} data-mid={m.id}> <div className={`${cls} ${textOfMessage(m).trim() ? "bubble agent" : ""}`} data-mid={m.id}>
<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} /> <MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} /> <AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
<MsgTime ms={m.created_at} /> <MsgTime ms={m.created_at} />
@@ -466,7 +466,7 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
const texts = allTexts.filter((p) => !isHarnessNote(p.text)); const texts = allTexts.filter((p) => !isHarnessNote(p.text));
const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[]; const imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
return ( return (
<div className={`msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}> <div className={`msg user-msg ${highlight === m.id ? "highlight" : ""}`} data-mid={m.id}>
{background.map((p, i) => ( {background.map((p, i) => (
<HarnessMessage key={i} text={p.text} /> <HarnessMessage key={i} text={p.text} />
))} ))}
@@ -781,6 +781,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="undo" /> <Icon name="undo" />
</a> </a>
)} )}
{session.icon && <Icon name={session.icon} className="chat-icon" />}
{session.title || t("session.untitled")} {session.title || t("session.untitled")}
{!readOnly && ( {!readOnly && (
<button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}> <button className="ghost small title-edit" onClick={() => setEditingTitle(true)} title={t("rename.title")} aria-label={t("rename.title")}>
@@ -877,6 +878,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="edit" /> {t("instructions.title")} <Icon name="edit" /> {t("instructions.title")}
</a> </a>
<ColorSwatches value={session.color} onChange={(color) => void update({ color })} /> <ColorSwatches value={session.color} onChange={(color) => void update({ color })} />
<IconPicker value={session.icon} color={session.color} onChange={(icon) => void update({ icon })} />
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}> <a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void planJob())} title={t("job.planHint")}>
<Icon name="clock" /> {t("job.plan")} <Icon name="clock" /> {t("job.plan")}
</a> </a>
@@ -1012,7 +1014,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</button> </button>
)} )}
{draft && ( {draft && (
<div className="msg"> <div className={`msg ${draft.parts.some((p) => p.type === "text" && p.text.trim()) ? "bubble agent" : ""}`}>
<AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} /> <AssistantParts parts={draft.parts} results={results} running={busy} live baseDir={session.cwd} />
</div> </div>
)} )}
+35 -2
View File
@@ -1,9 +1,16 @@
import { useT, type Key } from "../i18n"; 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). */ /** 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 }) { export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) {
const t = useT(); const t = useT();
return ( return (
@@ -27,3 +34,29 @@ export function ColorSwatches({ value, onChange }: { value?: string; onChange: (
</div> </div>
); );
} }
/** 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 (
<div className="icon-picker" role="radiogroup" aria-label={t("icon.label")} data-color={color}>
{SESSION_ICONS.map((i) => (
<button
key={i}
type="button"
role="radio"
aria-checked={value === i}
className={`icon-choice ${value === i ? "on" : ""}`}
title={i}
aria-label={i}
onClick={(e) => {
e.stopPropagation();
onChange(value === i ? "" : i);
}}
>
<Icon name={i} />
</button>
))}
</div>
);
}
+4 -1
View File
@@ -96,7 +96,10 @@ export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }
<li key={s.id} data-color={s.color} className={s.color ? "tagged" : ""}> <li key={s.id} data-color={s.color} className={s.color ? "tagged" : ""}>
<a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}> <a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}>
<div className="tl-title"> <div className="tl-title">
<span className={s.color ? "color-badge" : undefined}>{s.title || t("session.untitled")}</span> <span className={s.color ? "color-badge" : undefined}>
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
{s.title || t("session.untitled")}
</span>
<span className="tl-when" title={fmtDate(s.updated_at)}> <span className="tl-when" title={fmtDate(s.updated_at)}>
{fmtRelative(s.updated_at)} {fmtRelative(s.updated_at)}
</span> </span>
+7 -3
View File
@@ -1,4 +1,4 @@
import { ColorSwatches } from "./ColorSwatches"; import { ColorSwatches, IconPicker } from "./ColorSwatches";
import { Icon } from "./Icon"; import { Icon } from "./Icon";
import { StatusLeds } from "./StatusLeds"; import { StatusLeds } from "./StatusLeds";
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme"; import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
@@ -26,7 +26,7 @@ function groupKey(ms: number): Key {
const GROUP_BY = "codecraft.sidebarGroupBy"; const GROUP_BY = "codecraft.sidebarGroupBy";
const COLLAPSED = "codecraft.sidebarCollapsed"; const COLLAPSED = "codecraft.sidebarCollapsed";
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string; color?: string }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message)); const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { 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. */ /** 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[] }) { 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
<button className={`session-item ${active ? "active" : ""}`} data-color={s.color} onClick={() => navigate(`/s/${s.id}`)}> <button className={`session-item ${active ? "active" : ""}`} data-color={s.color} onClick={() => navigate(`/s/${s.id}`)}>
<div className="title"> <div className="title">
{attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />} {attention ? <i className="attn-dot" title={t("sidebar.needsInput")} /> : busy && <i className="busy-ring" title={t("activity.working")} />}
<span className={s.color ? "color-badge" : undefined}>{s.title || t("session.untitled")}</span> <span className={s.color ? "color-badge" : undefined}>
{s.icon && <Icon name={s.icon} className="tiny chat-icon" />}
{s.title || t("session.untitled")}
</span>
</div> </div>
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>} {s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
<div className="sub"> <div className="sub">
@@ -96,6 +99,7 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
</button> </button>
)} )}
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} /> <ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
<IconPicker value={s.icon} color={s.color} onChange={(icon) => (setMenu(false), organise(s.id, { icon }))} />
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}> <button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")} <Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button> </button>
+2
View File
@@ -400,6 +400,8 @@ export const de: Messages = {
"color.aqua": "Türkis", "color.aqua": "Türkis",
"color.blue": "Blau", "color.blue": "Blau",
"color.purple": "Violett", "color.purple": "Violett",
"color.pink": "Pink",
"icon.label": "Symbol dieses Chats",
"claude.title": "Claude Code", "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.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.", "claude.notInstalled": "Claude Code ist auf dem Server nicht installiert.",
+2
View File
@@ -400,6 +400,8 @@ export const en = {
"color.aqua": "Teal", "color.aqua": "Teal",
"color.blue": "Blue", "color.blue": "Blue",
"color.purple": "Purple", "color.purple": "Purple",
"color.pink": "Pink",
"icon.label": "Icon of this chat",
"claude.title": "Claude Code", "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.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.", "claude.notInstalled": "Claude Code is not installed on the server.",
+97 -24
View File
@@ -547,15 +547,45 @@ button.session-group:hover {
border: 1px solid var(--bg2); border: 1px solid var(--bg2);
line-height: 0; line-height: 0;
} }
/* Chat bubbles: the agent's answers on the left, your messages on the right in another colour. */
.msg-user { .msg-user {
background: var(--bg1); background: color-mix(in srgb, var(--accent) 26%, var(--bg1));
border: 1px solid var(--bg2); border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
border-radius: var(--radius); border-radius: 18px 4px 18px 18px;
padding: 8px 12px; color: var(--fg0);
padding: 9px 14px;
white-space: pre-wrap; white-space: pre-wrap;
word-break: break-word; word-break: break-word;
position: relative; 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. */ /* User messages are Markdown too; the line breaks come from keepLineBreaks. */
.msg-user .markdown { .msg-user .markdown {
white-space: normal; white-space: normal;
@@ -4687,41 +4717,64 @@ span.chat-tab > button {
} }
/* --- colour tags of chats ------------------------------------------------------ /* --- colour tags of chats ------------------------------------------------------
* The tag is a name; the colour comes from the active theme (--red, --accent, …), * The tag is a name. Eight clearly separated hues, drawn with a lightness that fits the theme
* the background is mixed into --bg0, so every theme (light or dark) stays readable. */ * (light or dark). Browsers without relative colours use the theme's own colours.
[data-color="red"] { --tag: var(--red); } * Backgrounds are mixed into --bg0, so text stays readable. */
[data-color="orange"] { --tag: var(--accent2, var(--red)); } [data-color="red"] { --tag: var(--red); --tag-h: 18; --tag-c: 0.14; }
[data-color="yellow"] { --tag: var(--accent, var(--green)); } [data-color="orange"] { --tag: var(--accent2, var(--red)); --tag-h: 57; --tag-c: 0.14; }
[data-color="green"] { --tag: var(--green); } [data-color="yellow"] { --tag: var(--accent, var(--green)); --tag-h: 104; --tag-c: 0.12; }
[data-color="aqua"] { --tag: var(--aqua); } [data-color="green"] { --tag: var(--green); --tag-h: 147; --tag-c: 0.17; }
[data-color="blue"] { --tag: var(--blue); } [data-color="aqua"] { --tag: var(--aqua); --tag-h: 190; --tag-c: 0.1; }
[data-color="purple"] { --tag: var(--purple); } [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] { .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] { .topbar[data-color] {
border-bottom: 2px solid var(--tag); border-bottom: 3px solid var(--tag);
background: color-mix(in srgb, var(--tag) 14%, var(--bg0)); background: color-mix(in srgb, var(--tag) 28%, var(--bg0));
} }
.session-item[data-color] { .session-item[data-color] {
box-shadow: inset 3px 0 0 var(--tag); box-shadow: inset 4px 0 0 var(--tag);
} }
.color-badge { .color-badge {
padding: 0 7px; padding: 0 8px;
border-radius: 999px; border-radius: 999px;
color: var(--fg0); color: var(--fg0);
background: color-mix(in srgb, var(--tag) 30%, var(--bg1)); background: color-mix(in srgb, var(--tag) 42%, var(--bg1));
border: 1px solid color-mix(in srgb, var(--tag) 60%, transparent); 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 { .color-swatches {
display: flex; display: flex;
flex-wrap: wrap;
gap: 8px; gap: 8px;
padding: 6px 10px; padding: 6px 10px;
} }
.color-swatches .swatch { .color-swatches .swatch {
width: 20px; width: 22px;
height: 20px; height: 22px;
padding: 0; padding: 0;
border-radius: 50%; border-radius: 50%;
background: var(--tag); background: var(--tag);
@@ -4731,8 +4784,28 @@ span.chat-tab > button {
border-color: var(--fg0); border-color: var(--fg0);
box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag); box-shadow: 0 0 0 2px var(--bg1), 0 0 0 4px var(--tag);
} }
.chat-pane[data-color] .composer-wrap { .icon-picker {
background: color-mix(in srgb, var(--tag) 9%, var(--bg0)); 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 ------------------------------------------------------ */ /* --- check badge of a turn ------------------------------------------------------ */
+2
View File
@@ -63,6 +63,8 @@ export interface Session {
summary?: string; summary?: string;
/** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */ /** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */
color?: string; color?: string;
/** Icon name of the chat (from the picker). */
icon?: string;
todos: Todo[] | null; todos: Todo[] | null;
last_usage?: Usage | null; last_usage?: Usage | null;
busy?: boolean; busy?: boolean;
+4
View File
@@ -3,6 +3,10 @@
* under "unreleased"; scripts/release.sh renames the key to the release version. * under "unreleased"; scripts/release.sh renames the key to the release version.
*/ */
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = { export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
"202611-00027": {
en: ["Chat bubbles (yours on the right), icons for chats and more vivid, clearly different colour tags (⋯ menu of a chat)"],
de: ["Sprechblasen (deine rechts), Icons für Chats und kräftigere, besser unterscheidbare Farbmarkierungen (⋯-Menü eines Chats)"],
},
"202611-00026": { "202611-00026": {
en: [ en: [
"Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model", "Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model",