sync: update from private repo (10f4905)
Some checks failed
CI / build-and-test (push) Has been cancelled
Some checks failed
CI / build-and-test (push) Has been cancelled
This commit is contained in:
parent
337c2be3c3
commit
afae52873b
13
Dockerfile
13
Dockerfile
@ -64,15 +64,20 @@ COPY --from=builder /app/vendor ./vendor
|
|||||||
COPY pieces ./pieces
|
COPY pieces ./pieces
|
||||||
COPY docs ./docs
|
COPY docs ./docs
|
||||||
|
|
||||||
# Ship a runnable default while still allowing a read-only config mount.
|
# Ship a runnable default while still allowing a config bind-mount.
|
||||||
COPY config.yaml.example ./config.yaml
|
COPY config.yaml.example ./config.yaml
|
||||||
|
|
||||||
RUN mkdir -p /data /workspaces \
|
# The app runs as the non-root `node` user and writes its state under ./data
|
||||||
&& chown -R node:node /data /workspaces
|
# (db, users, skills, secrets) — relative to WORKDIR /app, i.e. /app/data — plus
|
||||||
|
# /workspaces (worktree) and config.yaml (Settings save). Create and own those
|
||||||
|
# so a fresh deploy doesn't hit EACCES. /app/data and /workspaces are the volume
|
||||||
|
# mount points in docker-compose.
|
||||||
|
RUN mkdir -p /app/data /workspaces \
|
||||||
|
&& chown -R node:node /app/data /workspaces config.yaml
|
||||||
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
PORT=9876 \
|
PORT=9876 \
|
||||||
DB_PATH=/data/maestro.db
|
DB_PATH=/app/data/maestro.db
|
||||||
|
|
||||||
EXPOSE 9876
|
EXPOSE 9876
|
||||||
|
|
||||||
|
|||||||
@ -16,15 +16,17 @@ services:
|
|||||||
environment:
|
environment:
|
||||||
- NODE_ENV=production
|
- NODE_ENV=production
|
||||||
- PORT=9876
|
- PORT=9876
|
||||||
- DB_PATH=/data/maestro.db
|
- DB_PATH=/app/data/maestro.db
|
||||||
- WORKTREE_DIR=/workspaces
|
- WORKTREE_DIR=/workspaces
|
||||||
volumes:
|
volumes:
|
||||||
# SQLite DB 永続化
|
# アプリの状態 (DB / users / skills / secrets) を永続化。
|
||||||
- maestro-data:/data
|
# アプリは WORKDIR /app からの相対 ./data に書くので /app/data にマウントする。
|
||||||
|
- maestro-data:/app/data
|
||||||
# エージェントワークスペース永続化
|
# エージェントワークスペース永続化
|
||||||
- maestro-workspaces:/workspaces
|
- maestro-workspaces:/workspaces
|
||||||
# 設定ファイル (任意でホストから read-only マウント)
|
# config.yaml をホストから永続化したい場合は bind-mount (書き込み可)。
|
||||||
# - ./config.yaml:/app/config.yaml:ro
|
# Settings UI / npm run setup で書き換えるなら :ro は付けないこと。
|
||||||
|
# - ./config.yaml:/app/config.yaml
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "node", "-e", "fetch('http://localhost:9876/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
test: ["CMD", "node", "-e", "fetch('http://localhost:9876/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|||||||
@ -392,7 +392,7 @@ export function ChatMessage({ comment, taskId, imageBaseUrl, isStaleThinking }:
|
|||||||
<div className="text-2xs text-green-500 mb-1.5">
|
<div className="text-2xs text-green-500 mb-1.5">
|
||||||
{author} · {new Date(createdAt).toLocaleString()}
|
{author} · {new Date(createdAt).toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-sm leading-relaxed prose prose-sm prose-slate max-w-none">
|
<div className="text-sm leading-relaxed prose prose-sm prose-slate dark:prose-invert max-w-none">
|
||||||
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} />
|
<MarkdownPreview content={body} imageBaseUrl={imageBaseUrl ?? `/api/local/tasks/${taskId}/files/raw?section=output&path=`} taskId={taskId} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -328,7 +328,7 @@ export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = f
|
|||||||
};
|
};
|
||||||
|
|
||||||
const content_el = (
|
const content_el = (
|
||||||
<div ref={containerRef} className={`${showOutline ? 'prose prose-slate max-w-none mdxg-reader' : 'prose prose-sm max-w-none'} min-w-0 break-words [&_a]:[overflow-wrap:anywhere] [&_a]:break-all [&_code]:[overflow-wrap:anywhere] [&_pre]:max-w-full [&_pre]:overflow-x-auto`}>
|
<div ref={containerRef} className={`${showOutline ? 'prose prose-slate dark:prose-invert max-w-none mdxg-reader' : 'prose prose-sm dark:prose-invert max-w-none'} min-w-0 break-words [&_a]:[overflow-wrap:anywhere] [&_a]:break-all [&_code]:[overflow-wrap:anywhere] [&_pre]:max-w-full [&_pre]:overflow-x-auto`}>
|
||||||
{segments.map((seg, i) => {
|
{segments.map((seg, i) => {
|
||||||
if (seg.type === 'embed') {
|
if (seg.type === 'embed') {
|
||||||
return <EmbedBlock key={`embed-${seg.refId}-${i}`} refId={seg.refId} taskId={taskId!} />;
|
return <EmbedBlock key={`embed-${seg.refId}-${i}`} refId={seg.refId} taskId={taskId!} />;
|
||||||
|
|||||||
92
ui/src/components/layout/ThemeToggle.tsx
Normal file
92
ui/src/components/layout/ThemeToggle.tsx
Normal file
@ -0,0 +1,92 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { type ThemePref, readStoredTheme, setThemePref } from '../../lib/theme';
|
||||||
|
|
||||||
|
const ICON_PROPS = {
|
||||||
|
width: 14,
|
||||||
|
height: 14,
|
||||||
|
viewBox: '0 0 24 24',
|
||||||
|
fill: 'none',
|
||||||
|
stroke: 'currentColor',
|
||||||
|
strokeWidth: 1.8,
|
||||||
|
strokeLinecap: 'round' as const,
|
||||||
|
strokeLinejoin: 'round' as const,
|
||||||
|
'aria-hidden': true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const OPTIONS: Array<{ value: ThemePref; label: string; icon: JSX.Element }> = [
|
||||||
|
{
|
||||||
|
value: 'system',
|
||||||
|
label: 'システム設定に合わせる',
|
||||||
|
icon: (
|
||||||
|
<svg {...ICON_PROPS}>
|
||||||
|
<rect x="2" y="4" width="20" height="13" rx="2" />
|
||||||
|
<path d="M8 21h8M12 17v4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'light',
|
||||||
|
label: 'ライト',
|
||||||
|
icon: (
|
||||||
|
<svg {...ICON_PROPS}>
|
||||||
|
<circle cx="12" cy="12" r="4" />
|
||||||
|
<path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
value: 'dark',
|
||||||
|
label: 'ダーク',
|
||||||
|
icon: (
|
||||||
|
<svg {...ICON_PROPS}>
|
||||||
|
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
|
||||||
|
</svg>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact 3-way theme switch (system / light / dark) for the TopBar.
|
||||||
|
*
|
||||||
|
* All theme logic lives in lib/theme.ts: this only tracks the displayed
|
||||||
|
* preference and calls setThemePref (persist + apply) on selection. The
|
||||||
|
* pre-paint inline script in index.html + initTheme() keep the actual
|
||||||
|
* [data-theme] attribute correct on load and on OS changes.
|
||||||
|
*/
|
||||||
|
export function ThemeToggle() {
|
||||||
|
const [pref, setPref] = useState<ThemePref>(() => readStoredTheme());
|
||||||
|
|
||||||
|
const choose = (value: ThemePref) => {
|
||||||
|
setPref(value);
|
||||||
|
setThemePref(value);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="group"
|
||||||
|
aria-label="テーマ"
|
||||||
|
className="inline-flex items-center gap-0.5 rounded-md border border-hairline bg-surface p-0.5"
|
||||||
|
>
|
||||||
|
{OPTIONS.map((opt) => {
|
||||||
|
const active = pref === opt.value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={opt.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => choose(opt.value)}
|
||||||
|
aria-label={opt.label}
|
||||||
|
aria-pressed={active}
|
||||||
|
title={opt.label}
|
||||||
|
className={`flex items-center justify-center w-6 h-6 rounded transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent-ring ${
|
||||||
|
active
|
||||||
|
? 'bg-canvas text-slate-900 shadow-sm'
|
||||||
|
: 'text-slate-500 hover:text-slate-800'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{opt.icon}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import type { PageId } from '../../lib/urlState';
|
import type { PageId } from '../../lib/urlState';
|
||||||
import type { AuthUser } from '../../App';
|
import type { AuthUser } from '../../App';
|
||||||
|
import { ThemeToggle } from './ThemeToggle';
|
||||||
|
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
currentPage: PageId;
|
currentPage: PageId;
|
||||||
@ -137,6 +138,7 @@ export function TopBar({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<ThemeToggle />
|
||||||
{user && (
|
{user && (
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|||||||
@ -729,3 +729,37 @@
|
|||||||
}
|
}
|
||||||
.mdxg-outline a.depth-2 { padding-left: 1.25rem; }
|
.mdxg-outline a.depth-2 { padding-left: 1.25rem; }
|
||||||
.mdxg-outline a.depth-3 { padding-left: 2rem; font-size: 0.7rem; }
|
.mdxg-outline a.depth-3 { padding-left: 2rem; font-size: 0.7rem; }
|
||||||
|
|
||||||
|
/* ── Dark mode overrides for the MD reader + outline ───────────────────
|
||||||
|
`.mdxg-reader pre` (code blocks) are github-dark already and stay as-is.
|
||||||
|
Body text comes from `prose prose-slate` whose slate scale is var-remapped
|
||||||
|
(auto light in dark). These override only the hardcoded light-mode chrome
|
||||||
|
(#18181b headings, light table/blockquote fills, pink inline code, the
|
||||||
|
outline's light hover/active) that would otherwise be dark-on-dark or a
|
||||||
|
light island in dark mode. */
|
||||||
|
[data-theme="dark"] .mdxg-reader h1,
|
||||||
|
[data-theme="dark"] .mdxg-reader h2,
|
||||||
|
[data-theme="dark"] .mdxg-reader h3,
|
||||||
|
[data-theme="dark"] .mdxg-reader h4,
|
||||||
|
[data-theme="dark"] .mdxg-reader h5,
|
||||||
|
[data-theme="dark"] .mdxg-reader h6,
|
||||||
|
[data-theme="dark"] .mdxg-reader strong,
|
||||||
|
[data-theme="dark"] .mdxg-reader b { color: var(--ink); }
|
||||||
|
[data-theme="dark"] .mdxg-reader h1 { border-bottom-color: var(--slate-700); }
|
||||||
|
[data-theme="dark"] .mdxg-reader h2 { border-left-color: var(--slate-700); }
|
||||||
|
[data-theme="dark"] .mdxg-reader h3::before { background: var(--slate-700); }
|
||||||
|
[data-theme="dark"] .mdxg-reader h1 .mdxg-anchor,
|
||||||
|
[data-theme="dark"] .mdxg-reader h2 .mdxg-anchor,
|
||||||
|
[data-theme="dark"] .mdxg-reader h3 .mdxg-anchor { color: var(--slate-500); }
|
||||||
|
[data-theme="dark"] .mdxg-reader thead th { background: var(--surface-2); border-bottom-color: var(--hairline); }
|
||||||
|
[data-theme="dark"] .mdxg-reader tbody td { border-bottom-color: var(--hairline); }
|
||||||
|
[data-theme="dark"] .mdxg-reader tbody tr:nth-child(even) { background: var(--surface); }
|
||||||
|
[data-theme="dark"] .mdxg-reader blockquote { background: var(--surface); color: var(--muted); }
|
||||||
|
[data-theme="dark"] .mdxg-reader :not(pre) > code {
|
||||||
|
background: var(--surface-2);
|
||||||
|
color: #f3a4c0;
|
||||||
|
border-color: var(--hairline);
|
||||||
|
}
|
||||||
|
[data-theme="dark"] .mdxg-outline a { color: var(--muted); }
|
||||||
|
[data-theme="dark"] .mdxg-outline a:hover { background: var(--surface-2); color: var(--ink); }
|
||||||
|
[data-theme="dark"] .mdxg-outline a.active { background: var(--surface-2); color: var(--ink); }
|
||||||
|
|||||||
@ -57,7 +57,7 @@ const md = new Marked({ gfm: true, breaks: true, renderer });
|
|||||||
// remove any doubt we ALSO use Tailwind's `!` important prefix.
|
// remove any doubt we ALSO use Tailwind's `!` important prefix.
|
||||||
// Result: paragraphs ~4px apart, lines tight, headings reasonable.
|
// Result: paragraphs ~4px apart, lines tight, headings reasonable.
|
||||||
const COMPACT_PROSE = [
|
const COMPACT_PROSE = [
|
||||||
'prose prose-sm prose-slate max-w-none break-words min-w-0',
|
'prose prose-sm prose-slate dark:prose-invert max-w-none break-words min-w-0',
|
||||||
// overflow-wrap: anywhere ensures unbreakable strings (long URLs, hashes,
|
// overflow-wrap: anywhere ensures unbreakable strings (long URLs, hashes,
|
||||||
// file paths) wrap mid-token. `break-words` alone only honors word
|
// file paths) wrap mid-token. `break-words` alone only honors word
|
||||||
// boundaries which leaves them poking out of the container.
|
// boundaries which leaves them poking out of the container.
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||||
import { resolveTheme, isThemePref, readStoredTheme, writeStoredTheme, THEME_STORAGE_KEY } from './theme';
|
import { resolveTheme, isThemePref, readStoredTheme, writeStoredTheme, systemPrefersDark, THEME_STORAGE_KEY } from './theme';
|
||||||
|
|
||||||
describe('resolveTheme', () => {
|
describe('resolveTheme', () => {
|
||||||
it('explicit dark/light win regardless of system', () => {
|
it('explicit dark/light win regardless of system', () => {
|
||||||
@ -65,3 +65,13 @@ describe('writeStoredTheme', () => {
|
|||||||
expect(() => writeStoredTheme('dark')).not.toThrow();
|
expect(() => writeStoredTheme('dark')).not.toThrow();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('systemPrefersDark', () => {
|
||||||
|
afterEach(() => vi.unstubAllGlobals());
|
||||||
|
it('reflects the OS matchMedia result', () => {
|
||||||
|
vi.stubGlobal('window', { matchMedia: () => ({ matches: true }) });
|
||||||
|
expect(systemPrefersDark()).toBe(true);
|
||||||
|
vi.stubGlobal('window', { matchMedia: () => ({ matches: false }) });
|
||||||
|
expect(systemPrefersDark()).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -38,6 +38,20 @@ export function applyTheme(root: HTMLElement, resolved: ResolvedTheme): void {
|
|||||||
|
|
||||||
const DARK_MQ = '(prefers-color-scheme: dark)';
|
const DARK_MQ = '(prefers-color-scheme: dark)';
|
||||||
|
|
||||||
|
/** True if the OS currently prefers a dark color scheme. */
|
||||||
|
export function systemPrefersDark(): boolean {
|
||||||
|
return typeof window !== 'undefined' && window.matchMedia(DARK_MQ).matches;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persist a new preference and apply it to the document immediately. Used by
|
||||||
|
* the theme toggle UI. The pure resolution lives in resolveTheme.
|
||||||
|
*/
|
||||||
|
export function setThemePref(pref: ThemePref): void {
|
||||||
|
writeStoredTheme(pref);
|
||||||
|
applyTheme(document.documentElement, resolveTheme(pref, systemPrefersDark()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wire runtime theme. The INITIAL attribute is set by the inline script in
|
* Wire runtime theme. The INITIAL attribute is set by the inline script in
|
||||||
* index.html (pre-paint, no FOUC); this keeps it live and re-applies when the
|
* index.html (pre-paint, no FOUC); this keeps it live and re-applies when the
|
||||||
|
|||||||
@ -124,7 +124,7 @@ export function HelpPage({ isAdmin, onAskAi, selectedId, onSelect }: HelpPagePro
|
|||||||
<main ref={mainRef} className="flex-1 min-w-0 overflow-y-auto bg-canvas">
|
<main ref={mainRef} className="flex-1 min-w-0 overflow-y-auto bg-canvas">
|
||||||
<div className="flex max-w-5xl mx-auto px-8 py-8 gap-8">
|
<div className="flex max-w-5xl mx-auto px-8 py-8 gap-8">
|
||||||
<article
|
<article
|
||||||
className="prose prose-sm flex-1 min-w-0"
|
className="prose prose-sm dark:prose-invert flex-1 min-w-0"
|
||||||
onClick={handleContentClick}
|
onClick={handleContentClick}
|
||||||
dangerouslySetInnerHTML={{ __html: rendered.html }}
|
dangerouslySetInnerHTML={{ __html: rendered.html }}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -12,6 +12,10 @@ import typography from '@tailwindcss/typography';
|
|||||||
/** @type {import('tailwindcss').Config} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
export default {
|
export default {
|
||||||
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
content: ['./index.html', './src/**/*.{ts,tsx}'],
|
||||||
|
// Drive the `dark:` variant off our runtime [data-theme="dark"] attribute
|
||||||
|
// (set by index.html's pre-paint script + lib/theme.ts), not the OS media
|
||||||
|
// query — so `dark:prose-invert` follows the in-app theme toggle.
|
||||||
|
darkMode: ['selector', '[data-theme="dark"]'],
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user