<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" href="data:,">
<title>sessionwiki</title>
<style>
:root {
--bg: #faf9f6;
--bg-sunken: #f3f1ec;
--card: #ffffff;
--ink: #1e1d1a;
--ink-2: #6e6b62;
--ink-3: #a3a097;
--line: #e6e3db;
--line-strong: #d5d1c6;
--accent: #3b5bd6;
--accent-soft: #e8ecfa;
--mark: #ffe9a8;
--user-bg: #f1eee7;
--code-bg: #f4f2ec;
--dot-claude: #c97f56;
--dot-codex: #5f9e7d;
--dot-gemini: #7d8fd1;
--shadow: 0 1px 2px rgba(30, 29, 26, .05);
}
[data-theme="dark"] {
--bg: #101013;
--bg-sunken: #16161b;
--card: #1b1b21;
--ink: #e7e7ec;
--ink-2: #9b9ba8;
--ink-3: #686874;
--line: #27272f;
--line-strong: #34343e;
--accent: #93a7ef;
--accent-soft: #232a40;
--mark: #5d521f;
--user-bg: #20202a;
--code-bg: #16161c;
--shadow: 0 1px 2px rgba(0, 0, 0, .4);
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI Variable", "Segoe UI",
Inter, "Pretendard Variable", Pretendard, "Noto Sans KR", Roboto, sans-serif;
background: var(--bg);
color: var(--ink);
font-size: 14px;
font-weight: 440;
line-height: 1.6;
word-break: keep-all;
overflow-wrap: anywhere;
}
::selection { background: var(--accent-soft); }
.layout { display: flex; height: 100vh; }
aside {
width: 348px;
min-width: 270px;
border-right: 1px solid var(--line);
display: flex;
flex-direction: column;
background: var(--bg-sunken);
}
.brand { padding: 16px 18px 12px; }
.brand h1 {
margin: 0;
font-size: 15px;
font-weight: 640;
letter-spacing: -.2px;
}
.brand .stats { color: var(--ink-2); font-size: 12px; margin-top: 2px; font-weight: 440; }
.controls { padding: 0 12px 12px; display: flex; gap: 8px; }
.searchwrap { position: relative; flex: 1; }
.searchwrap svg {
position: absolute; left: 10px; top: 50%; transform: translateY(-50%);
width: 13px; height: 13px; stroke: var(--ink-3); fill: none; stroke-width: 2;
pointer-events: none;
}
.searchwrap kbd {
position: absolute; right: 9px; top: 50%; transform: translateY(-50%);
font: 600 10px/1 inherit; color: var(--ink-3);
border: 1px solid var(--line-strong); border-radius: 4px; padding: 2px 5px;
background: var(--card); pointer-events: none;
}
.controls input[type="search"] {
width: 100%;
padding: 8px 30px 8px 29px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--card);
font: inherit;
font-size: 13px;
color: inherit;
box-shadow: var(--shadow);
}
.controls input[type="search"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-soft);
}
.controls input[type="search"]:focus ~ kbd { display: none; }
.controls select {
padding: 8px 9px;
border: 1px solid var(--line);
border-radius: 10px;
background: var(--card);
font: inherit;
font-size: 13px;
color: var(--ink-2);
box-shadow: var(--shadow);
}
.controls select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
nav { overflow-y: auto; flex: 1; padding: 0 8px 8px; }
.group {
position: sticky; top: 0; z-index: 1;
padding: 10px 10px 5px;
font-size: 10.5px; font-weight: 640; letter-spacing: .07em; text-transform: uppercase;
color: var(--ink-3);
background: var(--bg-sunken);
}
.item {
padding: 9px 10px;
border-radius: 10px;
cursor: pointer;
border: 1px solid transparent;
}
.item:hover { background: var(--card); box-shadow: var(--shadow); }
.item.active { background: var(--card); border-color: var(--line); box-shadow: var(--shadow); }
.item .title {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: 13px;
font-weight: 480;
line-height: 1.45;
}
.item .meta {
display: flex; align-items: center; gap: 5px;
color: var(--ink-2); font-size: 11.5px; margin-top: 4px;
}
.dot { width: 6px; height: 6px; border-radius: 50%; flex: none; background: var(--ink-3); }
.dot.claude-code { background: var(--dot-claude); }
.dot.codex { background: var(--dot-codex); }
.dot.gemini { background: var(--dot-gemini); }
.item .snip { color: var(--ink-2); font-size: 12px; margin-top: 4px; line-height: 1.5; }
.item .preview {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
color: var(--ink-3);
font-size: 12px;
margin-top: 3px;
line-height: 1.45;
}
mark { background: var(--mark); color: inherit; padding: 0 1px; border-radius: 2px; }
.empty { padding: 22px 14px; color: var(--ink-2); font-size: 13px; }
.sidefoot {
padding: 9px 12px 11px;
border-top: 1px solid var(--line);
}
.sidefoot .hint { display: block; color: var(--ink-3); font-size: 10.5px; margin-bottom: 7px; padding: 0 2px; }
.footrow { display: flex; justify-content: space-between; align-items: center; gap: 8px; }
.themebtn {
font: 500 11.5px/1.2 inherit;
color: var(--ink-2);
background: var(--card);
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 11px;
cursor: pointer;
}
.themebtn:hover { color: var(--ink); border-color: var(--line-strong); }
.langctl { position: relative; }
.langbtn {
display: inline-flex; align-items: center; gap: 6px;
font: 500 11.5px/1.2 inherit;
color: var(--ink-2);
background: var(--card);
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 10px;
cursor: pointer;
}
.langbtn:hover { color: var(--ink); border-color: var(--line-strong); }
.langbtn svg { color: var(--ink-3); flex: none; }
.langbtn .chev { transition: transform .15s; }
.langctl.open .langbtn .chev { transform: rotate(180deg); }
.langctl.open .langbtn { border-color: var(--accent); color: var(--ink); }
.langmenu {
position: absolute; bottom: calc(100% + 6px); left: 0;
min-width: 132px;
background: var(--card);
border: 1px solid var(--line);
border-radius: 10px;
box-shadow: 0 6px 24px rgba(20, 18, 14, .14);
padding: 4px;
z-index: 5;
}
[data-theme="dark"] .langmenu { box-shadow: 0 6px 24px rgba(0, 0, 0, .5); }
.langmenu button {
display: flex; align-items: center; justify-content: space-between;
width: 100%;
font: 500 12.5px/1.3 inherit;
color: var(--ink-2);
background: none; border: 0; border-radius: 7px;
padding: 7px 10px;
cursor: pointer;
text-align: left;
}
.langmenu button:hover { background: var(--bg-sunken); color: var(--ink); }
.langmenu button[aria-selected="true"] { color: var(--accent); }
.langmenu button[aria-selected="true"]::after { content: "\2713"; font-size: 11px; }
main { flex: 1; overflow-y: auto; background: var(--bg); }
.placeholder { padding: 56px; color: var(--ink-2); max-width: 540px; }
.placeholder h2 { font-size: 16px; font-weight: 560; color: var(--ink); margin: 0 0 8px; }
.placeholder code, .doc-head code {
background: var(--code-bg); border: 1px solid var(--line); border-radius: 5px;
padding: 1px 5px; font-size: .92em;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
}
.doc-head {
position: sticky; top: 0; z-index: 2;
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--line);
padding: 14px 36px 12px;
}
.doc-head h2 {
margin: 0;
font-size: 16.5px;
font-weight: 560;
letter-spacing: -.2px;
line-height: 1.4;
max-width: 820px;
}
.doc-head .chips {
display: flex; flex-wrap: wrap; gap: 6px 14px;
margin-top: 6px;
color: var(--ink-2); font-size: 12px;
}
.doc-head .chips .chip { display: inline-flex; align-items: center; gap: 5px; }
.doc-head .path { color: var(--ink-3); font-size: 11.5px; margin-top: 4px; overflow-wrap: anywhere; }
.doc-head .synopsis {
margin-top: 8px;
max-width: 820px;
font-size: 13px;
color: var(--ink-2);
border-top: 1px solid var(--line);
padding-top: 8px;
}
.doc-head .resume {
margin-top: 6px;
font-size: 11.5px;
color: var(--ink-2);
}
.doc-head .resume code {
cursor: pointer;
}
.doc-head .resume code:hover { border-color: var(--accent); color: var(--ink); }
.doc-head .tags { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.tagchip {
font-size: 11.5px;
color: var(--accent);
background: var(--accent-soft);
border-radius: 999px;
padding: 2px 9px;
}
.doc-head .note {
margin-top: 7px;
font-size: 12.5px;
color: var(--ink-2);
border-left: 0;
background: var(--bg-sunken);
border: 1px solid var(--line);
border-radius: 8px;
padding: 7px 11px;
max-width: 820px;
white-space: pre-wrap;
}
.doc-head .files { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
.doc-head .files .lbl {
font-size: 11px; font-weight: 640; letter-spacing: .05em; text-transform: uppercase;
color: var(--ink-3); margin-right: 2px;
}
.filechip {
font-size: 11.5px;
font-family: var(--mono, ui-monospace, SFMono-Regular, Menlo, monospace);
color: var(--ink-2);
background: var(--bg-sunken);
border: 1px solid var(--line);
border-radius: 7px;
padding: 2px 8px;
cursor: pointer;
}
.filechip:hover { border-color: var(--accent); color: var(--ink); }
.filechip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
.doc { max-width: 800px; margin: 0 auto; padding: 26px 36px 90px; }
.seealso {
margin-top: 26px;
border-top: 1px solid var(--line);
padding-top: 14px;
}
.seealso h3 {
margin: 0 0 8px;
font-size: 11.5px;
font-weight: 640;
letter-spacing: .06em;
text-transform: uppercase;
color: var(--ink-3);
}
.seealso .rel {
display: block;
padding: 7px 10px;
border-radius: 9px;
cursor: pointer;
}
.seealso .rel:hover { background: var(--bg-sunken); }
.seealso .rel .t { font-size: 13px; color: var(--ink); }
.seealso .rel .m { font-size: 11.5px; color: var(--ink-3); margin-top: 2px; }
.seealso .rel .edits { margin-top: 6px; display: flex; flex-direction: column; gap: 4px; }
.seealso .rel .edit { display: flex; align-items: baseline; gap: 7px; min-width: 0; }
.seealso .rel .editkind {
flex: none; font-size: 9.5px; letter-spacing: .04em; text-transform: uppercase;
color: var(--ink-2); border: 1px solid var(--line); border-radius: 4px;
padding: 1px 5px; background: var(--bg-sunken);
}
.seealso .rel .editsnip {
flex: 1; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 11px; color: var(--ink-2); background: var(--code-bg);
border-radius: 4px; padding: 1px 6px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.seealso .rel .editmore { font-size: 10.5px; color: var(--ink-3); margin-top: 1px; }
.outline {
background: var(--bg-sunken);
border: 1px solid var(--line);
border-radius: 12px;
margin-bottom: 22px;
}
.outline summary {
list-style: none;
cursor: pointer;
padding: 10px 16px;
font-size: 12px;
font-weight: 600;
letter-spacing: .04em;
text-transform: uppercase;
color: var(--ink-2);
}
.outline ol {
margin: 0;
padding: 0 16px 12px 36px;
font-size: 13px;
color: var(--ink-2);
}
.outline li { margin: 4px 0; cursor: pointer; }
.outline li:hover { color: var(--accent); }
.msg { display: flex; gap: 12px; margin-bottom: 18px; }
.avatar {
flex: none;
width: 24px; height: 24px;
border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 10.5px; font-weight: 640;
margin-top: 2px;
}
.msg.user .avatar { background: var(--ink); color: var(--bg); }
.msg.assistant .avatar { background: var(--card); color: var(--ink-2); border: 1px solid var(--line-strong); }
.msg .content { flex: 1; min-width: 0; }
.msg .who { font-size: 11px; font-weight: 600; letter-spacing: .05em; text-transform: uppercase; color: var(--ink-3); margin-bottom: 3px; }
.msg .body {
white-space: pre-wrap;
font-size: 14px;
line-height: 1.65;
}
.msg.user .body {
background: var(--user-bg);
border-radius: 12px;
padding: 11px 15px;
}
.msg .body pre {
background: var(--code-bg);
border: 1px solid var(--line);
border-radius: 10px;
padding: 12px 14px;
overflow-x: auto;
white-space: pre;
font-size: 12.5px;
line-height: 1.55;
position: relative;
}
.msg .body pre[data-lang]::before {
content: attr(data-lang);
position: absolute; top: 6px; right: 10px;
font-size: 10px; color: var(--ink-3);
font-family: inherit;
}
.msg .body pre, .msg .body pre code {
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
}
.msg .body code:not(pre code) {
background: var(--code-bg);
border: 1px solid var(--line);
border-radius: 5px;
padding: 0 4px;
font-size: .92em;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
}
.msg .body a { color: var(--accent); text-decoration: none; }
.msg .body a:hover { text-decoration: underline; }
.toolrow { margin: 0 0 14px 36px; }
.toolrow details {
background: var(--bg-sunken);
border: 1px solid var(--line);
border-radius: 10px;
}
.toolrow summary {
list-style: none;
cursor: pointer;
display: flex; align-items: center; gap: 8px;
padding: 7px 12px;
color: var(--ink-2);
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 11.5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.toolrow summary::before {
content: "";
flex: none;
width: 5px; height: 5px;
border-right: 1.5px solid var(--ink-3);
border-bottom: 1.5px solid var(--ink-3);
transform: rotate(-45deg);
transition: transform .12s;
}
.toolrow details[open] summary::before { transform: rotate(45deg); }
.toolrow .body {
padding: 4px 14px 12px;
white-space: pre-wrap;
font-family: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
font-size: 12px;
color: var(--ink-2);
}
</style>
</head>
<body>
<div class="layout">
<aside>
<div class="brand">
<h1>sessionwiki</h1>
<div class="stats" id="stats">loading index…</div>
</div>
<div class="controls">
<div class="searchwrap">
<svg viewBox="0 0 16 16"><circle cx="7" cy="7" r="5"/><path d="m11 11 3.5 3.5"/></svg>
<input type="search" id="q" placeholder="Search sessions" autocomplete="off">
<kbd>/</kbd>
</div>
<select id="tool" aria-label="Filter by tool">
<option value="">all tools</option>
</select>
</div>
<nav id="list"></nav>
<div class="sidefoot">
<span class="hint" id="hint">read-only view · refresh: sessionwiki list</span>
<div class="footrow">
<div class="langctl" id="langctl">
<button class="langbtn" id="langbtn" aria-haspopup="listbox" aria-expanded="false">
<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.4">
<circle cx="8" cy="8" r="6.4"/><path d="M1.6 8h12.8M8 1.6c1.9 2 1.9 10.8 0 12.8M8 1.6c-1.9 2-1.9 10.8 0 12.8"/>
</svg>
<span id="langlabel">한국어</span>
<svg class="chev" viewBox="0 0 10 10" width="9" height="9" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M2 4l3 3 3-3"/></svg>
</button>
<div class="langmenu" id="langmenu" role="listbox" hidden>
<button role="option" data-lang="en">English</button>
<button role="option" data-lang="ko">한국어</button>
<button role="option" data-lang="ja">日本語</button>
<button role="option" data-lang="zh">中文</button>
</div>
</div>
<button class="themebtn" id="theme">Dark</button>
</div>
</div>
</aside>
<main id="main">
<div class="placeholder">
<h2 id="ph-title">Pick a session on the left</h2>
<p id="ph-p1">Everything your coding agents ever said, in one place. Search works across
every tool at once — partial words and CJK included.</p>
<p id="ph-p2">The index updates when you run <code>sessionwiki list</code> or
<code>search</code> in a terminal; reload this page afterwards.</p>
</div>
</main>
</div>
<script>
const $ = (id) => document.getElementById(id);
let activeEl = null;
let currentTag = null;
const I18N = {
en: {
loading: "loading index…",
searchPh: "Search sessions",
allTools: "all tools",
groups: { today: "Today", yesterday: "Yesterday", week: "This week", month: "This month", earlier: "Earlier" },
msgs: (n) => `${n} msgs`,
subagent: "subagent",
untitled: "(untitled)",
noMatches: "No matches.",
emptyIndex: "Index is empty - run: sessionwiki list",
phTitle: "Pick a session on the left",
phP1: "Everything your coding agents ever said, in one place. Search works across every tool at once - partial words and CJK included.",
phP2: "The index updates when you run `sessionwiki list` or `search` in a terminal; reload this page afterwards.",
messages: (n) => `${n} messages`,
resumeLabel: "resume in terminal: ",
copied: "copied",
clickToCopy: "Click to copy",
outline: (n) => `Outline · ${n} turns`,
seeAlso: "See also", filtering: "filtering", filterByTag: "filter by tag",
filesLabel: "Files", tracedBy: (f) => `Sessions that touched ${f}`,
fileHistory: (f) => `How ${f} was written`,
moreEdits: (n) => `+${n} more`, loadFailed: "Couldn't load",
hint: "read-only view · refresh: sessionwiki list",
themeDark: "Dark", themeLight: "Light",
stats: (s, t, b) => `${s} sessions · ${t} tools · ${b}`,
},
ko: {
loading: "인덱스 불러오는 중…",
searchPh: "세션 검색",
allTools: "모든 툴",
groups: { today: "오늘", yesterday: "어제", week: "이번 주", month: "이번 달", earlier: "이전" },
msgs: (n) => `메시지 ${n}`,
subagent: "서브에이전트",
untitled: "(제목 없음)",
noMatches: "결과가 없습니다.",
emptyIndex: "인덱스가 비어 있습니다 - 터미널에서 sessionwiki list 실행",
phTitle: "왼쪽에서 세션을 선택하세요",
phP1: "에이전트와 나눈 모든 대화를 한곳에서. 검색은 모든 툴을 한 번에 훑습니다 - 부분 단어, 한국어 모두 지원.",
phP2: "터미널에서 `sessionwiki list`나 `search`를 실행하면 인덱스가 갱신됩니다. 이후 이 페이지를 새로고침하세요.",
messages: (n) => `메시지 ${n}개`,
resumeLabel: "터미널에서 이어가기: ",
copied: "복사됨",
clickToCopy: "클릭하여 복사",
outline: (n) => `목차 · 질문 ${n}개`,
seeAlso: "관련 세션", filtering: "필터", filterByTag: "태그로 필터",
filesLabel: "파일", tracedBy: (f) => `${f} 를 편집한 세션`,
fileHistory: (f) => `${f} 가 어떻게 쓰였나`,
moreEdits: (n) => `+${n}개 더`, loadFailed: "불러오지 못함",
hint: "읽기 전용 · 갱신: sessionwiki list",
themeDark: "다크", themeLight: "라이트",
stats: (s, t, b) => `세션 ${s} · 툴 ${t} · ${b}`,
},
ja: {
loading: "インデックスを読み込み中…",
searchPh: "セッションを検索",
allTools: "すべてのツール",
groups: { today: "今日", yesterday: "昨日", week: "今週", month: "今月", earlier: "それ以前" },
msgs: (n) => `${n}件`,
subagent: "サブエージェント",
untitled: "(無題)",
noMatches: "該当なし。",
emptyIndex: "インデックスが空です - ターミナルで sessionwiki list を実行",
phTitle: "左からセッションを選択",
phP1: "エージェントとのすべての会話を一箇所で。検索は全ツールを一度に探します - 部分一致も日本語も対応。",
phP2: "ターミナルで `sessionwiki list` または `search` を実行するとインデックスが更新されます。その後このページを再読み込みしてください。",
messages: (n) => `${n}件のメッセージ`,
resumeLabel: "ターミナルで再開: ",
copied: "コピー済み",
clickToCopy: "クリックでコピー",
outline: (n) => `目次 · ${n}ターン`,
seeAlso: "関連セッション", filtering: "フィルタ", filterByTag: "タグで絞り込み",
filesLabel: "ファイル", tracedBy: (f) => `${f} を編集したセッション`,
fileHistory: (f) => `${f} はどう書かれたか`,
moreEdits: (n) => `他 ${n} 件`, loadFailed: "読み込めませんでした",
hint: "読み取り専用 · 更新: sessionwiki list",
themeDark: "ダーク", themeLight: "ライト",
stats: (s, t, b) => `セッション ${s} · ツール ${t} · ${b}`,
},
zh: {
loading: "正在加载索引…",
searchPh: "搜索会话",
allTools: "全部工具",
groups: { today: "今天", yesterday: "昨天", week: "本周", month: "本月", earlier: "更早" },
msgs: (n) => `${n}条`,
subagent: "子代理",
untitled: "(无标题)",
noMatches: "没有匹配结果。",
emptyIndex: "索引为空 - 请在终端运行 sessionwiki list",
phTitle: "从左侧选择一个会话",
phP1: "与代理的所有对话,尽在一处。搜索一次覆盖所有工具 - 支持部分匹配和中文。",
phP2: "在终端运行 `sessionwiki list` 或 `search` 后索引会更新,然后刷新本页。",
messages: (n) => `${n}条消息`,
resumeLabel: "在终端继续: ",
copied: "已复制",
clickToCopy: "点击复制",
outline: (n) => `大纲 · ${n}轮`,
seeAlso: "相关会话", filtering: "筛选", filterByTag: "按标签筛选",
filesLabel: "文件", tracedBy: (f) => `编辑过 ${f} 的会话`,
fileHistory: (f) => `${f} 是如何写成的`,
moreEdits: (n) => `还有 ${n} 处`, loadFailed: "加载失败",
hint: "只读视图 · 刷新: sessionwiki list",
themeDark: "深色", themeLight: "浅色",
stats: (s, t, b) => `${s} 个会话 · ${t} 个工具 · ${b}`,
},
};
const lang = localStorage.getItem("sdx-lang")
|| ((navigator.language || "en").slice(0, 2) in I18N ? (navigator.language || "en").slice(0, 2) : "en");
const L = I18N[lang] || I18N.en;
document.documentElement.lang = lang;
const LANG_NAMES = { en: "English", ko: "한국어", ja: "日本語", zh: "中文" };
function applyStaticStrings() {
$("q").placeholder = L.searchPh;
$("tool").options[0].textContent = L.allTools;
$("hint").textContent = L.hint;
$("ph-title").textContent = L.phTitle;
$("ph-p1").textContent = L.phP1;
$("ph-p2").textContent = L.phP2.replace(/`/g, "");
$("stats").textContent = L.loading;
$("langlabel").textContent = LANG_NAMES[lang] || "English";
for (const b of $("langmenu").children) {
b.setAttribute("aria-selected", b.dataset.lang === lang ? "true" : "false");
}
}
applyStaticStrings();
const langctl = $("langctl");
$("langbtn").onclick = (e) => {
e.stopPropagation();
const open = langctl.classList.toggle("open");
$("langmenu").hidden = !open;
$("langbtn").setAttribute("aria-expanded", String(open));
};
for (const b of $("langmenu").children) {
b.onclick = () => {
localStorage.setItem("sdx-lang", b.dataset.lang);
location.reload();
};
}
document.addEventListener("click", () => {
langctl.classList.remove("open");
$("langmenu").hidden = true;
$("langbtn").setAttribute("aria-expanded", "false");
});
function applyTheme(t) {
document.documentElement.dataset.theme = t;
$("theme").textContent = t === "dark" ? L.themeLight : L.themeDark;
}
const savedTheme = localStorage.getItem("sdx-theme")
|| (matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
applyTheme(savedTheme);
$("theme").onclick = () => {
const next = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
localStorage.setItem("sdx-theme", next);
applyTheme(next);
};
async function jget(url) {
const r = await fetch(url);
if (!r.ok) throw new Error(await r.text());
return r.json();
}
function fmtBytes(n) {
const u = ["B", "KB", "MB", "GB", "TB"];
let i = 0;
while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; }
return n.toFixed(i ? 1 : 0) + " " + u[i];
}
function fmtDate(iso) {
if (!iso) return "-";
return iso.slice(0, 16).replace("T", " ");
}
function dayGroup(iso) {
if (!iso) return "earlier";
const d = new Date(iso);
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const that = new Date(d.getFullYear(), d.getMonth(), d.getDate());
const diff = Math.round((today - that) / 86400000);
if (diff <= 0) return "today";
if (diff === 1) return "yesterday";
if (diff < 7) return "week";
if (diff < 31) return "month";
return "earlier";
}
const el = (tag, cls, text) => {
const n = document.createElement(tag);
if (cls) n.className = cls;
if (text !== undefined) n.textContent = text;
return n;
};
function snippetNodes(text) {
const frag = document.createDocumentFragment();
let inMark = false;
for (const part of text.split(/([\u0002\u0003])/)) {
if (part === "\u0002") { inMark = true; continue; }
if (part === "\u0003") { inMark = false; continue; }
if (!part) continue;
if (inMark) frag.appendChild(el("mark", "", part));
else frag.appendChild(document.createTextNode(part));
}
return frag;
}
function renderRich(text, container) {
const parts = text.split("```");
parts.forEach((seg, i) => {
if (i % 2 === 1) {
const nl = seg.indexOf("\n");
let lang = "", code = seg;
if (nl > -1 && nl <= 24 && /^[\w.+#-]*$/.test(seg.slice(0, nl).trim())) {
lang = seg.slice(0, nl).trim();
code = seg.slice(nl + 1);
}
const pre = el("pre");
if (lang) pre.dataset.lang = lang;
pre.appendChild(el("code", "", code.replace(/\n$/, "")));
container.appendChild(pre);
} else if (seg) {
renderInline(seg, container);
}
});
}
function renderInline(text, container) {
const re = /(`[^`\n]+`)|(\*\*[^*\n]+\*\*)|(https?:\/\/[^\s)\]>"']+)/g;
let last = 0, m;
while ((m = re.exec(text)) !== null) {
if (m.index > last) container.appendChild(document.createTextNode(text.slice(last, m.index)));
if (m[1]) container.appendChild(el("code", "", m[1].slice(1, -1)));
else if (m[2]) container.appendChild(el("strong", "", m[2].slice(2, -2)));
else {
const a = el("a", "", m[3]);
a.href = m[3];
a.target = "_blank";
a.rel = "noopener noreferrer";
container.appendChild(a);
}
last = re.lastIndex;
}
if (last < text.length) container.appendChild(document.createTextNode(text.slice(last)));
}
function renderItems(rows, isSearch) {
const list = $("list");
list.replaceChildren();
if (!rows.length) {
list.appendChild(el("div", "empty", isSearch ? L.noMatches : L.emptyIndex));
return;
}
let lastGroup = null;
for (const r of rows) {
if (!isSearch) {
const g = dayGroup(r.started);
if (g !== lastGroup) {
list.appendChild(el("div", "group", L.groups[g]));
lastGroup = g;
}
}
const item = el("div", "item");
item.appendChild(el("div", "title", r.title || L.untitled));
const meta = el("div", "meta");
meta.appendChild(el("span", "dot " + r.tool));
const sub = r.kind === "sub" ? ` · ${L.subagent}` : "";
const acct = r.account ? ` · @${r.account}` : ""; meta.appendChild(el("span", "", `${r.tool} · ${fmtDate(r.started)} · ${L.msgs(r.msgs)}${sub}${acct}`));
item.appendChild(meta);
if (isSearch && r.snippet) {
const snip = el("div", "snip");
snip.appendChild(snippetNodes(r.snippet));
item.appendChild(snip);
} else if (r.summary || r.preview) {
item.appendChild(el("div", "preview", (r.summary || r.preview).replace(/\s+/g, " ")));
}
if (r.tags && r.tags.length) {
const tg = el("div", "tags");
tg.style.marginTop = "5px";
for (const t of r.tags) tg.appendChild(el("span", "tagchip", "#" + t));
item.appendChild(tg);
}
item.onclick = () => { openSession(r.id); setActive(item); };
list.appendChild(item);
}
}
function setActive(elm) {
if (activeEl) activeEl.classList.remove("active");
elm.classList.add("active");
activeEl = elm;
}
async function openSession(id) {
const s = await jget("/api/session/" + encodeURIComponent(id));
const head = el("div", "doc-head");
head.appendChild(el("h2", "", s.title || L.untitled));
const chips = el("div", "chips");
const toolChip = el("span", "chip");
toolChip.appendChild(el("span", "dot " + s.tool));
toolChip.appendChild(document.createTextNode(s.tool));
chips.appendChild(toolChip);
chips.appendChild(el("span", "chip", s.project || "-"));
chips.appendChild(el("span", "chip", fmtDate(s.started)));
chips.appendChild(el("span", "chip", L.messages(s.messages.length)));
head.appendChild(chips);
head.appendChild(el("div", "path", s.path));
if (s.summary) {
head.appendChild(el("div", "synopsis", s.summary));
}
if (s.tags && s.tags.length) {
const row = el("div", "tags");
for (const t of s.tags) {
const chip = el("span", "tagchip", "#" + t);
chip.style.cursor = "pointer";
chip.title = L.filterByTag || "filter by tag";
chip.onclick = () => { $("q").value = ""; filterByTag(t); };
row.appendChild(chip);
}
head.appendChild(row);
}
if (s.touched && s.touched.length) {
const row = el("div", "files");
row.appendChild(el("span", "lbl", L.filesLabel));
for (const p of s.touched) {
const base = p.split("/").pop() || p;
const chip = el("span", "filechip", base);
chip.title = p;
chip.onclick = () => showFileHistory(p, chip);
row.appendChild(chip);
}
head.appendChild(row);
}
if (s.note) {
head.appendChild(el("div", "note", s.note));
}
if (s.resume) {
const row = el("div", "resume");
row.appendChild(document.createTextNode(L.resumeLabel));
const code = el("code", "", s.resume);
code.title = L.clickToCopy;
code.onclick = async () => {
try {
await navigator.clipboard.writeText(s.resume);
const prev = code.textContent;
code.textContent = L.copied;
setTimeout(() => { code.textContent = prev; }, 900);
} catch (e) { }
};
row.appendChild(code);
head.appendChild(row);
}
const doc = el("div", "doc");
const userTurns = [];
s.messages.forEach((m, i) => {
const t = m.text.trimStart();
if (m.role === "user" && !t.startsWith("<") && !t.startsWith("[Request interrupted")) userTurns.push(i);
});
const anchors = {};
if (userTurns.length >= 3) {
const det = el("details", "outline");
det.appendChild(el("summary", "", L.outline(userTurns.length)));
const ol = el("ol");
for (const i of userTurns) {
const li = el("li", "", s.messages[i].text.replace(/\s+/g, " ").slice(0, 110));
li.onclick = () => anchors[i] && anchors[i].scrollIntoView({ behavior: "smooth", block: "start" });
ol.appendChild(li);
}
det.appendChild(ol);
doc.appendChild(det);
}
for (const [mi, m] of s.messages.entries()) {
if (m.role === "tool") {
const row = el("div", "toolrow");
const det = el("details");
const sum = el("summary", "", m.text.split("\n")[0].slice(0, 140));
const body = el("div", "body", m.text);
det.append(sum, body);
row.appendChild(det);
doc.appendChild(row);
anchors[mi] = row;
continue;
}
const wrap = el("div", "msg " + m.role);
const av = el("div", "avatar", m.role === "user" ? "U" : "A");
if (m.ts) av.title = fmtDate(m.ts);
const content = el("div", "content");
content.appendChild(el("div", "who", m.role));
const body = el("div", "body");
renderRich(m.text, body);
content.appendChild(body);
wrap.append(av, content);
doc.appendChild(wrap);
anchors[mi] = wrap;
}
const seealso = el("div", "seealso");
doc.appendChild(seealso);
let seealsoGen = 0;
const relatedGen = ++seealsoGen;
jget("/api/related/" + encodeURIComponent(s.id || id)).then((rel) => {
if (relatedGen !== seealsoGen || !rel || !rel.length) return;
seealso.appendChild(el("h3", "", L.seeAlso || "See also"));
for (const r of rel) {
const a = el("div", "rel");
a.appendChild(el("div", "t", r.title || L.untitled));
a.appendChild(el("div", "m", `${r.tool} · ${fmtDate(r.started)} · ${L.msgs(r.msgs)}`));
a.onclick = () => openSession(r.id);
seealso.appendChild(a);
}
}).catch(() => {});
const EDITS_SHOWN = 6;
function showFileHistory(path, chip) {
document.querySelectorAll(".filechip.on").forEach((c) => c.classList.remove("on"));
chip.classList.add("on");
const base = path.split("/").pop() || path;
const my = ++seealsoGen;
jget("/api/file?path=" + encodeURIComponent(path)).then((hist) => {
if (my !== seealsoGen) return; seealso.replaceChildren(el("h3", "", L.fileHistory(base)));
for (const r of ((hist && hist.sessions) || [])) {
const a = el("div", "rel");
a.appendChild(el("div", "t", r.title || L.untitled));
a.appendChild(el("div", "m", `${r.tool} · ${fmtDate(r.started)} · ${L.msgs(r.msgs)}`));
if (r.edits && r.edits.length) {
const ev = el("div", "edits");
for (const e of r.edits.slice(0, EDITS_SHOWN)) {
const item = el("div", "edit");
item.appendChild(el("span", "editkind", e.kind));
if (e.snippet) {
const snip = el("code", "editsnip", e.snippet.replace(/\s+/g, " ").trim());
snip.title = e.snippet;
item.appendChild(snip);
}
ev.appendChild(item);
}
if (r.edits.length > EDITS_SHOWN) {
ev.appendChild(el("div", "editmore", L.moreEdits(r.edits.length - EDITS_SHOWN)));
}
a.appendChild(ev);
}
a.onclick = () => openSession(r.id);
seealso.appendChild(a);
}
seealso.scrollIntoView({ behavior: "smooth", block: "nearest" });
}).catch(() => {
if (my !== seealsoGen) return;
seealso.replaceChildren(el("h3", "", L.fileHistory(base)), el("div", "m", L.loadFailed));
});
}
$("main").replaceChildren(head, doc);
$("main").scrollTop = 0;
}
function filterByTag(tag) {
currentTag = tag;
$("q").value = "";
refresh();
}
async function refresh() {
const q = $("q").value.trim();
const tool = $("tool").value;
const t = tool ? `&tool=${encodeURIComponent(tool)}` : "";
renderTagBar();
if (q.length >= 3) {
renderItems(await jget(`/api/search?q=${encodeURIComponent(q)}${t}`), true);
} else if (currentTag) {
renderItems(await jget(`/api/sessions?limit=200${t}&tag=${encodeURIComponent(currentTag)}`), false);
} else {
renderItems(await jget(`/api/sessions?limit=200${t}`), false);
}
}
function renderTagBar() {
let bar = $("tagbar");
if (!currentTag) { if (bar) bar.remove(); return; }
if (!bar) {
bar = el("div", "");
bar.id = "tagbar";
bar.style.cssText = "padding:6px 14px;font-size:12px;color:var(--ink-2);border-bottom:1px solid var(--line)";
$("list").parentNode.insertBefore(bar, $("list"));
}
bar.replaceChildren();
const chip = el("span", "tagchip", "#" + currentTag);
const x = el("span", "", " ✕");
x.style.cursor = "pointer";
x.onclick = () => { currentTag = null; refresh(); };
bar.append(document.createTextNode((L.filtering || "filtering") + ": "), chip, x);
}
let timer = null;
$("q").addEventListener("input", () => {
clearTimeout(timer);
timer = setTimeout(refresh, 250);
});
$("tool").addEventListener("change", refresh);
document.addEventListener("keydown", (e) => {
if (e.key === "/" && document.activeElement !== $("q")) {
e.preventDefault();
$("q").focus();
}
if (e.key === "Escape" && document.activeElement === $("q")) {
$("q").value = "";
refresh();
}
});
(async function init() {
const stats = await jget("/api/stats");
let sessions = 0, bytes = 0;
for (const t of stats.tools) {
sessions += t.sessions;
bytes += t.bytes;
const opt = el("option", "", t.tool);
opt.value = t.tool;
$("tool").appendChild(opt);
}
$("stats").textContent =
L.stats(sessions.toLocaleString(), stats.tools.length, fmtBytes(bytes));
await refresh();
})();
</script>
</body>
</html>