Claude Code engine, image viewer, colour tags, checks per turn and more
- Claude Code as an engine: chats on a "claude-code/<alias>" model run through the claude CLI (stream-json); tool approvals go through our permission system. Sign in from the web UI (URL + pasted code), CLI version and update from Settings; the Docker image contains the CLI. - Small models (8k context) for side tasks: input is cut to the model's context window; one-line chat status, "What does this do?" on approvals, "Plan as automation" (job draft from a chat). - Mobile: top bar no longer widens the page, image viewer (zoom, swipe, mark up, use in message), jump to previous own message, message menu, time under every message. - Approvals: push actions (allow/reject), "always allow in this project" (never for dangerous commands) with revoke. - Per turn: changed files with diffs and rollback; project check command with a green/red badge and "let the agent fix it". - Colour tags for chats in the colours of the active theme. - Project defaults for new chats, project page (timeline), offline queue. Tested in a throwaway container with a mock model (8k small model): 26 API checks passed (permissions, diffs, checks, defaults, job draft, side-task budget, Claude Code status/login URL, unsigned-in chat). Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
084452abf6
commit
62fe5bf467
42 files changed
+3734
-55
No files matched your search
+34
-1
@@ -7,6 +7,12 @@ self.addEventListener("push", (event) => {
|
||||
data = { body: event.data ? event.data.text() : "" };
|
||||
}
|
||||
const title = data.title || "CodeCraft";
|
||||
const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de");
|
||||
// Approvals can be answered right from the notification.
|
||||
const actions = data.kind === "permission" && data.permission_id ? [
|
||||
{ action: "allow", title: de ? "Erlauben" : "Allow" },
|
||||
{ action: "reject", title: de ? "Ablehnen" : "Reject" },
|
||||
] : [];
|
||||
const prefix = { permission: "🔐 ", question: "❓ ", done: "✅ " }[data.kind] || "";
|
||||
event.waitUntil(
|
||||
self.registration.showNotification(prefix + title, {
|
||||
@@ -15,13 +21,40 @@ self.addEventListener("push", (event) => {
|
||||
renotify: true,
|
||||
icon: "/icon-192.png",
|
||||
badge: "/icon-192.png",
|
||||
data: { url: data.url || "/" },
|
||||
actions,
|
||||
requireInteraction: data.kind === "permission",
|
||||
data: { url: data.url || "/", permission_id: data.permission_id || null },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const info = event.notification.data || {};
|
||||
if ((event.action === "allow" || event.action === "reject") && info.permission_id) {
|
||||
const de = String((self.navigator && self.navigator.language) || "").toLowerCase().startsWith("de");
|
||||
const target = new URL(info.url || "/", self.location.origin).href;
|
||||
event.waitUntil(
|
||||
fetch("/api/permissions/" + encodeURIComponent(info.permission_id), {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ reply: event.action === "allow" ? "once" : "reject" }),
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) throw new Error(String(r.status));
|
||||
// Short confirmation, so the tap does not feel lost.
|
||||
return self.registration.showNotification(event.action === "allow" ? (de ? "Erlaubt" : "Allowed") : (de ? "Abgelehnt" : "Rejected"), {
|
||||
tag: "perm-" + info.permission_id,
|
||||
icon: "/icon-192.png",
|
||||
silent: true,
|
||||
data: { url: info.url },
|
||||
}).then(() => new Promise((res) => setTimeout(res, 2500))).then(() => self.registration.getNotifications({ tag: "perm-" + info.permission_id })).then((l) => l.forEach((n) => n.close()));
|
||||
})
|
||||
.catch(() => self.clients.openWindow(target)), // expired, answered elsewhere or signed out: open the app
|
||||
);
|
||||
return;
|
||||
}
|
||||
const url = new URL((event.notification.data && event.notification.data.url) || "/", self.location.origin).href;
|
||||
event.waitUntil(
|
||||
self.clients.matchAll({ type: "window", includeUncontrolled: true }).then((list) => {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useT } from "./i18n";
|
||||
import { navigate, useRoute, closePane, MAX_PANES } from "./router";
|
||||
import { applyEvent, loadSessions, resync, setState } from "./store";
|
||||
import type { Info } from "./types";
|
||||
import { ImageViewer } from "./components/ImageViewer";
|
||||
import { ProjectPage } from "./components/ProjectPage";
|
||||
import { Sidebar } from "./components/Sidebar";
|
||||
import { Login } from "./components/Login";
|
||||
import { ChatView } from "./components/ChatView";
|
||||
@@ -94,6 +96,7 @@ export function App() {
|
||||
</div>
|
||||
);
|
||||
else if (section === "settings") content = <Settings tab={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "p" && arg) content = <ProjectPage key={arg} cwd={decodeURIComponent(arg)} onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "stats") content = <Stats onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "images") content = <Images onMenu={() => setMenuOpen(true)} />;
|
||||
else if (section === "jobs" && arg) content = <JobEditor key={arg} id={arg} onMenu={() => setMenuOpen(true)} />;
|
||||
@@ -119,6 +122,7 @@ export function App() {
|
||||
<main className="main">{content}</main>
|
||||
<WhatsNew version={info.version} />
|
||||
<Palette />
|
||||
<ImageViewer />
|
||||
{shared && <ShareTarget shared={shared} onClose={() => setShared(null)} />}
|
||||
<DialogHost />
|
||||
</div>
|
||||
|
||||
+166
-23
@@ -2,11 +2,11 @@ import { takePrefill } from "../share";
|
||||
import { Icon } from "./Icon";
|
||||
import { FileChip, FileFigure, fileType, imageSize, imageSrc } from "./FileFigure";
|
||||
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, type RefObject } from "react";
|
||||
import { del, get, patch, post } from "../api";
|
||||
import { del, get, patch, post, put } from "../api";
|
||||
import { useFetch, useModels } from "../hooks";
|
||||
import { fmtCompact, fmtCost, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||
import { fmtCompact, fmtCost, fmtMsgTime, fmtNumber, fmtPercent, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { dismissError, getState, loadAround, loadOlder, loadSession, loadSessions, useStore } from "../store";
|
||||
import { dismissError, getState, loadAround, loadChecks, loadOlder, loadSession, loadSessions, useStore } from "../store";
|
||||
import type { Aggregate, ImageData, Message, Part, Session, Todo } from "../types";
|
||||
import { ActivityLine, Dots, Spinner, ThinkIcon, WorkBar } from "./Activity";
|
||||
import { useReasoningMode, useTodosCollapsed } from "../prefs";
|
||||
@@ -26,6 +26,13 @@ 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 { ProjectPermissions } from "./ProjectPermissions";
|
||||
import { OutboxBar } from "./OutboxBar";
|
||||
import { addToOutbox, isOffline } from "../outbox";
|
||||
import { TurnCheck } from "./TurnCheck";
|
||||
import { TurnChanges } from "./TurnChanges";
|
||||
import { ChatCtx, MessageMenu } from "./MessageMenu";
|
||||
import { MediaGallery } from "./MediaGallery";
|
||||
|
||||
type ToolResult = Extract<Part, { type: "tool_result" }>;
|
||||
@@ -283,11 +290,28 @@ const MessageList = memo(function MessageList({
|
||||
// Finished turns with tool steps fold into one line (the answer stays visible).
|
||||
const steps = b.messages.flatMap((m) => m.parts.filter((p) => p.type === "tool_call")) as ToolCall[];
|
||||
const usage = !(busy && i === lastWork) && <TurnUsage messages={b.messages} currency={currency} />;
|
||||
// Files the agent wrote or edited in this turn: diffs and "roll back" (not while it is still working).
|
||||
const changed = new Set(steps.filter((c) => (c.name === "write" || c.name === "edit") && typeof c.input?.path === "string").map((c) => c.input.path as string));
|
||||
const lastAnswer = [...b.messages].reverse().find((m) => m.role === "assistant");
|
||||
const checkBadge = lastAnswer && !(busy && i === lastWork) && <TurnCheck sessionId={sessionId} messageId={lastAnswer.id} canSend={canEdit} />;
|
||||
const prev = blocks[i - 1];
|
||||
const changes = changed.size > 0 && !(busy && i === lastWork) && (
|
||||
<TurnChanges
|
||||
sessionId={sessionId}
|
||||
fromId={b.messages[0].id}
|
||||
untilId={blocks[i + 1]?.messages[0].id}
|
||||
fileCount={changed.size}
|
||||
userMessage={canEdit && prev?.kind === "user" ? prev.messages[0] : undefined}
|
||||
onRewind={onRewind}
|
||||
/>
|
||||
);
|
||||
if (i === lastWork || steps.length < 2)
|
||||
return (
|
||||
<div key={key}>
|
||||
{items}
|
||||
{usage}
|
||||
{checkBadge}
|
||||
{changes}
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
@@ -296,6 +320,8 @@ const MessageList = memo(function MessageList({
|
||||
{items}
|
||||
</WorkBlock>
|
||||
{usage}
|
||||
{checkBadge}
|
||||
{changes}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
@@ -355,14 +381,29 @@ function WorkBlock({ messages, steps, open: forced, baseDir, children }: { messa
|
||||
</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} />}
|
||||
</>
|
||||
)}
|
||||
</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");
|
||||
|
||||
/** Small date/time line under a message. */
|
||||
function MsgTime({ ms }: { ms: number }) {
|
||||
if (!ms) return null;
|
||||
return (
|
||||
<time className="msg-time" dateTime={new Date(ms).toISOString()} title={new Date(ms).toLocaleString()}>
|
||||
{fmtMsgTime(ms)}
|
||||
</time>
|
||||
);
|
||||
}
|
||||
|
||||
/** An assistant message, or a user message that only carries tool results / harness notes. */
|
||||
const AssistantOrNote = memo(function AssistantOrNote({
|
||||
m,
|
||||
@@ -383,7 +424,9 @@ const AssistantOrNote = memo(function AssistantOrNote({
|
||||
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")} />
|
||||
<AssistantParts parts={m.parts} results={results} running={busy} live={false} baseDir={baseDir} />
|
||||
<MsgTime ms={m.created_at} />
|
||||
{regenerate && <RegenerateButton sessionId={regenerate.sessionId} messageId={m.id} model={regenerate.model} />}
|
||||
</div>
|
||||
);
|
||||
@@ -427,15 +470,10 @@ const MessageItem = memo(function MessageItem({ m, canEdit, onRewind, highlight
|
||||
{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">
|
||||
{canEdit && (
|
||||
<div className="msg-actions">
|
||||
<button className="small ghost" onClick={() => onRewind(m)} title={t("chat.rewind")}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<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) => (
|
||||
@@ -516,6 +554,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
// The Telegram switch in the ⋯ menu only matters with a paired bot.
|
||||
const telegramPaired = useFetch<{ paired: boolean }>("/account/telegram").data?.paired ?? false;
|
||||
const [editingInstructions, setEditingInstructions] = useState(false);
|
||||
const [showApprovals, setShowApprovals] = useState(false);
|
||||
const [planning, setPlanning] = useState(false);
|
||||
const models = useModels();
|
||||
const [loadError, setLoadError] = useState<string>();
|
||||
const [text, setText] = useState(() => takePrefill(sessionId) ?? "");
|
||||
@@ -537,11 +577,25 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
if (el) el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Scrolled away from the top: the jump buttons show up.
|
||||
const [scrolled, setScrolled] = useState(false);
|
||||
/** Scroll to the closest own message above the visible area (long chats on a phone). */
|
||||
const jumpPrevOwn = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const top = el.getBoundingClientRect().top;
|
||||
const above = [...el.querySelectorAll<HTMLElement>(".msg-user")].filter((n) => n.getBoundingClientRect().top < top - 12);
|
||||
const target = above[above.length - 1];
|
||||
if (target) target.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
else el.scrollTo({ top: 0, behavior: "smooth" });
|
||||
};
|
||||
|
||||
// Every chat starts following.
|
||||
useEffect(() => follow(true), [sessionId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadSession(sessionId).catch((e) => setLoadError(e.message));
|
||||
void loadChecks(sessionId);
|
||||
}, [sessionId]);
|
||||
|
||||
const permissions = allPermissions.filter((p) => p.session_id === sessionId || p.root_session_id === sessionId);
|
||||
@@ -633,7 +687,12 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
setText("");
|
||||
follow(true);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
// No connection: keep the text on this device and send it when the server is reachable again.
|
||||
if (isOffline(e) && txt && images.length === 0) {
|
||||
addToOutbox(sessionId, txt, effort || undefined);
|
||||
setText("");
|
||||
toast(t("outbox.saved"));
|
||||
} else toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -648,6 +707,46 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
setText(m.parts.map((p) => (p.type === "text" ? p.text : "")).join(""));
|
||||
};
|
||||
|
||||
/** New chats in this project start like this one (model, mode, colour, instructions, folder). */
|
||||
const saveDefaults = async (remove: boolean) => {
|
||||
try {
|
||||
if (remove) await del(`/sessions/${sessionId}/project-defaults`);
|
||||
else await put(`/sessions/${sessionId}/project-defaults`, {});
|
||||
toast(t(remove ? "projectDefaults.removed" : "projectDefaults.saved"), "success");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/** The project's check command (tests, build …), run after every turn that changed files. */
|
||||
const editCheck = async () => {
|
||||
try {
|
||||
const cur = await get<{ command: string; enabled: boolean }>(`/sessions/${sessionId}/check`);
|
||||
const cmd = await promptDialog({ title: t("check.title"), message: t("check.hint", { project: session.cwd }), defaultValue: cur.command, placeholder: "cargo test", confirmLabel: t("common.save") });
|
||||
if (cmd === null) return;
|
||||
await put(`/sessions/${sessionId}/check`, { command: cmd.trim(), enabled: cmd.trim() !== "" });
|
||||
toast(cmd.trim() ? t("check.saved") : t("check.removed"), "success");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
/** Draft an automation from this chat (small model), create it switched off and open its editor. */
|
||||
const planJob = async () => {
|
||||
if (planning) return;
|
||||
setPlanning(true);
|
||||
toast(t("job.drafting"));
|
||||
try {
|
||||
const d = await post<{ name: string; prompt: string; cwd: string; agent: string }>(`/sessions/${sessionId}/job-draft`, {});
|
||||
const job = await post<{ id: string }>("/jobs/create", { cwd: d.cwd, name: d.name, prompt: d.prompt, agent: d.agent === "plan" ? undefined : d.agent });
|
||||
navigate(`/jobs/${job.id}`);
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setPlanning(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async () => {
|
||||
if (!(await confirmDialog({ message: t("chat.deleteConfirm"), danger: true, confirmLabel: t("common.delete") }))) return;
|
||||
await del(`/sessions/${sessionId}`);
|
||||
@@ -670,8 +769,8 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
const pendingTool = busy && pendingCall ? { name: pendingCall.name, input: pendingCall.input, since: lastMsg.created_at } : undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<ChatCtx.Provider value={{ sessionId, canFork: !session.parent_id && !readOnly && !busy, canReply: !readOnly }}>
|
||||
<div className="topbar" data-color={session.color}>
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
@@ -718,7 +817,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
{!readOnly && (
|
||||
<>
|
||||
{!session.parent_id && (
|
||||
<button className="small ghost" onClick={() => setSharing(true)} title={t("share.title")}>
|
||||
<button className="small ghost hide-mobile" onClick={() => setSharing(true)} title={t("share.title")}>
|
||||
<Icon name="share" />
|
||||
</button>
|
||||
)}
|
||||
@@ -732,11 +831,11 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
<button className={`small ghost ${searching ? "active" : ""}`} onClick={() => setSearching(!searching)} title={t("chat.search")} aria-label={t("chat.search")}>
|
||||
<Icon name="search" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||
<button className="small ghost hide-mobile" onClick={tabs.openFiles} title={t("files.newTab")}>
|
||||
<Icon name="folder" />
|
||||
</button>
|
||||
{!session.parent_id && (
|
||||
<button className="small ghost" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||
<button className="small ghost hide-mobile" onClick={forkSession} disabled={busy} title={t("chat.fork")}>
|
||||
<Icon name="fork" />
|
||||
</button>
|
||||
)}
|
||||
@@ -745,6 +844,28 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
<Icon name="more" />
|
||||
</summary>
|
||||
<div className="menu-pop-body">
|
||||
{/* The buttons that do not fit into the narrow bar. */}
|
||||
{!session.parent_id && (
|
||||
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, setSharing(true))}>
|
||||
<Icon name="share" /> {t("share.title")}
|
||||
</a>
|
||||
)}
|
||||
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, tabs.openFiles())}>
|
||||
<Icon name="folder" /> {t("files.newTab")}
|
||||
</a>
|
||||
{!session.parent_id && !busy && (
|
||||
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, forkSession())}>
|
||||
<Icon name="fork" /> {t("chat.fork")}
|
||||
</a>
|
||||
)}
|
||||
{!busy && (
|
||||
<a href="#" className="mobile-only" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, post(`/sessions/${sessionId}/compact`).catch((err) => toast(err.message)))}>
|
||||
<Icon name="compact" /> {t("command.compact")}
|
||||
</a>
|
||||
)}
|
||||
<a href="#" className="mobile-only danger" onClick={(e) => (e.preventDefault(), (e.currentTarget.closest("details") as HTMLDetailsElement).open = false, remove())}>
|
||||
<Icon name="trash" /> {t("chat.delete")}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
@@ -755,6 +876,22 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
>
|
||||
<Icon name="edit" /> {t("instructions.title")}
|
||||
</a>
|
||||
<ColorSwatches value={session.color} onChange={(color) => void update({ color })} />
|
||||
<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>
|
||||
<a href="#" title={t("projectDefaults.hint")} onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(false))}>
|
||||
<Icon name="template" /> {t("projectDefaults.save")}
|
||||
</a>
|
||||
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void saveDefaults(true))}>
|
||||
<Icon name="undo" /> {t("projectDefaults.remove")}
|
||||
</a>
|
||||
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), void editCheck())}>
|
||||
<Icon name="check" /> {t("check.menu")}
|
||||
</a>
|
||||
<a href="#" onClick={(e) => (e.preventDefault(), ((e.currentTarget.closest("details") as HTMLDetailsElement).open = false), setShowApprovals(true))}>
|
||||
<Icon name="lock" /> {t("permProject.menu")}
|
||||
</a>
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
@@ -804,10 +941,10 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
<button className="small ghost" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
||||
<button className="small ghost hide-mobile" onClick={() => post(`/sessions/${sessionId}/compact`).catch((e) => toast(e.message))} disabled={busy} title={t("command.compact")}>
|
||||
<Icon name="compact" />
|
||||
</button>
|
||||
<button className="small ghost danger" onClick={remove} title={t("chat.delete")}>
|
||||
<button className="small ghost danger hide-mobile" onClick={remove} title={t("chat.delete")}>
|
||||
<Icon name="trash" />
|
||||
</button>
|
||||
</>
|
||||
@@ -831,7 +968,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
{searching && <ChatSearch sessionId={sessionId} onPick={(id) => void jumpTo(id)} onClose={() => setSearching(false)} />}
|
||||
<ChatTabBar state={tabs} />
|
||||
<ChatTabPanes state={tabs} />
|
||||
<div className="chat-pane" hidden={tabs.tab !== "chat"}>
|
||||
<div className="chat-pane" data-color={session.color} data-session-id={sessionId} hidden={tabs.tab !== "chat"}>
|
||||
{session.agent === "craft" && !session.parent_id && <PhaseBar phase={session.phase} deep={session.clean_room === false} />}
|
||||
<WorkBar active={busy && permissions.length === 0 && questions.length === 0} />
|
||||
<div
|
||||
@@ -846,6 +983,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
// … scrolling back to the very end resumes it.
|
||||
else if (fromBottom < 4 && !stick.current && el.scrollTop > last.top) follow(true);
|
||||
lastScroll.current = { top: el.scrollTop, height: el.scrollHeight };
|
||||
setScrolled(el.scrollTop > 240);
|
||||
}}
|
||||
onWheel={(e) => {
|
||||
if (e.deltaY < 0 && stick.current) follow(false);
|
||||
@@ -925,18 +1063,23 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!following && (
|
||||
{(!following || scrolled) && (
|
||||
<div className="jump-latest">
|
||||
<button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
|
||||
<Icon name="arrow-down" />
|
||||
<button onClick={jumpPrevOwn} title={t("chat.jumpPrevOwn")} aria-label={t("chat.jumpPrevOwn")}>
|
||||
<Icon name="arrow-up" />
|
||||
</button>
|
||||
{!following && <button className="primary" onClick={jumpToLatest} title={t("chat.jumpLatest")} aria-label={t("chat.jumpLatest")}>
|
||||
<Icon name="arrow-down" />
|
||||
</button>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showApprovals && <ProjectPermissions sessionId={sessionId} cwd={session.cwd} onClose={() => setShowApprovals(false)} />}
|
||||
{editingInstructions && <InstructionsDialog sessionId={sessionId} value={session.instructions ?? ""} onClose={() => setEditingInstructions(false)} />}
|
||||
{editingTitle && <RenameDialog sessionId={sessionId} title={session.title} onClose={() => setEditingTitle(false)} />}
|
||||
{sharing && <ShareDialog sessionId={sessionId} onClose={() => setSharing(false)} />}
|
||||
{rewinding && <RewindDialog sessionId={sessionId} messageId={rewinding.id} onDone={rewindDone} onClose={() => setRewinding(undefined)} />}
|
||||
{!readOnly && <OutboxBar sessionId={sessionId} />}
|
||||
{!readOnly && <QueueList sessionId={sessionId} busy={busy} />}
|
||||
{readOnly ? (
|
||||
<div className="readonly-bar">{t("chat.readOnlyHint")}</div>
|
||||
@@ -952,7 +1095,7 @@ export function ChatView({ sessionId, onMenu, onClosePane }: { sessionId: string
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</ChatCtx.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { useT, type Key } from "../i18n";
|
||||
|
||||
/** 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;
|
||||
|
||||
/** Row of colour dots; the selected one is ringed, a second click (or ✕) removes the tag. */
|
||||
export function ColorSwatches({ value, onChange }: { value?: string; onChange: (color: string) => void }) {
|
||||
const t = useT();
|
||||
return (
|
||||
<div className="color-swatches" role="radiogroup" aria-label={t("color.label")}>
|
||||
{SESSION_COLORS.map((c) => (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={value === c}
|
||||
className={`swatch ${value === c ? "on" : ""}`}
|
||||
data-color={c}
|
||||
title={t(`color.${c}` as Key)}
|
||||
aria-label={t(`color.${c}` as Key)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onChange(value === c ? "" : c);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { ModelSelect } from "./ModelSelect";
|
||||
import { EffortSelect } from "./Regenerate";
|
||||
import { VoiceButton } from "./Voice";
|
||||
import { Markdown } from "./Markdown";
|
||||
import { COMPOSE_EVENT, type ComposeDetail } from "../compose";
|
||||
import { confirmDialog, promptDialog, toast } from "../dialogs";
|
||||
|
||||
interface Suggestion {
|
||||
@@ -207,6 +208,27 @@ export function Composer({
|
||||
}
|
||||
};
|
||||
|
||||
// Content sent from elsewhere (quote a message, image viewer): latest closures via refs.
|
||||
const live = useRef({ text, addFiles, setText });
|
||||
live.current = { text, addFiles, setText };
|
||||
useEffect(() => {
|
||||
const on = (e: Event) => {
|
||||
const d = (e as CustomEvent<ComposeDetail>).detail;
|
||||
if (d.sessionId !== session.id) return;
|
||||
if (d.text) live.current.setText(live.current.text.trim() ? `${live.current.text.replace(/\s+$/, "")}\n\n${d.text}` : d.text);
|
||||
if (d.files?.length) void live.current.addFiles(d.files);
|
||||
requestAnimationFrame(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
setPreview(false);
|
||||
el.focus();
|
||||
el.setSelectionRange(el.value.length, el.value.length);
|
||||
});
|
||||
};
|
||||
window.addEventListener(COMPOSE_EVENT, on);
|
||||
return () => window.removeEventListener(COMPOSE_EVENT, on);
|
||||
}, [session.id]);
|
||||
|
||||
const removeFile = (path: string) => {
|
||||
setFiles((cur) => cur.filter((f) => f.path !== path));
|
||||
void fetch(`/api/files/delete?path=${encodeURIComponent(path)}`, { method: "DELETE", credentials: "same-origin" });
|
||||
|
||||
@@ -34,6 +34,7 @@ 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" />,
|
||||
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" />,
|
||||
clip: <path d="m20 11-8.5 8.5a5 5 0 0 1-7-7L13 4a3.5 3.5 0 0 1 5 5l-8.5 8.5a2 2 0 0 1-3-3L14 7" />,
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
type Tool = "pen" | "arrow" | "rect";
|
||||
interface Shape {
|
||||
tool: Tool;
|
||||
color: string;
|
||||
/** pen: all points; arrow/rect: start and end. */
|
||||
pts: { x: number; y: number }[];
|
||||
}
|
||||
|
||||
const COLORS = ["#ff3b30", "#ffcc00", "#34c759", "#0a84ff", "#ffffff", "#000000"];
|
||||
const MAX_SIDE = 2000;
|
||||
|
||||
function paint(ctx: CanvasRenderingContext2D, shapes: Shape[], w: number) {
|
||||
const lw = Math.max(3, Math.round(w / 280));
|
||||
for (const s of shapes) {
|
||||
ctx.strokeStyle = s.color;
|
||||
ctx.fillStyle = s.color;
|
||||
ctx.lineWidth = lw;
|
||||
ctx.lineCap = "round";
|
||||
ctx.lineJoin = "round";
|
||||
const a = s.pts[0];
|
||||
const b = s.pts[s.pts.length - 1];
|
||||
ctx.beginPath();
|
||||
if (s.tool === "pen") {
|
||||
ctx.moveTo(a.x, a.y);
|
||||
for (const p of s.pts.slice(1)) ctx.lineTo(p.x, p.y);
|
||||
if (s.pts.length === 1) ctx.lineTo(a.x + 0.1, a.y);
|
||||
ctx.stroke();
|
||||
} else if (s.tool === "rect") {
|
||||
ctx.strokeRect(a.x, a.y, b.x - a.x, b.y - a.y);
|
||||
} else {
|
||||
// Arrow: line with a filled head at the end.
|
||||
const ang = Math.atan2(b.y - a.y, b.x - a.x);
|
||||
const head = lw * 5;
|
||||
ctx.moveTo(a.x, a.y);
|
||||
ctx.lineTo(b.x - Math.cos(ang) * head * 0.6, b.y - Math.sin(ang) * head * 0.6);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(b.x, b.y);
|
||||
ctx.lineTo(b.x - head * Math.cos(ang - 0.45), b.y - head * Math.sin(ang - 0.45));
|
||||
ctx.lineTo(b.x - head * Math.cos(ang + 0.45), b.y - head * Math.sin(ang + 0.45));
|
||||
ctx.closePath();
|
||||
ctx.fill();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Draw on an image (pen, arrow, rectangle) and hand the result back as a PNG file. */
|
||||
export function ImageAnnotator({ src, name, onDone, onCancel }: { src: string; name: string; onDone: (file: File) => void; onCancel: () => void }) {
|
||||
const t = useT();
|
||||
const canvas = useRef<HTMLCanvasElement>(null);
|
||||
const bitmap = useRef<HTMLImageElement | undefined>(undefined);
|
||||
const [shapes, setShapes] = useState<Shape[]>([]);
|
||||
const [tool, setTool] = useState<Tool>("pen");
|
||||
const [color, setColor] = useState(COLORS[0]);
|
||||
const [ready, setReady] = useState(false);
|
||||
const drawing = useRef<Shape | null>(null);
|
||||
|
||||
const redraw = (extra?: Shape | null) => {
|
||||
const c = canvas.current;
|
||||
const img = bitmap.current;
|
||||
if (!c || !img) return;
|
||||
const ctx = c.getContext("2d")!;
|
||||
ctx.clearRect(0, 0, c.width, c.height);
|
||||
ctx.drawImage(img, 0, 0, c.width, c.height);
|
||||
paint(ctx, extra ? [...shapes, extra] : shapes, c.width);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const img = new Image();
|
||||
img.crossOrigin = "anonymous";
|
||||
img.onload = () => {
|
||||
const c = canvas.current;
|
||||
if (!c) return;
|
||||
const scale = Math.min(1, MAX_SIDE / Math.max(img.naturalWidth || 1, img.naturalHeight || 1));
|
||||
c.width = Math.max(1, Math.round((img.naturalWidth || 300) * scale));
|
||||
c.height = Math.max(1, Math.round((img.naturalHeight || 200) * scale));
|
||||
bitmap.current = img;
|
||||
setReady(true);
|
||||
};
|
||||
img.onerror = () => {
|
||||
toast(t("viewer.annotateFailed"));
|
||||
onCancel();
|
||||
};
|
||||
img.src = src;
|
||||
}, [src]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
useEffect(() => redraw(), [shapes, ready]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const point = (e: React.PointerEvent) => {
|
||||
const c = canvas.current!;
|
||||
const r = c.getBoundingClientRect();
|
||||
return { x: ((e.clientX - r.left) / r.width) * c.width, y: ((e.clientY - r.top) / r.height) * c.height };
|
||||
};
|
||||
|
||||
const finish = () => {
|
||||
const c = canvas.current;
|
||||
if (!c) return;
|
||||
c.toBlob((blob) => {
|
||||
if (!blob) return toast(t("viewer.annotateFailed"));
|
||||
const base = name.replace(/\.[a-z0-9]{1,5}$/i, "") || "image";
|
||||
onDone(new File([blob], `${base}-marked.png`, { type: "image/png" }));
|
||||
}, "image/png");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="annotator" onClick={(e) => e.stopPropagation()} onWheel={(e) => e.stopPropagation()}>
|
||||
<canvas
|
||||
ref={canvas}
|
||||
style={{ visibility: ready ? "visible" : "hidden" }}
|
||||
onPointerDown={(e) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
drawing.current = { tool, color, pts: [point(e)] };
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const d = drawing.current;
|
||||
if (!d) return;
|
||||
const p = point(e);
|
||||
d.pts = d.tool === "pen" ? [...d.pts, p] : [d.pts[0], p];
|
||||
redraw(d);
|
||||
}}
|
||||
onPointerUp={() => {
|
||||
const d = drawing.current;
|
||||
drawing.current = null;
|
||||
if (d) setShapes((s) => [...s, d]);
|
||||
}}
|
||||
onPointerCancel={() => {
|
||||
drawing.current = null;
|
||||
redraw();
|
||||
}}
|
||||
/>
|
||||
<div className="annotator-tools" role="toolbar" aria-label={t("viewer.annotate")}>
|
||||
{(["pen", "arrow", "rect"] as Tool[]).map((k) => (
|
||||
<button key={k} className={`small ${tool === k ? "primary" : "ghost"}`} onClick={() => setTool(k)} aria-pressed={tool === k} title={t(`viewer.tool.${k}` as const)}>
|
||||
<Icon name={k === "pen" ? "edit" : k === "arrow" ? "arrow-right" : "box-off"} />
|
||||
</button>
|
||||
))}
|
||||
<span className="annotator-colors">
|
||||
{COLORS.map((c) => (
|
||||
<button key={c} className={`swatch ${c === color ? "on" : ""}`} style={{ background: c }} onClick={() => setColor(c)} aria-label={c} aria-pressed={c === color} />
|
||||
))}
|
||||
</span>
|
||||
<button className="small ghost" onClick={() => setShapes((s) => s.slice(0, -1))} disabled={!shapes.length} title={t("viewer.undo")} aria-label={t("viewer.undo")}>
|
||||
<Icon name="undo" />
|
||||
</button>
|
||||
<span className="grow" />
|
||||
<button className="small" onClick={onCancel}>
|
||||
{t("common.cancel")}
|
||||
</button>
|
||||
<button className="small primary" onClick={finish} disabled={!ready}>
|
||||
<Icon name="clip" /> {t("viewer.attach")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Icon } from "./Icon";
|
||||
import { ImageAnnotator } from "./ImageAnnotator";
|
||||
import { compose } from "../compose";
|
||||
import { toast } from "../dialogs";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
/** Images that open in the viewer: everything in the app, except UI chrome. */
|
||||
const SKIP = ".viewer-overlay, .sidebar, .topbar, button, .modal-backdrop .viewer, .model-viewer, .media-stage, .composer, .brand, .login";
|
||||
|
||||
interface Item {
|
||||
src: string;
|
||||
alt: string;
|
||||
}
|
||||
|
||||
const clamp = (v: number, a: number, b: number) => Math.min(b, Math.max(a, v));
|
||||
|
||||
/**
|
||||
* Popup image viewer: a click on any image of the app opens it fullscreen.
|
||||
* Pinch / wheel / double tap to zoom, drag to pan, arrows or swipe for the other
|
||||
* images of the same chat, Esc to close. Mounted once; listens for clicks globally.
|
||||
*/
|
||||
export function ImageViewer() {
|
||||
const t = useT();
|
||||
const [list, setList] = useState<Item[]>([]);
|
||||
const [idx, setIdx] = useState(0);
|
||||
const [open, setOpen] = useState(false);
|
||||
// Chat the image belongs to (for "use in message"); drawing mode.
|
||||
const [sessionId, setSessionId] = useState<string>();
|
||||
const [marking, setMarking] = useState(false);
|
||||
const [view, setView] = useState({ x: 0, y: 0, s: 1 });
|
||||
const pointers = useRef(new Map<number, { x: number; y: number }>());
|
||||
const gesture = useRef<{ d: number; s: number } | null>(null);
|
||||
const moved = useRef(false);
|
||||
const lastTap = useRef(0);
|
||||
const swipe = useRef<{ x: number; y: number } | null>(null);
|
||||
|
||||
const reset = useCallback(() => setView({ x: 0, y: 0, s: 1 }), []);
|
||||
const close = useCallback(() => setOpen(false), []);
|
||||
const go = useCallback(
|
||||
(d: number) => {
|
||||
setIdx((i) => clamp(i + d, 0, list.length - 1));
|
||||
reset();
|
||||
},
|
||||
[list.length, reset],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onClick = (e: MouseEvent) => {
|
||||
const img = (e.target as HTMLElement | null)?.closest?.("img");
|
||||
if (!img || img.closest(SKIP) || e.defaultPrevented || e.button !== 0) return;
|
||||
if (img.naturalWidth && img.naturalWidth < 24) return; // icons
|
||||
e.preventDefault(); // the surrounding link would open a new tab
|
||||
e.stopPropagation();
|
||||
const scope = img.closest(".chat, .files-main, .gallery, .main") ?? document;
|
||||
const all = [...scope.querySelectorAll("img")].filter((i) => !i.closest(SKIP) && i.src && (i.naturalWidth === 0 || i.naturalWidth >= 24));
|
||||
const seen = new Set<string>();
|
||||
const items: Item[] = [];
|
||||
let start = 0;
|
||||
for (const i of all) {
|
||||
if (seen.has(i.currentSrc || i.src)) {
|
||||
if (i === img) start = items.findIndex((x) => x.src === (i.currentSrc || i.src));
|
||||
continue;
|
||||
}
|
||||
seen.add(i.currentSrc || i.src);
|
||||
if (i === img) start = items.length;
|
||||
items.push({ src: i.currentSrc || i.src, alt: i.alt });
|
||||
}
|
||||
setSessionId((img.closest("[data-session-id]") as HTMLElement | null)?.dataset.sessionId);
|
||||
setMarking(false);
|
||||
setList(items.length ? items : [{ src: img.currentSrc || img.src, alt: img.alt }]);
|
||||
setIdx(Math.max(0, start));
|
||||
reset();
|
||||
setOpen(true);
|
||||
};
|
||||
// Capture phase: before the link around a thumbnail navigates.
|
||||
document.addEventListener("click", onClick, true);
|
||||
return () => document.removeEventListener("click", onClick, true);
|
||||
}, [reset]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (marking) return;
|
||||
if (e.key === "Escape") close();
|
||||
else if (e.key === "ArrowLeft") go(-1);
|
||||
else if (e.key === "ArrowRight") go(1);
|
||||
else if (e.key === "+" || e.key === "=") setView((v) => ({ ...v, s: clamp(v.s * 1.25, 1, 8) }));
|
||||
else if (e.key === "-") setView((v) => ({ ...v, s: clamp(v.s / 1.25, 1, 8), ...(v.s / 1.25 <= 1 ? { x: 0, y: 0 } : {}) }));
|
||||
else return;
|
||||
e.preventDefault();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [open, close, go, marking]);
|
||||
|
||||
if (!open || !list[idx]) return null;
|
||||
const cur = list[idx];
|
||||
|
||||
/** The image as a file for the composer (variants, questions about it, …). */
|
||||
async function useInChat() {
|
||||
try {
|
||||
const blob = await (await fetch(cur.src, { credentials: "same-origin" })).blob();
|
||||
const ext = blob.type.split("/")[1]?.split("+")[0] || "png";
|
||||
compose({ sessionId: sessionId!, files: [new File([blob], `image.${ext}`, { type: blob.type || "image/png" })] });
|
||||
toast(t("viewer.attached"), "success");
|
||||
close();
|
||||
} catch {
|
||||
toast(t("viewer.annotateFailed"));
|
||||
}
|
||||
}
|
||||
|
||||
const dist = () => {
|
||||
const [a, b] = [...pointers.current.values()];
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="viewer-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t("viewer.title")}
|
||||
onClick={() => {
|
||||
if (!moved.current) close();
|
||||
}}
|
||||
onWheel={(e) => setView((v) => {
|
||||
const s = clamp(v.s * (e.deltaY < 0 ? 1.15 : 1 / 1.15), 1, 8);
|
||||
return s === 1 ? { x: 0, y: 0, s } : { ...v, s };
|
||||
})}
|
||||
>
|
||||
<div className="viewer-bar" onClick={(e) => e.stopPropagation()}>
|
||||
<span className="viewer-count">{list.length > 1 ? `${idx + 1} / ${list.length}` : ""}</span>
|
||||
<span className="viewer-name">{cur.alt && !/^(image|SVG)$/i.test(cur.alt) ? cur.alt : ""}</span>
|
||||
{sessionId && (
|
||||
<>
|
||||
<button className="small ghost" onClick={() => setMarking(true)} title={t("viewer.annotate")} aria-label={t("viewer.annotate")}>
|
||||
<Icon name="edit" />
|
||||
</button>
|
||||
<button className="small ghost" onClick={() => void useInChat()} title={t("viewer.useInChat")} aria-label={t("viewer.useInChat")}>
|
||||
<Icon name="reply" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<a className="button small ghost" href={cur.src} target="_blank" rel="noreferrer" title={t("viewer.open")} aria-label={t("viewer.open")}>
|
||||
<Icon name="share" />
|
||||
</a>
|
||||
<a className="button small ghost" href={cur.src} download title={t("viewer.download")} aria-label={t("viewer.download")}>
|
||||
<Icon name="download" />
|
||||
</a>
|
||||
<button className="small ghost" onClick={close} aria-label={t("common.close")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
{marking && (
|
||||
<ImageAnnotator
|
||||
src={cur.src}
|
||||
name={cur.alt || "image"}
|
||||
onCancel={() => setMarking(false)}
|
||||
onDone={(file) => {
|
||||
compose({ sessionId: sessionId!, files: [file] });
|
||||
toast(t("viewer.attached"), "success");
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{!marking && idx > 0 && (
|
||||
<button className="viewer-nav prev" onClick={(e) => (e.stopPropagation(), go(-1))} aria-label={t("media.prev")}>
|
||||
<Icon name="arrow-left" />
|
||||
</button>
|
||||
)}
|
||||
{!marking && idx < list.length - 1 && (
|
||||
<button className="viewer-nav next" onClick={(e) => (e.stopPropagation(), go(1))} aria-label={t("media.next")}>
|
||||
<Icon name="arrow-right" />
|
||||
</button>
|
||||
)}
|
||||
{!marking && <img
|
||||
key={cur.src}
|
||||
className="viewer-img"
|
||||
src={cur.src}
|
||||
alt={cur.alt}
|
||||
draggable={false}
|
||||
style={{ transform: `translate(${view.x}px, ${view.y}px) scale(${view.s})`, cursor: view.s > 1 ? "grab" : "zoom-in" }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onPointerDown={(e) => {
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
moved.current = false;
|
||||
if (pointers.current.size === 2) gesture.current = { d: dist(), s: view.s };
|
||||
else swipe.current = { x: e.clientX, y: e.clientY };
|
||||
}}
|
||||
onPointerMove={(e) => {
|
||||
const p = pointers.current.get(e.pointerId);
|
||||
if (!p) return;
|
||||
const dx = e.clientX - p.x;
|
||||
const dy = e.clientY - p.y;
|
||||
pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });
|
||||
if (pointers.current.size === 2 && gesture.current) {
|
||||
moved.current = true;
|
||||
const s = clamp((gesture.current.s * dist()) / gesture.current.d, 1, 8);
|
||||
setView((v) => (s === 1 ? { x: 0, y: 0, s } : { ...v, s }));
|
||||
} else if (view.s > 1) {
|
||||
if (Math.abs(dx) + Math.abs(dy) > 1) moved.current = true;
|
||||
setView((v) => ({ ...v, x: v.x + dx, y: v.y + dy }));
|
||||
}
|
||||
}}
|
||||
onPointerUp={(e) => {
|
||||
pointers.current.delete(e.pointerId);
|
||||
gesture.current = null;
|
||||
const s0 = swipe.current;
|
||||
swipe.current = null;
|
||||
if (s0 && view.s === 1) {
|
||||
const dx = e.clientX - s0.x;
|
||||
const dy = e.clientY - s0.y;
|
||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) * 1.5) {
|
||||
moved.current = true;
|
||||
go(dx < 0 ? 1 : -1);
|
||||
} else if (dy > 90 && Math.abs(dy) > Math.abs(dx) * 1.5) {
|
||||
moved.current = true;
|
||||
close();
|
||||
}
|
||||
}
|
||||
// Double tap / double click: zoom in or back.
|
||||
if (!moved.current) {
|
||||
const now = Date.now();
|
||||
if (now - lastTap.current < 300) setView((v) => (v.s > 1 ? { x: 0, y: 0, s: 1 } : { ...v, s: 2.5 }));
|
||||
lastTap.current = now;
|
||||
}
|
||||
}}
|
||||
onPointerCancel={(e) => {
|
||||
pointers.current.delete(e.pointerId);
|
||||
gesture.current = null;
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Icon } from "./Icon";
|
||||
import { useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { getLang, useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { PermissionRequest, QuestionRequest } from "../types";
|
||||
@@ -31,7 +31,24 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
||||
const chosen = selected.filter(Boolean).length;
|
||||
const partial = hunkCount > 1 && chosen < hunkCount;
|
||||
|
||||
const reply = async (r: "once" | "always" | "reject") => {
|
||||
const [explaining, setExplaining] = useState(false);
|
||||
const [explanation, setExplanation] = useState<string>();
|
||||
const [explainError, setExplainError] = useState<string>();
|
||||
/** The small model explains in plain words what the action does (approving on a phone without reading code). */
|
||||
const explain = async () => {
|
||||
setExplaining(true);
|
||||
setExplainError(undefined);
|
||||
try {
|
||||
const r = await post<{ text: string }>(`/permissions/${req.id}/explain`, { lang: getLang() });
|
||||
setExplanation(r.text);
|
||||
} catch (e: any) {
|
||||
setExplainError(e.message);
|
||||
} finally {
|
||||
setExplaining(false);
|
||||
}
|
||||
};
|
||||
|
||||
const reply = async (r: "once" | "always" | "project" | "reject") => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const hunks = r === "once" && partial ? selected.flatMap((on, i) => (on ? [i] : [])) : undefined;
|
||||
@@ -47,6 +64,10 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
||||
? t("permission.alwaysCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") })
|
||||
: t("permission.alwaysTool", { tool: req.tool });
|
||||
|
||||
// Dangerous commands are only ever allowed for the session (the server decides; this hides the button).
|
||||
const dangerous = !!command && /\b(rm\s+-[a-zA-Z]*[rRf]|sudo|su|git\s+(push|reset|clean|rebase|checkout|restore)|docker|docker-compose|systemctl|chmod\s+-R|chown\s+-R|dd|mkfs|shutdown|reboot|kill|pkill|killall|crontab)\b|\|\s*(ba)?sh\b|publish/.test(command);
|
||||
const projectLabel = req.tool === "bash" ? t("permission.projectCommand", { prefix: (command ?? "").split(/\s+/).slice(0, 2).join(" ") }) : t("permission.projectTool", { tool: req.tool });
|
||||
|
||||
return (
|
||||
<div className="card" role="alertdialog" aria-label={t("permission.title")}>
|
||||
<h3>
|
||||
@@ -72,6 +93,16 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
||||
</details>
|
||||
)
|
||||
)}
|
||||
{explanation ? (
|
||||
<div className="explain" role="note">
|
||||
<Icon name="sparkle" className="tiny" /> {explanation}
|
||||
</div>
|
||||
) : (
|
||||
<button className="small ghost explain-btn" disabled={explaining} onClick={explain}>
|
||||
<Icon name="sparkle" /> {explaining ? t("permission.explaining") : t("permission.explain")}
|
||||
</button>
|
||||
)}
|
||||
{explainError && <div className="hint">{explainError}</div>}
|
||||
<input
|
||||
style={{ width: "100%", marginTop: 8 }}
|
||||
placeholder={t("permission.feedback")}
|
||||
@@ -86,6 +117,11 @@ export function PermissionCard({ req, currentId }: { req: PermissionRequest; cur
|
||||
<button disabled={busy || partial} onClick={() => reply("always")}>
|
||||
{alwaysLabel}
|
||||
</button>
|
||||
{!dangerous && (
|
||||
<button disabled={busy || partial} onClick={() => reply("project")} title={t("permission.projectHint")}>
|
||||
{projectLabel}
|
||||
</button>
|
||||
)}
|
||||
<button className="danger" disabled={busy} onClick={() => reply("reject")}>
|
||||
{feedback ? t("permission.rejectWithFeedback") : t("permission.reject")}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { post } from "../api";
|
||||
import { asQuote, compose } from "../compose";
|
||||
import { useT } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { loadSessions } from "../store";
|
||||
import { toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/** What the message menus of one chat need to know. */
|
||||
export const ChatCtx = createContext<{ sessionId: string; canFork: boolean; canReply: boolean }>({ sessionId: "", canFork: false, canReply: false });
|
||||
|
||||
/**
|
||||
* Actions of one message: copy, quote into the reply, fork the chat from here and
|
||||
* (for own messages) edit and resend. Hover on desktop, always faintly visible on touch.
|
||||
*/
|
||||
export function MessageMenu({ messageId, text, forkable, onRewind }: { messageId: string; text: string; forkable: boolean; onRewind?: () => void }) {
|
||||
const t = useT();
|
||||
const { sessionId, canFork, canReply } = useContext(ChatCtx);
|
||||
const close = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
(e.currentTarget.closest("details") as HTMLDetailsElement).open = false;
|
||||
};
|
||||
if (!text.trim() && !onRewind) return null;
|
||||
return (
|
||||
<details className="menu-pop msg-menu">
|
||||
<summary className="small ghost" title={t("msg.actions")} aria-label={t("msg.actions")}>
|
||||
<Icon name="more" />
|
||||
</summary>
|
||||
<div className="menu-pop-body">
|
||||
{text.trim() && (
|
||||
<a
|
||||
href="#"
|
||||
onClick={(e) => {
|
||||
close(e);
|
||||
const done = () => toast(t("msg.copied"), "success");
|
||||
if (navigator.clipboard?.writeText) void navigator.clipboard.writeText(text).then(done, () => toast(t("msg.copyFailed")));
|
||||
else toast(t("msg.copyFailed"));
|
||||
}}
|
||||
>
|
||||
<Icon name="copy" /> {t("msg.copy")}
|
||||
</a>
|
||||
)}
|
||||
{text.trim() && canReply && (
|
||||
<a href="#" onClick={(e) => (close(e), compose({ sessionId, text: asQuote(text) }))}>
|
||||
<Icon name="reply" /> {t("msg.quote")}
|
||||
</a>
|
||||
)}
|
||||
{onRewind && (
|
||||
<a href="#" onClick={(e) => (close(e), onRewind())}>
|
||||
<Icon name="edit" /> {t("chat.rewind")}
|
||||
</a>
|
||||
)}
|
||||
{forkable && canFork && (
|
||||
<a
|
||||
href="#"
|
||||
onClick={async (e) => {
|
||||
close(e);
|
||||
try {
|
||||
const fork = await post<{ id: string }>(`/sessions/${sessionId}/fork`, { message_id: messageId });
|
||||
await loadSessions();
|
||||
toast(t("chat.forked"));
|
||||
navigate(`/s/${fork.id}`);
|
||||
} catch (err: any) {
|
||||
toast(err.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Icon name="fork" /> {t("msg.forkHere")}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -328,7 +328,8 @@ export function NewSession({ onMenu, onCreated }: { onMenu: () => void; onCreate
|
||||
const isCraft = task === "craft";
|
||||
const s = await post<{ id: string }>("/sessions", {
|
||||
cwd,
|
||||
model: model || undefined,
|
||||
// The server default is not sent, so the project's own default model can apply.
|
||||
model: model && model !== config?.default_model ? model : undefined,
|
||||
agent: task === "cad" ? "build" : task,
|
||||
clean_room: isCraft ? !deep : undefined,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useEffect } from "react";
|
||||
import { useT } from "../i18n";
|
||||
import { flushOutbox, removeFromOutbox, useOutbox } from "../outbox";
|
||||
import { useStore } from "../store";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/** Messages waiting for a connection: sent automatically once the server is reachable again. */
|
||||
export function OutboxBar({ sessionId }: { sessionId: string }) {
|
||||
const t = useT();
|
||||
const items = useOutbox(sessionId);
|
||||
const connected = useStore((s) => s.connected);
|
||||
useEffect(() => {
|
||||
if (!items.length) return;
|
||||
const go = () => void flushOutbox(sessionId);
|
||||
if (connected && navigator.onLine) go();
|
||||
window.addEventListener("online", go);
|
||||
return () => window.removeEventListener("online", go);
|
||||
}, [sessionId, connected, items.length]);
|
||||
if (!items.length) return null;
|
||||
return (
|
||||
<div className="outbox" role="status">
|
||||
<div className="outbox-head">
|
||||
<Icon name="clock" className="tiny" /> {t("outbox.waiting", { n: items.length })}
|
||||
<span className="grow" />
|
||||
<button className="small" onClick={() => void flushOutbox(sessionId)}>
|
||||
{t("outbox.sendNow")}
|
||||
</button>
|
||||
</div>
|
||||
{items.map((i) => (
|
||||
<div key={i.id} className="outbox-item">
|
||||
<span>{i.text.length > 120 ? `${i.text.slice(0, 120)}…` : i.text}</span>
|
||||
<button className="small ghost" onClick={() => removeFromOutbox(i.id)} aria-label={t("outbox.discard")} title={t("outbox.discard")}>
|
||||
<Icon name="close" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { useMemo } from "react";
|
||||
import { useFetch } from "../hooks";
|
||||
import { fmtCost, fmtDate, fmtRelative, useT, type Key } from "../i18n";
|
||||
import { navigate } from "../router";
|
||||
import { useStore } from "../store";
|
||||
import type { Session } from "../types";
|
||||
import { Icon } from "./Icon";
|
||||
import { basename } from "./FileFigure";
|
||||
|
||||
interface Overview {
|
||||
sessions: Record<string, { files: number; check: string | null; checks_failed: number; cost: number }>;
|
||||
check: { command: string; enabled: boolean } | null;
|
||||
defaults: { model?: string; agent?: string; permission_mode?: string; color?: string; instructions?: string; folder?: string } | null;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
const day = (ms: number) => new Date(ms).toDateString();
|
||||
|
||||
/** One project folder: its chats as a timeline (newest first) with what each one did. */
|
||||
export function ProjectPage({ cwd, onMenu }: { cwd: string; onMenu: () => void }) {
|
||||
const t = useT();
|
||||
const all = useStore((s) => s.sessions);
|
||||
const { data } = useFetch<Overview>(`/projects/overview?cwd=${encodeURIComponent(cwd)}`);
|
||||
const sessions = useMemo(() => all.filter((s) => s.cwd === cwd && !s.archived).sort((a, b) => b.updated_at - a.updated_at), [all, cwd]);
|
||||
const archived = all.filter((s) => s.cwd === cwd && s.archived).length;
|
||||
|
||||
// Day groups: "Today", "Yesterday", date.
|
||||
const groups = useMemo(() => {
|
||||
const out: { label: string; items: Session[] }[] = [];
|
||||
const today = day(Date.now());
|
||||
const yesterday = day(Date.now() - 86_400_000);
|
||||
for (const s of sessions) {
|
||||
const d = day(s.updated_at);
|
||||
const label = d === today ? t("project.today") : d === yesterday ? t("project.yesterday") : fmtDate(s.updated_at, false);
|
||||
const last = out[out.length - 1];
|
||||
if (last?.label === label) last.items.push(s);
|
||||
else out.push({ label, items: [s] });
|
||||
}
|
||||
return out;
|
||||
}, [sessions, t]);
|
||||
|
||||
const totalCost = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.cost ?? 0), 0);
|
||||
const totalFiles = sessions.reduce((n, s) => n + (data?.sessions[s.id]?.files ?? 0), 0);
|
||||
|
||||
const newChat = () => {
|
||||
try {
|
||||
localStorage.setItem("codecraft.lastDir", cwd);
|
||||
} catch {
|
||||
// Without storage the wizard starts at its usual folder.
|
||||
}
|
||||
navigate("/new");
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="topbar">
|
||||
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
|
||||
<Icon name="menu" />
|
||||
</button>
|
||||
<div className="grow">
|
||||
<h1>
|
||||
<Icon name="folder" /> {basename(cwd) || "/"}
|
||||
</h1>
|
||||
<div className="path mono">{cwd}</div>
|
||||
</div>
|
||||
<button className="small primary" onClick={newChat}>
|
||||
<Icon name="plus" /> {t("project.newChat")}
|
||||
</button>
|
||||
</div>
|
||||
<div className="chat-scroll">
|
||||
<div className="chat project-page">
|
||||
<div className="project-summary">
|
||||
<span className="chip">{t("project.chats", { n: sessions.length })}</span>
|
||||
{totalFiles > 0 && <span className="chip">{t("project.files", { n: totalFiles })}</span>}
|
||||
{data && totalCost > 0 && <span className="chip">{fmtCost(totalCost, data.currency)}</span>}
|
||||
{data?.check?.enabled && (
|
||||
<span className="chip mono" title={t("check.title")}>
|
||||
<Icon name="check" className="tiny" /> {data.check.command}
|
||||
</span>
|
||||
)}
|
||||
{data?.defaults?.model && (
|
||||
<span className="chip" title={t("projectDefaults.hint")}>
|
||||
<Icon name="template" className="tiny" /> {data.defaults.model.split("/").pop()}
|
||||
</span>
|
||||
)}
|
||||
{archived > 0 && <span className="hint">{t("project.archived", { n: archived })}</span>}
|
||||
</div>
|
||||
{sessions.length === 0 && <div className="empty">{t("project.empty")}</div>}
|
||||
{groups.map((g) => (
|
||||
<section key={g.label} className="timeline-day">
|
||||
<h3>{g.label}</h3>
|
||||
<ol className="timeline">
|
||||
{g.items.map((s) => {
|
||||
const info = data?.sessions[s.id];
|
||||
return (
|
||||
<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="tl-when" title={fmtDate(s.updated_at)}>
|
||||
{fmtRelative(s.updated_at)}
|
||||
</span>
|
||||
</div>
|
||||
{s.summary && <div className="tl-summary">{s.summary}</div>}
|
||||
<div className="tl-meta">
|
||||
<span>{s.agent}</span>
|
||||
<span>{s.model.split("/").pop()}</span>
|
||||
{!!info?.files && <span>{t("project.files", { n: info.files })}</span>}
|
||||
{info?.check && (
|
||||
<span className={`check-${info.check}`}>
|
||||
<Icon name={info.check === "passed" ? "check" : info.check === "running" ? "clock" : "x-mark"} className="tiny" /> {t(`check.${info.check}` as Key)}
|
||||
</span>
|
||||
)}
|
||||
{!!info?.checks_failed && <span className="check-failed">{t("project.checksFailed", { n: info.checks_failed })}</span>}
|
||||
{!!info?.cost && data && <span>{fmtCost(info.cost, data.currency)}</span>}
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, get } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import { toast } from "../dialogs";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/** "bash:git status" → "git status"; tool names stay. */
|
||||
const label = (key: string) => (key.startsWith("bash:") ? `$ ${key.slice(5)}` : key);
|
||||
|
||||
/** What the user allowed "for this project" (all chats of the folder) – with a way to revoke it. */
|
||||
export function ProjectPermissions({ sessionId, cwd, onClose }: { sessionId: string; cwd: string; onClose: () => void }) {
|
||||
const t = useT();
|
||||
const [keys, setKeys] = useState<string[]>();
|
||||
useEffect(() => {
|
||||
get<string[]>(`/sessions/${sessionId}/approvals`).then(setKeys, (e) => (toast(e.message), setKeys([])));
|
||||
}, [sessionId]);
|
||||
const revoke = async (key: string) => {
|
||||
try {
|
||||
await api(`/sessions/${sessionId}/approvals`, { method: "DELETE", json: { key } });
|
||||
setKeys((k) => k?.filter((x) => x !== key));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="modal-backdrop" onClick={onClose}>
|
||||
<div className="modal" role="dialog" aria-label={t("permProject.title")} onClick={(e) => e.stopPropagation()}>
|
||||
<h3>
|
||||
<Icon name="lock" /> {t("permProject.title")}
|
||||
</h3>
|
||||
<p style={{ fontSize: 13, color: "var(--fg3)", marginBottom: 4 }}>{t("permProject.intro")}</p>
|
||||
<div className="path mono" style={{ marginBottom: 8 }}>{cwd}</div>
|
||||
{keys === undefined ? (
|
||||
<div className="empty">…</div>
|
||||
) : keys.length === 0 ? (
|
||||
<p style={{ fontSize: 13 }}>{t("permProject.empty")}</p>
|
||||
) : (
|
||||
<ul className="rewind-files">
|
||||
{keys.map((k) => (
|
||||
<li key={k} className="row" style={{ justifyContent: "space-between" }}>
|
||||
<code>{label(k)}</code>
|
||||
<button className="small danger" onClick={() => void revoke(k)}>
|
||||
{t("permProject.revoke")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="row" style={{ justifyContent: "flex-end", marginTop: 10 }}>
|
||||
<button onClick={onClose}>{t("common.close")}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
import { ColorSwatches } from "./ColorSwatches";
|
||||
import { Icon } from "./Icon";
|
||||
import { StatusLeds } from "./StatusLeds";
|
||||
import { cycleThemeMode, getThemeMode, onThemeChange } from "../theme";
|
||||
@@ -25,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 }) => patch(`/sessions/${id}`, body).catch((e) => toast(e.message));
|
||||
const organise = (id: string, body: Partial<Pick<Session, "pinned" | "archived">> & { folder?: string; color?: 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[] }) {
|
||||
@@ -53,11 +54,12 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
||||
};
|
||||
return (
|
||||
<div className={`session-row ${menu ? "menu-open" : ""}`}>
|
||||
<button className={`session-item ${active ? "active" : ""}`} onClick={() => navigate(`/s/${s.id}`)}>
|
||||
<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>{s.title || t("session.untitled")}</span>
|
||||
<span className={s.color ? "color-badge" : undefined}>{s.title || t("session.untitled")}</span>
|
||||
</div>
|
||||
{s.summary && <div className="summary" title={s.summary}>{s.summary}</div>}
|
||||
<div className="sub">
|
||||
{s.shared && <span title={t("sidebar.shared")}><Icon name="share" className="tiny" /> </span>}
|
||||
{basename(s.cwd)} · {s.agent} · {fmtRelative(s.updated_at)}
|
||||
@@ -93,6 +95,7 @@ function SessionRow({ s, active, attention, busy, folders }: { s: Session; activ
|
||||
<Icon name="folder-up" /> {t("sidebar.removeFromFolder")}
|
||||
</button>
|
||||
)}
|
||||
<ColorSwatches value={s.color} onChange={(color) => (setMenu(false), organise(s.id, { color }))} />
|
||||
<button role="menuitem" onClick={() => (setMenu(false), organise(s.id, { archived: !s.archived }))}>
|
||||
<Icon name="archive" /> {t(s.archived ? "sidebar.unarchive" : "sidebar.archiveOne")}
|
||||
</button>
|
||||
@@ -194,6 +197,11 @@ function GroupHeader({
|
||||
)}
|
||||
{menu && (
|
||||
<div className="session-menu" role="menu" onClick={(e) => e.stopPropagation()}>
|
||||
{g.id.startsWith("project:") && (
|
||||
<button role="menuitem" onClick={() => (setMenu(false), navigate(`/p/${encodeURIComponent(g.id.slice("project:".length))}`))}>
|
||||
<Icon name="clock" /> {t("project.open")}
|
||||
</button>
|
||||
)}
|
||||
<button role="menuitem" onClick={() => (setMenu(false), onPin?.())}>
|
||||
<Icon name="pin" /> {t(pinned ? "sidebar.groupUnpin" : "sidebar.groupPin")}
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useState } from "react";
|
||||
import { get } from "../api";
|
||||
import { useT } from "../i18n";
|
||||
import type { Message } from "../types";
|
||||
import { Diff } from "./Diff";
|
||||
import { Icon } from "./Icon";
|
||||
import { basename } from "./FileFigure";
|
||||
|
||||
interface Change {
|
||||
path: string;
|
||||
created: boolean;
|
||||
deleted?: boolean;
|
||||
diff?: string;
|
||||
truncated?: boolean;
|
||||
}
|
||||
|
||||
const count = (diff: string | undefined, sign: "+" | "-") => (diff ?? "").split("\n").filter((l) => l.startsWith(sign) && !l.startsWith(sign.repeat(3))).length;
|
||||
|
||||
/**
|
||||
* What one turn changed: files with their diffs (from the checkpoints the agent leaves before
|
||||
* every write/edit) and a button to roll the chat and the files back to before the turn.
|
||||
*/
|
||||
export function TurnChanges({
|
||||
sessionId,
|
||||
fromId,
|
||||
untilId,
|
||||
fileCount,
|
||||
userMessage,
|
||||
onRewind,
|
||||
}: {
|
||||
sessionId: string;
|
||||
fromId: string;
|
||||
untilId?: string;
|
||||
fileCount: number;
|
||||
userMessage?: Message;
|
||||
onRewind?: (m: Message) => void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [changes, setChanges] = useState<Change[]>();
|
||||
const [error, setError] = useState<string>();
|
||||
|
||||
const toggle = () => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
if (next && !changes) {
|
||||
const q = `since_message=${encodeURIComponent(fromId)}&diff=true${untilId ? `&until_message=${encodeURIComponent(untilId)}` : ""}`;
|
||||
get<Change[]>(`/sessions/${sessionId}/changes?${q}`).then(setChanges, (e) => setError(e.message));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="turn-changes">
|
||||
<button className="small ghost" onClick={toggle} aria-expanded={open}>
|
||||
<Icon name={open ? "chevron-down" : "chevron-right"} className="tiny" /> {t("turn.changes", { n: fileCount })}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="turn-changes-body">
|
||||
{error && <div className="notice err">{error}</div>}
|
||||
{!changes && !error && <div className="hint">…</div>}
|
||||
{changes?.length === 0 && <div className="hint">{t("turn.noChanges")}</div>}
|
||||
{changes?.map((c) => (
|
||||
<details key={c.path} className="turn-file">
|
||||
<summary title={c.path}>
|
||||
<span className="mono">{basename(c.path)}</span>
|
||||
{c.created && <span className="chip">{t("turn.new")}</span>}
|
||||
{c.deleted && <span className="chip warn">{t("turn.deleted")}</span>}
|
||||
<span className="turn-stat">
|
||||
<span className="add">+{count(c.diff, "+")}</span> <span className="del">−{count(c.diff, "-")}</span>
|
||||
</span>
|
||||
</summary>
|
||||
{c.diff ? <Diff diff={c.diff} /> : <div className="hint">{t("turn.noDiff")}</div>}
|
||||
{c.truncated && <div className="hint">{t("turn.truncated")}</div>}
|
||||
</details>
|
||||
))}
|
||||
{userMessage && onRewind && changes && changes.length > 0 && (
|
||||
<button className="small" onClick={() => onRewind(userMessage)}>
|
||||
<Icon name="undo" /> {t("turn.rollback")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { useState } from "react";
|
||||
import { post } from "../api";
|
||||
import { toast } from "../dialogs";
|
||||
import { useT } from "../i18n";
|
||||
import { useStore } from "../store";
|
||||
import { Spinner } from "./Activity";
|
||||
import { Icon } from "./Icon";
|
||||
|
||||
/** Green/red badge of the project's check (tests, build …) for one turn; output and "let the agent fix it" on click. */
|
||||
export function TurnCheck({ sessionId, messageId, canSend }: { sessionId: string; messageId: string; canSend: boolean }) {
|
||||
const t = useT();
|
||||
const check = useStore((s) => s.checks[sessionId]?.[messageId]);
|
||||
const [open, setOpen] = useState(false);
|
||||
if (!check) return null;
|
||||
const seconds = check.duration_ms >= 1000 ? ` · ${(check.duration_ms / 1000).toFixed(check.duration_ms >= 10_000 ? 0 : 1)} s` : "";
|
||||
const label =
|
||||
check.status === "running" ? t("check.running") : check.status === "passed" ? t("check.passed") : check.status === "timeout" ? t("check.timeout") : t("check.failed");
|
||||
const fix = async () => {
|
||||
try {
|
||||
await post(`/sessions/${sessionId}/prompt`, { text: `${t("check.fixPrompt")}\n\n\`\`\`\n${check.output}\n\`\`\`` });
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="turn-check">
|
||||
<button className={`chip check-${check.status}`} onClick={() => setOpen(!open)} aria-expanded={open} disabled={check.status === "running"}>
|
||||
{check.status === "running" ? <Spinner small /> : <Icon name={check.status === "passed" ? "check" : "x-mark"} className="tiny" />} {label}
|
||||
{seconds}
|
||||
</button>
|
||||
{open && check.status !== "running" && (
|
||||
<div className="turn-check-body">
|
||||
<pre className="mono">{check.output || t("check.noOutput")}</pre>
|
||||
{check.status !== "passed" && canSend && (
|
||||
<button className="small primary" onClick={fix}>
|
||||
<Icon name="sparkle" /> {t("check.fix")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { GitAccount } from "./GitAccount";
|
||||
import { ApiTokens } from "./ApiTokens";
|
||||
import { CliInstall } from "./CliInstall";
|
||||
import { Memory } from "./Memory";
|
||||
import { ClaudeCode } from "./ClaudeCode";
|
||||
import { Telegram } from "./Telegram";
|
||||
|
||||
export function Account() {
|
||||
@@ -52,6 +53,7 @@ export function Account() {
|
||||
<Memory />
|
||||
<GitAccount />
|
||||
<CliInstall />
|
||||
<ClaudeCode />
|
||||
<Telegram />
|
||||
<ApiTokens />
|
||||
</>
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, get, post } from "../../api";
|
||||
import { confirmDialog, toast } from "../../dialogs";
|
||||
import { useT } from "../../i18n";
|
||||
import { Icon } from "../Icon";
|
||||
import { Spinner } from "../Activity";
|
||||
import { useStore } from "../../store";
|
||||
|
||||
interface Status {
|
||||
installed: boolean;
|
||||
logged_in?: boolean;
|
||||
method?: string;
|
||||
email?: string;
|
||||
org?: string;
|
||||
expires?: string;
|
||||
login_in_progress?: boolean;
|
||||
error?: string;
|
||||
version?: string | null;
|
||||
latest?: string | null;
|
||||
update_available?: boolean;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Claude Code: sign in from the browser. The CLI prints a sign-in URL; the Anthropic page shows
|
||||
* a code after login that is pasted here and handed back to the CLI on the server.
|
||||
*/
|
||||
export function ClaudeCode() {
|
||||
const t = useT();
|
||||
const [status, setStatus] = useState<Status>();
|
||||
const [url, setUrl] = useState<string>();
|
||||
const [code, setCode] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [console_, setConsole] = useState(false);
|
||||
const isAdmin = useStore((s) => s.me?.role === "admin");
|
||||
const [updating, setUpdating] = useState(false);
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [updateLog, setUpdateLog] = useState<string>();
|
||||
|
||||
const check = async () => {
|
||||
setChecking(true);
|
||||
try {
|
||||
setStatus(await get<Status>("/claude/status?refresh=true"));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
};
|
||||
const update = async () => {
|
||||
setUpdating(true);
|
||||
setUpdateLog(undefined);
|
||||
try {
|
||||
const r = await post<Status>("/claude/update", {});
|
||||
setStatus(r);
|
||||
setUpdateLog(r.output);
|
||||
toast(t("claude.updated", { version: r.version ?? "" }), "success");
|
||||
} catch (e: any) {
|
||||
setUpdateLog(e.message);
|
||||
toast(t("claude.updateFailed"));
|
||||
} finally {
|
||||
setUpdating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const load = () => get<Status>("/claude/status").then(setStatus, (e) => setStatus({ installed: false, error: e.message }));
|
||||
useEffect(() => void load(), []);
|
||||
|
||||
const start = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
const r = await post<{ url: string }>("/claude/login", { console: console_ });
|
||||
setUrl(r.url);
|
||||
setCode("");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const submit = async () => {
|
||||
setBusy(true);
|
||||
try {
|
||||
setStatus(await post<Status>("/claude/login/code", { code }));
|
||||
setUrl(undefined);
|
||||
setCode("");
|
||||
toast(t("claude.signedIn"), "success");
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
const cancel = async () => {
|
||||
await api("/claude/login", { method: "DELETE" }).catch(() => undefined);
|
||||
setUrl(undefined);
|
||||
setCode("");
|
||||
};
|
||||
const logout = async () => {
|
||||
if (!(await confirmDialog({ message: t("claude.logoutConfirm"), danger: true, confirmLabel: t("claude.logout") }))) return;
|
||||
try {
|
||||
setStatus(await post<Status>("/claude/logout", {}));
|
||||
} catch (e: any) {
|
||||
toast(e.message);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel">
|
||||
<h3>
|
||||
<Icon name="robot" /> {t("claude.title")}
|
||||
<span className="spacer" />
|
||||
{status?.logged_in && (
|
||||
<button className="small ghost danger" onClick={logout}>
|
||||
{t("claude.logout")}
|
||||
</button>
|
||||
)}
|
||||
</h3>
|
||||
<div className="hint" style={{ marginBottom: 10 }}>{t("claude.intro")}</div>
|
||||
{!status && <Spinner />}
|
||||
{status && !status.installed && <div className="notice err">{status.error ?? t("claude.notInstalled")}</div>}
|
||||
{status?.installed && (
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap", marginBottom: 10 }}>
|
||||
<span className="chip mono">{t("claude.version", { version: status.version ?? "?" })}</span>
|
||||
{status.update_available ? (
|
||||
<>
|
||||
<span className="chip warn">{t("claude.updateAvailable", { version: status.latest ?? "" })}</span>
|
||||
{isAdmin ? (
|
||||
<button className="small primary" disabled={updating} onClick={update}>
|
||||
{updating ? <Spinner small /> : <Icon name="download" />} {updating ? t("claude.updating") : t("claude.update")}
|
||||
</button>
|
||||
) : (
|
||||
<span className="hint">{t("claude.updateAdmin")}</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
status.latest && <span className="chip">{t("claude.upToDate")}</span>
|
||||
)}
|
||||
<button className="small ghost" disabled={checking || updating} onClick={check} title={t("claude.checkNow")} aria-label={t("claude.checkNow")}>
|
||||
{checking ? <Spinner small /> : <Icon name="refresh" />}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{updateLog && (
|
||||
<details style={{ marginBottom: 10 }}>
|
||||
<summary className="hint" style={{ cursor: "pointer" }}>{t("claude.updateLog")}</summary>
|
||||
<pre className="mono" style={{ whiteSpace: "pre-wrap", maxHeight: 200, overflow: "auto" }}>{updateLog}</pre>
|
||||
</details>
|
||||
)}
|
||||
{status?.installed && status.logged_in && (
|
||||
<div className="row" style={{ gap: 8, flexWrap: "wrap" }}>
|
||||
<span className="chip">
|
||||
<Icon name="check" className="tiny" /> {t("claude.signedInAs", { who: status.email || status.org || status.method || "" })}
|
||||
</span>
|
||||
{status.method && <span className="chip">{status.method}</span>}
|
||||
{status.expires && <span className="hint">{t("claude.expires", { date: new Date(status.expires).toLocaleDateString() })}</span>}
|
||||
</div>
|
||||
)}
|
||||
{status?.installed && !status.logged_in && !url && (
|
||||
<>
|
||||
<label className="row" style={{ marginBottom: 8 }}>
|
||||
<input type="checkbox" checked={console_} onChange={(e) => setConsole(e.target.checked)} /> {t("claude.console")}
|
||||
</label>
|
||||
<button className="primary" disabled={busy} onClick={start}>
|
||||
{busy ? <Spinner small /> : <Icon name="lock" />} {t("claude.signIn")}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{url && (
|
||||
<ol className="cli-steps">
|
||||
<li>
|
||||
<a className="button" href={url} target="_blank" rel="noreferrer">
|
||||
<Icon name="external" /> {t("claude.openLogin")}
|
||||
</a>
|
||||
<div className="hint">{t("claude.step1")}</div>
|
||||
</li>
|
||||
<li>
|
||||
{t("claude.step2")}
|
||||
<div className="row" style={{ marginTop: 6 }}>
|
||||
<input style={{ flex: 1 }} value={code} placeholder={t("claude.codePlaceholder")} autoComplete="off" autoCapitalize="off" spellCheck={false} onChange={(e) => setCode(e.target.value)} onKeyDown={(e) => e.key === "Enter" && code.trim() && void submit()} />
|
||||
<button className="primary" disabled={busy || !code.trim()} onClick={submit}>
|
||||
{busy ? <Spinner small /> : <Icon name="check" />} {t("claude.confirm")}
|
||||
</button>
|
||||
<button onClick={cancel}>{t("common.cancel")}</button>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/** Put content into the composer of a chat from anywhere in the app (quote, image viewer, …). */
|
||||
export interface ComposeDetail {
|
||||
sessionId: string;
|
||||
/** Appended to the text field (a blank line before it if there is text already). */
|
||||
text?: string;
|
||||
/** Attached like dropped files (images stay images). */
|
||||
files?: File[];
|
||||
}
|
||||
|
||||
export const COMPOSE_EVENT = "codecraft:compose";
|
||||
|
||||
export function compose(detail: ComposeDetail) {
|
||||
window.dispatchEvent(new CustomEvent<ComposeDetail>(COMPOSE_EVENT, { detail }));
|
||||
}
|
||||
|
||||
/** "> quoted\n> lines\n\n" for the composer. */
|
||||
export function asQuote(text: string) {
|
||||
const lines = text.trim().split("\n").map((l) => `> ${l}`.trimEnd());
|
||||
return `${lines.join("\n")}\n\n`;
|
||||
}
|
||||
@@ -366,6 +366,98 @@ export const de: Messages = {
|
||||
"media.showInChat": "Im Chat zeigen",
|
||||
"media.open": "Öffnen",
|
||||
"media.prev": "Vorheriges",
|
||||
"msg.actions": "Nachrichtenaktionen",
|
||||
"msg.copy": "Text kopieren",
|
||||
"msg.copied": "Kopiert",
|
||||
"msg.copyFailed": "Kopieren ist hier nicht möglich",
|
||||
"msg.quote": "In Antwort zitieren",
|
||||
"msg.forkHere": "Ab hier abzweigen",
|
||||
"chat.jumpPrevOwn": "Vorherige eigene Nachricht",
|
||||
"viewer.annotate": "Markieren",
|
||||
"viewer.annotateFailed": "Dieses Bild lässt sich nicht bearbeiten",
|
||||
"viewer.attach": "An Nachricht anhängen",
|
||||
"viewer.undo": "Rückgängig",
|
||||
"viewer.tool.pen": "Stift",
|
||||
"viewer.tool.arrow": "Pfeil",
|
||||
"viewer.tool.rect": "Rechteck",
|
||||
"viewer.useInChat": "In Nachricht verwenden",
|
||||
"viewer.attached": "Bild an die Nachricht angehängt",
|
||||
"turn.changes": "{n} Datei(en) geändert",
|
||||
"turn.noChanges": "Für diesen Schritt wurden keine Dateiänderungen gefunden.",
|
||||
"turn.noDiff": "Kein Text-Diff verfügbar.",
|
||||
"turn.truncated": "Der Diff ist gekürzt.",
|
||||
"turn.new": "neu",
|
||||
"turn.deleted": "gelöscht",
|
||||
"turn.rollback": "Auf den Stand vor diesem Schritt zurücksetzen",
|
||||
"job.plan": "Als Automatisierung planen",
|
||||
"job.planHint": "Macht aus diesem Chat Anweisungen für eine wiederkehrende Automatisierung (aus, bis du den Zeitplan setzt)",
|
||||
"job.drafting": "Automatisierung wird aus diesem Chat entworfen …",
|
||||
"color.label": "Farbe dieses Chats",
|
||||
"color.red": "Rot",
|
||||
"color.orange": "Orange",
|
||||
"color.yellow": "Gelb",
|
||||
"color.green": "Grün",
|
||||
"color.aqua": "Türkis",
|
||||
"color.blue": "Blau",
|
||||
"color.purple": "Violett",
|
||||
"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.",
|
||||
"claude.console": "Anthropic Console (API-Abrechnung) statt Claude-Abo",
|
||||
"claude.signIn": "Anmelden",
|
||||
"claude.openLogin": "Anmeldeseite öffnen",
|
||||
"claude.step1": "Bei Anthropic in einem beliebigen Browser (auch auf einem anderen Gerät) anmelden und den Zugriff erlauben.",
|
||||
"claude.step2": "Den Code einfügen, den die Seite danach anzeigt:",
|
||||
"claude.codePlaceholder": "Code von der Anmeldeseite",
|
||||
"claude.confirm": "Bestätigen",
|
||||
"claude.signedIn": "Bei Claude Code angemeldet",
|
||||
"claude.signedInAs": "Angemeldet: {who}",
|
||||
"claude.expires": "gültig bis {date}",
|
||||
"claude.logout": "Abmelden",
|
||||
"claude.logoutConfirm": "Auf dem Server von Claude Code abmelden?",
|
||||
"claude.version": "CLI {version}",
|
||||
"claude.updateAvailable": "Update verfügbar: {version}",
|
||||
"claude.upToDate": "aktuell",
|
||||
"claude.update": "Update installieren",
|
||||
"claude.updating": "Installiere …",
|
||||
"claude.updated": "Claude Code auf {version} aktualisiert",
|
||||
"claude.updateFailed": "Das Update ist fehlgeschlagen (Details unten)",
|
||||
"claude.updateAdmin": "Ein Administrator kann es installieren.",
|
||||
"claude.checkNow": "Nach Updates suchen",
|
||||
"claude.updateLog": "Ausgabe des Updates",
|
||||
"check.menu": "Prüfung nach jedem Schritt …",
|
||||
"check.title": "Prüfung nach jedem Schritt",
|
||||
"check.hint": "Befehl, der in {project} nach jedem Schritt mit Dateiänderungen läuft (z. B. cargo test, npm test). Das Ergebnis erscheint als Badge am Schritt. Leer = aus.",
|
||||
"check.saved": "Prüfung gespeichert",
|
||||
"check.removed": "Prüfung entfernt",
|
||||
"check.running": "Prüfung läuft …",
|
||||
"check.passed": "Prüfung bestanden",
|
||||
"check.failed": "Prüfung fehlgeschlagen",
|
||||
"check.timeout": "Prüfung: Zeitüberschreitung",
|
||||
"check.noOutput": "(keine Ausgabe)",
|
||||
"check.fix": "Vom Agenten beheben lassen",
|
||||
"check.fixPrompt": "Die Prüfung des Projekts ist nach deiner letzten Änderung fehlgeschlagen. Bitte beheben und erneut ausführen. Ausgabe:",
|
||||
"projectDefaults.save": "Als Projekt-Standard verwenden",
|
||||
"projectDefaults.remove": "Projekt-Standard entfernen",
|
||||
"projectDefaults.hint": "Neue Chats in diesem Projektordner starten mit Modell, Modus, Farbe, Anweisungen und Ordner dieses Chats",
|
||||
"projectDefaults.saved": "Neue Chats in diesem Projekt starten wie dieser",
|
||||
"projectDefaults.removed": "Projekt-Standard entfernt",
|
||||
"outbox.waiting": "{n} Nachricht(en) warten auf eine Verbindung",
|
||||
"outbox.sendNow": "Jetzt senden",
|
||||
"outbox.discard": "Verwerfen",
|
||||
"outbox.saved": "Keine Verbindung – die Nachricht ist gespeichert und wird gesendet, sobald du wieder online bist",
|
||||
"project.open": "Projektübersicht",
|
||||
"project.newChat": "Neuer Chat",
|
||||
"project.today": "Heute",
|
||||
"project.yesterday": "Gestern",
|
||||
"project.chats": "{n} Chat(s)",
|
||||
"project.files": "{n} Datei(en) geändert",
|
||||
"project.archived": "{n} archiviert",
|
||||
"project.checksFailed": "{n} Prüfung(en) fehlgeschlagen",
|
||||
"project.empty": "In diesem Ordner gibt es noch keine Chats.",
|
||||
"viewer.title": "Bildbetrachter",
|
||||
"viewer.open": "In neuem Tab öffnen",
|
||||
"viewer.download": "Herunterladen",
|
||||
"media.next": "Nächstes",
|
||||
"media.back": "Zurück zur Übersicht",
|
||||
"chat.turnUsageHint": "Alle {n} Anfragen dieser Antwort (Tokens gesendet/empfangen, Kosten)",
|
||||
@@ -724,6 +816,16 @@ export const de: Messages = {
|
||||
|
||||
"permission.title": "Freigabe erforderlich",
|
||||
"permission.allowOnce": "Einmal erlauben",
|
||||
"permission.projectCommand": "„{prefix}“ in diesem Projekt immer erlauben",
|
||||
"permission.projectTool": "{tool} in diesem Projekt immer erlauben",
|
||||
"permission.projectHint": "Wird für diesen Projektordner gemerkt: in allen Chats, auch nach einem Neustart. Widerrufen im ⋯-Menü.",
|
||||
"permission.explain": "Was macht das?",
|
||||
"permission.explaining": "Erkläre …",
|
||||
"permProject.title": "Projekt-Freigaben",
|
||||
"permProject.intro": "In diesem Projektordner ohne Rückfrage erlaubt (alle Chats):",
|
||||
"permProject.empty": "Für dieses Projekt ist noch nichts freigegeben. Der Knopf „… in diesem Projekt immer erlauben“ bei einer Freigabe-Anfrage legt hier Einträge an.",
|
||||
"permProject.revoke": "Widerrufen",
|
||||
"permProject.menu": "Projekt-Freigaben",
|
||||
"permission.alwaysTool": "{tool} in dieser Session immer erlauben",
|
||||
"permission.alwaysCommand": "„{prefix}“ in dieser Session immer erlauben",
|
||||
"permission.reject": "Ablehnen",
|
||||
|
||||
@@ -366,6 +366,98 @@ export const en = {
|
||||
"media.open": "Open",
|
||||
"media.prev": "Previous",
|
||||
"media.next": "Next",
|
||||
"msg.actions": "Message actions",
|
||||
"msg.copy": "Copy text",
|
||||
"msg.copied": "Copied",
|
||||
"msg.copyFailed": "Copying is not possible here",
|
||||
"msg.quote": "Quote in reply",
|
||||
"msg.forkHere": "Fork from here",
|
||||
"chat.jumpPrevOwn": "Previous own message",
|
||||
"viewer.annotate": "Mark up",
|
||||
"viewer.annotateFailed": "This image cannot be edited",
|
||||
"viewer.attach": "Attach to message",
|
||||
"viewer.undo": "Undo",
|
||||
"viewer.tool.pen": "Pen",
|
||||
"viewer.tool.arrow": "Arrow",
|
||||
"viewer.tool.rect": "Rectangle",
|
||||
"viewer.useInChat": "Use in message",
|
||||
"viewer.attached": "Image attached to the message",
|
||||
"turn.changes": "{n} file(s) changed",
|
||||
"turn.noChanges": "No file changes found for this turn.",
|
||||
"turn.noDiff": "No text diff available.",
|
||||
"turn.truncated": "The diff is shortened.",
|
||||
"turn.new": "new",
|
||||
"turn.deleted": "deleted",
|
||||
"turn.rollback": "Roll back to before this turn",
|
||||
"job.plan": "Plan as automation",
|
||||
"job.planHint": "Turns this chat into instructions for a recurring automation (switched off until you set the schedule)",
|
||||
"job.drafting": "Drafting the automation from this chat …",
|
||||
"color.label": "Colour of this chat",
|
||||
"color.red": "Red",
|
||||
"color.orange": "Orange",
|
||||
"color.yellow": "Yellow",
|
||||
"color.green": "Green",
|
||||
"color.aqua": "Teal",
|
||||
"color.blue": "Blue",
|
||||
"color.purple": "Purple",
|
||||
"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.",
|
||||
"claude.console": "Anthropic Console (API billing) instead of the Claude subscription",
|
||||
"claude.signIn": "Sign in",
|
||||
"claude.openLogin": "Open the sign-in page",
|
||||
"claude.step1": "Sign in at Anthropic in any browser (also on another device) and allow access.",
|
||||
"claude.step2": "Paste the code the page shows afterwards:",
|
||||
"claude.codePlaceholder": "Code from the sign-in page",
|
||||
"claude.confirm": "Confirm",
|
||||
"claude.signedIn": "Signed in to Claude Code",
|
||||
"claude.signedInAs": "Signed in: {who}",
|
||||
"claude.expires": "valid until {date}",
|
||||
"claude.logout": "Sign out",
|
||||
"claude.logoutConfirm": "Sign out of Claude Code on the server?",
|
||||
"claude.version": "CLI {version}",
|
||||
"claude.updateAvailable": "Update available: {version}",
|
||||
"claude.upToDate": "up to date",
|
||||
"claude.update": "Install update",
|
||||
"claude.updating": "Installing …",
|
||||
"claude.updated": "Claude Code updated to {version}",
|
||||
"claude.updateFailed": "The update failed (details below)",
|
||||
"claude.updateAdmin": "An administrator can install it.",
|
||||
"claude.checkNow": "Check for updates",
|
||||
"claude.updateLog": "Output of the update",
|
||||
"check.menu": "Check after each turn …",
|
||||
"check.title": "Check after each turn",
|
||||
"check.hint": "Command that runs in {project} after every turn that changed files (e.g. cargo test, npm test). The result appears as a badge on the turn. Empty = off.",
|
||||
"check.saved": "Check saved",
|
||||
"check.removed": "Check removed",
|
||||
"check.running": "Check running …",
|
||||
"check.passed": "Check passed",
|
||||
"check.failed": "Check failed",
|
||||
"check.timeout": "Check timed out",
|
||||
"check.noOutput": "(no output)",
|
||||
"check.fix": "Let the agent fix it",
|
||||
"check.fixPrompt": "The check of the project failed after your last change. Please fix the cause and run it again. Output:",
|
||||
"projectDefaults.save": "Use as project default",
|
||||
"projectDefaults.remove": "Remove project default",
|
||||
"projectDefaults.hint": "New chats in this project folder start with this chat's model, mode, colour, instructions and folder",
|
||||
"projectDefaults.saved": "New chats in this project will start like this one",
|
||||
"projectDefaults.removed": "Project default removed",
|
||||
"outbox.waiting": "{n} message(s) waiting for a connection",
|
||||
"outbox.sendNow": "Send now",
|
||||
"outbox.discard": "Discard",
|
||||
"outbox.saved": "No connection – the message is saved and sent when you are back online",
|
||||
"project.open": "Project overview",
|
||||
"project.newChat": "New chat",
|
||||
"project.today": "Today",
|
||||
"project.yesterday": "Yesterday",
|
||||
"project.chats": "{n} chat(s)",
|
||||
"project.files": "{n} file(s) changed",
|
||||
"project.archived": "{n} archived",
|
||||
"project.checksFailed": "{n} failed check(s)",
|
||||
"project.empty": "No chats in this folder yet.",
|
||||
"viewer.title": "Image viewer",
|
||||
"viewer.open": "Open in new tab",
|
||||
"viewer.download": "Download",
|
||||
"media.back": "Back to the overview",
|
||||
"chat.turnUsageHint": "All {n} requests of this answer (tokens sent/received, cost)",
|
||||
"chat.reviewRound": "Review {round}: changes requested",
|
||||
@@ -723,6 +815,16 @@ export const en = {
|
||||
|
||||
"permission.title": "Permission required",
|
||||
"permission.allowOnce": "Allow once",
|
||||
"permission.projectCommand": "Always allow \"{prefix}\" in this project",
|
||||
"permission.projectTool": "Always allow {tool} in this project",
|
||||
"permission.projectHint": "Remembered for this project folder, in all chats, also after a restart. Revoke it in the ⋯ menu.",
|
||||
"permission.explain": "What does this do?",
|
||||
"permission.explaining": "Explaining …",
|
||||
"permProject.title": "Project permissions",
|
||||
"permProject.intro": "Allowed without asking in this project folder (all chats):",
|
||||
"permProject.empty": "Nothing is allowed for this project yet. The button \"Always allow … in this project\" on an approval adds entries here.",
|
||||
"permProject.revoke": "Revoke",
|
||||
"permProject.menu": "Project permissions",
|
||||
"permission.alwaysTool": "Always allow {tool} in this session",
|
||||
"permission.alwaysCommand": "Always allow \"{prefix}\" in this session",
|
||||
"permission.reject": "Reject",
|
||||
|
||||
@@ -72,6 +72,13 @@ export function fmtCompact(n: number) {
|
||||
return new Intl.NumberFormat(current, { notation: "compact", maximumFractionDigits: 1 }).format(n);
|
||||
}
|
||||
|
||||
/** Short date and time of a message: only the time for today. */
|
||||
export function fmtMsgTime(ms: number) {
|
||||
const d = new Date(ms);
|
||||
const today = d.toDateString() === new Date().toDateString();
|
||||
return new Intl.DateTimeFormat(current, today ? { timeStyle: "short" } : { dateStyle: "short", timeStyle: "short" }).format(d);
|
||||
}
|
||||
|
||||
export function fmtCost(n: number, currency: string) {
|
||||
const digits = n !== 0 && Math.abs(n) < 1 ? 4 : 2;
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { ApiError, post } from "./api";
|
||||
|
||||
/** Messages written without a connection: kept on this device and sent when it is back. */
|
||||
export interface OutboxItem {
|
||||
id: string;
|
||||
sessionId: string;
|
||||
text: string;
|
||||
effort?: string;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
const KEY = "codecraft.outbox";
|
||||
const listeners = new Set<() => void>();
|
||||
let cache: OutboxItem[] | undefined;
|
||||
|
||||
function load(): OutboxItem[] {
|
||||
if (cache) return cache;
|
||||
try {
|
||||
const v = JSON.parse(localStorage.getItem(KEY) ?? "[]");
|
||||
cache = Array.isArray(v) ? v : [];
|
||||
} catch {
|
||||
cache = [];
|
||||
}
|
||||
return cache!;
|
||||
}
|
||||
|
||||
function save(items: OutboxItem[]) {
|
||||
cache = items;
|
||||
try {
|
||||
localStorage.setItem(KEY, JSON.stringify(items));
|
||||
} catch {
|
||||
// Storage blocked: the list still works until the page is closed.
|
||||
}
|
||||
listeners.forEach((l) => l());
|
||||
}
|
||||
|
||||
export function addToOutbox(sessionId: string, text: string, effort?: string) {
|
||||
save([...load(), { id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`, sessionId, text, effort, createdAt: Date.now() }]);
|
||||
}
|
||||
|
||||
export function removeFromOutbox(id: string) {
|
||||
save(load().filter((i) => i.id !== id));
|
||||
}
|
||||
|
||||
export function useOutbox(sessionId: string): OutboxItem[] {
|
||||
const all = useSyncExternalStore(
|
||||
(l) => {
|
||||
listeners.add(l);
|
||||
return () => listeners.delete(l);
|
||||
},
|
||||
() => load(),
|
||||
);
|
||||
return all.filter((i) => i.sessionId === sessionId);
|
||||
}
|
||||
|
||||
/** True for failures of the connection itself (not answers of the server). */
|
||||
export function isOffline(e: unknown) {
|
||||
return !(e instanceof ApiError) && (!navigator.onLine || e instanceof TypeError);
|
||||
}
|
||||
|
||||
let flushing = false;
|
||||
|
||||
/** Send the waiting messages of a chat in order; stops at the first connection failure. Returns how many were sent. */
|
||||
export async function flushOutbox(sessionId: string): Promise<number> {
|
||||
if (flushing) return 0;
|
||||
flushing = true;
|
||||
let sent = 0;
|
||||
try {
|
||||
for (const item of load().filter((i) => i.sessionId === sessionId)) {
|
||||
try {
|
||||
await post(`/sessions/${item.sessionId}/prompt`, { text: item.text, effort: item.effort });
|
||||
removeFromOutbox(item.id);
|
||||
sent++;
|
||||
} catch (e) {
|
||||
if (isOffline(e)) break;
|
||||
// The server refused it (deleted chat, read-only …): it would never go through.
|
||||
removeFromOutbox(item.id);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
flushing = false;
|
||||
}
|
||||
return sent;
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import type {
|
||||
PermissionRequest,
|
||||
QuestionRequest,
|
||||
ServerEvent,
|
||||
TurnCheckInfo,
|
||||
Session,
|
||||
} from "./types";
|
||||
|
||||
@@ -51,6 +52,8 @@ export interface State {
|
||||
waiting: Record<string, string | undefined>;
|
||||
/** Compaction running in a session (auto = context nearly full). */
|
||||
compacting: Record<string, { auto: boolean; tokens: number | null; limit: number | null; since: number } | undefined>;
|
||||
/** Check results per session, by the id of the turn's last answer. */
|
||||
checks: Record<string, Record<string, TurnCheckInfo> | undefined>;
|
||||
}
|
||||
|
||||
let state: State = {
|
||||
@@ -73,6 +76,7 @@ let state: State = {
|
||||
steps: {},
|
||||
waiting: {},
|
||||
compacting: {},
|
||||
checks: {},
|
||||
};
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
@@ -203,6 +207,11 @@ export function applyEvent(ev: ServerEvent) {
|
||||
},
|
||||
}));
|
||||
break;
|
||||
case "check_result": {
|
||||
const info: TurnCheckInfo = { message_id: ev.message_id, status: ev.status, exit_code: ev.exit_code, duration_ms: ev.duration_ms, output: ev.output };
|
||||
setState((s) => ({ checks: { ...s.checks, [ev.session_id]: { ...s.checks[ev.session_id], [ev.message_id]: info } } }));
|
||||
break;
|
||||
}
|
||||
case "mcp_status":
|
||||
setState(() => ({ mcp: ev.servers }));
|
||||
break;
|
||||
@@ -259,6 +268,12 @@ export async function loadSession(id: string) {
|
||||
}));
|
||||
}
|
||||
|
||||
/** Check results of a chat (badges on its turns). */
|
||||
export async function loadChecks(id: string) {
|
||||
const list = await get<TurnCheckInfo[]>(`/sessions/${id}/checks`).catch(() => [] as TurnCheckInfo[]);
|
||||
setState((s) => ({ checks: { ...s.checks, [id]: { ...Object.fromEntries(list.map((c) => [c.message_id, c])), ...s.checks[id] } } }));
|
||||
}
|
||||
|
||||
/** Older messages before the loaded ones (scrolling up). Returns how many came. */
|
||||
export async function loadOlder(id: string, stopAtSummary = true): Promise<number> {
|
||||
const first = getState().messages[id]?.[0];
|
||||
|
||||
+438
-7
@@ -439,6 +439,9 @@ button.session-group:hover {
|
||||
.main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
/* Nothing in the bars may widen the page (and push it sideways on phones). */
|
||||
overflow-x: clip;
|
||||
max-width: 100vw;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
@@ -518,6 +521,31 @@ button.session-group:hover {
|
||||
|
||||
.msg {
|
||||
margin-bottom: 14px;
|
||||
position: relative;
|
||||
}
|
||||
/* Message actions: hover on desktop, faintly visible on touch. */
|
||||
.msg-menu {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
right: 2px;
|
||||
z-index: 3;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s;
|
||||
}
|
||||
.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);
|
||||
line-height: 0;
|
||||
}
|
||||
.msg-user {
|
||||
background: var(--bg1);
|
||||
@@ -542,14 +570,16 @@ button.session-group:hover {
|
||||
background: var(--bg1);
|
||||
cursor: text;
|
||||
}
|
||||
.msg-user .msg-actions {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
display: none;
|
||||
}
|
||||
.msg-user:hover .msg-actions {
|
||||
.msg-time {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.msg-user .msg-time {
|
||||
text-align: right;
|
||||
}
|
||||
.msg-user img {
|
||||
max-height: 160px;
|
||||
@@ -1357,6 +1387,23 @@ table.grid input[type="number"] {
|
||||
.topbar .path {
|
||||
display: none;
|
||||
}
|
||||
.topbar .grow {
|
||||
min-width: 0;
|
||||
}
|
||||
.topbar .chip {
|
||||
flex-shrink: 1;
|
||||
min-width: 0;
|
||||
max-width: 30vw;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.menu-pop-body {
|
||||
max-width: calc(100vw - 16px);
|
||||
}
|
||||
.menu-pop-body .mobile-only {
|
||||
display: block;
|
||||
}
|
||||
.topbar button.small {
|
||||
padding: 2px 5px;
|
||||
}
|
||||
@@ -2526,6 +2573,7 @@ button.danger-solid {
|
||||
height: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
pointer-events: none;
|
||||
z-index: 4;
|
||||
}
|
||||
@@ -4439,3 +4487,386 @@ span.chat-tab > button {
|
||||
height: 64px;
|
||||
color: var(--accent);
|
||||
}
|
||||
|
||||
/* --- image viewer ------------------------------------------------------------ */
|
||||
.viewer-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.92);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
touch-action: none;
|
||||
overscroll-behavior: contain;
|
||||
}
|
||||
.viewer-img {
|
||||
max-width: 100vw;
|
||||
max-height: 100dvh;
|
||||
object-fit: contain;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
touch-action: none;
|
||||
transition: transform 0.08s ease-out;
|
||||
background: repeating-conic-gradient(#222 0% 25%, #111 0% 50%) 50% / 16px 16px;
|
||||
}
|
||||
.viewer-bar {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 10px;
|
||||
padding-top: max(8px, env(safe-area-inset-top));
|
||||
background: linear-gradient(rgba(0, 0, 0, 0.6), transparent);
|
||||
color: #eee;
|
||||
font-size: 13px;
|
||||
}
|
||||
.viewer-bar .button,
|
||||
.viewer-bar button {
|
||||
color: #eee;
|
||||
}
|
||||
.viewer-count {
|
||||
font-variant-numeric: tabular-nums;
|
||||
min-width: 3.5em;
|
||||
}
|
||||
.viewer-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.viewer-nav {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
z-index: 1;
|
||||
width: 40px;
|
||||
height: 56px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
color: #eee;
|
||||
}
|
||||
.viewer-nav.prev {
|
||||
left: 6px;
|
||||
}
|
||||
.viewer-nav.next {
|
||||
right: 6px;
|
||||
}
|
||||
@media (pointer: coarse) {
|
||||
.viewer-nav {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
/* Thumbnails invite a tap. */
|
||||
.chat img.inline-image,
|
||||
.image-refs img,
|
||||
.user-files img,
|
||||
.image-result img,
|
||||
.svg-block img {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
/* --- image markup ------------------------------------------------------------ */
|
||||
.annotator {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
padding: 52px 8px 64px;
|
||||
}
|
||||
.annotator canvas {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
object-fit: contain;
|
||||
touch-action: none;
|
||||
cursor: crosshair;
|
||||
background: #111;
|
||||
box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.annotator-tools {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
padding: 8px 10px;
|
||||
padding-bottom: max(8px, env(safe-area-inset-bottom));
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
}
|
||||
.annotator-colors {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
margin: 0 6px;
|
||||
}
|
||||
.annotator-colors .swatch {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
.annotator-colors .swatch.on {
|
||||
border-color: #fff;
|
||||
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.35);
|
||||
}
|
||||
|
||||
/* --- approvals: explanation, session summary ------------------------------------ */
|
||||
.explain {
|
||||
margin: 8px 0 0;
|
||||
padding: 6px 10px;
|
||||
border-left: 3px solid var(--purple);
|
||||
border-radius: 4px;
|
||||
background: var(--bg1);
|
||||
font-size: 12.5px;
|
||||
color: var(--fg2);
|
||||
}
|
||||
.explain .icon {
|
||||
color: var(--purple);
|
||||
}
|
||||
.explain-btn {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.session-item .summary {
|
||||
font-size: 12px;
|
||||
color: var(--fg3);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* --- changes of one turn ------------------------------------------------------- */
|
||||
.turn-changes {
|
||||
margin: -6px 0 12px;
|
||||
}
|
||||
.turn-changes-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg1);
|
||||
}
|
||||
.turn-changes-body > button {
|
||||
align-self: flex-start;
|
||||
}
|
||||
.turn-file > summary {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.turn-file .turn-stat {
|
||||
margin-left: auto;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.turn-file .turn-stat .add {
|
||||
color: var(--green);
|
||||
}
|
||||
.turn-file .turn-stat .del {
|
||||
color: var(--red);
|
||||
}
|
||||
.turn-file .diff {
|
||||
max-height: 50vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
/* --- 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); }
|
||||
|
||||
.chat-pane[data-color] {
|
||||
background: color-mix(in srgb, var(--tag) 9%, var(--bg0));
|
||||
}
|
||||
.topbar[data-color] {
|
||||
border-bottom: 2px solid var(--tag);
|
||||
background: color-mix(in srgb, var(--tag) 14%, var(--bg0));
|
||||
}
|
||||
.session-item[data-color] {
|
||||
box-shadow: inset 3px 0 0 var(--tag);
|
||||
}
|
||||
.color-badge {
|
||||
padding: 0 7px;
|
||||
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);
|
||||
}
|
||||
.color-swatches {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.color-swatches .swatch {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: var(--tag);
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
.color-swatches .swatch.on {
|
||||
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));
|
||||
}
|
||||
|
||||
/* --- check badge of a turn ------------------------------------------------------ */
|
||||
.turn-check {
|
||||
margin: -6px 0 12px;
|
||||
}
|
||||
.turn-check .chip {
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
}
|
||||
.turn-check .check-passed {
|
||||
color: var(--green);
|
||||
}
|
||||
.turn-check .check-failed,
|
||||
.turn-check .check-timeout {
|
||||
color: var(--red);
|
||||
}
|
||||
.turn-check-body {
|
||||
margin-top: 4px;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg1);
|
||||
}
|
||||
.turn-check-body pre {
|
||||
margin: 0 0 6px;
|
||||
max-height: 40vh;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* --- messages waiting for a connection ------------------------------------------ */
|
||||
.outbox {
|
||||
margin: 0 12px 6px;
|
||||
padding: 6px 10px;
|
||||
border: 1px dashed var(--accent2, var(--accent));
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg1);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.outbox-head,
|
||||
.outbox-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.outbox-item span {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
color: var(--fg3);
|
||||
}
|
||||
|
||||
/* --- project page: timeline -------------------------------------------------------- */
|
||||
.project-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.timeline-day h3 {
|
||||
margin: 14px 0 6px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--muted);
|
||||
}
|
||||
.timeline {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 0 0 14px;
|
||||
border-left: 2px solid var(--bg3);
|
||||
}
|
||||
.timeline li {
|
||||
position: relative;
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
.timeline li::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
top: 12px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--tag, var(--bg3));
|
||||
border: 2px solid var(--bg0);
|
||||
}
|
||||
.timeline li > a {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--bg2);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg1);
|
||||
color: var(--fg1);
|
||||
text-decoration: none;
|
||||
}
|
||||
.timeline li > a:hover {
|
||||
border-color: var(--bg3);
|
||||
}
|
||||
.tl-title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
color: var(--fg0);
|
||||
font-weight: 600;
|
||||
}
|
||||
.tl-when {
|
||||
margin-left: auto;
|
||||
font-weight: 400;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tl-summary {
|
||||
margin-top: 2px;
|
||||
color: var(--fg2);
|
||||
font-size: 13px;
|
||||
}
|
||||
.tl-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 12px;
|
||||
margin-top: 4px;
|
||||
font-size: 11.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.tl-meta .check-passed {
|
||||
color: var(--green);
|
||||
}
|
||||
.tl-meta .check-failed,
|
||||
.tl-meta .check-timeout {
|
||||
color: var(--red);
|
||||
}
|
||||
@@ -59,6 +59,10 @@ export interface Session {
|
||||
parent_id?: string;
|
||||
created_at: number;
|
||||
updated_at: number;
|
||||
/** One line "where things stand" (small model), for the session list. */
|
||||
summary?: string;
|
||||
/** Colour tag: a palette name (red … purple), drawn in the colours of the theme. */
|
||||
color?: string;
|
||||
todos: Todo[] | null;
|
||||
last_usage?: Usage | null;
|
||||
busy?: boolean;
|
||||
@@ -112,6 +116,16 @@ export interface QuestionRequest {
|
||||
questions: Question[];
|
||||
}
|
||||
|
||||
/** Result of the project's check command after a turn. */
|
||||
export type CheckStatus = "running" | "passed" | "failed" | "timeout";
|
||||
export interface TurnCheckInfo {
|
||||
message_id: string;
|
||||
status: CheckStatus;
|
||||
exit_code: number | null;
|
||||
duration_ms: number;
|
||||
output: string;
|
||||
}
|
||||
|
||||
export type ServerEvent =
|
||||
| { type: "session_updated"; session: Session }
|
||||
| { type: "session_deleted"; session_id: string }
|
||||
@@ -129,6 +143,7 @@ export type ServerEvent =
|
||||
| { type: "waiting"; session_id: string; reason: string | null }
|
||||
| { type: "queue_updated"; session_id: string; items: QueueItem[] }
|
||||
| { type: "step_started"; session_id: string; message_id: string; started_at: number; prompt_tokens: number; estimated: boolean }
|
||||
| { type: "check_result"; session_id: string; message_id: string; status: CheckStatus; exit_code: number | null; duration_ms: number; output: string }
|
||||
| { type: "compaction"; session_id: string; state: "started" | "done" | "failed"; auto: boolean; tokens: number | null; limit: number | null }
|
||||
| { type: "mcp_status"; servers: Record<string, McpServerStatus> }
|
||||
| { type: "resync" };
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
* under "unreleased"; scripts/release.sh renames the key to the release version.
|
||||
*/
|
||||
export const WHATS_NEW: Record<string, { en: string[]; de: string[] }> = {
|
||||
unreleased: {
|
||||
en: [
|
||||
"Claude Code as an engine: sign in under Settings → Account, then pick a “Claude Code” model",
|
||||
"Image viewer, message menu, colour tags for chats, approvals from push notifications, check after each turn, project page",
|
||||
],
|
||||
de: [
|
||||
"Claude Code als Engine: unter Einstellungen → Konto anmelden, dann ein „Claude Code“-Modell wählen",
|
||||
"Bildbetrachter, Nachrichtenmenü, Farben für Chats, Freigaben per Push, Prüfung nach jedem Schritt, Projektseite",
|
||||
],
|
||||
},
|
||||
"202611-00024": {
|
||||
en: ["3D models with fal.ai: from an image (also pasted into the chat), a text prompt or both – open them in the 3D view"],
|
||||
de: ["3D-Modelle mit fal.ai: aus einem Bild (auch eingefügt), einem Text oder beidem – öffnen in der 3D-Ansicht"],
|
||||
|
||||
Reference in new issue
Block a user