<script lang="ts">
	import { tick } from 'svelte';
	import { app, type AskUserQuestion } from './app.svelte';
	import { diffSections } from './diff';
	import Markdown from './Markdown.svelte';

	let draft = $state('');

	// The composer textarea; auto-grows with the draft up to a cap.
	let draftEl: HTMLTextAreaElement | null = $state(null);

	// True on touch-primary devices (phones, tablets): the on-screen
	// keyboard's Enter is a newline key and sending happens via the
	// inset button. False on desktops with a hardware keyboard: Enter
	// sends, Shift+Enter newline (matches the desktop composer).
	let coarse = $state(false);

	$effect(() => {
		const mq = window.matchMedia('(pointer: coarse)');
		coarse = mq.matches;
		const onChange = (e: MediaQueryListEvent): void => {
			coarse = e.matches;
		};
		mq.addEventListener('change', onChange);
		return () => mq.removeEventListener('change', onChange);
	});

	// Grow the bubble with the draft (Telegram-style) up to ~9 lines;
	// past that the textarea scrolls.
	$effect(() => {
		void draft;
		const el = draftEl;
		if (el) {
			el.style.height = '0px';
			el.style.height = `${el.scrollHeight}px`;
		}
	});

	// Which assistant row briefly shows the "copied" check.
	let copiedMd = $state<string | null>(null);
	async function copyMarkdown(rowId: string, text: string): Promise<void> {
		await navigator.clipboard.writeText(text);
		copiedMd = rowId;
		setTimeout(() => {
			if (copiedMd === rowId) {
				copiedMd = null;
			}
		}, 1500);
	}

	// Full-size preview target for a tool-returned image
	// (screenshot, image-file read).
	let lightboxUrl = $state<string | null>(null);

	// User-row id whose action chips (edit & resend / replay) are
	// showing. Tap a user bubble to toggle; any action clears it.
	let actionsFor = $state<string | null>(null);

	function toggleActions(rowId: string): void {
		actionsFor = actionsFor === rowId ? null : rowId;
	}

	async function editAndResend(rowId: string): Promise<void> {
		actionsFor = null;
		const text = await app.revertToMessage(rowId);
		if (text !== null) {
			draft = text;
		}
	}

	async function replayFrom(rowId: string): Promise<void> {
		actionsFor = null;
		await app.replayFromMessage(rowId);
	}

	// Same live-progress label as the desktop's compaction row:
	// chunk counters when the pass split the prefix, plus the ≈token
	// count of summary text streamed so far.
	function compactionProgress(row: { chunksDone: number; chunksTotal: number; summaryTokens: number }): string {
		const parts: string[] = [];
		if (row.chunksTotal > 1) {
			parts.push(
				row.chunksDone >= row.chunksTotal
					? 'merging part summaries'
					: `part ${row.chunksDone + 1} of ${row.chunksTotal}`,
			);
		}
		if (row.summaryTokens > 0) {
			const tokens = row.summaryTokens >= 1000 ? `${(row.summaryTokens / 1000).toFixed(1)}k` : `${row.summaryTokens}`;
			parts.push(`≈${tokens} tokens written`);
		}
		return parts.length > 0 ? ` (${parts.join(', ')})` : '';
	}

	// Queued-steer actions. Un-queue pops the message back into the
	// composer for editing; go-now cancels the running turn so the
	// steer drains immediately. The backend's events update the
	// transcript for both.
	async function unqueue(rowId: string): Promise<void> {
		actionsFor = null;
		const text = await app.unqueueSteer(rowId);
		if (text !== null) {
			draft = draft.trim() ? `${text} ${draft}` : text;
		}
	}

	async function goNow(rowId: string): Promise<void> {
		actionsFor = null;
		await app.drainSteerNow(rowId);
	}

	// Per-question answer state for the active ask_user prompt.
	// Map of questionId → { selected: Set<string>, freeText: string }
	let answers = $state<Record<string, { selected: Set<string>; freeText: string }>>({});

	// Image attachments for the next send. Two entry paths: paste
	// (desktop browsers) and the 📷 button (mobile: opens the photo
	// library / camera via the file input). Client-side downscale to
	// ≤1600px + webp/jpeg keeps a phone photo from shipping 8 MB of
	// base64 over the relay — the runner re-encodes again on ingest.
	let attachments = $state<{ data_url: string; mime: string }[]>([]);
	let fileInput = $state<HTMLInputElement | undefined>();

	async function addImageBlob(blob: Blob): Promise<void> {
		try {
			const bitmap = await createImageBitmap(blob);
			const maxDim = 1600;
			const scale = Math.min(1, maxDim / Math.max(bitmap.width, bitmap.height));
			const canvas = document.createElement('canvas');
			canvas.width = Math.max(1, Math.round(bitmap.width * scale));
			canvas.height = Math.max(1, Math.round(bitmap.height * scale));
			canvas.getContext('2d')?.drawImage(bitmap, 0, 0, canvas.width, canvas.height);
			bitmap.close();
			// Prefer webp; Safari may silently fall back to png from
			// toDataURL('image/webp'), so detect via the emitted mime.
			let dataUrl = canvas.toDataURL('image/webp', 0.85);
			if (!dataUrl.startsWith('data:image/webp')) {
				dataUrl = canvas.toDataURL('image/jpeg', 0.85);
			}
			const mime = dataUrl.slice(5, dataUrl.indexOf(';'));
			attachments = [...attachments, { data_url: dataUrl, mime }];
		} catch {
			// Undecodable file (not an image, corrupt): skip silently.
		}
	}

	function onPaste(e: ClipboardEvent): void {
		const items = e.clipboardData?.items;
		if (!items) {
			return;
		}
		for (const item of items) {
			if (item.kind === 'file' && item.type.startsWith('image/')) {
				const blob = item.getAsFile();
				if (blob) {
					e.preventDefault();
					void addImageBlob(blob);
				}
			}
		}
	}

	async function onFilesPicked(): Promise<void> {
		const files = fileInput?.files;
		if (!files) {
			return;
		}
		for (const file of files) {
			await addImageBlob(file);
		}
		if (fileInput) {
			fileInput.value = '';
		}
	}

	function removeAttachment(index: number): void {
		attachments = attachments.toSpliced(index, 1);
	}

	async function send(): Promise<void> {
		const text = draft.trim();
		if (!text && attachments.length === 0) {
			return;
		}
		const images = attachments;
		draft = '';
		attachments = [];
		await app.sendPrompt(text || 'See attached image(s).', images);
	}

	function onKeydown(e: KeyboardEvent): void {
		// Hardware keyboards: Enter sends, Shift+Enter newline. Touch
		// keyboards: Enter is a plain newline key — the send button is
		// the send path, like Telegram/WhatsApp on phones.
		if (!coarse && e.key === 'Enter' && !e.shiftKey) {
			e.preventDefault();
			void send();
		}
		// ArrowUp on an empty composer (no modifiers) pulls the most
		// recent queued steer back into the draft for editing — same
		// gesture as the desktop composer. Only fires when something
		// is actually queued, so a plain empty composer keeps the
		// textarea's default no-op.
		if (e.key === 'ArrowUp' && !e.shiftKey && !e.ctrlKey && !e.altKey && !e.metaKey && draft.length === 0) {
			const queued = app.rows.findLast((r) => r.kind === 'user' && r.queued);
			if (queued !== undefined) {
				e.preventDefault();
				void unqueue(queued.id);
			}
		}
	}

	function toggleOption(qId: string, optId: string, multi: boolean): void {
		if (!answers[qId]) {
			answers[qId] = { selected: new Set(), freeText: '' };
		}
		if (multi) {
			if (answers[qId].selected.has(optId)) {
				answers[qId].selected.delete(optId);
			} else {
				answers[qId].selected.add(optId);
			}
		} else {
			// Single-select: clear and set.
			answers[qId].selected.clear();
			answers[qId].selected.add(optId);
		}
		// Trigger reactivity.
		answers = { ...answers };
	}

	async function submitPrompt(): Promise<void> {
		if (!app.pendingPrompt) {
			return;
		}
		const response = app.pendingPrompt.questions.map((q) => ({
			question_id: q.id,
			selected: [...(answers[q.id]?.selected ?? [])],
			free_text: answers[q.id]?.freeText ?? '',
		}));
		await app.respondToPrompt(app.pendingPrompt.callId, response);
		answers = {};
	}

	// Single-select auto-submits when an option is clicked.
	function clickOption(q: AskUserQuestion, optId: string): void {
		toggleOption(q.id, optId, q.multi);
		if (!q.multi) {
			void submitPrompt();
		}
	}

	function truncate(s: string, max: number): string {
		return s.length > max ? s.slice(0, max) + '...' : s;
	}

	function short(s: string, n = 40): string {
		return s.length > n ? s.slice(0, n) + '…' : s;
	}

	/** Build a human-readable summary line for a tool call from its
	 * name + raw JSON args string. Falls back to the tool name alone
	 * when args are missing or unparseable. */
	function toolSummary(name: string, argsStr: string): string {
		if (!argsStr) {
			return name;
		}
		let args: Record<string, unknown>;
		try {
			args = JSON.parse(argsStr) as Record<string, unknown>;
		} catch {
			return name;
		}
		const p = (k: string): string => (typeof args[k] === 'string' ? (args[k] as string) : '');
		switch (name) {
			case 'read_file': {
				const path = p('path') || 'file';
				const range =
					p('start_line') || p('end_line') ? `:${p('start_line') || 1}${p('end_line') ? '-' + p('end_line') : ''}` : '';
				return `read ${path}${range}`;
			}
			case 'write_file':
				return `write ${p('path') || 'file'}`;
			case 'edit_file': {
				const find = short(p('find'), 30);
				return `edit ${p('path') || 'file'} — "${find}"`;
			}
			case 'list_dir':
				return `list ${p('path') || 'dir'}`;
			case 'grep':
				return `grep "${short(p('pattern'), 30)}"`;
			case 'bash': {
				const cmd = p('cmd');
				if (!cmd) {
					return 'bash';
				}
				const firstLine = cmd.split('\n')[0] ?? '';
				return `$ ${short(firstLine, 50)}`;
			}
			case 'web_search':
				return `search "${short(p('query'), 30)}"`;
			case 'web_fetch':
				return p('url') ? short(p('url'), 50) : 'web fetch';
			case 'todo_write':
				return 'update todo';
			case 'ask_user':
				return 'ask user';
			case 'read_process':
				return `poll ${p('id') || 'process'}`;
			case 'stop_process':
				return `stop ${p('id') || 'process'}`;
			case 'spawn_worker':
				return p('name') ? `spawn worker ${p('name')}` : 'spawn worker';
			case 'observe_worker':
				return 'observe worker';
			case 'steer_worker':
				return 'steer worker';
			case 'abort_worker':
				return 'abort worker';
			case 'commit_worker_changes':
				return 'commit worker changes';
			case 'merge_worker_changes':
				return 'merge worker changes';
			case 'discard_worker_worktree':
				return 'discard worker worktree';
			case 'retire_worker':
				return 'retire worker';
			case 'review_worker_changes':
				return 'review worker changes';
			case 'respond_to_worker_prompt':
				return 'answer worker prompt';
			default:
				return name;
		}
	}

	/** Coordinator tools that target a worker by `worker_id` in
	 * their args. A worker id *is* the worker's session id, so a
	 * tool row can deep-link to that session. */
	const WORKER_ARG_TOOLS = new Set([
		'observe_worker',
		'steer_worker',
		'abort_worker',
		'commit_worker_changes',
		'merge_worker_changes',
		'review_worker_changes',
		'respond_to_worker_prompt',
		'retire_worker',
		'workspace_scm_status',
	]);

	/** Pull a `worker_id` field out of a raw JSON string. */
	function pullWorkerId(raw: string): string | null {
		try {
			const parsed = JSON.parse(raw) as Record<string, unknown>;
			return typeof parsed['worker_id'] === 'string' ? (parsed['worker_id'] as string) : null;
		} catch {
			return null;
		}
	}

	/** Extract the worker session id a tool row refers to, or null.
	 * `spawn_worker` carries it in the result (`worker_id`); the
	 * other worker tools carry it in the args. */
	function workerSessionId(name: string, argsStr: string, result: string): string | null {
		if (name === 'spawn_worker') {
			return result ? pullWorkerId(result) : null;
		}
		if (WORKER_ARG_TOOLS.has(name)) {
			return argsStr ? pullWorkerId(argsStr) : null;
		}
		return null;
	}

	/** Structured tool body content for tools where a simple
	 * text preview isn't enough — `edit_file` shows a diff-like
	 * find→replace view, `write_file` shows a content preview.
	 * Returns `null` when the tool doesn't have structured body
	 * (caller falls through to `toolResultPreview`). */
	type ToolBody = { kind: 'diff'; find: string; replace: string } | { kind: 'content'; text: string };

	function toolBody(name: string, argsStr: string, _result: string): ToolBody | null {
		let args: Record<string, unknown>;
		try {
			args = JSON.parse(argsStr) as Record<string, unknown>;
		} catch {
			return null;
		}
		const p = (k: string): string => (typeof args[k] === 'string' ? (args[k] as string) : '');
		if (name === 'edit_file') {
			const find = p('find');
			const replace = p('replace');
			if (!find && !replace) {
				return null;
			}
			return { kind: 'diff', find, replace };
		}
		if (name === 'write_file') {
			const content = p('content');
			if (!content) {
				return null;
			}
			return { kind: 'content', text: content };
		}
		return null;
	}

	/** Extract the most useful preview from a tool result string.
	 * Tool-specific parsing where the JSON shape is known, falling
	 * back to a plain-text truncation. */
	function toolResultPreview(name: string, result: string): string {
		if (!result) {
			return '';
		}
		// bash returns {cmd, target, exit_code, stdout, stderr}.
		if (name === 'bash') {
			try {
				const parsed = JSON.parse(result) as Record<string, unknown>;
				const stdout = typeof parsed['stdout'] === 'string' ? (parsed['stdout'] as string) : '';
				const stderr = typeof parsed['stderr'] === 'string' ? (parsed['stderr'] as string) : '';
				const code = typeof parsed['exit_code'] === 'number' ? parsed['exit_code'] : null;
				const out = stdout || stderr || '';
				const prefix = code !== null && code !== 0 ? `[exit ${code}] ` : '';
				return truncate(prefix + out, 300);
			} catch {
				return truncate(result, 300);
			}
		}
		// read_file returns line-prefixed content; show a short
		// snippet without the line-number prefixes.
		if (name === 'read_file') {
			const lines = result.split('\n').slice(0, 5);
			const stripped = lines.map((l) => l.replace(/^\d+\|/, '')).join('\n');
			return truncate(stripped, 300);
		}
		// grep returns `path:line: match` lines; show the first few.
		if (name === 'grep') {
			const lines = result.split('\n').filter((l) => l.trim());
			const count = lines.length;
			if (count === 0) {
				return 'no matches';
			}
			return truncate(`${count} match${count === 1 ? '' : 'es'}\n${lines.slice(0, 3).join('\n')}`, 300);
		}
		// Generic fallback: try JSON, then plain text.
		try {
			const parsed = JSON.parse(result);
			if (typeof parsed === 'string') {
				return truncate(parsed, 200);
			}
			if (parsed && typeof parsed === 'object') {
				if (typeof parsed['error'] === 'string') {
					return truncate(parsed['error'], 200);
				}
				if (typeof parsed['content'] === 'string') {
					return truncate(parsed['content'], 200);
				}
				if (typeof parsed['output'] === 'string') {
					return truncate(parsed['output'], 200);
				}
			}
		} catch {
			// Not JSON — treat as plain text.
		}
		return truncate(result, 200);
	}

	const title = $derived(app.sessions.find((s) => s.id === app.activeSession)?.title ?? '');
	const activeSummary = $derived(app.sessions.find((s) => s.id === app.activeSession));
	const worktreeRoot = $derived(activeSummary?.worktree_root ?? null);
	const worktreeBranch = $derived(activeSummary?.worktree_branch ?? null);

	/** Split a multi-file unified diff into per-file sections keyed
	 * by the `b/<path>` of each `diff --git` header. */
	const isCoordinator = $derived(app.sessions.find((s) => s.id === app.activeSession)?.mode === 'coordinator');

	// Title rename: tap the title to edit inline. The rename
	// propagates to the IDE via the broadcast `session_title_updated`
	// (app.renameSession), so the local input just resets.
	let editingTitle = $state(false);
	let titleDraft = $state('');
	let titleInputEl = $state<HTMLInputElement | null>(null);

	function startRename(): void {
		titleDraft = title;
		editingTitle = true;
		void tick().then(() => {
			titleInputEl?.focus();
			titleInputEl?.select();
		});
	}

	async function commitRename(): Promise<void> {
		const next = titleDraft.trim();
		editingTitle = false;
		if (next && next !== title) {
			await app.renameSession(next);
		}
	}

	function onRenameKeydown(e: KeyboardEvent): void {
		if (e.key === 'Enter') {
			e.preventDefault();
			void commitRename();
		} else if (e.key === 'Escape') {
			e.preventDefault();
			editingTitle = false;
		}
	}

	// Context-cap editor: tap the token widget to expand. The draft
	// is in k (`500` = a 500k cap), seeded from the loaded settings
	// each time the editor opens.
	let capOpen = $state(false);
	let capDraft = $state('');

	function toggleCapEditor(): void {
		capOpen = !capOpen;
		if (capOpen) {
			const ctx = app.contextCap;
			capDraft = ctx?.capK ? String(ctx.capK) : '';
		}
	}

	function saveCap(): void {
		const parsed = parseInt(capDraft, 10);
		capOpen = false;
		void app.setContextCap(Number.isFinite(parsed) && parsed > 0 ? parsed : null);
	}

	function onCapKeydown(e: KeyboardEvent): void {
		if (e.key === 'Enter') {
			e.preventDefault();
			saveCap();
		} else if (e.key === 'Escape') {
			e.preventDefault();
			capOpen = false;
		}
	}

	// Pin the transcript to the bottom while rows stream in, unless
	// the user scrolled away to read — same gesture as the desktop's
	// CoderThinking body (within-24px threshold absorbs subpixel
	// scroll positions on HiDPI).
	const PIN_THRESHOLD_PX = 24;
	let transcriptEl = $state<HTMLDivElement>();
	let pinned = $state(true);

	// Trailing render window over `app.rows` — the phone-sized
	// mirror of the desktop transcript windowing (test plan 0093).
	// Opening a long session mounts only the last `INITIAL_WINDOW`
	// rows; scrolling near the top (or the "Load older" pill) pulls
	// more in with a scroll anchor so nothing lurches. The mounted
	// count is capped at `WINDOW_MAX`: past it the window *slides*,
	// clipping rows off the (off-screen) bottom and detaching from
	// the live tail — "Load newer" / "Jump to latest" reel it back.
	const INITIAL_WINDOW = 50;
	const WINDOW_GROW_STEP = 50;
	const WINDOW_MAX = 300;
	const LOAD_MORE_THRESHOLD_PX = 600;

	let visibleCount = $state(INITIAL_WINDOW);
	let bottomClip = $state(0);

	const windowEnd = $derived(Math.max(0, app.rows.length - bottomClip));
	const windowStart = $derived(Math.max(0, windowEnd - visibleCount));
	const windowedRows = $derived(app.rows.slice(windowStart, windowEnd));
	const hiddenAbove = $derived(windowStart);
	const hiddenBelow = $derived(app.rows.length - windowEnd);

	// Scroll anchoring for a window change: pin a concrete edge row
	// element + its viewport-relative top before the slice changes,
	// then nudge `scrollTop` by how far it moved once the DOM
	// settles. Works for prepend, clip, and cap-slide alike.
	let pendingAnchorNode: HTMLElement | null = null;
	let pendingAnchorNodeTop = 0;
	// Set while applying the anchor's programmatic scroll so the
	// synthetic scroll event doesn't re-trigger a grow and cascade
	// the whole history in.
	let applyingAnchor = false;

	function isAnchorRow(child: Element): child is HTMLElement {
		return (
			child instanceof HTMLElement && !child.classList.contains('load-pill') && !child.classList.contains('jump-latest')
		);
	}

	function edgeRowEl(edge: 'first' | 'last'): HTMLElement | null {
		if (!transcriptEl) {
			return null;
		}
		const children = Array.from(transcriptEl.children).filter(isAnchorRow);
		if (children.length === 0) {
			return null;
		}
		return edge === 'first' ? children[0]! : children[children.length - 1]!;
	}

	function captureScrollAnchor(edge: 'first' | 'last' = 'first'): void {
		const el = edgeRowEl(edge);
		if (el && transcriptEl) {
			pendingAnchorNode = el;
			pendingAnchorNodeTop = el.getBoundingClientRect().top - transcriptEl.getBoundingClientRect().top;
		}
	}

	/** Pull older rows into the window; at the cap, slide instead
	 *  (drop the same count off the off-screen bottom). */
	function growWindowUp(): void {
		if (hiddenAbove <= 0) {
			return;
		}
		const step = Math.min(WINDOW_GROW_STEP, hiddenAbove);
		if (visibleCount + step <= WINDOW_MAX) {
			visibleCount += step;
			return;
		}
		visibleCount = WINDOW_MAX;
		bottomClip += step;
	}

	/** Mirror of `growWindowUp` for a detached bottom edge. */
	function growWindowDown(): void {
		if (hiddenBelow <= 0) {
			return;
		}
		const step = Math.min(WINDOW_GROW_STEP, hiddenBelow);
		bottomClip -= step;
		visibleCount = Math.min(visibleCount + step, WINDOW_MAX);
	}

	function loadOlderRows(): void {
		// Locally-loaded history exhausted but the session has older
		// pages on disk: fetch the next page, expanding the window
		// top by the rows it prepends so they come into view (the
		// scroll anchor keeps position). `visibleCount` honours the
		// cap; past it the window slides and "Load newer" / "Jump to
		// latest" reel the bottom back.
		if (hiddenAbove <= 0 && app.hasMoreHistory) {
			captureScrollAnchor('first');
			void app.loadOlderHistory().then(() => {
				visibleCount = Math.min(WINDOW_MAX, visibleCount + app.lastOlderPageRows);
			});
			return;
		}
		captureScrollAnchor('first');
		growWindowUp();
	}

	function loadNewerRows(): void {
		captureScrollAnchor('last');
		growWindowDown();
	}

	/** Snap the window back to the live tail and scroll to the
	 *  bottom — the escape hatch from a detached window. */
	function jumpToLatest(): void {
		bottomClip = 0;
		visibleCount = INITIAL_WINDOW;
		pendingAnchorNode = null;
		pinned = true;
		void tick().then(() => {
			if (transcriptEl) {
				transcriptEl.scrollTop = transcriptEl.scrollHeight;
			}
		});
	}

	function onTranscriptScroll(): void {
		const el = transcriptEl;
		if (!el) {
			return;
		}
		const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
		// Only sticky when the window is anchored to the live tail;
		// a detached window's bottom edge is not the latest row.
		pinned = bottomClip === 0 && distance <= PIN_THRESHOLD_PX;
		if (applyingAnchor) {
			return;
		}
		if (el.scrollTop <= LOAD_MORE_THRESHOLD_PX && hiddenAbove > 0) {
			loadOlderRows();
		} else if (distance <= LOAD_MORE_THRESHOLD_PX && hiddenBelow > 0) {
			loadNewerRows();
		}
	}

	// Apply the captured scroll anchor after the windowed slice
	// re-renders.
	$effect(() => {
		void windowedRows;
		if (pendingAnchorNode === null) {
			return;
		}
		const node = pendingAnchorNode;
		const prevTop = pendingAnchorNodeTop;
		pendingAnchorNode = null;
		void tick().then(() => {
			if (!transcriptEl || !node.isConnected) {
				return;
			}
			const newTop = node.getBoundingClientRect().top - transcriptEl.getBoundingClientRect().top;
			const delta = newTop - prevTop;
			if (delta !== 0) {
				applyingAnchor = true;
				transcriptEl.scrollTop += delta;
				requestAnimationFrame(() => {
					applyingAnchor = false;
				});
			}
		});
	});

	let lastRowCount = 0;
	$effect(() => {
		const count = app.rows.length;
		if (count < lastRowCount) {
			// The transcript was reset (reconnect re-replays into an
			// emptied list): snap the window back to the tail and
			// re-arm sticky-bottom.
			pinned = true;
			visibleCount = INITIAL_WINDOW;
			bottomClip = 0;
			pendingAnchorNode = null;
		}
		const appended = count - lastRowCount;
		lastRowCount = count;
		const el = transcriptEl;
		if (!el) {
			return;
		}
		if (!pinned) {
			// Reading history: clip new arrivals off the bottom so
			// nothing on screen moves; "Jump to latest" appears.
			if (appended > 0) {
				bottomClip += appended;
			}
			return;
		}
		el.scrollTop = el.scrollHeight;
	});
