shine-code-submit 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/.claude-plugin/marketplace.json +13 -0
  2. package/.claude-plugin/plugin.json +10 -0
  3. package/LICENSE +21 -0
  4. package/README.md +217 -0
  5. package/bin/launcher.cjs +35 -0
  6. package/bun.lock +45 -0
  7. package/dist/install.cjs +8 -0
  8. package/hooks/hooks.json +81 -0
  9. package/package.json +68 -0
  10. package/src/cli/main.ts +128 -0
  11. package/src/daemon/auth.ts +7 -0
  12. package/src/daemon/bus.ts +25 -0
  13. package/src/daemon/git.ts +118 -0
  14. package/src/daemon/logger.ts +66 -0
  15. package/src/daemon/main.ts +95 -0
  16. package/src/daemon/server.ts +208 -0
  17. package/src/daemon/spool-consumer.ts +52 -0
  18. package/src/daemon/stats.ts +33 -0
  19. package/src/daemon/store.ts +147 -0
  20. package/src/daemon/token-cache.ts +35 -0
  21. package/src/daemon/transcript.ts +130 -0
  22. package/src/daemon/ui-assets.ts +4 -0
  23. package/src/daemon/ui.ts +34 -0
  24. package/src/daemon/ws.ts +54 -0
  25. package/src/hook/main.ts +154 -0
  26. package/src/install/bun.ts +97 -0
  27. package/src/install/deploy.ts +78 -0
  28. package/src/install/json-safe.ts +43 -0
  29. package/src/install/main.ts +167 -0
  30. package/src/install/paths.ts +28 -0
  31. package/src/install/register.ts +108 -0
  32. package/src/shared/config.ts +77 -0
  33. package/src/shared/daemonctl.ts +110 -0
  34. package/src/shared/id.ts +19 -0
  35. package/src/shared/paths.ts +23 -0
  36. package/src/shared/pidfile.ts +32 -0
  37. package/src/shared/spool.ts +60 -0
  38. package/src/shared/types.ts +116 -0
  39. package/ui/.build/app.js +76 -0
  40. package/ui/app.tsx +31 -0
  41. package/ui/components/App.tsx +55 -0
  42. package/ui/components/CommitsModule.tsx +25 -0
  43. package/ui/components/CommitsView.tsx +84 -0
  44. package/ui/components/Conversation.tsx +101 -0
  45. package/ui/components/DiffBlock.tsx +64 -0
  46. package/ui/components/EventDetail.tsx +8 -0
  47. package/ui/components/EventItem.tsx +24 -0
  48. package/ui/components/EventList.tsx +22 -0
  49. package/ui/components/EventsModule.tsx +123 -0
  50. package/ui/components/Header.tsx +15 -0
  51. package/ui/components/Icon.tsx +114 -0
  52. package/ui/components/Markdown.tsx +13 -0
  53. package/ui/components/Message.tsx +60 -0
  54. package/ui/components/OverviewModule.tsx +86 -0
  55. package/ui/components/SessionDetail.tsx +51 -0
  56. package/ui/components/SessionTree.tsx +94 -0
  57. package/ui/components/SessionsModule.tsx +39 -0
  58. package/ui/components/SessionsPanel.tsx +15 -0
  59. package/ui/components/SideNav.tsx +46 -0
  60. package/ui/components/Splitter.tsx +23 -0
  61. package/ui/components/StatsModule.tsx +133 -0
  62. package/ui/components/Status.tsx +66 -0
  63. package/ui/components/SummaryView.tsx +83 -0
  64. package/ui/components/SystemModule.tsx +38 -0
  65. package/ui/components/ToolCard.tsx +57 -0
  66. package/ui/hooks/useAllCommits.ts +59 -0
  67. package/ui/hooks/useApi.ts +13 -0
  68. package/ui/hooks/useCommits.ts +45 -0
  69. package/ui/hooks/useConversation.ts +43 -0
  70. package/ui/hooks/useEvents.ts +45 -0
  71. package/ui/hooks/useSplitter.ts +43 -0
  72. package/ui/hooks/useStatsPolling.ts +35 -0
  73. package/ui/hooks/useWebSocket.ts +38 -0
  74. package/ui/index.html +13 -0
  75. package/ui/lib/aggregate.ts +81 -0
  76. package/ui/lib/diff.ts +45 -0
  77. package/ui/lib/export.ts +45 -0
  78. package/ui/lib/format.ts +100 -0
  79. package/ui/lib/util.ts +106 -0
  80. package/ui/state/AppContext.tsx +65 -0
  81. package/ui/style.css +849 -0
  82. package/ui/types.ts +46 -0
