Дизайн десктопного клиента: 3 варианта (sidebar / voice-first / three-pane)

This commit is contained in:
Porfiry
2026-09-18 23:57:14 +03:00
parent d62f7da3f6
commit a515008e19
7 changed files with 1170 additions and 2 deletions
+25
View File
@@ -0,0 +1,25 @@
# Вариант 3 — Three-pane / Command center
## Суть
Три панели: диалоги, чат и **состояние агента**. Работа агента видна не как текст
в переписке, а как отдельные раскрывающиеся шаги.
## Ключевые решения
- **Раскладка:** 268 + центр + 306. Третья панель — модель, контекст, инструменты,
память агента, файлы сессии.
- **Шаг агента** — сворачиваемый блок: что делал, сколько заняло, детали по клику.
Это то, чем агент отличается от собеседника, и здесь оно вынесено в структуру.
- **Кнопка записи:** компактная рядом с «Отправить»; при записи снизу появляется
строка: квадрат «стоп», индикатор уровня, текущая расшифровка, таймер.
- **Короткая диктовка** (от 2 с) уходит сразу, текст остаётся в поле.
## Что хорошо
- Видно, что агент делает прямо сейчас и сколько это стоит.
- Удобно на широком экране, ничего не надо прятать.
## Что слабо
- На узком экране (ноутбук 1366) третью панель придётся убирать.
- Настройки сессии — не то, на что хочется смотреть постоянно.
## Кому
Тому, кто держит агента включённым весь день и следит за расходом.
+345
View File
@@ -0,0 +1,345 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Вариант 3 — Three-pane / Command center</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0F1115; --panel: #161A21; --panel2: #1E242D; --panel3: #252C37; --line: #2A323D;
--fg: #ECEFF3; --dim: #808FA0; --accent: #7C8CF8; --ok: #4ADE80; --warn: #FBBF24; --hot: #F87171;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
-webkit-font-smoothing: antialiased;
background: #06080A; color: var(--fg); line-height: 1.45;
display: flex; align-items: center; justify-content: center; min-height: 100vh; padding: 22px;
}
.win { width: 1360px; height: 780px; background: var(--bg); border-radius: 13px; overflow: hidden;
border: 1px solid var(--line); display: flex; flex-direction: column; box-shadow: 0 26px 90px rgba(0,0,0,.7); }
/* ---- строка меню-заголовка ---- */
.titlebar { height: 42px; flex: 0 0 42px; display: flex; align-items: center; gap: 12px;
padding: 0 16px; background: var(--panel); border-bottom: 1px solid var(--line); }
.lights { display: flex; gap: 7px; }
.lights i { width: 11px; height: 11px; border-radius: 50%; }
.lights i:nth-child(1) { background: #FF5F57; } .lights i:nth-child(2) { background: #FEBC2E; }
.lights i:nth-child(3) { background: #28C840; }
.tb-title { font-size: 12.5px; color: var(--dim); }
.tb-title b { color: var(--fg); font-weight: 550; }
.tb-right { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: 11.5px; color: var(--dim); }
.pill { background: var(--panel2); border: 1px solid var(--line); border-radius: 20px; padding: 3px 11px;
display: flex; align-items: center; gap: 6px; }
.pill .d { width: 7px; height: 7px; border-radius: 50%; background: var(--ok); }
.body { flex: 1; display: flex; min-height: 0; }
/* ========== ПАНЕЛЬ 1: чаты ========== */
.p1 { width: 268px; flex: 0 0 268px; background: var(--panel); border-right: 1px solid var(--line);
display: flex; flex-direction: column; }
.p-head { padding: 14px 15px 9px; display: flex; align-items: center; justify-content: space-between; }
.p-head h2 { font-size: 11px; letter-spacing: 1.3px; text-transform: uppercase; color: var(--dim); font-weight: 650; }
.p-head button { background: var(--accent); border: none; color: #fff; border-radius: 7px;
width: 24px; height: 24px; cursor: pointer; font-size: 15px; line-height: 1; }
.f-tabs { display: flex; gap: 4px; padding: 0 12px 10px; flex-wrap: wrap; }
.f-tab { font-size: 11px; padding: 4px 9px; border-radius: 7px; color: var(--dim); background: var(--panel2);
cursor: pointer; border: 1px solid transparent; }
.f-tab.on { color: var(--fg); border-color: var(--accent); background: rgba(124,140,248,.14); }
.q { margin: 0 12px 9px; padding: 7px 11px; border-radius: 8px; border: 1px solid var(--line);
background: var(--bg); color: var(--fg); font-size: 12.5px; outline: none; }
.q:focus { border-color: var(--accent); } .q::placeholder { color: var(--dim); }
.cf { flex: 1; overflow-y: auto; padding: 0 8px 12px; }
.c { display: flex; gap: 10px; padding: 9px 10px; border-radius: 9px; cursor: pointer; }
.c:hover { background: var(--panel2); }
.c.on { background: var(--panel2); box-shadow: inset 2px 0 0 var(--accent); }
.c .a { width: 32px; height: 32px; flex: 0 0 32px; border-radius: 9px; display: grid; place-items: center;
font-size: 11.5px; font-weight: 700; background: var(--panel3); color: var(--accent); }
.c .m { flex: 1; min-width: 0; }
.c .n { font-size: 13px; font-weight: 550; display: flex; justify-content: space-between; gap: 6px; }
.c .n span.t { font-size: 10px; color: var(--dim); font-weight: 400; }
.c .p { font-size: 11.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
.c.new .n { color: var(--accent); }
/* ========== ПАНЕЛЬ 2: диалог ========== */
.p2 { flex: 1; display: flex; flex-direction: column; min-width: 0; border-right: 1px solid var(--line); }
.p2h { height: 54px; flex: 0 0 54px; display: flex; align-items: center; gap: 11px; padding: 0 18px;
border-bottom: 1px solid var(--line); }
.p2h .nm { font-size: 14px; font-weight: 600; }
.p2h .sub { font-size: 11px; color: var(--dim); }
.p2h .acts { margin-left: auto; display: flex; gap: 6px; }
.p2h .acts button { background: var(--panel2); border: 1px solid var(--line); color: var(--dim);
border-radius: 7px; padding: 5px 11px; font-size: 11.5px; cursor: pointer; }
.p2h .acts button:hover { color: var(--fg); }
.flow { flex: 1; overflow-y: auto; padding: 18px 22px; display: flex; flex-direction: column; gap: 11px; }
.u { align-self: flex-end; max-width: 78%; background: #2A3350; padding: 9px 13px; border-radius: 12px 12px 3px 12px;
font-size: 13.5px; }
.a { align-self: flex-start; max-width: 78%; background: var(--panel2); padding: 9px 13px;
border-radius: 12px 12px 12px 3px; font-size: 13.5px; }
.tm { font-size: 10px; color: var(--dim); margin-top: 4px; }
/* свёрнутый шаг работы агента — то, что отличает агента от чата */
.step { align-self: flex-start; width: 100%; max-width: 92%; background: var(--panel);
border: 1px solid var(--line); border-radius: 10px; overflow: hidden; }
.step-h { display: flex; align-items: center; gap: 9px; padding: 8px 12px; cursor: pointer; font-size: 12.5px; }
.step-h:hover { background: var(--panel2); }
.step-h .ico { color: var(--accent); }
.step-h .nm { color: var(--fg); font-weight: 550; }
.step-h .stat { margin-left: auto; font-size: 10.5px; color: var(--ok); }
.step-h .chev { color: var(--dim); font-size: 10px; }
.step-b { display: none; padding: 0 12px 11px 34px; font-size: 12px; color: var(--dim); }
.step.open .step-b { display: block; }
.step-b code { color: #A8B6C8; background: var(--bg); border: 1px solid var(--line); border-radius: 5px;
padding: 1px 6px; font-size: 11px; }
/* ========== ПАНЕЛЬ 3: состояние агента ========== */
.p3 { width: 306px; flex: 0 0 306px; background: var(--panel); display: flex; flex-direction: column; }
.sec { border-bottom: 1px solid var(--line); padding: 13px 15px; }
.sec h3 { font-size: 10.5px; letter-spacing: 1.2px; text-transform: uppercase; color: var(--dim);
font-weight: 650; margin-bottom: 10px; display: flex; align-items: center; gap: 7px; }
.kv { display: flex; justify-content: space-between; font-size: 12px; margin-bottom: 7px; }
.kv span:first-child { color: var(--dim); }
.kv span:last-child { font-weight: 550; }
.bar { height: 5px; border-radius: 3px; background: var(--panel3); overflow: hidden; margin-top: 4px; }
.bar i { display: block; height: 100%; background: var(--accent); }
.tool-chip { display: flex; align-items: center; gap: 8px; font-size: 12px; padding: 6px 0; }
.tool-chip .s { width: 7px; height: 7px; border-radius: 50%; }
.tool-chip .s.run { background: var(--warn); animation: p 1s infinite; }
.tool-chip .s.ok { background: var(--ok); }
@keyframes p { 50% { opacity: .3; } }
.mem { font-size: 11.5px; color: var(--dim); padding: 7px 9px; background: var(--panel2);
border-radius: 8px; margin-bottom: 6px; line-height: 1.4; }
.mem b { color: var(--fg); font-weight: 550; display: block; font-size: 11.5px; margin-bottom: 2px; }
/* ========== КОМПОЗЕР (низ панели 2) ========== */
.comp { border-top: 1px solid var(--line); background: var(--panel); padding: 13px 18px 14px; }
.rec-state { display: none; align-items: center; gap: 12px; background: var(--bg); border: 1px solid var(--hot);
border-radius: 10px; padding: 10px 13px; margin-bottom: 10px; }
.rec-state.on { display: flex; }
.stop-rec { width: 34px; height: 34px; flex: 0 0 34px; border-radius: 50%; background: var(--hot);
border: none; color: #fff; cursor: pointer; display: grid; place-items: center; font-size: 12px; }
.sq { width: 11px; height: 11px; background: #fff; border-radius: 2px; }
.meter { flex: 1; display: flex; align-items: center; gap: 3px; height: 26px; }
.meter i { width: 3px; background: var(--hot); border-radius: 2px; height: 25%; animation: mn .9s infinite ease-in-out; }
.meter i:nth-child(2n) { animation-delay: .12s; } .meter i:nth-child(3n) { animation-delay: .24s; }
@keyframes mn { 0%,100% { height: 20%; } 50% { height: 100%; } }
.rt { font-variant-numeric: tabular-nums; font-size: 12.5px; color: var(--hot); font-weight: 600; }
.rec-state .live-txt { font-size: 11.5px; color: var(--dim); max-width: 260px; overflow: hidden;
text-overflow: ellipsis; white-space: nowrap; }
.crow { display: flex; align-items: flex-end; gap: 8px; }
.cbox { flex: 1; display: flex; align-items: flex-end; background: var(--bg); border: 1px solid var(--line);
border-radius: 10px; padding: 9px 12px; }
.cbox:focus-within { border-color: var(--accent); }
.cbox textarea { flex: 1; background: transparent; border: none; outline: none; color: var(--fg);
font-family: inherit; font-size: 13.5px; resize: none; height: 21px; max-height: 110px; line-height: 1.45; }
.cbox textarea::placeholder { color: var(--dim); }
.ic { width: 36px; height: 36px; border-radius: 9px; border: 1px solid var(--line); background: var(--bg);
color: var(--dim); cursor: pointer; display: grid; place-items: center; font-size: 15px; flex: 0 0 36px; }
.ic:hover { color: var(--fg); }
.ic.rec { border-color: var(--accent); color: var(--accent); }
.ic.rec.live { background: var(--hot); border-color: var(--hot); color: #fff; animation: rp 1.4s infinite; }
@keyframes rp { 0%,100% { box-shadow: 0 0 0 0 rgba(248,113,113,.5); } 50% { box-shadow: 0 0 0 9px rgba(248,113,113,0); } }
.send { height: 36px; padding: 0 16px; border-radius: 9px; border: none; background: var(--accent);
color: #fff; font-size: 12.5px; font-weight: 600; cursor: pointer; }
.send:disabled { background: var(--panel2); color: var(--dim); cursor: default; }
.keys { font-size: 10.5px; color: var(--dim); margin-top: 9px; display: flex; gap: 14px; }
kbd { background: var(--panel2); border: 1px solid var(--line); border-radius: 4px; padding: 1px 5px; font-size: 10px; }
/* окно целиком влезает на любой экран — не режем картинку */
@media (max-height: 820px) { body { padding: 8px; } .window, .win { transform: scale(.78); } }
@media (max-height: 700px) { .window, .win { transform: scale(.66); } }
@media (max-height: 600px) { .window, .win { transform: scale(.56); } }
</style>
</head>
<body>
<div class="win">
<div class="titlebar">
<div class="lights"><i></i><i></i><i></i></div>
<div class="tb-title">agentik-desktop — <b>agentik · рабочий</b></div>
<div class="tb-right">
<span class="pill"><span class="d"></span> агент на связи</span>
<span>Память: 128 заметок</span>
</div>
</div>
<div class="body">
<!-- ======== ПАНЕЛЬ 1 ======== -->
<aside class="p1">
<div class="p-head"><h2>Диалоги</h2><button title="Новый">+</button></div>
<div class="f-tabs">
<div class="f-tab on">Все</div><div class="f-tab">Работа</div>
<div class="f-tab">Инфра</div><div class="f-tab">Дом</div><div class="f-tab">+</div>
</div>
<input class="q" placeholder="Поиск диалога…">
<div class="cf">
<div class="c on"><div class="a">АГ</div><div class="m">
<div class="n"><span>agentik · рабочий</span><span class="t">14:22</span></div>
<div class="p">Собрал отчёт, жду правок</div></div></div>
<div class="c new"><div class="a">ОЧ</div><div class="m">
<div class="n"><span>Отчёт за квартал</span><span class="t">12:05</span></div>
<div class="p">Файл лежит на диске</div></div></div>
<div class="c"><div class="a">РЗ</div><div class="m">
<div class="n"><span>Разбор логов nginx</span><span class="t">вчера</span></div>
<div class="p">Нашёл 3 ошибки 502</div></div></div>
<div class="c"><div class="a">ПО</div><div class="m">
<div class="n"><span>Solana-бэкенд</span><span class="t">вчера</span></div>
<div class="p">Девайс-нет стартовал</div></div></div>
<div class="c"><div class="a">КН</div><div class="m">
<div class="n"><span>Книги</span><span class="t">пн</span></div>
<div class="p">Разложил по полкам</div></div></div>
</div>
</aside>
<!-- ======== ПАНЕЛЬ 2 ======== -->
<section class="p2">
<div class="p2h">
<div>
<div class="nm">agentik · рабочий</div>
<div class="sub">codding-big · контекст 34%</div>
</div>
<div class="acts"><button>Стоп</button><button>Очистить</button></div>
</div>
<div class="flow" id="flow">
<div class="u">Разберись, почему CI красный, и выпусти релиз 3<span class="tm">14:05</span></div>
<div class="step open" onclick="this.classList.toggle('open')">
<div class="step-h"><span class="ico">🔍</span><span class="nm">Поиск причины</span>
<span class="stat">готово · 4 с</span><span class="chev">▼</span></div>
<div class="step-b">Нашёл: в именах тестов длинное тире, сборка идёт в ASCII.<br>
<code>InvalidPathException: unmappable characters</code></div>
</div>
<div class="step" onclick="this.classList.toggle('open')">
<div class="step-h"><span class="ico">✎</span><span class="nm">Правка 16 имён тестов</span>
<span class="stat">готово · 12 с</span><span class="chev">▼</span></div>
<div class="step-b">Заменено на обычный дефис в 4 файлах. Ничего другого не тронуто.</div>
</div>
<div class="step" onclick="this.classList.toggle('open')">
<div class="step-h"><span class="ico">⚙</span><span class="nm">Сборка и проверка</span>
<span class="stat">готово · 1 м 10 с</span><span class="chev">▼</span></div>
<div class="step-b">14 модулей, тесты зелёные, jar-ы собраны.</div>
</div>
<div class="a">Готово. CI зелёный, релиз «3» опубликован: 101 артефакт в Nexus.<span class="tm">14:22</span></div>
</div>
<div class="comp">
<div class="rec-state" id="recState">
<button class="stop-rec" onclick="stopRec(false)"><span class="sq"></span></button>
<div class="meter" id="meter"></div>
<span class="live-txt" id="liveTxt">слушаю…</span>
<span class="rt" id="rt">0:00</span>
<button class="ic" onclick="stopRec(true)" title="Отменить">✕</button>
</div>
<div class="crow">
<div class="cbox"><textarea id="ta" placeholder="Сообщение агенту…"></textarea></div>
<button class="ic rec" id="recBtn" title="Голосовое (Ctrl+R)">🎙</button>
<button class="send" id="sendBtn" disabled>Отправить</button>
</div>
<div class="keys">
<span><kbd>Ctrl</kbd>+<kbd>R</kbd> запись</span>
<span><kbd>Enter</kbd> отправить</span>
<span><kbd>Shift</kbd>+<kbd>Enter</kbd> перенос</span>
</div>
</div>
</section>
<!-- ======== ПАНЕЛЬ 3 ======== -->
<aside class="p3">
<div class="sec">
<h3>Сессия</h3>
<div class="kv"><span>Модель</span><span>codding-big</span></div>
<div class="kv"><span>Контекст</span><span>34 %</span></div>
<div class="bar"><i style="width:34%"></i></div>
<div class="kv" style="margin-top:9px"><span>Ходов</span><span>18</span></div>
<div class="kv"><span>Стоимость</span><span>0.42 ₽</span></div>
</div>
<div class="sec">
<h3>Инструменты</h3>
<div class="tool-chip"><span class="s ok"></span> shell · <span style="color:var(--dim)">готов</span></div>
<div class="tool-chip"><span class="s ok"></span> read_file · <span style="color:var(--dim)">готов</span></div>
<div class="tool-chip"><span class="s run"></span> write_file · <span style="color:var(--dim)">работает</span></div>
<div class="tool-chip"><span class="s ok"></span> memory · <span style="color:var(--dim)">готов</span></div>
</div>
<div class="sec">
<h3>Память агента</h3>
<div class="mem"><b>Про CI agentik</b>длинное тире в именах тестов ломает сборку в ASCII</div>
<div class="mem"><b>Про Nexus</b>публикация идёт через общий action devops/publish</div>
</div>
<div class="sec" style="border-bottom:none">
<h3>Файлы сессии</h3>
<div class="kv"><span>standalone-3.jar</span><span style="color:var(--dim)">240 МБ</span></div>
<div class="kv"><span>agentik-cli-3.jar</span><span style="color:var(--dim)">7.2 МБ</span></div>
</div>
</aside>
</div>
</div>
<script>
const ta = document.getElementById('ta'), sendBtn = document.getElementById('sendBtn');
const flow = document.getElementById('flow');
const recBtn = document.getElementById('recBtn'), recState = document.getElementById('recState');
const liveTxt = document.getElementById('liveTxt');
document.getElementById('meter').innerHTML = Array.from({length: 34}, () => '<i></i>').join('');
ta.addEventListener('input', () => {
sendBtn.disabled = ta.value.trim() === '';
ta.style.height = '21px'; ta.style.height = Math.min(ta.scrollHeight, 110) + 'px';
});
function send() {
const t = ta.value.trim(); if (!t) return;
add(t, 'u'); ta.value = ''; ta.style.height = '21px'; sendBtn.disabled = true;
setTimeout(() => {
const s = document.createElement('div'); s.className = 'step';
s.onclick = () => s.classList.toggle('open');
s.innerHTML = '<div class="step-h"><span class="ico">⚙</span><span class="nm">Выполняю</span>' +
'<span class="stat">идёт…</span><span class="chev">▼</span></div>' +
'<div class="step-b">Задача: ' + t + '</div>';
flow.appendChild(s);
setTimeout(() => {
s.querySelector('.stat').textContent = 'готово · 3 с';
s.querySelector('.stat').style.color = 'var(--ok)';
s.classList.add('open');
add('Сделано: ' + t, 'a');
}, 1400);
}, 300);
}
function add(text, who) {
const d = document.createElement('div'); d.className = who;
const n = new Date();
d.innerHTML = text + '<span class="tm">' + n.getHours() + ':' + String(n.getMinutes()).padStart(2,'0') + '</span>';
flow.appendChild(d); flow.scrollTop = flow.scrollHeight;
}
sendBtn.onclick = send;
ta.addEventListener('keydown', e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); send(); } });
const P = 'разберись с красным си ай и выпусти новый релиз'.split(' ');
let tm = null, sec = 0, wi = 0;
function startRec() {
sec = 0; wi = 0; recState.classList.add('on'); recBtn.classList.add('live');
tm = setInterval(() => {
sec++; document.getElementById('rt').textContent = '0:' + String(sec).padStart(2,'0');
if (wi < P.length) liveTxt.textContent = P.slice(0, ++wi).join(' ');
}, 500);
}
function stopRec(cancel) {
clearInterval(tm); recState.classList.remove('on'); recBtn.classList.remove('live');
if (cancel) return;
const t = liveTxt.textContent;
if (t && sec >= 2) { add(t, 'u'); setTimeout(() => add('Принято голосом, начинаю.', 'a'), 800); }
}
recBtn.onclick = () => recState.classList.contains('on') ? stopRec(false) : startRec();
document.addEventListener('keydown', e => {
if (e.ctrlKey && e.key.toLowerCase() === 'r') { e.preventDefault(); recBtn.click(); }
if (e.key === 'Escape' && recState.classList.contains('on')) stopRec(true);
});
</script>
</body>
</html>