</script>

<div class="session">
	<div class="row session-head">
		<button class="ghost back" onclick={() => app.closeSession()}>←</button>
		{#if isCoordinator}<span class="badge" title="Coordinator — orchestrates worker agents">coord</span>{/if}
		{#if editingTitle}
			<input
				bind:this={titleInputEl}
				class="session-title-input"
				bind:value={titleDraft}
				onkeydown={onRenameKeydown}
				onblur={() => void commitRename()}
				enterkeyhint="done"
			/>
		{:else}
			<button class="session-title" title="Rename session" onclick={startRename}>
				<strong>{title || 'Untitled session'}</strong>
			</button>
		{/if}
		{#if worktreeRoot}
			<button
				class="ghost review-btn"
				title="Review this agent's work against the default branch"
				disabled={app.loadingReview}
				onclick={() => void app.loadReview(worktreeRoot)}>⇄</button
			>
		{/if}
		{#if app.busy}
			<span class="pip live" title="Running"></span>
		{:else if app.awaitingInput}
			<span class="pip" style="background: var(--accent)" title="Input needed"></span>
		{/if}
	</div>

	{#if app.tokenUsage}
		{@const usage = app.tokenUsage}
		<div class="token-widget">
			<button
				class="token-bar"
				class:cap-open={capOpen}
				title="{usage.total.toLocaleString()} / {usage.contextWindow.toLocaleString()} tokens — tap to set the context cap"
				onclick={toggleCapEditor}
			>
				<span class="token-pct">{usage.pct}%</span>
				<div class="token-meter">
					<div class="token-fill" style="width: {Math.min(100, usage.pct)}%"></div>
				</div>
				<span class="token-detail"
					>{usage.total.toLocaleString()} / {usage.contextWindow.toLocaleString()}{#if usage.cacheScore}
						· ⚡{usage.cacheScore} cached{/if}</span
				>
			</button>
			{#if capOpen}
				{@const ctx = app.contextCap}
				<div class="cap-editor">
					<div class="cap-line">
						<span class="cap-usage">
							{usage.total.toLocaleString()} of {usage.contextWindow.toLocaleString()} tokens{#if ctx}&nbsp;· <span
									class="cap-model">{ctx.slug}</span
								>{/if}
						</span>
					</div>
					<div class="cap-controls">
						<input
							class="cap-input"
							type="number"
							min="0"
							step="10"
							inputmode="numeric"
							placeholder={ctx ? 'model max' : ''}
							bind:value={capDraft}
							onkeydown={onCapKeydown}
							disabled={app.savingProvider}
						/>
						<span class="cap-unit">k</span>
						<button class="ghost" onclick={saveCap} disabled={app.savingProvider}>Save</button>
						{#if ctx?.capK}
							<button
								class="ghost"
								title="Clear the cap (use the model's full window)"
								onclick={() => {
									capOpen = false;
									void app.setContextCap(null);
								}}
								disabled={app.savingProvider}>Clear</button
							>
						{/if}
					</div>
					<p class="muted cap-hint">Cap the context in thousands of tokens. Empty = the model's full window.</p>
				</div>
			{/if}
		</div>
	{/if}

	<div class="transcript" bind:this={transcriptEl} onscroll={onTranscriptScroll}>
		{#if hiddenAbove > 0}
			<button type="button" class="load-pill" onclick={loadOlderRows}>
				Load {Math.min(WINDOW_GROW_STEP, hiddenAbove)} older ({hiddenAbove} hidden)
			</button>
		{:else if app.hasMoreHistory}
			<button type="button" class="load-pill" disabled={app.loadingOlder} onclick={loadOlderRows}>
				{app.loadingOlder ? 'Loading…' : 'Load older'}
			</button>
		{/if}
		{#each windowedRows as row (row.kind + row.id)}
			{#if row.kind === 'user'}
				<div
					class="bubble user"
					class:queued={row.queued}
					class:actionable={row.queued || !app.busy}
					role="button"
					tabindex="0"
					onclick={() => toggleActions(row.id)}
					onkeydown={(e) => {
						if (e.key === 'Enter' || e.key === ' ') {
							e.preventDefault();
							toggleActions(row.id);
						}
					}}
				>
					{#if row.fromCoordinator}<span class="coordinator-tag" title="Sent by the coordinator, not by you"
							>coordinator</span
						>{/if}
					{row.text}
					{#if row.images.length > 0}
						<div class="bubble-images">
							{#each row.images as url, i (i)}
								<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_noninteractive_element_interactions -->
								<img
									src={url}
									alt="attachment {i + 1}"
									onclick={(e) => {
										e.stopPropagation();
										lightboxUrl = url;
									}}
								/>
							{/each}
						</div>
					{/if}
					{#if row.queued}<span class="queued-tag">queued</span>{/if}
				</div>
				{#if actionsFor === row.id && row.queued}
					<div class="user-actions">
						<button
							class="ghost action-chip"
							onclick={() => unqueue(row.id)}
							title="Pop this queued message back into the composer to edit it">✎ Un-queue</button
						>
						<button
							class="ghost action-chip"
							onclick={() => goNow(row.id)}
							title="Stop the current turn and run this message now">▶ Go now</button
						>
					</div>
				{:else if actionsFor === row.id && !app.busy}
					<div class="user-actions">
						<button
							class="ghost action-chip"
							onclick={() => editAndResend(row.id)}
							title="Drop this message and everything after it; put the text back in the composer"
							>✎ Edit & resend</button
						>
						<button
							class="ghost action-chip"
							onclick={() => replayFrom(row.id)}
							title="Drop this message and everything after it, then re-send the same prompt">↻ Replay</button
						>
					</div>
				{/if}
			{:else if row.kind === 'assistant'}
				{#if row.thinking}
					<details class="thinking">
						<summary>Thinking…</summary>
						<div class="thinking-body">{row.thinking}</div>
					</details>
				{/if}
				{#if row.text.trim()}
					<div class="bubble assistant">
						<button class="copy-md" title="Copy raw markdown" onclick={() => void copyMarkdown(row.id, row.text)}
							>{copiedMd === row.id ? '✓' : '⧉'}</button
						>
						<Markdown text={row.text} repoUrl={app.repoUrl} />
					</div>
				{/if}
			{:else if row.kind === 'tool'}
				{@const body = toolBody(row.name, row.args, row.result)}
				{@const workerId = workerSessionId(row.name, row.args, row.result)}
				<details class="tool" class:error={row.status === 'error'}>
					<summary>
						<span class="pip" class:live={row.status === 'running'}></span>
						<span class="tool-name">{toolSummary(row.name, row.args)}</span>
						{#if row.status === 'done'}<span class="tool-check">✓</span>{:else if row.status === 'error'}<span
								class="tool-check">✗</span
							>{/if}
					</summary>
					{#if body?.kind === 'diff'}
						<div class="tool-diff">
							{#if body.find}
								<pre class="diff-old">{truncate(body.find, 400)}</pre>
							{/if}
							{#if body.replace}
								<pre class="diff-new">{truncate(body.replace, 400)}</pre>
							{/if}
						</div>
					{:else if body?.kind === 'content'}
						<pre class="tool-content">{truncate(body.text, 400)}</pre>
					{:else if row.result}
						<div class="tool-result-preview">{toolResultPreview(row.name, row.result)}</div>
					{/if}
					{#if row.images.length > 0}
						<!-- Screenshots / image-file reads the tool
						     returned; click opens full-size. -->
						<div class="tool-images">
							{#each row.images as img, i (img.dataUrl.length + ':' + i)}
								<button type="button" class="tool-image" onclick={() => (lightboxUrl = img.dataUrl)}>
									<img src={img.dataUrl} alt="{img.mime} returned by {row.name} ({i + 1})" />
								</button>
							{/each}
						</div>
					{/if}
					{#if workerId}
						<button class="ghost worker-link" onclick={() => app.openSession(workerId)}> → Open worker session </button>
					{/if}
				</details>
			{:else if row.kind === 'ask_user'}
				<div class="ask-user" class:answered={row.answered}>
					{#if !row.answered && app.pendingPrompt?.callId === row.callId}
						{#each app.pendingPrompt.questions as q (q.id)}
							<div class="question">
								<p class="question-text">{q.question}</p>
								<div class="options">
									{#each q.options as opt (opt.id)}
										<button
											type="button"
											class="option"
											class:selected={answers[q.id]?.selected.has(opt.id) ?? false}
											onclick={() => clickOption(q, opt.id)}
										>
											{opt.label}
										</button>
									{/each}
								</div>
								<input
									type="text"
									class="free-text"
									placeholder="Other…"
									value={answers[q.id]?.freeText ?? ''}
									oninput={(e) => {
										const cur = answers[q.id] ?? { selected: new Set(), freeText: '' };
										cur.freeText = (e.target as HTMLInputElement).value;
										answers[q.id] = cur;
										answers = { ...answers };
									}}
								/>
							</div>
						{/each}
						{#if app.pendingPrompt.questions.some((q) => q.multi)}
							<button type="button" class="primary" onclick={submitPrompt}>Submit</button>
						{/if}
					{:else}
						<p class="muted">
							{#if row.answered}Answered{:else}Waiting for response…{/if}
						</p>
					{/if}
				</div>
			{:else if row.kind === 'diff'}
				<details class="diff">
					<summary>{row.files.length} file{row.files.length !== 1 ? 's' : ''} changed</summary>
					{#each row.files as f}
						<div class="diff-file">{f}</div>
					{/each}
					{#if row.diff}
						<pre class="diff-body">{truncate(row.diff, 1000)}</pre>
					{/if}
				</details>
			{:else if row.kind === 'compaction'}
				<div class="compaction">
					{#if row.done}
						<details>
							<summary>Context compacted</summary>
							<div class="muted">{row.summary}</div>
						</details>
					{:else}
						<span class="muted">Compacting context…{compactionProgress(row)}</span>
					{/if}
				</div>
			{:else if row.kind === 'subagent'}
				<div class="subagent" class:finished={row.finished}>
					<span class="pip" class:live={!row.finished}></span>
					Sub-agent {#if row.detached}<span class="detached">detached</span>{/if}
					{#if row.folder}in {row.folder}{/if}
					{#if row.finished}✓{:else}running…{/if}
					<!-- A worker's subagent id is its session id; research
					     sub-agents (task tool) have no session, so only
					     link when the id resolves in the session list. -->
					{#if app.sessions.some((s) => s.id === row.subagentId)}
						<button class="ghost worker-link" onclick={() => app.openSession(row.subagentId)}>open →</button>
					{/if}
				</div>
			{/if}
		{/each}
		{#if hiddenBelow > 0}
			<button type="button" class="load-pill" onclick={loadNewerRows}>
				Load {Math.min(WINDOW_GROW_STEP, hiddenBelow)} newer ({hiddenBelow} below)
			</button>
			<button type="button" class="jump-latest" onclick={jumpToLatest}>Jump to latest ↓</button>
		{/if}
		{#each app.pendingSends.filter((ps) => ps.sessionId === app.activeSession) as ps (ps.id)}
			<div class="pending-send" class:unconfirmed={ps.status !== 'sending'}>
				<div class="pending-text">{ps.text}</div>
				{#if ps.status === 'sending'}
					<div class="pending-status muted">Sending…</div>
				{:else if ps.status === 'unsent'}
					<div class="pending-status">
						Not sent — no connection to the relay. It will be sent <strong>automatically</strong> when the connection is back.
					</div>
					<div class="pending-actions">
						<button class="ghost" onclick={() => navigator.clipboard.writeText(ps.text)}>Copy</button>
						<button class="ghost" onclick={() => void app.resendPending(ps.id)}>Send now</button>
						<button class="ghost" onclick={() => app.dismissPending(ps.id)}>Dismiss</button>
					</div>
				{:else}
					<div class="pending-status">
						Delivery unconfirmed{#if ps.error}&nbsp;<span class="muted">({ps.error})</span>{/if} — the IDE may be asleep or
						offline. The message is <strong>not lost</strong>: it often still arrives when the IDE reconnects, and this
						row disappears once it does.
					</div>
					<div class="pending-actions">
						<button class="ghost" onclick={() => navigator.clipboard.writeText(ps.text)}>Copy</button>
						<button
							class="ghost"
							title="Send again now (if the original also arrives, the coder gets it twice)"
							onclick={() => void app.resendPending(ps.id)}>Resend</button
						>
						<button class="ghost" onclick={() => app.dismissPending(ps.id)}>Dismiss</button>
					</div>
				{/if}
			</div>
		{/each}
		{#if app.rows.length === 0}
			{#if isCoordinator}
				<div class="empty-hint">
					<p><strong>Coordinator session</strong></p>
					<p class="muted">
						An orchestrator that spawns and manages worker agents. It can't edit files itself — it delegates each task
						to a worker in its own git worktree.
					</p>
					<p class="muted">
						Describe a goal (e.g. <em>look at the open GitHub issues and open a PR for each</em>) and it will decompose
						it into worker tasks.
					</p>
				</div>
			{:else}
				<p class="muted">No messages yet. Send one below.</p>
			{/if}
		{/if}
	</div>

	{#if !app.repoUrl}
		<!-- Precondition debug for #123 autolinks (they need the repo's
		     remote URL from workspace_scm_status): visible only when
		     unmet, with a manual retry — "why aren't my links
		     rendering" should be answerable from the phone. -->
		<div class="link-debug">
			<span>#123 links off — repo URL {app.loadingScm ? 'loading…' : 'not loaded'}</span>
			{#if !app.loadingScm}
				<button class="link-debug-retry" onclick={() => void app.loadScmStatus()}>retry</button>
			{/if}
		</div>
	{/if}

	{#if app.turnError && !app.busy}
		<div class="turn-error" role="alert">
			<span class="turn-error-text">{app.turnError}</span>
			<button class="turn-error-retry" onclick={() => void app.retryLastTurn()}>Retry</button>
		</div>
	{/if}

	{#if attachments.length > 0}
		<div class="attach-row">
			{#each attachments as att, i (att.data_url)}
				<div class="attach-chip">
					<img src={att.data_url} alt="attachment {i + 1}" />
					<button class="attach-x" title="Remove image" onclick={() => removeAttachment(i)}>✕</button>
				</div>
			{/each}
		</div>
	{/if}

	<div class="composer">
		<input
			bind:this={fileInput}
			type="file"
			accept="image/*"
			multiple
			class="attach-input"
			onchange={() => void onFilesPicked()}
		/>
		<button class="attach-btn" title="Attach images" aria-label="Attach images" onclick={() => fileInput?.click()}
			>📷</button
		>
		<textarea
			bind:this={draftEl}
			bind:value={draft}
			onkeydown={onKeydown}
			onpaste={onPaste}
			placeholder={isCoordinator ? 'Describe a goal for the coordinator' : 'Message the coder'}
			rows="1"
		></textarea>
		<!-- Send stays available while a turn runs: the message queues
		     as a steer (`coder_send` → `send_to_as_user`), same as the
		     desktop. Stop is a separate, smaller button next to it. -->
		{#if app.busy}
			<button class="send-btn stop" title="Stop" aria-label="Stop" onclick={() => app.abort()}>
				<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"
					><rect x="6.5" y="6.5" width="11" height="11" rx="2" fill="currentColor" /></svg
				>
			</button>
		{/if}
		<button
			class="send-btn"
			title={app.busy ? 'Queue message' : 'Send'}
			aria-label={app.busy ? 'Queue message' : 'Send'}
			onclick={send}
			disabled={!draft.trim()}
		>
			<svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"
				><path
					fill="currentColor"
					d="M3.4 20.4l17.5-7.5c0.8-0.4 0.8-1.5 0-1.9L3.4 3.6c-0.7-0.3-1.5 0.2-1.5 1l0 5.3c0 0.5 0.4 0.9 0.9 1L17 12 2.8 13.1c-0.5 0.1-0.9 0.5-0.9 1l0 5.3c0 0.8 0.8 1.3 1.5 1z"
				/></svg
			>
		</button>
	</div>
</div>

{#if app.review !== null}
	{@const review = app.review}
	{@const sections = diffSections(review.diff)}
	<div class="review-overlay">
		<div class="row review-head">
			<button class="ghost back" onclick={() => app.closeReview()}>←</button>
			<strong class="review-title">
				{#if review.base_ref}
					Review vs {review.base_ref}{#if worktreeBranch}&nbsp;<span class="muted">({worktreeBranch})</span>{/if}
				{:else}
					Review
				{/if}
			</strong>
		</div>
		<div class="review-body">
			{#if review.base_ref === null}
				<p class="muted">
					Nothing to review against — this checkout is on the default branch (or has no remote default).
				</p>
			{:else if review.files.length === 0}
				<p class="muted">No changes vs {review.base_ref}.</p>
			{:else}
				<p class="muted review-summary">
					{review.files.length} file{review.files.length === 1 ? '' : 's'} changed (committed + uncommitted; untracked files
					not shown)
				</p>
				{#each review.files as f (f.path)}
					<details class="review-file">
						<summary>
							<span class="review-file-status {f.status}">{f.status[0]?.toUpperCase()}</span>
							<span class="review-file-path">{f.path}</span>
						</summary>
						{#if sections.has(f.path)}
							<pre class="diff-body">{sections.get(f.path)}</pre>
						{:else}
							<p class="muted review-nodiff">No text diff (binary, rename, or diff truncated at 64 kB).</p>
						{/if}
					</details>
				{/each}
			{/if}
		</div>
	</div>
{/if}

{#if lightboxUrl !== null}
	<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
	<div
		class="lightbox-backdrop"
		onclick={() => (lightboxUrl = null)}
		role="dialog"
		aria-modal="true"
		aria-label="Image preview"
		tabindex="-1"
	>
		<img class="lightbox-image" src={lightboxUrl} alt="Tool result at full size" />
		<button type="button" class="lightbox-close" title="Close" onclick={() => (lightboxUrl = null)}>×</button>
	</div>
{/if}

<style>
	.session {
		display: flex;
		flex-direction: column;
		/* dvh tracks the mobile browser chrome collapsing/expanding;
		   vh alone leaves the composer hidden behind the toolbar. */
		height: 100dvh;
		padding: 0.75rem;
		gap: 0.5rem;
	}
	.session-head {
		gap: 0.5rem;
	}
	.back {
		flex: none;
		padding: 0.6rem 0.7rem;
	}
	.session-title {
		flex: 1;
		min-width: 0;
		background: none;
		border: none;
		padding: 0;
		color: inherit;
		text-align: left;
		cursor: text;
	}
	.session-title strong {
		display: block;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
		font-size: 0.95rem;
	}
	.session-title-input {
		flex: 1;
		min-width: 0;
		font: inherit;
		font-size: 0.95rem;
		font-weight: 600;
		background: var(--bg-elev);
		color: var(--fg);
		border: 1px solid var(--accent);
		border-radius: var(--radius);
		padding: 0.2rem 0.4rem;
	}
	.transcript {
		flex: 1;
		overflow-y: auto;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
		padding: 0.25rem;
	}
	.load-pill {
		flex: none;
		align-self: center;
		background: var(--bg-elev);
		border: 1px solid var(--border);
		border-radius: 999px;
		color: var(--fg-muted);
		font-size: 0.8rem;
		padding: 0.4rem 0.9rem;
	}
	.jump-latest {
		position: sticky;
		bottom: 0.25rem;
		flex: none;
		align-self: center;
		background: var(--bg-elev-2);
		border: 1px solid var(--accent);
		border-radius: 999px;
		color: var(--fg);
		font-size: 0.8rem;
		padding: 0.4rem 0.9rem;
		box-shadow: 0 2px 8px rgb(0 0 0 / 40%);
	}
	.bubble {
		padding: 0.5rem 0.7rem;
		border-radius: var(--radius);
		word-break: break-word;
	}
	.bubble.user {
		background: var(--bg-elev-2);
		align-self: flex-end;
		max-width: 85%;
		/* User text is plain (not markdown) — keep typed newlines. */
		white-space: pre-wrap;
	}
	.bubble.user.queued {
		opacity: 0.6;
	}
	.bubble.user.actionable {
		cursor: pointer;
	}
	.user-actions {
		display: flex;
		gap: 0.4rem;
		justify-content: flex-end;
		margin-top: -0.2rem;
	}
	.action-chip {
		font-size: 0.75rem;
		padding: 0.25rem 0.6rem;
		border: 1px solid var(--border);
		border-radius: 999px;
	}
	.bubble-images {
		display: flex;
		flex-wrap: wrap;
		gap: 0.4rem;
		margin-top: 0.4rem;
	}
	.bubble-images img {
		max-height: 120px;
		max-width: 45%;
		border-radius: 6px;
		border: 1px solid var(--border);
		cursor: zoom-in;
	}
	.queued-tag {
		font-size: 0.7rem;
		margin-left: 0.3rem;
		color: var(--fg-muted);
	}
	/* Coordinator-sent message into a worker — same pill language
	   as the `coord` session badge so the reader can tell the
	   orchestrator's instructions from their own. */
	.coordinator-tag {
		display: inline-block;
		font-size: 0.65rem;
		font-weight: 600;
		letter-spacing: 0.04em;
		text-transform: uppercase;
		padding: 0 0.4rem;
		margin-right: 0.25rem;
		border-radius: 999px;
		background: color-mix(in srgb, var(--accent) 22%, transparent);
		color: var(--accent);
	}
	.bubble.assistant {
		background: var(--bg-elev);
		border: 1px solid var(--border);
	}
	.thinking {
		font-size: 0.8rem;
		color: var(--fg-muted);
	}
	.thinking summary {
		cursor: pointer;
		font-style: italic;
	}
	.thinking-body {
		white-space: pre-wrap;
		padding: 0.3rem 0;
	}
	.tool {
		font-size: 0.8rem;
		color: var(--fg-muted);
	}
	.tool summary {
		cursor: pointer;
		display: flex;
		align-items: center;
		gap: 0.4rem;
	}
	.tool.error {
		color: var(--danger);
	}
	.tool-name {
		font-family: var(--mono, monospace);
		font-size: 0.75rem;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
	}
	.tool-check {
		flex: none;
		font-size: 0.7rem;
	}
	.tool-result-preview {
		font-size: 0.72rem;
		font-family: var(--mono, monospace);
		color: var(--fg);
		margin: 0.25rem 0 0.25rem 1rem;
		padding: 0.25rem 0.4rem;
		background: var(--bg-elev);
		border-radius: var(--radius);
		white-space: pre-wrap;
		word-break: break-word;
		max-height: 120px;
		overflow-y: auto;
	}
	.tool-images {
		display: flex;
		flex-wrap: wrap;
		gap: 6px;
		margin: 0.25rem 0 0.25rem 1rem;
	}
	.tool-image {
		padding: 0;
		border: 1px solid var(--border, #333);
		border-radius: var(--radius);
		background: var(--bg-elev);
		cursor: zoom-in;
		overflow: hidden;
	}
	.tool-image img {
		display: block;
		max-width: 200px;
		max-height: 140px;
		object-fit: contain;
	}
	.lightbox-backdrop {
		position: fixed;
		inset: 0;
		z-index: 90;
		display: flex;
		align-items: center;
		justify-content: center;
		background: rgba(0, 0, 0, 0.75);
	}
	.lightbox-image {
		max-width: 92vw;
		max-height: 92vh;
		object-fit: contain;
		border-radius: var(--radius);
	}
	.lightbox-close {
		position: absolute;
		top: 12px;
		right: 16px;
		border: none;
		background: transparent;
		color: #fff;
		font-size: 24px;
		cursor: pointer;
	}
	.tool-diff {
		margin: 0.25rem 0 0.25rem 1rem;
		display: flex;
		flex-direction: column;
		gap: 1px;
	}
	.diff-old,
	.diff-new {
		font-size: 0.72rem;
		font-family: var(--mono, monospace);
		margin: 0;
		padding: 0.25rem 0.4rem;
		white-space: pre-wrap;
		word-break: break-word;
		max-height: 150px;
		overflow-y: auto;
		border-radius: var(--radius);
	}
	.diff-old {
		background: rgba(248, 81, 73, 0.12);
		border-left: 3px solid #f85149;
		color: #ffa19b;
	}
	.diff-new {
		background: rgba(63, 185, 80, 0.1);
		border-left: 3px solid #3fb950;
		color: #7ee78c;
	}
	.worker-link {
		font-size: 0.75rem;
		padding: 0.2rem 0.6rem;
		margin: 0.25rem 0 0.25rem 1rem;
		border: 1px solid var(--border);
		border-radius: 999px;
		color: var(--accent);
	}
	.subagent .worker-link {
		margin: 0;
	}
	.tool-content {
		font-size: 0.72rem;
		font-family: var(--mono, monospace);
		margin: 0.25rem 0 0.25rem 1rem;
		padding: 0.25rem 0.4rem;
		background: var(--bg-elev);
		border-radius: var(--radius);
		white-space: pre-wrap;
		word-break: break-word;
		max-height: 200px;
		overflow-y: auto;
	}
	.ask-user {
		background: var(--bg-elev);
		border: 1px solid var(--accent);
		border-radius: var(--radius);
		padding: 0.6rem;
		display: flex;
		flex-direction: column;
		gap: 0.5rem;
	}
	.ask-user.answered {
		border-color: var(--border);
		opacity: 0.7;
	}
	.question {
		display: flex;
		flex-direction: column;
		gap: 0.3rem;
	}
	.question-text {
		font-weight: 600;
		margin: 0;
	}
	.options {
		display: flex;
		flex-direction: column;
		gap: 0.25rem;
	}
	.option {
		background: none;
		border: 1px solid var(--border);
		border-radius: var(--radius);
		color: var(--fg);
		cursor: pointer;
		padding: 0.4rem 0.5rem;
		font-size: 0.85rem;
		text-align: left;
	}
	.option.selected {
		border-color: var(--accent);
		background: var(--bg-elev-2);
	}
	.free-text {
		background: var(--bg-elev-2);
		border: 1px solid var(--border);
		border-radius: var(--radius);
		color: var(--fg);
		padding: 0.3rem 0.5rem;
		font-size: 0.85rem;
		font: inherit;
	}
	.diff {
		font-size: 0.8rem;
	}
	.diff summary {
		cursor: pointer;
		color: var(--fg-muted);
	}
	.diff-file {
		font-family: var(--mono, monospace);
		font-size: 0.75rem;
		padding: 0.1rem 0;
	}
	.diff-body {
		font-size: 0.7rem;
		white-space: pre-wrap;
		word-break: break-all;
		margin-top: 0.3rem;
		max-height: 250px;
		overflow-y: auto;
	}
	.token-widget {
		flex: none;
		background: var(--bg-elev);
		border-bottom: 1px solid var(--border);
	}
	.token-bar {
		display: flex;
		align-items: center;
		gap: 0.4rem;
		width: 100%;
		padding: 0.25rem 0.6rem;
		background: none;
		border: none;
		font: inherit;
		font-size: 0.65rem;
		color: var(--fg-muted);
		cursor: pointer;
		text-align: left;
	}
	.token-bar.cap-open {
		color: var(--fg);
	}
	.cap-editor {
		padding: 0.2rem 0.6rem 0.5rem;
		border-top: 1px solid var(--border);
	}
	.cap-line {
		font-size: 0.7rem;
		color: var(--fg-muted);
		margin-bottom: 0.4rem;
	}
	.cap-model {
		font-family: var(--mono, monospace);
		font-size: 0.65rem;
	}
	.cap-controls {
		display: flex;
		align-items: center;
		gap: 0.4rem;
	}
	.cap-input {
		width: 6rem;
		min-width: 0;
		font: inherit;
		background: var(--bg-elev-2);
		color: var(--fg);
		border: 1px solid var(--border);
		border-radius: var(--radius);
		padding: 0.35rem 0.5rem;
	}
	.cap-unit {
		color: var(--fg-muted);
		font-size: 0.8rem;
	}
	.cap-hint {
		margin: 0.4rem 0 0;
		font-size: 0.7rem;
		line-height: 1.35;
	}
	.token-pct {
		flex: none;
		font-weight: 600;
		min-width: 2.5rem;
	}
	.token-meter {
		flex: 1;
		height: 4px;
		background: var(--border);
		border-radius: 2px;
		overflow: hidden;
	}
	.token-fill {
		height: 100%;
		background: var(--accent);
		border-radius: 2px;
		transition: width 0.3s ease;
	}
	.token-detail {
		flex: none;
		font-family: var(--mono, monospace);
		font-size: 0.6rem;
	}
	.compaction {
		font-size: 0.8rem;
	}
	.subagent {
		font-size: 0.8rem;
		color: var(--fg-muted);
		display: flex;
		align-items: center;
		gap: 0.4rem;
	}
	.subagent .detached {
		font-size: 0.65rem;
		text-transform: uppercase;
		letter-spacing: 0.04em;
		border: 1px solid var(--border);
		border-radius: 999px;
		padding: 0 0.35rem;
	}
	.composer {
		display: flex;
		gap: 0.5rem;
		align-items: flex-end;
		background: var(--bg-elev);
		border: 1px solid var(--border);
		/* Pill: one continuous bubble holding the textarea and the
		   inset send button, Telegram-style. */
		border-radius: 24px;
		padding: 0.4rem 0.4rem 0.4rem 0.9rem;
	}
	.composer:focus-within {
		border-color: var(--accent);
	}
	/* Inset circular send / stop button. */
	.send-btn {
		flex: none;
		width: 40px;
		height: 40px;
		display: grid;
		place-items: center;
		border: none;
		border-radius: 50%;
		background: var(--accent);
		color: var(--accent-fg, #fff);
		padding: 0;
	}
	.send-btn svg {
		/* Optical centering: the paper-plane glyph sits slightly
		   left of its viewBox centre. */
		margin-left: 2px;
	}
	/* Stop sits beside send (a queued steer is the common action
	   mid-turn), so it shrinks and drops to a muted danger tone. */
	.send-btn.stop {
		width: 34px;
		height: 34px;
		background: var(--bg-elev-2);
		color: var(--danger);
		border: 1px solid var(--border);
	}
	.send-btn.stop svg {
		margin-left: 0;
	}
	.send-btn:disabled {
		opacity: 0.45;
	}
	.bubble.assistant {
		position: relative;
	}
	.copy-md {
		position: absolute;
		top: 0.25rem;
		right: 0.25rem;
		padding: 0.1rem 0.4rem;
		font-size: 0.8rem;
		line-height: 1.2;
		background: var(--bg-elev-2);
		color: var(--fg-muted);
		border: 1px solid var(--border);
		border-radius: 6px;
		opacity: 0.55;
	}
	.copy-md:active {
		opacity: 1;
	}
	.attach-row {
		display: flex;
		gap: 0.5rem;
		margin: 0 0.75rem 0.3rem;
		overflow-x: auto;
	}
	.attach-chip {
		position: relative;
		flex: none;
	}
	.attach-chip img {
		height: 56px;
		width: auto;
		border-radius: 6px;
		border: 1px solid var(--border);
		display: block;
	}
	.attach-x {
		position: absolute;
		top: -6px;
		right: -6px;
		width: 20px;
		height: 20px;
		min-height: 0;
		padding: 0;
		border-radius: 50%;
		background: var(--bg-elev-2);
		border: 1px solid var(--border);
		color: var(--danger);
		font-size: 0.65rem;
		line-height: 1;
	}
	.attach-input {
		display: none;
	}
	.attach-btn {
		flex: none;
		background: none;
		border: none;
		font-size: 1.1rem;
		padding: 0.4rem 0.3rem;
		min-height: 0;
		align-self: flex-end;
	}
	.link-debug {
		display: flex;
		align-items: center;
		gap: 0.6rem;
		margin: 0 0.75rem 0.3rem;
		padding: 0.25rem 0.6rem;
		font-size: 0.72rem;
		color: var(--fg-muted);
		background: var(--bg-elev-1);
		border: 1px dashed var(--border);
		border-radius: var(--radius);
	}
	.link-debug-retry {
		padding: 0.1rem 0.5rem;
		font-size: 0.72rem;
		background: none;
		border: 1px solid var(--border);
		border-radius: 6px;
		color: var(--fg-muted);
		min-height: 0;
	}
	.turn-error {
		display: flex;
		align-items: center;
		gap: 0.6rem;
		margin: 0 0.75rem 0.4rem;
		padding: 0.45rem 0.6rem;
		background: var(--bg-elev-2);
		border: 1px solid var(--danger);
		border-radius: var(--radius);
	}
	.turn-error-text {
		flex: 1;
		color: var(--danger);
		font-size: 0.8rem;
		word-break: break-word;
		display: -webkit-box;
		-webkit-line-clamp: 3;
		-webkit-box-orient: vertical;
		overflow: hidden;
	}
	.turn-error-retry {
		flex-shrink: 0;
		padding: 0.3rem 0.8rem;
		background: var(--danger);
		color: var(--bg);
		border: none;
		border-radius: 6px;
		font-size: 0.85rem;
		font-weight: 600;
	}
	.pending-send {
		align-self: flex-end;
		max-width: 88%;
		background: var(--bg-elev);
		border: 1px dashed var(--border);
		border-radius: var(--radius);
		padding: 0.5rem 0.7rem;
		display: flex;
		flex-direction: column;
		gap: 0.35rem;
	}
	.pending-send.unconfirmed {
		border-color: var(--accent);
	}
	.pending-text {
		white-space: pre-wrap;
		word-break: break-word;
	}
	.pending-status {
		font-size: 0.75rem;
		line-height: 1.35;
	}
	.pending-actions {
		display: flex;
		gap: 0.4rem;
	}
	.pending-actions .ghost {
		padding: 0.25rem 0.6rem;
		font-size: 0.8rem;
	}
	.review-btn {
		flex: none;
		padding: 0.3rem 0.55rem;
		font-size: 0.95rem;
		line-height: 1;
	}
	.review-overlay {
		position: fixed;
		inset: 0;
		z-index: 40;
		display: flex;
		flex-direction: column;
		background: var(--bg);
		padding: 0.75rem;
		padding-top: calc(0.75rem + env(safe-area-inset-top));
		gap: 0.5rem;
	}
	.review-head {
		gap: 0.5rem;
	}
	.review-title {
		flex: 1;
		min-width: 0;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
		font-size: 0.95rem;
	}
	.review-body {
		flex: 1;
		overflow-y: auto;
		display: flex;
		flex-direction: column;
		gap: 0.4rem;
	}
	.review-summary {
		margin: 0;
		font-size: 0.75rem;
	}
	.review-file {
		background: var(--bg-elev);
		border: 1px solid var(--border);
		border-radius: var(--radius);
		padding: 0.4rem 0.6rem;
	}
	.review-file summary {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		cursor: pointer;
		font-size: 0.8rem;
		font-family: var(--mono, monospace);
	}
	.review-file-path {
		min-width: 0;
		overflow: hidden;
		text-overflow: ellipsis;
		white-space: nowrap;
		direction: rtl;
		text-align: left;
	}
	.review-file-status {
		flex: none;
		font-weight: 700;
		font-size: 0.75rem;
		width: 1.1em;
	}
	.review-file-status.added,
	.review-file-status.untracked {
		color: var(--ok);
	}
	.review-file-status.modified {
		color: var(--accent);
	}
	.review-file-status.deleted {
		color: var(--danger);
	}
	.review-nodiff {
		margin: 0.4rem 0 0;
		font-size: 0.75rem;
	}
	.empty-hint {
		padding: 1rem 0.5rem;
		display: flex;
		flex-direction: column;
		gap: 0.4rem;
	}
	.empty-hint p {
		margin: 0;
		line-height: 1.4;
	}
	.empty-hint .muted {
		font-size: 0.85rem;
	}
	.composer textarea {
		flex: 1;
		min-width: 0;
		resize: none;
		font: inherit;
		background: none;
		color: var(--fg);
		border: none;
		outline: none;
		padding: 0.55rem 0.2rem;
		/* Auto-grows via JS; capped around 9 lines of text. */
		max-height: 11.5em;
	}
</style>