package/ui/app.tsx ADDED
@@ -0,0 +1,31 @@
1
+ // Shine Code Submit 查看页(React 入口)。token 从 URL ?t= 取、存 sessionStorage 后清掉 URL;
2
+ // 随后 createRoot 挂载 <App/>(AppProvider 在 App 内部包裹全局状态)。
3
+ import { StrictMode } from "react";
4
+ import { createRoot } from "react-dom/client";
5
+ import { App } from "./components/App";
6
+
7
+ const TOKEN_KEY = "shine_code_submit_token";
8
+
9
+ function getToken(): string | null {
10
+ const fromUrl = new URLSearchParams(location.search).get("t");
11
+ if (fromUrl) {
12
+ sessionStorage.setItem(TOKEN_KEY, fromUrl);
13
+ history.replaceState(null, "", location.pathname);
14
+ return fromUrl;
15
+ }
16
+ return sessionStorage.getItem(TOKEN_KEY);
17
+ }
18
+
19
+ const token = getToken();
20
+ const root = document.getElementById("root")!;
21
+ if (!token) {
22
+ root.innerHTML =
23
+ '<div style="padding:2rem;font-family:sans-serif;color:#ccc;background:#0f1115;min-height:100vh">' +
24
+ "缺少 token。请通过 <code>shine-code-submit ui</code> 命令打开。</div>";
25
+ } else {
26
+ createRoot(root).render(
27
+ <StrictMode>
28
+ <App token={token} />
29
+ </StrictMode>,
30
+ );
31
+ }
@@ -0,0 +1,55 @@
1
+ import { AppProvider, useApp } from "../state/AppContext";
2
+ import { Header } from "./Header";
3
+ import { SideNav } from "./SideNav";
4
+ import { SessionsModule } from "./SessionsModule";
5
+ import { EventsModule } from "./EventsModule";
6
+ import { CommitsModule } from "./CommitsModule";
7
+ import { OverviewModule } from "./OverviewModule";
8
+ import { StatsModule } from "./StatsModule";
9
+ import { SystemModule } from "./SystemModule";
10
+
11
+ /** 模块路由:按 activeModule 渲染对应模块。 */
12
+ function ModuleRouter() {
13
+ const { activeModule } = useApp();
14
+ switch (activeModule) {
15
+ case "overview":
16
+ return <OverviewModule />;
17
+ case "sessions":
18
+ return <SessionsModule />;
19
+ // case "events":
20
+ // return <EventsModule />; // 暂时屏蔽
21
+ // case "commits":
22
+ // return <CommitsModule />; // 暂时屏蔽
23
+ case "stats":
24
+ return <StatsModule />;
25
+ case "system":
26
+ return <SystemModule />;
27
+ default:
28
+ return <OverviewModule />;
29
+ }
30
+ }
31
+
32
+ function Layout() {
33
+ const { navCollapsed } = useApp();
34
+ return (
35
+ <>
36
+ <Header />
37
+ <div className="body-middle">
38
+ <aside id="nav-panel" className={`panel${navCollapsed ? " collapsed" : ""}`}>
39
+ <SideNav />
40
+ </aside>
41
+ <section id="events-panel" className="panel">
42
+ <ModuleRouter />
43
+ </section>
44
+ </div>
45
+ </>
46
+ );
47
+ }
48
+
49
+ export function App({ token }: { token: string }) {
50
+ return (
51
+ <AppProvider token={token}>
52
+ <Layout />
53
+ </AppProvider>
54
+ );
55
+ }
@@ -0,0 +1,25 @@
1
+ import { useApi } from "../hooks/useApi";
2
+ import { useCommits } from "../hooks/useCommits";
3
+ import { useApp } from "../state/AppContext";
4
+ import { CommitsView } from "./CommitsView";
5
+
6
+ /** 提交模块:最近活跃项目(sessions[0].cwd)的 git log。
7
+ * Step 3:cwd 自管(不再依赖 selectedSessionId),数据经 useCommits 拉取。 */
8
+ export function CommitsModule() {
9
+ const { token, sessions } = useApp();
10
+ const api = useApi(token);
11
+ const cwd = sessions[0]?.cwd ?? null;
12
+ const { commits, loading, error } = useCommits(api, cwd, true);
13
+ return (
14
+ <>
15
+ <div className="toolbar">
16
+ {cwd && (
17
+ <span className="filter-label" title={cwd}>
18
+ {cwd}
19
+ </span>
20
+ )}
21
+ </div>
22
+ <CommitsView commits={commits} loading={loading} error={error} cwd={cwd} />
23
+ </>
24
+ );
25
+ }
@@ -0,0 +1,84 @@
1
+ import { useState } from "react";
2
+ import { Icon } from "./Icon";
3
+ import { fmtDateTime } from "../lib/util";
4
+ import type { CommitLog } from "../types";
5
+
6
+ /** 单条提交:时间 + +新增/-删除 + 说明 + 作者;点击展开文件级明细。 */
7
+ function CommitRow({ c }: { c: CommitLog }) {
8
+ const [open, setOpen] = useState(false);
9
+ return (
10
+ <li
11
+ className={`commit${open ? " open" : ""}`}
12
+ title="点击展开/收起文件明细"
13
+ onClick={() => setOpen((v) => !v)}
14
+ >
15
+ <div className="commit-row">
16
+ <span className="commit-ts">{fmtDateTime(c.time)}</span>
17
+ <span className="commit-add">+{c.added}</span>
18
+ <span className="commit-del">-{c.deleted}</span>
19
+ <span className="commit-subject">{c.subject || "(无说明)"}</span>
20
+ <span className="commit-author">{c.author}</span>
21
+ </div>
22
+ {open && c.files.length > 0 && (
23
+ <ul className="commit-files">
24
+ {c.files.map((f, i) => (
25
+ <li key={i} className="commit-file">
26
+ <span className="cf-add">+{f.added}</span>
27
+ <span className="cf-del">-{f.deleted}</span>
28
+ <span className="cf-path">{f.path}</span>
29
+ </li>
30
+ ))}
31
+ </ul>
32
+ )}
33
+ </li>
34
+ );
35
+ }
36
+
37
+ /** 提交列表(props 驱动,Step 3 下沉)。数据由 CommitsModule 经 useCommits 传入。 */
38
+ export function CommitsView({
39
+ commits,
40
+ loading,
41
+ error,
42
+ cwd,
43
+ }: {
44
+ commits: CommitLog[];
45
+ loading: boolean;
46
+ error: string | null;
47
+ cwd: string | null;
48
+ }) {
49
+ let body;
50
+ // 仅首次加载(无缓存)才显加载态;切回已有数据静默刷新
51
+ if (loading && commits.length === 0) {
52
+ body = (
53
+ <div className="empty-state">
54
+ <span className="es-hint">加载提交…</span>
55
+ </div>
56
+ );
57
+ } else if (error) {
58
+ body = (
59
+ <div className="empty-state">
60
+ <Icon name="warning" size={28} />
61
+ <span className="es-hint">{error}</span>
62
+ {cwd && <span className="es-sub">{cwd}</span>}
63
+ </div>
64
+ );
65
+ } else if (!commits.length) {
66
+ body = (
67
+ <div className="empty-state">
68
+ <Icon name="inbox" size={30} />
69
+ <span className="es-hint">暂无提交</span>
70
+ <span className="es-sub">该仓库最近无提交记录</span>
71
+ </div>
72
+ );
73
+ } else {
74
+ body = (
75
+ <ul id="commits" className="commit-list">
76
+ {commits.map((c) => (
77
+ <CommitRow key={c.hash} c={c} />
78
+ ))}
79
+ </ul>
80
+ );
81
+ }
82
+
83
+ return <div id="commits-view" className="commits-view">{body}</div>;
84
+ }
@@ -0,0 +1,101 @@
1
+ import { useMemo, type ReactNode } from "react";
2
+ import { fmtTokens, fmtUsage, fmtUsageFull, sumUsage } from "../lib/util";
3
+ import type { TranscriptMessage } from "../types";
4
+ import { Icon } from "./Icon";
5
+ import { Message } from "./Message";
6
+
7
+ function msgMatches(m: TranscriptMessage, q: string): boolean {
8
+ if (!q) return true;
9
+ if ((m.text || "").toLowerCase().includes(q)) return true;
10
+ if ((m.thinking || "").toLowerCase().includes(q)) return true;
11
+ if ((m.toolName || "").toLowerCase().includes(q)) return true;
12
+ return (m.tools || []).some(
13
+ (t) =>
14
+ (t.name || "").toLowerCase().includes(q) ||
15
+ JSON.stringify(t.input).toLowerCase().includes(q),
16
+ );
17
+ }
18
+
19
+ function Skeleton() {
20
+ return (
21
+ <>
22
+ <div className="skeleton skel-bubble" />
23
+ <div className="skeleton skel-block" />
24
+ <div className="skeleton skel-bubble" />
25
+ <div className="skeleton skel-block" style={{ width: "68%" }} />
26
+ </>
27
+ );
28
+ }
29
+
30
+ /** 对话视图:Step 2 起改为 props 驱动(去 Context),供 SessionDetail 复用。
31
+ * 数据(messages/loading/error)与搜索态由调用方传入。 */
32
+ export function Conversation({
33
+ messages,
34
+ loading,
35
+ error,
36
+ search,
37
+ }: {
38
+ messages: TranscriptMessage[];
39
+ loading: boolean;
40
+ error: string | null;
41
+ search: string;
42
+ }) {
43
+ const q = search.trim().toLowerCase();
44
+ const shown = useMemo(
45
+ () => (q ? messages.filter((m) => msgMatches(m, q)) : messages),
46
+ [messages, q],
47
+ );
48
+ const tokenTotal = useMemo(() => sumUsage(messages), [messages]);
49
+
50
+ let body: ReactNode;
51
+ // 仅首次加载(无缓存数据)才显骨架;切回已有数据时静默刷新,避免切换闪烁
52
+ if (loading && messages.length === 0) {
53
+ body = <Skeleton />;
54
+ } else if (error) {
55
+ body = (
56
+ <div className="empty-state">
57
+ <Icon name="warning" size={28} />
58
+ <span className="es-hint">加载失败</span>
59
+ <span className="es-sub">{error}</span>
60
+ </div>
61
+ );
62
+ } else if (!messages.length) {
63
+ body = (
64
+ <div className="empty-state">
65
+ <Icon name="chat" size={30} />
66
+ <span className="es-hint">暂无对话</span>
67
+ <span className="es-sub">该会话无 transcript 或尚未产生对话</span>
68
+ </div>
69
+ );
70
+ } else if (!shown.length) {
71
+ body = (
72
+ <div className="empty-state">
73
+ <span className="es-hint">无匹配消息</span>
74
+ <span className="es-sub">“{search}” 未命中(0/{messages.length})</span>
75
+ </div>
76
+ );
77
+ } else {
78
+ body = (
79
+ <>
80
+ {q && <div className="search-count">{`${shown.length}/${messages.length} 条匹配`}</div>}
81
+ {shown.map((m, i) => (
82
+ <Message key={i} m={m} />
83
+ ))}
84
+ </>
85
+ );
86
+ }
87
+
88
+ return (
89
+ <div id="conversation" className="conversation">
90
+ {!loading && !error && messages.length > 0 && (tokenTotal.input > 0 || tokenTotal.output > 0) && (
91
+ <div className="conv-token-bar" title={fmtUsageFull(tokenTotal)}>
92
+ 本会话累计 <b>{fmtUsage(tokenTotal)}</b>
93
+ <span className="conv-token-cache">
94
+ {" "}· 缓存读 {fmtTokens(tokenTotal.cacheRead)} / 写 {fmtTokens(tokenTotal.cacheCreation)}
95
+ </span>
96
+ </div>
97
+ )}
98
+ {body}
99
+ </div>
100
+ );
101
+ }
@@ -0,0 +1,64 @@
1
+ import type { ReactNode } from "react";
2
+ import type { DiffLine } from "../lib/diff";
3
+
4
+ /** 渲染行级 diff(连续 >3 行 context 折叠为蓝色分隔条)。 */
5
+ export function DiffBlock({ diffs }: { diffs: DiffLine[] }) {
6
+ const rows: ReactNode[] = [];
7
+ let ctxBuf: DiffLine[] = [];
8
+ let key = 0;
9
+ const flushCtx = () => {
10
+ if (!ctxBuf.length) return;
11
+ if (ctxBuf.length <= 3) {
12
+ for (const dl of ctxBuf) {
13
+ rows.push(
14
+ <div className="diff-line ctx" key={key++}>
15
+ <span className="diff-text">{dl.text}</span>
16
+ </div>,
17
+ );
18
+ }
19
+ } else {
20
+ const first = ctxBuf[0]!;
21
+ const last = ctxBuf[ctxBuf.length - 1]!;
22
+ rows.push(
23
+ <div className="diff-line ctx" key={key++}>
24
+ <span className="diff-text">{first.text}</span>
25
+ </div>,
26
+ );
27
+ rows.push(
28
+ <div className="diff-folds" key={key++}>
29
+ ⋯ {ctxBuf.length - 2} 行未改
30
+ </div>,
31
+ );
32
+ rows.push(
33
+ <div className="diff-line ctx" key={key++}>
34
+ <span className="diff-text">{last.text}</span>
35
+ </div>,
36
+ );
37
+ }
38
+ ctxBuf = [];
39
+ };
40
+ for (const dl of diffs) {
41
+ if (dl.op === "ctx") {
42
+ ctxBuf.push(dl);
43
+ continue;
44
+ }
45
+ flushCtx();
46
+ if (dl.op === "add") {
47
+ rows.push(
48
+ <div className="diff-line add" key={key++}>
49
+ <span className="diff-sign">+</span>
50
+ <span className="diff-text">{dl.text}</span>
51
+ </div>,
52
+ );
53
+ } else {
54
+ rows.push(
55
+ <div className="diff-line del" key={key++}>
56
+ <span className="diff-sign">-</span>
57
+ <span className="diff-text">{dl.text}</span>
58
+ </div>,
59
+ );
60
+ }
61
+ }
62
+ flushCtx();
63
+ return <div className="diff-block">{rows}</div>;
64
+ }
@@ -0,0 +1,8 @@
1
+ import { formatDetail } from "../lib/format";
2
+
3
+ /** 事件详情:formatDetail 产出 HTML(内部已 escapeHtml),用 dangerouslySetInnerHTML 渲染。 */
4
+ export function EventDetail({ payload }: { payload: unknown }) {
5
+ return (
6
+ <div className="event-detail" dangerouslySetInnerHTML={{ __html: formatDetail(payload) }} />
7
+ );
8
+ }
@@ -0,0 +1,24 @@
1
+ import { useState } from "react";
2
+ import { eventSummary } from "../lib/format";
3
+ import { fmtTime } from "../lib/util";
4
+ import type { HookEvent } from "../types";
5
+ import { EventDetail } from "./EventDetail";
6
+
7
+ export function EventItem({ ev }: { ev: HookEvent }) {
8
+ const [open, setOpen] = useState(false);
9
+ return (
10
+ <li
11
+ className={`event ${ev.type}${open ? " open" : ""}`}
12
+ title="点击展开/收起完整内容"
13
+ onClick={() => setOpen((v) => !v)}
14
+ >
15
+ <div className="event-row">
16
+ <span className="ts">{fmtTime(ev.timestamp)}</span>
17
+ <span className="type">{ev.type}</span>
18
+ <span className="sess">{ev.sessionId.slice(0, 10)}</span>
19
+ <span className="summary">{eventSummary(ev)}</span>
20
+ </div>
21
+ {open && <EventDetail payload={ev.payload} />}
22
+ </li>
23
+ );
24
+ }
@@ -0,0 +1,22 @@
1
+ import type { HookEvent } from "../types";
2
+ import { Icon } from "./Icon";
3
+ import { EventItem } from "./EventItem";
4
+
5
+ export function EventList({ events, filtered }: { events: HookEvent[]; filtered: boolean }) {
6
+ if (events.length === 0) {
7
+ return (
8
+ <div className="empty-state">
9
+ <Icon name="inbox" size={30} />
10
+ <span className="es-hint">还没有事件</span>
11
+ <span className="es-sub">在 Claude Code 里开个会话,事件会实时出现在这里</span>
12
+ </div>
13
+ );
14
+ }
15
+ return (
16
+ <ul id="events" className={`event-list${filtered ? " filtered" : ""}`}>
17
+ {events.map((ev) => (
18
+ <EventItem key={ev.eventId} ev={ev} />
19
+ ))}
20
+ </ul>
21
+ );
22
+ }
@@ -0,0 +1,123 @@
1
+ import { useMemo, useState } from "react";
2
+ import { useApi } from "../hooks/useApi";
3
+ import { useEvents } from "../hooks/useEvents";
4
+ import { useWebSocket } from "../hooks/useWebSocket";
5
+ import { useApp } from "../state/AppContext";
6
+ import { download } from "../lib/export";
7
+ import { fmtUsage, fmtUsageFull, shortDir } from "../lib/util";
8
+ import { Icon } from "./Icon";
9
+ import { EventList } from "./EventList";
10
+ import { SessionTree } from "./SessionTree";
11
+ import { Splitter } from "./Splitter";
12
+ import type { HookEvent } from "../types";
13
+
14
+ /** 事件模块(统一事件入口):左侧会话树(筛选)+ 右侧事件流。
15
+ * 选会话 → 顶部显该会话摘要(sid/cwd/token,与对话页对称)+ 只看该会话事件;
16
+ * 全部 → 跨会话全局实时流。树宽可拖拽(--tree-w)。 */
17
+ export function EventsModule() {
18
+ const { token, sessions } = useApp();
19
+ const api = useApi(token);
20
+ const [search, setSearch] = useState("");
21
+ const [sessionFilter, setSessionFilter] = useState<string | null>(null);
22
+ const [live, setLive] = useState<HookEvent[]>([]);
23
+ const { events, loading, error } = useEvents(api, sessionFilter, true);
24
+ useWebSocket(token, (ev) => setLive((prev) => [ev, ...prev].slice(0, 200)));
25
+
26
+ const session = sessionFilter ? sessions.find((s) => s.sessionId === sessionFilter) : undefined;
27
+ const tokenTotal = session?.tokenTotal ?? null;
28
+
29
+ const all = useMemo(() => {
30
+ const seen = new Set<string>();
31
+ const merged: HookEvent[] = [];
32
+ for (const ev of [...live, ...events]) {
33
+ if (sessionFilter && ev.sessionId !== sessionFilter) continue;
34
+ if (seen.has(ev.eventId)) continue;
35
+ seen.add(ev.eventId);
36
+ merged.push(ev);
37
+ }
38
+ return merged;
39
+ }, [live, events, sessionFilter]);
40
+
41
+ const q = search.trim().toLowerCase();
42
+ const filtered = useMemo(() => {
43
+ if (!q) return all;
44
+ return all.filter((ev) =>
45
+ (ev.type + " " + JSON.stringify(ev.payload ?? "")).toLowerCase().includes(q),
46
+ );
47
+ }, [all, q]);
48
+
49
+ const onExport = () => {
50
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
51
+ const sid = sessionFilter ? sessionFilter.slice(0, 8) : "all";
52
+ download(`events-${sid}-${stamp}.json`, JSON.stringify(filtered, null, 2), "application/json");
53
+ };
54
+
55
+ let body;
56
+ if (loading && all.length === 0) {
57
+ body = (
58
+ <div className="empty-state">
59
+ <span className="es-hint">加载事件…</span>
60
+ </div>
61
+ );
62
+ } else if (error) {
63
+ body = (
64
+ <div className="empty-state">
65
+ <Icon name="warning" size={28} />
66
+ <span className="es-hint">{error}</span>
67
+ </div>
68
+ );
69
+ } else {
70
+ body = <EventList events={filtered} filtered={false} />;
71
+ }
72
+
73
+ return (
74
+ <div className="events-with-tree">
75
+ <aside className="events-tree-panel panel">
76
+ <div className="panel-header">
77
+ <h2>会话</h2>
78
+ </div>
79
+ <SessionTree selectedId={sessionFilter} onSelect={setSessionFilter} allLabel="全部会话" />
80
+ </aside>
81
+ <Splitter orient="v" varName="--tree-w" />
82
+ <div className="events-main">
83
+ {session && (
84
+ <div className="detail-head">
85
+ <span className="detail-sid" title={session.sessionId}>
86
+ {session.sessionId.slice(0, 8)}
87
+ </span>
88
+ {session.cwd && (
89
+ <span className="detail-cwd" title={session.cwd}>
90
+ {shortDir(session.cwd)}
91
+ </span>
92
+ )}
93
+ {tokenTotal && (tokenTotal.input > 0 || tokenTotal.output > 0) && (
94
+ <span className="detail-token" title={fmtUsageFull(tokenTotal)}>
95
+ {fmtUsage(tokenTotal)}
96
+ </span>
97
+ )}
98
+ </div>
99
+ )}
100
+ <div className="toolbar">
101
+ <input
102
+ className="search-input"
103
+ type="search"
104
+ placeholder="搜索事件…"
105
+ value={search}
106
+ onChange={(e) => setSearch(e.target.value)}
107
+ autoComplete="off"
108
+ />
109
+ <button
110
+ className="icon-btn"
111
+ type="button"
112
+ title="导出事件为 JSON"
113
+ aria-label="导出"
114
+ onClick={onExport}
115
+ >
116
+ <Icon name="download" />
117
+ </button>
118
+ </div>
119
+ {body}
120
+ </div>
121
+ </div>
122
+ );
123
+ }
@@ -0,0 +1,15 @@
1
+ import { Icon } from "./Icon";
2
+ import { Status } from "./Status";
3
+
4
+ /** 顶栏:标题 + 右侧运行状态(Step 5 移除 log toggle,日志已收进「系统」模块)。 */
5
+ export function Header() {
6
+ return (
7
+ <header>
8
+ <div className="title">
9
+ <Icon name="diamond" size={14} />
10
+ <span>Shine Code Submit</span>
11
+ </div>
12
+ <Status />
13
+ </header>
14
+ );
15
+ }
@@ -0,0 +1,114 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /** 统一图标:24×24 viewBox、stroke=currentColor、1.6 描边、圆角端点。
4
+ * 跨平台渲染一致(替代 emoji/几何符号),尺寸随 --icon 或 size 覆盖。 */
5
+ export type IconName =
6
+ | "sessions"
7
+ | "log"
8
+ | "download"
9
+ | "close"
10
+ | "chevron"
11
+ | "info"
12
+ | "inbox"
13
+ | "chat"
14
+ | "diamond"
15
+ | "activity"
16
+ | "warning"
17
+ | "home"
18
+ | "git"
19
+ | "chart"
20
+ | "server";
21
+
22
+ const PATHS: Record<IconName, ReactNode> = {
23
+ sessions: (
24
+ <>
25
+ <rect x="3" y="3" width="18" height="18" rx="2" />
26
+ <path d="M9 3v18" />
27
+ </>
28
+ ),
29
+ log: (
30
+ <>
31
+ <rect x="6" y="3" width="12" height="18" rx="2" />
32
+ <path d="M9 8h6 M9 12h6 M9 16h3" />
33
+ </>
34
+ ),
35
+ download: (
36
+ <>
37
+ <path d="M12 3v12" />
38
+ <path d="M7 10l5 5 5-5" />
39
+ <path d="M5 21h14" />
40
+ </>
41
+ ),
42
+ close: <path d="M6 6l12 12 M18 6L6 18" />,
43
+ chevron: <path d="M9 6l6 6-6 6" />,
44
+ info: (
45
+ <>
46
+ <circle cx="12" cy="12" r="9" />
47
+ <path d="M12 11v5 M12 8h.01" />
48
+ </>
49
+ ),
50
+ inbox: (
51
+ <>
52
+ <path d="M3 12h5l2 3h4l2-3h5" />
53
+ <rect x="4" y="4" width="16" height="16" rx="1" />
54
+ </>
55
+ ),
56
+ chat: <path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z" />,
57
+ diamond: <path d="M12 3l9 9-9 9-9-9z" />,
58
+ activity: <path d="M3 12h4l3-8 4 16 3-8h4" />,
59
+ warning: (
60
+ <>
61
+ <path d="M12 3l9 17H3z" />
62
+ <path d="M12 10v4 M12 17h.01" />
63
+ </>
64
+ ),
65
+ home: (
66
+ <>
67
+ <path d="M3 11l9-8 9 8" />
68
+ <path d="M5 10v10h4v-6h6v6h4V10" />
69
+ </>
70
+ ),
71
+ git: (
72
+ <>
73
+ <circle cx="6" cy="6" r="2" />
74
+ <circle cx="6" cy="18" r="2" />
75
+ <circle cx="18" cy="9" r="2" />
76
+ <path d="M6 8v8M15 9h1" />
77
+ </>
78
+ ),
79
+ chart: <path d="M4 20V12M10 20V5M16 20v-8M3 20h17" />,
80
+ server: (
81
+ <>
82
+ <rect x="4" y="4" width="16" height="6" rx="1" />
83
+ <rect x="4" y="14" width="16" height="6" rx="1" />
84
+ <path d="M8 7h.01M8 17h.01" />
85
+ </>
86
+ ),
87
+ };
88
+
89
+ export function Icon({
90
+ name,
91
+ size,
92
+ className,
93
+ }: {
94
+ name: IconName;
95
+ size?: number;
96
+ className?: string;
97
+ }) {
98
+ return (
99
+ <svg
100
+ width={size ?? 16}
101
+ height={size ?? 16}
102
+ viewBox="0 0 24 24"
103
+ fill="none"
104
+ stroke="currentColor"
105
+ strokeWidth={1.6}
106
+ strokeLinecap="round"
107
+ strokeLinejoin="round"
108
+ className={`icon icon-${name}${className ? " " + className : ""}`}
109
+ aria-hidden="true"
110
+ >
111
+ {PATHS[name]}
112
+ </svg>
113
+ );
114
+ }