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>
This commit is contained in:
Richard ZinkandClaude Sonnet 5.5 committed 2026-11-14 23:31:52 +01:00
1 parent f34a0c7a73
commit ce0d8afe69
13 files changed
+180 -37

No files matched your search

+7 -5
View File
@@ -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 (
<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}>
<Icon name="chevron-right" className="tiny" /> {t("chat.workSteps", { n: steps.length })}
{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" : ""}`;
if (m.role === "assistant") {
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")} />
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
<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 imgs = m.parts.filter((p) => p.type === "image") as { image: ImageData }[];
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) => (
<HarnessMessage key={i} text={p.text} />
))}
@@ -781,6 +781,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
<Icon name="undo" />
</a>
)}
{session.icon && <Icon name={session.icon} className="chat-icon" />}
{session.title || t("session.untitled")}
{!readOnly && (
<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")}
</a>
<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")}>
<Icon name="clock" /> {t("job.plan")}
</a>
@@ -1012,7 +1014,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
</button>
)}
{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} />
</div>
)}
+35 -2
View File
@@ -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: (
</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" : ""}>
<a href={`#/s/${s.id}`} onClick={(e) => (e.preventDefault(), navigate(`/s/${s.id}`))}>
<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)}>
{fmtRelative(s.updated_at)}
</span>
+7 -3
View File
@@ -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<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. */
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}`)}>
<div className="title">
{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>
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
<div className="sub">
@@ -96,6 +99,7 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
</button>
)}
<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 }))}>
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
</button>