Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c08c6c7436 | ||
|
|
1b3e3af4b7 | ||
|
|
44dd6978d4 | ||
|
|
8a90f83db5 | ||
|
|
ce0d8afe69 |
No files matched your search
@@ -3,6 +3,34 @@
|
||||
All notable changes to CodeCraft. Versions follow [Semantic Versioning](https://semver.org/);
|
||||
until 1.0, minor versions may contain breaking changes.
|
||||
|
||||
## [v202611-00028] – 2026-11-14
|
||||
|
||||
### Added
|
||||
|
||||
- **18 more chat icons** (gamepad, computer, food, coffee, home, book, code, camera, cart, heart,
|
||||
star, globe, calendar, car, plane, bulb, money, palette).
|
||||
|
||||
### Changed
|
||||
|
||||
- **The ⋯ menu of a message sits inside its bubble** (in the foot next to the time, also in your own
|
||||
bubbles); its popup opens upwards within the bubble and is no wider than the bubble.
|
||||
|
||||
## [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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</p>
|
||||
|
||||
<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-00028" src="https://img.shields.io/badge/version-v202611--00028-d69e2e?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="React PWA" src="https://img.shields.io/badge/frontend-React%20PWA-2d81ff?style=flat-square&logo=react">
|
||||
|
||||
+31
-1
@@ -193,10 +193,20 @@ 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<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.
|
||||
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",
|
||||
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
|
||||
];
|
||||
|
||||
impl Session {
|
||||
pub fn clean_room(&self, default: bool) -> bool {
|
||||
@@ -290,3 +300,23 @@ pub fn new_id() -> String {
|
||||
pub fn now_ms() -> i64 {
|
||||
chrono::Utc::now().timestamp_millis()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod icon_tests {
|
||||
use super::*;
|
||||
|
||||
/// The client's picker (ColorSwatches.tsx) and the server's whitelist must list the same names.
|
||||
#[test]
|
||||
fn session_icons_match_the_client() {
|
||||
let ts = include_str!("../../web/src/components/ColorSwatches.tsx");
|
||||
let list = ts.split("SESSION_ICONS = [").nth(1).and_then(|r| r.split("] as const").next()).expect("icon list in ColorSwatches.tsx");
|
||||
let mut client: Vec<&str> = list.split('"').skip(1).step_by(2).collect();
|
||||
let mut server: Vec<&str> = SESSION_ICONS.to_vec();
|
||||
client.sort();
|
||||
server.sort();
|
||||
assert_eq!(client, server);
|
||||
let mut dedup = server.clone();
|
||||
dedup.dedup();
|
||||
assert_eq!(dedup.len(), server.len(), "duplicate icon names");
|
||||
}
|
||||
}
|
||||
+11
-1
@@ -647,6 +647,7 @@ async fn create_session(Extension(user): Extension<User>, State(app): State<AppS
|
||||
telegram_quiet: true,
|
||||
summary: String::new(),
|
||||
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)?;
|
||||
Ok(Json(json!(s)))
|
||||
@@ -942,6 +943,8 @@ struct UpdateSession {
|
||||
telegram_quiet: Option<bool>,
|
||||
/// Colour tag ("" removes it).
|
||||
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 {
|
||||
@@ -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)) {
|
||||
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)) {
|
||||
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 {
|
||||
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)))
|
||||
}
|
||||
@@ -1454,7 +1463,7 @@ async fn put_project_defaults(Extension(user): Extension<User>, 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<User>, State(app): State<AppSt
|
||||
telegram_quiet: true,
|
||||
summary: String::new(),
|
||||
color: None,
|
||||
icon: None,
|
||||
};
|
||||
let s = app.save_session(s)?;
|
||||
st.assistant_session = Some(s.id.clone());
|
||||
|
||||
@@ -323,6 +323,7 @@ pub async fn run_with(app: &Arc<App>, 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)?;
|
||||
|
||||
@@ -899,6 +899,7 @@ fn create_child(app: &Arc<App>, 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 }]);
|
||||
|
||||
@@ -543,6 +543,7 @@ async fn new_session(app: &Arc<App>, 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!("{} <b>{}</b> · {} · {}", tr(de, "Neue Sitzung", "New session"), esc(&s.cwd), esc(&s.agent), esc(&s.model)), None).await?;
|
||||
|
||||
@@ -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,23 +374,32 @@ 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>}
|
||||
</button>
|
||||
{answerText && (
|
||||
<>
|
||||
{answer && <MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />}
|
||||
<Markdown text={answerText} baseDir={baseDir} />
|
||||
<ImageRefs text={answerText} baseDir={baseDir} />
|
||||
{answer && <MsgTime ms={answer.created_at} />}
|
||||
{answer && <MsgFoot ms={answer.created_at} menu={<MessageMenu messageId={answer.id} text={answerText} forkable={!answer.parts.some((p) => p.type === "tool_call")} />} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Foot of a bubble: time on the left, the ⋯ menu inside the bubble on the right. */
|
||||
function MsgFoot({ ms, menu }: { ms: number; menu: ReactNode }) {
|
||||
return (
|
||||
<div className="msg-foot">
|
||||
<MsgTime ms={ms} />
|
||||
{menu}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Text parts of a message. */
|
||||
const textOfMessage = (m: Message) => (m.parts.filter((p) => p.type === "text") as { text: string }[]).map((p) => p.text).join("\n\n");
|
||||
|
||||
@@ -423,10 +432,9 @@ const AssistantOrNote = memo(function AssistantOrNote({
|
||||
const cls = `msg ${highlight === m.id ? "highlight" : ""}`;
|
||||
if (m.role === "assistant") {
|
||||
return (
|
||||
<div className={cls} data-mid={m.id}>
|
||||
<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />
|
||||
<div className={`${cls} ${textOfMessage(m).trim() ? "bubble agent" : ""}`} data-mid={m.id}>
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||||
<MsgTime ms={m.created_at} />
|
||||
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={textOfMessage(m)} forkable={!m.parts.some((p) => p.type === "tool_call")} />} />
|
||||
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
||||
</div>
|
||||
);
|
||||
@@ -466,14 +474,12 @@ 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} />
|
||||
))}
|
||||
<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />
|
||||
<div className="msg-user">
|
||||
<UserText text={texts.map((p) => p.text).join("\n\n")} />
|
||||
<MsgTime ms={m.created_at} />
|
||||
{imgs.length > 0 && (
|
||||
<div className="user-files">
|
||||
{imgs.map((p, i) => (
|
||||
@@ -483,6 +489,7 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<MsgFoot ms={m.created_at} menu={<MessageMenu messageId={m.id} text={texts.map((p) => p.text).join("\n\n")} forkable onRewind={canEdit ? () => onRewind(m) : undefined} />} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -781,6 +788,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 +885,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 +1021,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>
|
||||
)}
|
||||
|
||||
@@ -1,9 +1,17 @@
|
||||
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",
|
||||
"gamepad", "computer", "food", "coffee", "home", "book", "code", "camera", "cart", "heart", "star", "globe", "calendar", "car", "plane", "bulb", "money", "palette",
|
||||
] 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 +35,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>
|
||||
);
|
||||
}
|
||||
@@ -34,6 +34,97 @@ const P: Record<string, ReactNode> = {
|
||||
download: <path d="M12 4v11M7 10l5 5 5-5M5 20h14" />,
|
||||
compact: <path d="M8 4l4 4 4-4M8 20l4-4 4 4M4 12h16" />,
|
||||
trash: <path d="M4 7h16M9 7V4h6v3M6 7l1 13h10l1-13M10 11v6M14 11v6" />,
|
||||
gamepad: (
|
||||
<>
|
||||
<path d="M7 8h10a4 4 0 0 1 4 4.2l-.6 3.8a2.4 2.4 0 0 1-4.1 1.3L14 15h-4l-2.3 2.3a2.4 2.4 0 0 1-4.1-1.3L3 12.2A4 4 0 0 1 7 8Z" />
|
||||
<path d="M8 10.5v3M6.5 12h3M15.5 11h.01M17.5 13h.01" />
|
||||
</>
|
||||
),
|
||||
computer: (
|
||||
<>
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M8 20h8M12 16v4" />
|
||||
</>
|
||||
),
|
||||
food: (
|
||||
<>
|
||||
<path d="M5 3v6a2 2 0 0 0 4 0V3M7 11v10" />
|
||||
<path d="M17 21V3c-2.6 1.4-3.5 4.2-3.5 7.5H17" />
|
||||
</>
|
||||
),
|
||||
home: (
|
||||
<>
|
||||
<path d="M4 11.5 12 4l8 7.5V20H4Z" />
|
||||
<path d="M10 20v-5h4v5" />
|
||||
</>
|
||||
),
|
||||
book: (
|
||||
<>
|
||||
<path d="M5 4h11a3 3 0 0 1 3 3v13H8a3 3 0 0 1-3-3Z" />
|
||||
<path d="M5 17a3 3 0 0 1 3-3h11" />
|
||||
</>
|
||||
),
|
||||
code: <path d="m8 7-5 5 5 5M16 7l5 5-5 5M14 5l-4 14" />,
|
||||
camera: (
|
||||
<>
|
||||
<path d="M4 8h3l1.5-2h7L17 8h3v11H4Z" />
|
||||
<circle cx="12" cy="13" r="3.5" />
|
||||
</>
|
||||
),
|
||||
cart: (
|
||||
<>
|
||||
<path d="M3 4h2.5l2 11h10l2-8H7" />
|
||||
<circle cx="9" cy="19" r="1.4" />
|
||||
<circle cx="17" cy="19" r="1.4" />
|
||||
</>
|
||||
),
|
||||
heart: <path d="M12 20s-8-4.6-8-10.5A4.5 4.5 0 0 1 12 7a4.5 4.5 0 0 1 8 2.5C20 15.4 12 20 12 20Z" />,
|
||||
star: <path d="m12 3.5 2.6 5.4 5.9.8-4.3 4.1 1 5.9L12 16.9l-5.2 2.8 1-5.9-4.3-4.1 5.9-.8Z" />,
|
||||
globe: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M3.5 12h17M12 3.5c3 3 3 14 0 17M12 3.5c-3 3-3 14 0 17" />
|
||||
</>
|
||||
),
|
||||
calendar: (
|
||||
<>
|
||||
<rect x="4" y="5" width="16" height="15" rx="2" />
|
||||
<path d="M4 10h16M8 3v4M16 3v4" />
|
||||
</>
|
||||
),
|
||||
coffee: (
|
||||
<>
|
||||
<path d="M5 9h11v5a5 5 0 0 1-5 5H10a5 5 0 0 1-5-5Z" />
|
||||
<path d="M16 10h1.5a2.5 2.5 0 0 1 0 5H16M8 3v3M12 3v3" />
|
||||
</>
|
||||
),
|
||||
car: (
|
||||
<>
|
||||
<path d="M3.5 16v-4l2-5h13l2 5v4Z" />
|
||||
<path d="M3.5 12h17" />
|
||||
<circle cx="7.5" cy="16" r="1.5" />
|
||||
<circle cx="16.5" cy="16" r="1.5" />
|
||||
</>
|
||||
),
|
||||
plane: (
|
||||
<>
|
||||
<path d="M21 3 3 10.5l6.5 2.5L12 20Z" />
|
||||
<path d="M9.5 13 21 3" />
|
||||
</>
|
||||
),
|
||||
bulb: <path d="M9 18h6M10 21h4M12 3a6 6 0 0 0-3.5 10.9c.6.5 1 1.3 1 2.1h5c0-.8.4-1.6 1-2.1A6 6 0 0 0 12 3Z" />,
|
||||
money: (
|
||||
<>
|
||||
<circle cx="12" cy="12" r="8.5" />
|
||||
<path d="M14.5 9.2c-.5-.8-1.4-1.2-2.5-1.2-1.5 0-2.5.8-2.5 1.9 0 2.7 5 1.2 5 4 0 1.1-1 2-2.5 2-1.1 0-2.1-.5-2.6-1.4M12 6.5V8M12 16v1.5" />
|
||||
</>
|
||||
),
|
||||
palette: (
|
||||
<>
|
||||
<path d="M12 3.5a8.5 8.5 0 1 0 0 17c1.3 0 1.8-1 1.3-2-.6-1.2.2-2.5 1.6-2.5H17a3.5 3.5 0 0 0 3.5-3.5C20.5 7 16.8 3.5 12 3.5Z" />
|
||||
<path d="M7.5 11h.01M9.5 7.5h.01M14 7.5h.01" />
|
||||
</>
|
||||
),
|
||||
reply: <path d="M9 7 4 12l5 5M4 12h9a7 7 0 0 1 7 7" />,
|
||||
edit: <path d="M4 20h4L19 9l-4-4L4 16v4ZM13.5 6.5l4 4" />,
|
||||
undo: <path d="M9 14 4 9l5-5M4 9h10a6 6 0 0 1 0 12h-3" />,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
+135
-40
@@ -524,38 +524,90 @@ button.session-group:hover {
|
||||
position: relative;
|
||||
}
|
||||
/* Message actions: hover on desktop, faintly visible on touch. */
|
||||
.msg-menu {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
z-index: 3;
|
||||
opacity: 0;
|
||||
/* The ⋯ menu sits in the foot of the bubble (next to the time); its popup opens upwards inside
|
||||
* the bubble and never gets wider than the bubble. Faint on touch, appears on hover on desktop. */
|
||||
.msg-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
.msg-foot .msg-time {
|
||||
margin-top: 0;
|
||||
flex: 1;
|
||||
}
|
||||
.msg-menu.menu-pop {
|
||||
position: static; /* the popup is placed against the bubble */
|
||||
flex-shrink: 0;
|
||||
opacity: 0.45;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.msg:hover > .msg-menu,
|
||||
.msg:hover .msg-menu,
|
||||
.msg-menu:focus-within,
|
||||
.msg-menu[open] {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.msg-menu {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
.msg-menu > summary {
|
||||
background: var(--bg1);
|
||||
border: 1px solid var(--bg2);
|
||||
.msg-menu.menu-pop > summary {
|
||||
padding: 0 4px;
|
||||
border-radius: 6px;
|
||||
line-height: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.msg-menu.menu-pop > summary:hover {
|
||||
background: color-mix(in srgb, var(--fg1) 14%, transparent);
|
||||
}
|
||||
.msg-menu.menu-pop .menu-pop-body {
|
||||
top: auto;
|
||||
bottom: 30px;
|
||||
right: 8px;
|
||||
z-index: 30;
|
||||
min-width: 0;
|
||||
width: max-content;
|
||||
max-width: max(calc(100% - 16px), 11rem);
|
||||
}
|
||||
.msg-menu.menu-pop .menu-pop-body a {
|
||||
white-space: normal;
|
||||
}
|
||||
/* 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;
|
||||
@@ -578,7 +630,7 @@ button.session-group:hover {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-user .msg-time {
|
||||
.msg-user .msg-foot .msg-time {
|
||||
text-align: right;
|
||||
}
|
||||
.msg-user img {
|
||||
@@ -4687,41 +4739,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 +4806,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 ------------------------------------------------------ */
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -3,6 +3,14 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
"202611-00028": {
|
||||
en: ["18 more icons for chats (gamepad, computer, food, …); the ⋯ menu of a message now sits inside its bubble"],
|
||||
de: ["18 weitere Icons für Chats (Gamepad, Computer, Essen, …); das ⋯-Menü einer Nachricht sitzt jetzt in der Sprechblase"],
|
||||
},
|
||||
"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": {
|
||||
en: [
|
||||
"Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model",
|
||||
|
||||
Reference in new issue
Block a user