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
+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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user