Complete BFF removal migration

- Updated Nginx config to serve static SPA from web-client-new/dist/
- All API requests now proxy directly to Tracker (port 8100)
- WebSocket connections go directly to Tracker with JWT auth
- Stopped and disabled team-board-bff and team-board-web services
- Fixed file permissions for nginx to serve static files
- Migration complete: Next.js+BFF → Vite+React+Direct-Tracker
This commit is contained in:
markov 2026-02-23 23:27:49 +01:00
parent f3c7f5b07e
commit 8a1aadf144
69 changed files with 5963 additions and 2 deletions

1
agent-coder Submodule

@ -0,0 +1 @@
Subproject commit 054690b080840ee0d34d72f3fcc8ac4d1698b8dc

2
docs

@ -1 +1 @@
Subproject commit bffb769002cdea2bbe7c782905c08bcb720cc10a Subproject commit d3b9aead966bf1fed95ced5e5705c61e817cd45a

1
picogent Submodule

@ -0,0 +1 @@
Subproject commit a8f205609be8ef26976b75e41432a668f09fd2c4

1
runner Submodule

@ -0,0 +1 @@
Subproject commit 265585d0e46a19c6a5152537ae6e33870fb38c8c

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"4688b3e3-e901-4cc5-80c0-ba7927406562","timestamp":"2026-02-23T13:23:16.804Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"d19fac6c","parentId":null,"timestamp":"2026-02-23T13:23:16.807Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"e2dba49a","parentId":"d19fac6c","timestamp":"2026-02-23T13:23:16.807Z","thinkingLevel":"off"}
{"type":"message","id":"7eac73e1","parentId":"e2dba49a","timestamp":"2026-02-23T13:23:16.818Z","message":{"role":"user","content":[{"type":"text","text":"Привет"}],"timestamp":1771852996813}}
{"type":"message","id":"9162f6ad","parentId":"7eac73e1","timestamp":"2026-02-23T13:23:18.540Z","message":{"role":"assistant","content":[{"type":"text","text":"Привет! Чем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":16,"cacheRead":0,"cacheWrite":2353,"totalTokens":2372,"cost":{"input":0.000009,"output":0.00024,"cacheRead":0,"cacheWrite":0.00882375,"total":0.00907275}},"stopReason":"stop","timestamp":1771852996815}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"19f6bc69-4e9f-4c26-9f94-bddb6b3382aa","timestamp":"2026-02-23T17:32:14.804Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"92a78907","parentId":null,"timestamp":"2026-02-23T17:32:14.808Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"58cc41e1","parentId":"92a78907","timestamp":"2026-02-23T17:32:14.808Z","thinkingLevel":"off"}
{"type":"message","id":"2ba8a39b","parentId":"58cc41e1","timestamp":"2026-02-23T17:32:14.823Z","message":{"role":"user","content":[{"type":"text","text":"тест"}],"timestamp":1771867934817}}
{"type":"message","id":"90f52428","parentId":"2ba8a39b","timestamp":"2026-02-23T17:32:17.520Z","message":{"role":"assistant","content":[{"type":"text","text":"Привет! Всё работает. Чем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":22,"cacheRead":0,"cacheWrite":2352,"totalTokens":2377,"cost":{"input":0.000009,"output":0.00033,"cacheRead":0,"cacheWrite":0.00882,"total":0.009159}},"stopReason":"stop","timestamp":1771867934819}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"3b331d16-19a9-4795-a726-ca8fe1900ae3","timestamp":"2026-02-23T17:37:13.657Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"ca385742","parentId":null,"timestamp":"2026-02-23T17:37:13.660Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"04fa6da7","parentId":"ca385742","timestamp":"2026-02-23T17:37:13.660Z","thinkingLevel":"off"}
{"type":"message","id":"d36017dd","parentId":"04fa6da7","timestamp":"2026-02-23T17:37:13.672Z","message":{"role":"user","content":[{"type":"text","text":"Привет"}],"timestamp":1771868233667}}
{"type":"message","id":"8eb86cb5","parentId":"d36017dd","timestamp":"2026-02-23T17:37:15.315Z","message":{"role":"assistant","content":[{"type":"text","text":"Привет! Чем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":16,"cacheRead":0,"cacheWrite":2353,"totalTokens":2372,"cost":{"input":0.000009,"output":0.00024,"cacheRead":0,"cacheWrite":0.00882375,"total":0.00907275}},"stopReason":"stop","timestamp":1771868233669}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"5e6a728f-a1ca-4c33-b557-8f3d6d8d3401","timestamp":"2026-02-23T17:39:05.200Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"b5258502","parentId":null,"timestamp":"2026-02-23T17:39:05.200Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"ea5321eb","parentId":"b5258502","timestamp":"2026-02-23T17:39:05.200Z","thinkingLevel":"off"}
{"type":"message","id":"e671d698","parentId":"ea5321eb","timestamp":"2026-02-23T17:39:05.202Z","message":{"role":"user","content":[{"type":"text","text":"Всем"}],"timestamp":1771868345201}}
{"type":"message","id":"f4abee88","parentId":"e671d698","timestamp":"2026-02-23T17:39:07.626Z","message":{"role":"assistant","content":[{"type":"text","text":"Привет! Чем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":16,"cacheRead":0,"cacheWrite":2353,"totalTokens":2372,"cost":{"input":0.000009,"output":0.00024,"cacheRead":0,"cacheWrite":0.00882375,"total":0.00907275}},"stopReason":"stop","timestamp":1771868345201}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"606fa873-eabe-4398-bc1d-6eccf8e64416","timestamp":"2026-02-23T17:39:25.714Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"400dc1e6","parentId":null,"timestamp":"2026-02-23T17:39:25.715Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"afb98b84","parentId":"400dc1e6","timestamp":"2026-02-23T17:39:25.715Z","thinkingLevel":"off"}
{"type":"message","id":"a71759f1","parentId":"afb98b84","timestamp":"2026-02-23T17:39:25.717Z","message":{"role":"user","content":[{"type":"text","text":"Ты кто?"}],"timestamp":1771868365716}}
{"type":"message","id":"266fae99","parentId":"a71759f1","timestamp":"2026-02-23T17:39:28.747Z","message":{"role":"assistant","content":[{"type":"text","text":"Я **Claude Code** — официальный CLI-инструмент от Anthropic, работающий внутри **pi** (coding agent harness).\n\nПомогаю с разработкой: читаю файлы, выполняю команды, редактирую код, пишу новые файлы.\n\nЧем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":90,"cacheRead":0,"cacheWrite":2356,"totalTokens":2449,"cost":{"input":0.000009,"output":0.00135,"cacheRead":0,"cacheWrite":0.008835,"total":0.010194000000000002}},"stopReason":"stop","timestamp":1771868365716}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"91eb6623-665f-41cb-b6ef-7e61685595d3","timestamp":"2026-02-23T17:39:46.717Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"131a07f1","parentId":null,"timestamp":"2026-02-23T17:39:46.717Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"77719979","parentId":"131a07f1","timestamp":"2026-02-23T17:39:46.717Z","thinkingLevel":"off"}
{"type":"message","id":"b0bc5158","parentId":"77719979","timestamp":"2026-02-23T17:39:46.719Z","message":{"role":"user","content":[{"type":"text","text":"Ты?"}],"timestamp":1771868386718}}
{"type":"message","id":"db00199f","parentId":"b0bc5158","timestamp":"2026-02-23T17:39:48.716Z","message":{"role":"assistant","content":[{"type":"text","text":"Я Claude Code — AI-ассистент от Anthropic, встроенный в среду pi. Помогаю с кодом: читаю файлы, выполняю команды, редактирую и пишу код.\n\nЧем могу помочь по проекту **team-board**?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":81,"cacheRead":0,"cacheWrite":2354,"totalTokens":2438,"cost":{"input":0.000009,"output":0.001215,"cacheRead":0,"cacheWrite":0.0088275,"total":0.0100515}},"stopReason":"stop","timestamp":1771868386718}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"4d01fe50-8e6b-4986-96ab-3fb7e50b0aeb","timestamp":"2026-02-23T17:50:13.930Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"7cdcc386","parentId":null,"timestamp":"2026-02-23T17:50:13.933Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"280f3897","parentId":"7cdcc386","timestamp":"2026-02-23T17:50:13.933Z","thinkingLevel":"off"}
{"type":"message","id":"dc510af5","parentId":"280f3897","timestamp":"2026-02-23T17:50:13.944Z","message":{"role":"user","content":[{"type":"text","text":"Привет"}],"timestamp":1771869013940}}
{"type":"message","id":"9a6880b0","parentId":"dc510af5","timestamp":"2026-02-23T17:50:14.942Z","message":{"role":"assistant","content":[{"type":"text","text":"Привет! Чем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":16,"cacheRead":0,"cacheWrite":2353,"totalTokens":2372,"cost":{"input":0.000009,"output":0.00024,"cacheRead":0,"cacheWrite":0.00882375,"total":0.00907275}},"stopReason":"stop","timestamp":1771869013941}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"f723fba6-4abd-4575-8beb-4bde615d5f88","timestamp":"2026-02-23T17:50:29.043Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"fd01de27","parentId":null,"timestamp":"2026-02-23T17:50:29.044Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"5b5852ab","parentId":"fd01de27","timestamp":"2026-02-23T17:50:29.044Z","thinkingLevel":"off"}
{"type":"message","id":"dc2ee19e","parentId":"5b5852ab","timestamp":"2026-02-23T17:50:29.046Z","message":{"role":"user","content":[{"type":"text","text":"Ты кто?"}],"timestamp":1771869029045}}
{"type":"message","id":"5093e81f","parentId":"dc2ee19e","timestamp":"2026-02-23T17:50:32.766Z","message":{"role":"assistant","content":[{"type":"text","text":"Я **Claude Code** — официальный CLI-инструмент от Anthropic, встроенный в среду **pi** (coding agent harness).\n\nПомогаю с разработкой: читаю файлы, выполняю команды, редактирую и пишу код. Работаю в твоём проекте `/root/projects/team-board`.\n\nЧем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":106,"cacheRead":0,"cacheWrite":2356,"totalTokens":2465,"cost":{"input":0.000009,"output":0.00159,"cacheRead":0,"cacheWrite":0.008835,"total":0.010434}},"stopReason":"stop","timestamp":1771869029045}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"96511099-2402-47ca-b891-f73cd9d4ed10","timestamp":"2026-02-23T17:50:52.660Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"99473765","parentId":null,"timestamp":"2026-02-23T17:50:52.661Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"3869ed95","parentId":"99473765","timestamp":"2026-02-23T17:50:52.661Z","thinkingLevel":"off"}
{"type":"message","id":"c2159f9a","parentId":"3869ed95","timestamp":"2026-02-23T17:50:52.662Z","message":{"role":"user","content":[{"type":"text","text":"Что я написал до этого?"}],"timestamp":1771869052662}}
{"type":"message","id":"281c9bef","parentId":"c2159f9a","timestamp":"2026-02-23T17:50:55.062Z","message":{"role":"assistant","content":[{"type":"text","text":"Я не имею доступа к истории предыдущих разговоров — каждая сессия начинается с чистого листа. Я не знаю, что вы писали ранее.\n\nМогу помочь с текущим проектом. Хотите, посмотрю что есть в рабочей директории?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":92,"cacheRead":0,"cacheWrite":2359,"totalTokens":2454,"cost":{"input":0.000009,"output":0.00138,"cacheRead":0,"cacheWrite":0.00884625,"total":0.01023525}},"stopReason":"stop","timestamp":1771869052662}}

View File

@ -0,0 +1,9 @@
{"type":"session","version":3,"id":"a1ea0f7c-793e-4ab3-9fcb-cadfd0120688","timestamp":"2026-02-23T17:52:45.615Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"da509cb0","parentId":null,"timestamp":"2026-02-23T17:52:45.616Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"ad78b3bd","parentId":"da509cb0","timestamp":"2026-02-23T17:52:45.616Z","thinkingLevel":"off"}
{"type":"message","id":"13563583","parentId":"ad78b3bd","timestamp":"2026-02-23T17:52:45.618Z","message":{"role":"user","content":[{"type":"text","text":"ws test"}],"timestamp":1771869165617}}
{"type":"message","id":"70fde107","parentId":"13563583","timestamp":"2026-02-23T17:52:46.798Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01SrGugAYsEvxzopxm1DnVbu","name":"ls","arguments":{}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":35,"cacheRead":0,"cacheWrite":2352,"totalTokens":2390,"cost":{"input":0.000009,"output":0.000525,"cacheRead":0,"cacheWrite":0.00882,"total":0.009354}},"stopReason":"toolUse","timestamp":1771869165618}}
{"type":"message","id":"4aa64c26","parentId":"70fde107","timestamp":"2026-02-23T17:52:46.800Z","message":{"role":"toolResult","toolCallId":"toolu_01SrGugAYsEvxzopxm1DnVbu","toolName":"ls","content":[{"type":"text","text":".git/\nagent-coder/\ndocs/\npicogent/\nrunner/\nsessions/\ntracker/\nweb-client/"}],"isError":false,"timestamp":1771869166800}}
{"type":"message","id":"55edf4cb","parentId":"4aa64c26","timestamp":"2026-02-23T17:52:48.270Z","message":{"role":"assistant","content":[{"type":"toolCall","id":"toolu_01MVnKfv9STxRYVhdbjz49Tc","name":"ls","arguments":{"path":"/root/projects/team-board"}}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":1,"output":58,"cacheRead":2352,"cacheWrite":79,"totalTokens":2490,"cost":{"input":0.000003,"output":0.00087,"cacheRead":0.0007056,"cacheWrite":0.00029625,"total":0.00187485}},"stopReason":"toolUse","timestamp":1771869166800}}
{"type":"message","id":"2dfe231f","parentId":"55edf4cb","timestamp":"2026-02-23T17:52:48.271Z","message":{"role":"toolResult","toolCallId":"toolu_01MVnKfv9STxRYVhdbjz49Tc","toolName":"ls","content":[{"type":"text","text":".git/\nagent-coder/\ndocs/\npicogent/\nrunner/\nsessions/\ntracker/\nweb-client/"}],"isError":false,"timestamp":1771869168270}}
{"type":"message","id":"21b34f80","parentId":"2dfe231f","timestamp":"2026-02-23T17:52:49.924Z","message":{"role":"assistant","content":[{"type":"text","text":"Что именно ты хочешь протестировать? Уточни задачу — WebSocket тесты, запуск тестов, или что-то другое?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":1,"output":46,"cacheRead":2431,"cacheWrite":100,"totalTokens":2578,"cost":{"input":0.000003,"output":0.00069,"cacheRead":0.0007293,"cacheWrite":0.000375,"total":0.0017973}},"stopReason":"stop","timestamp":1771869168271}}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"8ffb09d6-9355-4d5e-b42c-8ce7cd1428c1","timestamp":"2026-02-23T17:53:39.508Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"2a988b7a","parentId":null,"timestamp":"2026-02-23T17:53:39.509Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"5e0cb2a9","parentId":"2a988b7a","timestamp":"2026-02-23T17:53:39.509Z","thinkingLevel":"off"}
{"type":"message","id":"f34c0738","parentId":"5e0cb2a9","timestamp":"2026-02-23T17:53:39.511Z","message":{"role":"user","content":[{"type":"text","text":"скажи просто ок"}],"timestamp":1771869219510}}
{"type":"message","id":"9283af31","parentId":"f34c0738","timestamp":"2026-02-23T17:53:40.397Z","message":{"role":"assistant","content":[{"type":"text","text":"ок"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":5,"cacheRead":0,"cacheWrite":2357,"totalTokens":2365,"cost":{"input":0.000009,"output":0.00007500000000000001,"cacheRead":0,"cacheWrite":0.00883875,"total":0.00892275}},"stopReason":"stop","timestamp":1771869219510}}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"0dedd6e4-330f-41e3-ae13-e9161d1fe283","timestamp":"2026-02-23T18:05:32.036Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"abfbf886","parentId":null,"timestamp":"2026-02-23T18:05:32.037Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"dcbc0d77","parentId":"abfbf886","timestamp":"2026-02-23T18:05:32.037Z","thinkingLevel":"off"}
{"type":"message","id":"a5b47e28","parentId":"dcbc0d77","timestamp":"2026-02-23T18:05:32.038Z","message":{"role":"user","content":[{"type":"text","text":"Привет"}],"timestamp":1771869932037}}
{"type":"message","id":"77389f3e","parentId":"a5b47e28","timestamp":"2026-02-23T18:05:33.322Z","message":{"role":"assistant","content":[{"type":"text","text":"Привет! Чем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":16,"cacheRead":0,"cacheWrite":2353,"totalTokens":2372,"cost":{"input":0.000009,"output":0.00024,"cacheRead":0,"cacheWrite":0.00882375,"total":0.00907275}},"stopReason":"stop","timestamp":1771869932038}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"993930bc-f563-4078-abb4-001b4a0f9553","timestamp":"2026-02-23T18:05:50.035Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"7f48018f","parentId":null,"timestamp":"2026-02-23T18:05:50.036Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"12f69479","parentId":"7f48018f","timestamp":"2026-02-23T18:05:50.036Z","thinkingLevel":"off"}
{"type":"message","id":"7e53fea8","parentId":"12f69479","timestamp":"2026-02-23T18:05:50.038Z","message":{"role":"user","content":[{"type":"text","text":"Ты кто?"}],"timestamp":1771869950037}}
{"type":"message","id":"f4a7941f","parentId":"7e53fea8","timestamp":"2026-02-23T18:05:53.120Z","message":{"role":"assistant","content":[{"type":"text","text":"Я **Claude Code** — официальный CLI-инструмент от Anthropic, встроенный в агент-харнесс **pi**.\n\nПомогаю с разработкой: читаю файлы, выполняю команды, редактирую и пишу код. Работаю в контексте твоего проекта `/root/projects/team-board`.\n\nЧем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":108,"cacheRead":0,"cacheWrite":2356,"totalTokens":2467,"cost":{"input":0.000009,"output":0.0016200000000000001,"cacheRead":0,"cacheWrite":0.008835,"total":0.010464000000000001}},"stopReason":"stop","timestamp":1771869950038}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"5100ff20-d518-4e01-b383-4822ef03951a","timestamp":"2026-02-23T18:11:28.464Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"830ead6b","parentId":null,"timestamp":"2026-02-23T18:11:28.464Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"136f5199","parentId":"830ead6b","timestamp":"2026-02-23T18:11:28.464Z","thinkingLevel":"off"}
{"type":"message","id":"e8e00a63","parentId":"136f5199","timestamp":"2026-02-23T18:11:28.465Z","message":{"role":"user","content":[{"type":"text","text":"Привет"}],"timestamp":1771870288465}}
{"type":"message","id":"ce648c7f","parentId":"e8e00a63","timestamp":"2026-02-23T18:11:29.704Z","message":{"role":"assistant","content":[{"type":"text","text":"Привет! Чем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":16,"cacheRead":0,"cacheWrite":2353,"totalTokens":2372,"cost":{"input":0.000009,"output":0.00024,"cacheRead":0,"cacheWrite":0.00882375,"total":0.00907275}},"stopReason":"stop","timestamp":1771870288465}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"ba83f5ae-c30a-45c3-b09c-817d5180dc02","timestamp":"2026-02-23T18:11:37.609Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"4f974b33","parentId":null,"timestamp":"2026-02-23T18:11:37.609Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"bcedea64","parentId":"4f974b33","timestamp":"2026-02-23T18:11:37.609Z","thinkingLevel":"off"}
{"type":"message","id":"2a7821f0","parentId":"bcedea64","timestamp":"2026-02-23T18:11:37.610Z","message":{"role":"user","content":[{"type":"text","text":"Ты кто?"}],"timestamp":1771870297610}}
{"type":"message","id":"302c7f6d","parentId":"2a7821f0","timestamp":"2026-02-23T18:11:40.066Z","message":{"role":"assistant","content":[{"type":"text","text":"Я **Claude Code** — официальный CLI-инструмент от Anthropic, встроенный в среду **pi** (coding agent harness).\n\nПомогаю с разработкой: читаю файлы, выполняю команды, редактирую и создаю код.\n\nЧем могу помочь по проекту **team-board**?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":95,"cacheRead":0,"cacheWrite":2356,"totalTokens":2454,"cost":{"input":0.000009,"output":0.001425,"cacheRead":0,"cacheWrite":0.008835,"total":0.010269}},"stopReason":"stop","timestamp":1771870297610}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"c409d11a-d081-46b9-a1b5-c90ee2c5df37","timestamp":"2026-02-23T18:11:50.161Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"a1a0bc9b","parentId":null,"timestamp":"2026-02-23T18:11:50.162Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"52a770dd","parentId":"a1a0bc9b","timestamp":"2026-02-23T18:11:50.162Z","thinkingLevel":"off"}
{"type":"message","id":"82494aae","parentId":"52a770dd","timestamp":"2026-02-23T18:11:50.163Z","message":{"role":"user","content":[{"type":"text","text":"Что я написал раньше?"}],"timestamp":1771870310162}}
{"type":"message","id":"e55ee905","parentId":"82494aae","timestamp":"2026-02-23T18:11:52.308Z","message":{"role":"assistant","content":[{"type":"text","text":"Это наш первый разговор — у меня нет истории предыдущих сообщений от тебя. Каждая сессия начинается с чистого листа.\n\nМогу помочь с чем-то конкретным по проекту?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":72,"cacheRead":0,"cacheWrite":2359,"totalTokens":2434,"cost":{"input":0.000009,"output":0.00108,"cacheRead":0,"cacheWrite":0.00884625,"total":0.00993525}},"stopReason":"stop","timestamp":1771870310162}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"2dee83b3-7712-48a7-bf58-eee5cf5643b1","timestamp":"2026-02-23T18:12:01.934Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"ba9e31ab","parentId":null,"timestamp":"2026-02-23T18:12:01.934Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"44898673","parentId":"ba9e31ab","timestamp":"2026-02-23T18:12:01.934Z","thinkingLevel":"off"}
{"type":"message","id":"4eb3b65c","parentId":"44898673","timestamp":"2026-02-23T18:12:01.935Z","message":{"role":"user","content":[{"type":"text","text":"Можешь"}],"timestamp":1771870321935}}
{"type":"message","id":"f9b2a2da","parentId":"4eb3b65c","timestamp":"2026-02-23T18:12:03.473Z","message":{"role":"assistant","content":[{"type":"text","text":"Да, могу! Чем помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":16,"cacheRead":0,"cacheWrite":2354,"totalTokens":2373,"cost":{"input":0.000009,"output":0.00024,"cacheRead":0,"cacheWrite":0.0088275,"total":0.0090765}},"stopReason":"stop","timestamp":1771870321935}}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"8980c11d-528e-4644-a8bc-0bb8fb523b5e","timestamp":"2026-02-23T18:12:50.387Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"04907e6f","parentId":null,"timestamp":"2026-02-23T18:12:50.388Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"eca913e3","parentId":"04907e6f","timestamp":"2026-02-23T18:12:50.388Z","thinkingLevel":"off"}
{"type":"message","id":"bfe91f30","parentId":"eca913e3","timestamp":"2026-02-23T18:12:50.389Z","message":{"role":"user","content":[{"type":"text","text":"Ты тут?"}],"timestamp":1771870370388}}
{"type":"message","id":"64b303d2","parentId":"bfe91f30","timestamp":"2026-02-23T18:12:51.529Z","message":{"role":"assistant","content":[{"type":"text","text":"Да, здесь! Чем могу помочь?"}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":19,"cacheRead":0,"cacheWrite":2356,"totalTokens":2378,"cost":{"input":0.000009,"output":0.000285,"cacheRead":0,"cacheWrite":0.008835,"total":0.009129000000000002}},"stopReason":"stop","timestamp":1771870370389}}

View File

@ -0,0 +1,5 @@
{"type":"session","version":3,"id":"866c1e6c-af06-4b19-bb32-230740ce10d4","timestamp":"2026-02-23T18:13:13.304Z","cwd":"/root/projects/team-board/picogent"}
{"type":"model_change","id":"40a22a32","parentId":null,"timestamp":"2026-02-23T18:13:13.304Z","provider":"anthropic","modelId":"claude-sonnet-4-6"}
{"type":"thinking_level_change","id":"182c516f","parentId":"40a22a32","timestamp":"2026-02-23T18:13:13.304Z","thinkingLevel":"off"}
{"type":"message","id":"33395ae1","parentId":"182c516f","timestamp":"2026-02-23T18:13:13.305Z","message":{"role":"user","content":[{"type":"text","text":"Что я спросил в первом сообщении?"}],"timestamp":1771870393305}}
{"type":"message","id":"49039b1c","parentId":"33395ae1","timestamp":"2026-02-23T18:13:14.784Z","message":{"role":"assistant","content":[{"type":"text","text":"Это наш первый разговор — предыдущих сообщений от тебя нет."}],"api":"anthropic-messages","provider":"anthropic","model":"claude-sonnet-4-6","usage":{"input":3,"output":29,"cacheRead":0,"cacheWrite":2364,"totalTokens":2396,"cost":{"input":0.000009,"output":0.000435,"cacheRead":0,"cacheWrite":0.008865,"total":0.009309}},"stopReason":"stop","timestamp":1771870393305}}

@ -1 +1 @@
Subproject commit dbd20ff550311f01d353cd04fc09fa419ca2a6b5 Subproject commit e39c26d3212a7b656bd5629c6feaafcb259f71e3

1
web-client Submodule

@ -0,0 +1 @@
Subproject commit 5a1878ae027ed91dc0f2f7a9ccde1bf69329e2ba

2
web-client-vite/.env Normal file
View File

@ -0,0 +1,2 @@
VITE_API_URL=https://dev.team.uix.su
VITE_WS_URL=wss://dev.team.uix.su

24
web-client-vite/.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
web-client-vite/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>web-client-vite</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3951
web-client-vite/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,33 @@
{
"name": "web-client-vite",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.0",
"react-dom": "^19.2.0",
"react-router-dom": "^7.13.1"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@tailwindcss/vite": "^4.2.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"tailwindcss": "^4.2.1",
"typescript": "~5.9.3",
"typescript-eslint": "^8.48.0",
"vite": "^7.3.1"
}
}

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

@ -0,0 +1,42 @@
#root {
max-width: 1280px;
margin: 0 auto;
padding: 2rem;
text-align: center;
}
.logo {
height: 6em;
padding: 1.5em;
will-change: filter;
transition: filter 300ms;
}
.logo:hover {
filter: drop-shadow(0 0 2em #646cffaa);
}
.logo.react:hover {
filter: drop-shadow(0 0 2em #61dafbaa);
}
@keyframes logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@media (prefers-reduced-motion: no-preference) {
a:nth-of-type(2) .logo {
animation: logo-spin infinite 20s linear;
}
}
.card {
padding: 2em;
}
.read-the-docs {
color: #888;
}

View File

@ -0,0 +1,48 @@
import { BrowserRouter, Routes, Route } from "react-router-dom";
import AuthGuard from "@/components/AuthGuard";
import LoginPage from "@/pages/LoginPage";
import HomePage from "@/pages/HomePage";
import ProjectPage from "@/pages/ProjectPage";
import CreateProjectPage from "@/pages/CreateProjectPage";
import SettingsLayout from "@/pages/settings/SettingsLayout";
import SettingsPage from "@/pages/settings/SettingsPage";
import AgentsPage from "@/pages/settings/AgentsPage";
function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={
<AuthGuard>
<HomePage />
</AuthGuard>
} />
<Route path="/projects/:slug" element={
<AuthGuard>
<ProjectPage />
</AuthGuard>
} />
<Route path="/projects/new" element={
<AuthGuard>
<CreateProjectPage />
</AuthGuard>
} />
<Route path="/settings" element={
<AuthGuard>
<SettingsLayout />
</AuthGuard>
}>
<Route index element={<SettingsPage />} />
<Route path="agents" element={<AgentsPage />} />
</Route>
</Routes>
</BrowserRouter>
);
}
export default App;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

View File

@ -0,0 +1,28 @@
import type { Member } from "@/lib/api";
interface Props {
agent: Member;
onClose: () => void;
onUpdated: () => void;
}
export default function AgentModal({ agent, onClose, onUpdated: _onUpdated }: Props) {
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Агент: {agent.name}</h2>
<p className="text-[var(--muted)] text-sm mb-4">@{agent.slug}</p>
<p className="text-[var(--muted)] text-sm mb-4">Coming soon...</p>
<div className="flex gap-2">
<button
onClick={onClose}
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
text-sm hover:bg-white/5 transition-colors"
>
Закрыть
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { Navigate } from "react-router-dom";
import { isAuthenticated } from "@/lib/auth-client";
import { wsClient } from "@/lib/ws";
export default function AuthGuard({ children }: { children: React.ReactNode }) {
const [checked, setChecked] = useState(false);
const [isAuth, setIsAuth] = useState(false);
useEffect(() => {
const authStatus = isAuthenticated();
setIsAuth(authStatus);
setChecked(true);
if (authStatus) {
// Connect WebSocket after auth check
if (!wsClient.connected) {
wsClient.connect();
}
}
return () => {
// Don't disconnect on unmount — singleton stays alive
};
}, []);
if (!checked) {
return (
<div className="flex h-screen items-center justify-center text-[var(--muted)]">
Загрузка...
</div>
);
}
if (!isAuth) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}

View File

@ -0,0 +1,16 @@
interface Props {
chatId: string;
fullscreen?: boolean;
}
export default function ChatPanel({ chatId, fullscreen }: Props) {
return (
<div className="flex items-center justify-center h-full text-[var(--muted)]">
<div className="text-center">
<h3 className="text-lg mb-2">💬 Chat Panel</h3>
<p className="text-sm">Chat ID: {chatId}</p>
<p className="text-xs mt-1">Coming soon...</p>
</div>
</div>
);
}

View File

@ -0,0 +1,24 @@
interface Props {
onCreated: () => void;
onClose: () => void;
}
export default function CreateAgentModal({ onCreated, onClose }: Props) {
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Создать агента</h2>
<p className="text-[var(--muted)] text-sm mb-4">Coming soon...</p>
<div className="flex gap-2">
<button
onClick={onClose}
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
text-sm hover:bg-white/5 transition-colors"
>
Закрыть
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,114 @@
import { useState } from "react";
import { createProject, type Project } from "@/lib/api";
interface Props {
onCreated: (project: Project) => void;
onClose: () => void;
}
export default function CreateProjectModal({ onCreated, onClose }: Props) {
const [name, setName] = useState("");
const [slug, setSlug] = useState("");
const [description, setDescription] = useState("");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const project = await createProject({ name, slug, description: description || undefined });
onCreated(project);
} catch (err: any) {
setError(err.message || "Ошибка создания проекта");
} finally {
setLoading(false);
}
};
const generateSlug = () => {
if (name && !slug) {
const generated = name.toLowerCase()
.replace(/[^a-zа-я0-9\s]/g, '')
.replace(/\s+/g, '-');
setSlug(generated);
}
};
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-6 w-full max-w-md">
<h2 className="text-xl font-bold mb-4">Создать проект</h2>
{error && (
<div className="mb-4 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm">
{error}
</div>
)}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm mb-1">Название</label>
<input
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={generateSlug}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
outline-none focus:border-[var(--accent)] text-sm"
placeholder="Мой проект"
required
/>
</div>
<div>
<label className="block text-sm mb-1">Slug (URL)</label>
<input
type="text"
value={slug}
onChange={(e) => setSlug(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
outline-none focus:border-[var(--accent)] text-sm font-mono"
placeholder="my-project"
pattern="[a-z0-9-]+"
required
/>
</div>
<div>
<label className="block text-sm mb-1">Описание (опционально)</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
className="w-full px-3 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
outline-none focus:border-[var(--accent)] text-sm resize-none"
rows={3}
placeholder="Краткое описание проекта"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 py-2 bg-[var(--bg)] border border-[var(--border)] rounded
text-sm hover:bg-white/5 transition-colors"
>
Отмена
</button>
<button
type="submit"
disabled={loading || !name || !slug}
className="flex-1 py-2 bg-[var(--accent)] text-white rounded text-sm
hover:opacity-90 transition-opacity disabled:opacity-50"
>
{loading ? "Создаём..." : "Создать"}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@ -0,0 +1,16 @@
interface Props {
projectId: string;
projectSlug: string;
}
export default function KanbanBoard({ projectId, projectSlug }: Props) {
return (
<div className="flex items-center justify-center h-full text-[var(--muted)]">
<div className="text-center">
<h3 className="text-lg mb-2">📋 Kanban Board</h3>
<p className="text-sm">Project: {projectSlug}</p>
<p className="text-xs mt-1">Coming soon...</p>
</div>
</div>
);
}

View File

@ -0,0 +1,17 @@
import type { Project } from "@/lib/api";
interface Props {
project: Project;
}
export default function ProjectFiles({ project }: Props) {
return (
<div className="flex items-center justify-center h-full text-[var(--muted)]">
<div className="text-center">
<h3 className="text-lg mb-2">📁 Project Files</h3>
<p className="text-sm">Project: {project.name}</p>
<p className="text-xs mt-1">Coming soon...</p>
</div>
</div>
);
}

View File

@ -0,0 +1,18 @@
import type { Project } from "@/lib/api";
interface Props {
project: Project;
onUpdated: (project: Project) => void;
}
export default function ProjectSettings({ project, onUpdated }: Props) {
return (
<div className="flex items-center justify-center h-full text-[var(--muted)]">
<div className="text-center">
<h3 className="text-lg mb-2"> Project Settings</h3>
<p className="text-sm">Project: {project.name}</p>
<p className="text-xs mt-1">Coming soon...</p>
</div>
</div>
);
}

View File

@ -0,0 +1,111 @@
import { useState } from "react";
import { Link } from "react-router-dom";
import type { Project } from "@/lib/api";
import { logout } from "@/lib/auth-client";
interface Props {
projects: Project[];
activeSlug?: string;
}
export default function Sidebar({ projects, activeSlug }: Props) {
const [open, setOpen] = useState(false);
const nav = (
<>
<div className="p-4 border-b border-[var(--border)] flex items-center justify-between">
<Link to="/" className="text-lg font-bold tracking-tight">
Team Board
</Link>
{/* Close button on mobile */}
<button
className="md:hidden text-[var(--muted)] hover:text-[var(--fg)] text-xl"
onClick={() => setOpen(false)}
>
</button>
</div>
<nav className="flex-1 overflow-y-auto p-2">
<Link
to="/projects/new"
onClick={() => setOpen(false)}
className="flex items-center gap-2 px-3 py-2 mb-2 rounded text-sm text-[var(--accent)]
hover:bg-[var(--accent)]/10 transition-colors"
>
<span className="text-lg leading-none">+</span>
Новый проект
</Link>
<div className="text-xs uppercase text-[var(--muted)] px-2 py-1 mb-1">Проекты</div>
{projects.map((p) => (
<Link
key={p.id}
to={`/projects/${p.slug}`}
onClick={() => setOpen(false)}
className={`block px-3 py-2 rounded text-sm transition-colors ${
activeSlug === p.slug
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "hover:bg-white/5 text-[var(--fg)]"
}`}
>
{p.name}
</Link>
))}
</nav>
<div className="p-3 border-t border-[var(--border)] flex items-center justify-between">
<span className="text-xs text-[var(--muted)]">Team Board v0.2</span>
<div className="flex items-center gap-2">
<Link
to="/settings"
onClick={() => setOpen(false)}
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors cursor-pointer"
title="Настройки"
>
</Link>
<button
onClick={logout}
className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors cursor-pointer"
title="Выйти"
>
🚪
</button>
</div>
</div>
</>
);
return (
<>
{/* Mobile hamburger */}
<button
className="md:hidden fixed top-3 left-3 z-40 p-2 bg-[var(--card)] border border-[var(--border)]
rounded-lg text-[var(--fg)]"
onClick={() => setOpen(true)}
>
<svg width="20" height="20" viewBox="0 0 20 20" fill="currentColor">
<path d="M2 4.5h16M2 10h16M2 15.5h16" stroke="currentColor" strokeWidth="1.5" fill="none" />
</svg>
</button>
{/* Mobile overlay */}
{open && (
<div className="md:hidden fixed inset-0 bg-black/60 z-40" onClick={() => setOpen(false)} />
)}
{/* Sidebar: always visible on desktop, slide-in on mobile */}
<aside
className={`
fixed md:static z-50 top-0 left-0 h-screen w-60 shrink-0
border-r border-[var(--border)] bg-[var(--card)] flex flex-col
transition-transform duration-200
${open ? "translate-x-0" : "-translate-x-full md:translate-x-0"}
`}
>
{nav}
</aside>
</>
);
}

View File

@ -0,0 +1,16 @@
@import "tailwindcss";
:root {
--bg: #0a0a0a;
--fg: #ededed;
--accent: #3b82f6;
--border: #262626;
--card: #141414;
--muted: #737373;
}
body {
background: var(--bg);
color: var(--fg);
font-family: system-ui, -apple-system, sans-serif;
}

View File

@ -0,0 +1,279 @@
/**
* API client for Team Board Tracker.
*/
const API_BASE = import.meta.env.VITE_API_URL!; // Required — set in .env
function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem("tb_token");
}
async function request<T = any>(path: string, options: RequestInit = {}): Promise<T> {
const token = getToken();
const headers: Record<string, string> = {
"Content-Type": "application/json",
...(options.headers as Record<string, string>),
};
if (token) headers["Authorization"] = `Bearer ${token}`;
const res = await fetch(`${API_BASE}${path}`, { ...options, headers });
if (!res.ok) {
if (res.status === 401 && typeof window !== "undefined") {
localStorage.removeItem("tb_token");
window.location.href = "/login";
throw new Error("Unauthorized");
}
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || `HTTP ${res.status}`);
}
if (res.status === 204) return {} as T;
return res.json();
}
// --- Types ---
export interface AgentConfig {
capabilities: string[];
chat_listen: string;
task_listen: string;
prompt: string | null;
model: string | null;
}
export interface Member {
id: string;
name: string;
slug: string;
type: "human" | "agent";
role: string;
status: string;
avatar_url: string | null;
agent_config: AgentConfig | null;
token?: string | null;
}
export interface MemberCreateResponse extends Member {
token?: string;
}
export interface Project {
id: string;
name: string;
slug: string;
description: string | null;
repo_urls: string[];
status: string;
task_counter: number;
chat_id: string | null;
}
export interface Step {
id: string;
title: string;
done: boolean;
position: number;
}
export interface Task {
id: string;
project_id: string;
parent_id: string | null;
number: number;
key: string;
title: string;
description: string | null;
type: string;
status: string;
priority: string;
labels: string[];
assignee_slug: string | null;
reviewer_slug: string | null;
watchers: string[];
depends_on: string[];
position: number;
time_spent: number;
steps: Step[];
}
export interface Attachment {
id: string;
filename: string;
mime_type: string | null;
size: number;
}
export interface Message {
id: string;
chat_id: string | null;
task_id: string | null;
parent_id: string | null;
author_type: string;
author_slug: string;
content: string;
mentions: string[];
voice_url: string | null;
attachments: Attachment[];
created_at: string;
}
// --- Auth ---
export async function login(login: string, password: string) {
return request("/api/auth/login", {
method: "POST",
body: JSON.stringify({ login, password }),
});
}
// --- Projects ---
export async function getProjects(): Promise<Project[]> {
return request("/api/v1/projects");
}
export async function getProject(slug: string): Promise<Project> {
return request(`/api/v1/projects/${slug}`);
}
export async function createProject(data: { name: string; slug: string; description?: string }): Promise<Project> {
return request("/api/v1/projects", { method: "POST", body: JSON.stringify(data) });
}
export async function updateProject(slug: string, data: Partial<Pick<Project, "name" | "description" | "repo_urls" | "status">>): Promise<Project> {
return request(`/api/v1/projects/${slug}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteProject(slug: string): Promise<void> {
await request(`/api/v1/projects/${slug}`, { method: "DELETE" });
}
// --- Tasks ---
export async function getTasks(projectId: string): Promise<Task[]> {
return request(`/api/v1/tasks?project_id=${projectId}`);
}
export async function getTask(taskId: string): Promise<Task> {
return request(`/api/v1/tasks/${taskId}`);
}
export async function createTask(projectSlug: string, data: Partial<Task>): Promise<Task> {
return request(`/api/v1/tasks?project_slug=${projectSlug}`, {
method: "POST",
body: JSON.stringify(data),
});
}
export async function updateTask(taskId: string, data: Partial<Task>): Promise<Task> {
return request(`/api/v1/tasks/${taskId}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function deleteTask(taskId: string): Promise<void> {
await request(`/api/v1/tasks/${taskId}`, { method: "DELETE" });
}
export async function takeTask(taskId: string, slug: string): Promise<Task> {
return request(`/api/v1/tasks/${taskId}/take?slug=${slug}`, { method: "POST" });
}
export async function rejectTask(taskId: string, reason: string): Promise<{ok: boolean; reason: string; old_assignee: string}> {
return request(`/api/v1/tasks/${taskId}/reject`, {
method: "POST",
body: JSON.stringify({ reason })
});
}
export async function assignTask(taskId: string, assigneeSlug: string): Promise<Task> {
return request(`/api/v1/tasks/${taskId}/assign`, {
method: "POST",
body: JSON.stringify({ assignee_slug: assigneeSlug })
});
}
export async function watchTask(taskId: string, slug: string): Promise<{ok: boolean; watchers: string[]}> {
return request(`/api/v1/tasks/${taskId}/watch?slug=${slug}`, { method: "POST" });
}
export async function unwatchTask(taskId: string, slug: string): Promise<{ok: boolean; watchers: string[]}> {
return request(`/api/v1/tasks/${taskId}/watch?slug=${slug}`, { method: "DELETE" });
}
// --- Steps ---
export async function getSteps(taskId: string): Promise<Step[]> {
return request(`/api/v1/tasks/${taskId}/steps`);
}
export async function createStep(taskId: string, title: string): Promise<Step> {
return request(`/api/v1/tasks/${taskId}/steps`, {
method: "POST",
body: JSON.stringify({ title }),
});
}
export async function updateStep(taskId: string, stepId: string, data: Partial<Step>): Promise<Step> {
return request(`/api/v1/tasks/${taskId}/steps/${stepId}`, {
method: "PATCH",
body: JSON.stringify(data),
});
}
export async function deleteStep(taskId: string, stepId: string): Promise<void> {
await request(`/api/v1/tasks/${taskId}/steps/${stepId}`, { method: "DELETE" });
}
// --- Messages (unified: chat + task comments) ---
export async function getMessages(params: { chat_id?: string; task_id?: string; limit?: number }): Promise<Message[]> {
const qs = new URLSearchParams();
if (params.chat_id) qs.set("chat_id", params.chat_id);
if (params.task_id) qs.set("task_id", params.task_id);
if (params.limit) qs.set("limit", String(params.limit));
return request(`/api/v1/messages?${qs}`);
}
export async function sendMessage(data: {
chat_id?: string;
task_id?: string;
content: string;
mentions?: string[];
}): Promise<Message> {
return request("/api/v1/messages", { method: "POST", body: JSON.stringify(data) });
}
// --- Members ---
export async function getMembers(): Promise<Member[]> {
return request("/api/v1/members");
}
export async function getMember(slug: string): Promise<Member> {
return request(`/api/v1/members/${slug}`);
}
export async function createMember(data: {
name: string;
slug: string;
type?: string;
agent_config?: Partial<AgentConfig>;
}): Promise<MemberCreateResponse> {
return request("/api/v1/members", { method: "POST", body: JSON.stringify(data) });
}
export async function updateMember(slug: string, data: {
name?: string;
role?: string;
status?: string;
agent_config?: Partial<AgentConfig>;
}): Promise<Member> {
return request(`/api/v1/members/${slug}`, { method: "PATCH", body: JSON.stringify(data) });
}
export async function regenerateToken(slug: string): Promise<{ token: string }> {
return request(`/api/v1/members/${slug}/regenerate-token`, { method: "POST" });
}
export async function revokeToken(slug: string): Promise<void> {
await request(`/api/v1/members/${slug}/revoke-token`, { method: "POST" });
}

View File

@ -0,0 +1,28 @@
/**
* Client-side JWT auth.
* Token stored in localStorage, sent as Authorization: Bearer header.
*/
const TOKEN_KEY = "tb_token";
export function getToken(): string | null {
if (typeof window === "undefined") return null;
return localStorage.getItem(TOKEN_KEY);
}
export function setToken(token: string) {
localStorage.setItem(TOKEN_KEY, token);
}
export function clearToken() {
localStorage.removeItem(TOKEN_KEY);
}
export function isAuthenticated(): boolean {
return !!getToken();
}
export function logout() {
clearToken();
window.location.href = "/login";
}

View File

@ -0,0 +1,119 @@
/**
* WebSocket client for Team Board.
* Connects directly to Tracker.
*/
type MessageHandler = (data: any) => void;
class WSClient {
private ws: WebSocket | null = null;
private handlers: Map<string, Set<MessageHandler>> = new Map();
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
private pendingQueue: any[] = [];
private authenticated = false;
private _lobbyId: string | null = null;
private _projects: Array<{ id: string; slug: string; name: string }> = [];
private _online: string[] = [];
get lobbyId() { return this._lobbyId; }
get projects() { return this._projects; }
get online() { return this._online; }
get connected() { return this.ws?.readyState === WebSocket.OPEN; }
connect() {
const token = localStorage.getItem("tb_token");
if (!token) return;
const wsBase = import.meta.env.VITE_WS_URL;
if (!wsBase) {
console.error("VITE_WS_URL is not set!");
return;
}
this.authenticated = false;
this.ws = new WebSocket(`${wsBase}/ws?token=${token}`);
this.ws.onmessage = (event) => {
try {
const msg = JSON.parse(event.data);
const type = msg.type;
// Handle auth.ok — flush pending queue
if (type === "auth.ok") {
this._lobbyId = msg.data?.lobby_chat_id || null;
this._projects = msg.data?.projects || [];
this._online = msg.data?.online || [];
this.authenticated = true;
// Flush queued messages
for (const queued of this.pendingQueue) {
this.ws!.send(JSON.stringify(queued));
}
this.pendingQueue = [];
}
// Dispatch to handlers
const fns = this.handlers.get(type);
if (fns) fns.forEach((fn) => fn(msg.data || msg));
// Also dispatch to wildcard
const all = this.handlers.get("*");
if (all) all.forEach((fn) => fn(msg));
} catch (e) {
console.error("WS parse error:", e);
}
};
this.ws.onclose = () => {
this.authenticated = false;
this.reconnectTimer = setTimeout(() => this.connect(), 3000);
};
this.ws.onerror = () => {};
}
disconnect() {
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
this.ws?.close();
this.ws = null;
this.authenticated = false;
this.pendingQueue = [];
}
on(type: string, handler: MessageHandler) {
if (!this.handlers.has(type)) this.handlers.set(type, new Set());
this.handlers.get(type)!.add(handler);
return () => this.handlers.get(type)?.delete(handler);
}
send(data: any) {
if (this.ws?.readyState === WebSocket.OPEN && this.authenticated) {
this.ws.send(JSON.stringify(data));
} else {
// Queue until connected + authenticated
this.pendingQueue.push(data);
}
}
// Convenience methods
subscribeProject(projectId: string) {
this.send({ type: "project.subscribe", project_id: projectId });
}
unsubscribeProject(projectId: string) {
this.send({ type: "project.unsubscribe", project_id: projectId });
}
sendChat(chatId: string, content: string, mentions: string[] = []) {
this.send({ type: "chat.send", chat_id: chatId, content, mentions });
}
sendTaskComment(taskId: string, content: string, mentions: string[] = []) {
this.send({ type: "chat.send", task_id: taskId, content, mentions });
}
heartbeat(status: string = "online") {
this.send({ type: "heartbeat", status });
}
}
export const wsClient = new WSClient();

View File

@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@ -0,0 +1,15 @@
import { useNavigate } from "react-router-dom";
import CreateProjectModal from "@/components/CreateProjectModal";
export default function CreateProjectPage() {
const navigate = useNavigate();
return (
<div className="flex h-screen items-center justify-center">
<CreateProjectModal
onCreated={(project) => navigate(`/projects/${project.slug}`)}
onClose={() => navigate(-1)}
/>
</div>
);
}

View File

@ -0,0 +1,31 @@
import { useEffect } from "react";
import { Navigate } from "react-router-dom";
import { getProjects } from "@/lib/api";
import { useState } from "react";
export default function HomePage() {
const [projects, setProjects] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
getProjects()
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
if (loading) {
return (
<div className="flex h-screen items-center justify-center text-[var(--muted)]">
Загрузка...
</div>
);
}
if (projects.length === 0) {
return <Navigate to="/projects/new" replace />;
}
// Redirect to first project
return <Navigate to={`/projects/${projects[0].slug}`} replace />;
}

View File

@ -0,0 +1,84 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { login } from "@/lib/api";
import { setToken } from "@/lib/auth-client";
export default function LoginPage() {
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
const data = await login(username, password);
if (data.token) {
setToken(data.token);
navigate("/", { replace: true });
} else {
setError("Ошибка авторизации");
}
} catch (err: any) {
setError(err.message || "Ошибка соединения");
} finally {
setLoading(false);
}
};
return (
<div className="flex h-screen items-center justify-center">
<form onSubmit={handleSubmit} className="w-full max-w-80 px-4">
<h1 className="text-3xl font-bold mb-1 text-center">Team Board</h1>
<p className="text-[var(--muted)] mb-6 text-center text-sm">AI Agent Collaboration Platform</p>
{error && (
<div className="mb-4 p-2 bg-red-500/10 border border-red-500/30 rounded text-red-400 text-sm text-center">
{error}
</div>
)}
<input
type="text"
placeholder="Логин"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full mb-3 px-4 py-2.5 bg-[var(--card)] border border-[var(--border)] rounded-lg
outline-none focus:border-[var(--accent)] text-sm"
autoFocus
/>
<input
type="password"
placeholder="Пароль"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full mb-4 px-4 py-2.5 bg-[var(--card)] border border-[var(--border)] rounded-lg
outline-none focus:border-[var(--accent)] text-sm"
/>
<button
type="submit"
disabled={loading}
className="w-full py-2.5 bg-[var(--accent)] text-white rounded-lg hover:opacity-90
transition-opacity text-sm font-medium disabled:opacity-50"
>
{loading ? "..." : "Войти"}
</button>
<div className="mt-6 text-center">
<div className="text-xs text-[var(--muted)] mb-2">или</div>
<button
type="button"
disabled
className="w-full py-2.5 bg-[var(--card)] border border-[var(--border)] text-[var(--muted)]
rounded-lg text-sm cursor-not-allowed opacity-50"
>
Войти через Authentik (скоро)
</button>
</div>
</form>
</div>
);
}

View File

@ -0,0 +1,96 @@
import { useEffect, useState } from "react";
import { useParams } from "react-router-dom";
import { getProjects, type Project } from "@/lib/api";
import Sidebar from "@/components/Sidebar";
import KanbanBoard from "@/components/KanbanBoard";
import ChatPanel from "@/components/ChatPanel";
import ProjectFiles from "@/components/ProjectFiles";
import ProjectSettings from "@/components/ProjectSettings";
const TABS = [
{ key: "board", label: "📋 Доска" },
{ key: "chat", label: "💬 Чат" },
{ key: "files", label: "📁 Файлы" },
{ key: "settings", label: "⚙️ Настройки" },
];
export default function ProjectPage() {
const { slug } = useParams<{ slug: string }>();
const [projects, setProjects] = useState<Project[]>([]);
const [loading, setLoading] = useState(true);
const [activeTab, setActiveTab] = useState("board");
useEffect(() => {
getProjects()
.then(setProjects)
.catch(() => {})
.finally(() => setLoading(false));
}, []);
const project = projects.find((p) => p.slug === slug);
const handleProjectUpdated = (updated: Project) => {
setProjects((prev) => prev.map((p) => (p.id === updated.id ? updated : p)));
};
if (loading) {
return <div className="flex h-screen items-center justify-center text-[var(--muted)]">Загрузка...</div>;
}
if (!project) {
return <div className="flex h-screen items-center justify-center text-[var(--muted)]">Проект не найден</div>;
}
return (
<div className="flex h-screen">
<Sidebar projects={projects} activeSlug={slug} />
<main className="flex-1 flex flex-col overflow-hidden">
<header className="border-b border-[var(--border)] px-6 py-3 pl-14 md:pl-6">
<div className="flex items-center justify-between mb-2">
<div>
<h1 className="text-xl font-bold">{project.name}</h1>
{project.description && (
<p className="text-sm text-[var(--muted)]">{project.description}</p>
)}
</div>
</div>
<div className="flex gap-1">
{TABS.map((tab) => (
<button
key={tab.key}
onClick={() => setActiveTab(tab.key)}
className={`px-3 py-1.5 rounded text-sm transition-colors ${
activeTab === tab.key
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "text-[var(--muted)] hover:bg-white/5 hover:text-[var(--fg)]"
}`}
>
{tab.label}
</button>
))}
</div>
</header>
<div className="flex-1 overflow-hidden">
{activeTab === "board" && (
<KanbanBoard projectId={project.id} projectSlug={project.slug} />
)}
{activeTab === "chat" && project.chat_id && (
<ChatPanel chatId={project.chat_id} fullscreen />
)}
{activeTab === "chat" && !project.chat_id && (
<div className="flex items-center justify-center h-full text-[var(--muted)] text-sm">
Чат недоступен
</div>
)}
{activeTab === "files" && (
<ProjectFiles project={project} />
)}
{activeTab === "settings" && (
<ProjectSettings project={project} onUpdated={handleProjectUpdated} />
)}
</div>
</main>
</div>
);
}

View File

@ -0,0 +1,101 @@
import { useEffect, useState } from "react";
import { getMembers, type Member } from "@/lib/api";
import CreateAgentModal from "@/components/CreateAgentModal";
import AgentModal from "@/components/AgentModal";
export default function AgentsPage() {
const [agents, setAgents] = useState<Member[]>([]);
const [loading, setLoading] = useState(true);
const [showCreate, setShowCreate] = useState(false);
const [selected, setSelected] = useState<Member | null>(null);
const load = async () => {
try {
const members = await getMembers();
setAgents(members.filter((m) => m.type === "agent"));
} finally {
setLoading(false);
}
};
useEffect(() => {
load();
}, []);
return (
<div className="p-6">
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold">🤖 Агенты</h1>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-[var(--accent)] text-white rounded-lg text-sm hover:opacity-90"
>
+ Создать агента
</button>
</div>
{loading ? (
<div className="text-[var(--muted)] text-sm">Загрузка...</div>
) : agents.length === 0 ? (
<div className="text-[var(--muted)] text-sm">Нет агентов</div>
) : (
<div className="grid gap-4 grid-cols-1 md:grid-cols-2 lg:grid-cols-3">
{agents.map((agent) => (
<div
key={agent.id}
onClick={() => setSelected(agent)}
className="bg-[var(--card)] border border-[var(--border)] rounded-xl p-4 cursor-pointer hover:border-[var(--accent)]/50 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<h3 className="font-semibold">{agent.name}</h3>
<span
className={`text-xs px-2 py-0.5 rounded-full ${
agent.status === "online"
? "bg-green-500/20 text-green-400"
: "bg-gray-500/20 text-gray-400"
}`}
>
{agent.status === "online" ? "online" : "offline"}
</span>
</div>
<div className="text-xs text-[var(--muted)] mb-3">@{agent.slug}</div>
{agent.agent_config?.capabilities && agent.agent_config.capabilities.length > 0 && (
<div className="flex flex-wrap gap-1 mb-3">
{agent.agent_config.capabilities.map((cap) => (
<span
key={cap}
className="text-xs px-2 py-0.5 bg-[var(--accent)]/10 text-[var(--accent)] rounded"
>
{cap}
</span>
))}
</div>
)}
<div className="text-xs text-[var(--muted)] space-y-1">
<div>💬 chat: {agent.agent_config?.chat_listen || "—"}</div>
<div>📋 tasks: {agent.agent_config?.task_listen || "—"}</div>
</div>
</div>
))}
</div>
)}
{showCreate && (
<CreateAgentModal
onCreated={() => load()}
onClose={() => setShowCreate(false)}
/>
)}
{selected && (
<AgentModal
agent={selected}
onClose={() => setSelected(null)}
onUpdated={() => { setSelected(null); load(); }}
/>
)}
</div>
);
}

View File

@ -0,0 +1,54 @@
import { Link, useLocation, Outlet } from "react-router-dom";
import { logout } from "@/lib/auth-client";
const MENU = [
{ href: "/settings", label: "Общие", icon: "⚙️" },
{ href: "/settings/agents", label: "Агенты", icon: "🤖" },
];
export default function SettingsLayout() {
const location = useLocation();
return (
<div className="flex-1 flex overflow-hidden">
<aside className="w-52 shrink-0 border-r border-[var(--border)] bg-[var(--card)] flex flex-col">
<div className="p-4 border-b border-[var(--border)] flex items-center gap-2">
<Link to="/" className="text-[var(--muted)] hover:text-[var(--fg)] transition-colors cursor-pointer" title="Назад">
</Link>
<h2 className="text-sm font-bold uppercase text-[var(--muted)]">Настройки</h2>
</div>
<nav className="flex-1 p-2 space-y-1">
{MENU.map((item) => {
const active = location.pathname === item.href;
return (
<Link
key={item.href}
to={item.href}
className={`flex items-center gap-2 px-3 py-2 rounded text-sm transition-colors ${
active
? "bg-[var(--accent)]/10 text-[var(--accent)]"
: "text-[var(--fg)] hover:bg-white/5"
}`}
>
<span>{item.icon}</span>
{item.label}
</Link>
);
})}
</nav>
<div className="p-3 border-t border-[var(--border)]">
<button
onClick={logout}
className="text-xs text-[var(--muted)] hover:text-[var(--fg)] w-full text-left"
>
Выйти
</button>
</div>
</aside>
<div className="flex-1 overflow-y-auto">
<Outlet />
</div>
</div>
);
}

View File

@ -0,0 +1,10 @@
export default function SettingsPage() {
return (
<div className="p-6">
<h1 className="text-2xl font-bold mb-4"> Общие настройки</h1>
<div className="text-[var(--muted)] text-sm">
<p>Coming soon...</p>
</div>
</div>
);
}

View File

@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
/* Path mapping */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
}
},
"include": ["src"]
}

View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

View File

@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
resolve: {
alias: {
'@': '/src',
},
},
})

194
ws_test.py Normal file
View File

@ -0,0 +1,194 @@
#!/usr/bin/env python3
"""
Direct WebSocket test for Team Board Tracker.
Usage:
python ws_test.py [--url ws://localhost:8100/ws] [--token <token>]
Without --token: creates a test agent via REST API first.
Uses only stdlib + websockets (already in tracker venv).
"""
import asyncio
import json
import argparse
import urllib.request
import urllib.error
from datetime import datetime
import websockets
BASE_URL = "http://localhost:8100"
WS_URL = "ws://localhost:8100/ws"
TEST_SLUG = "ws-test-bot"
TEST_NAME = "WS Test Bot"
def ts():
return datetime.now().strftime("%H:%M:%S.%f")[:-3]
def log(tag, msg, data=None):
extra = f" {json.dumps(data, ensure_ascii=False, indent=None)}" if data else ""
print(f"[{ts()}] {tag:<12} {msg}{extra}")
# ── REST helpers ──────────────────────────────────────────────────────────────
API = "/api/v1"
def http_get(path: str) -> tuple[int, dict]:
try:
with urllib.request.urlopen(f"{BASE_URL}{path}") as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, {}
def http_post(path: str, body: dict) -> tuple[int, dict]:
data = json.dumps(body).encode()
req = urllib.request.Request(
f"{BASE_URL}{path}",
data=data,
headers={"Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as r:
return r.status, json.loads(r.read())
except urllib.error.HTTPError as e:
return e.code, json.loads(e.read()) if e.fp else {}
def ensure_test_agent() -> str:
"""Create or get test agent, return token."""
code, data = http_get(f"{API}/members/{TEST_SLUG}")
if code == 200:
token = data.get("token")
if token:
log("SETUP", f"Found existing agent '{TEST_SLUG}'", {"token": token[:20] + "..."})
return token
# Regenerate
code2, d2 = http_post(f"{API}/members/{TEST_SLUG}/regenerate-token", {})
if code2 == 200:
token = d2["token"]
log("SETUP", "Regenerated token", {"token": token[:20] + "..."})
return token
raise RuntimeError(f"Failed to regenerate token: {code2} {d2}")
# Create
payload = {
"name": TEST_NAME,
"slug": TEST_SLUG,
"type": "agent",
"role": "member",
"agent_config": {"chat_listen": "all", "task_listen": "all"},
}
code, data = http_post(f"{API}/members", payload)
if code not in (200, 201):
raise RuntimeError(f"Failed to create agent: {code} {data}")
token = data["token"]
log("SETUP", f"Created agent '{TEST_SLUG}'", {"token": token[:20] + "..."})
return token
# ── WS test ───────────────────────────────────────────────────────────────────
async def run_test(ws_url: str, token: str):
log("WS", f"Connecting → {ws_url}")
async with websockets.connect(ws_url) as ws:
log("WS", "Connected ✅")
# ── 1. Auth ──
auth = {"type": "auth", "token": token}
log("SEND", "auth", auth)
await ws.send(json.dumps(auth))
raw = await asyncio.wait_for(ws.recv(), timeout=5.0)
msg = json.loads(raw)
log("RECV", msg.get("type", "?"), msg)
if msg.get("type") != "auth.ok":
log("ERROR", f"Expected auth.ok, got '{msg.get('type')}'")
return
info = msg.get("data", {})
lobby_id = info.get("lobby_chat_id")
projects = info.get("projects", [])
online = info.get("online", [])
log("INFO", f"online={online}, lobby={lobby_id}, projects={[p['name'] for p in projects]}")
# ── 2. Heartbeat ──
hb = {"type": "heartbeat", "status": "idle"}
log("SEND", "heartbeat", hb)
await ws.send(json.dumps(hb))
await asyncio.sleep(0.3)
# ── 3. Subscribe to project ──
if projects:
p = projects[0]
sub = {"type": "project.subscribe", "project_id": p["id"]}
log("SEND", "project.subscribe", sub)
await ws.send(json.dumps(sub))
await asyncio.sleep(0.3)
# ── 4. Send chat message ──
if lobby_id:
chat_msg = {
"type": "chat.send",
"chat_id": lobby_id,
"content": f"[ws_test.py] ping @ {ts()}",
"mentions": [],
}
log("SEND", "chat.send", chat_msg)
await ws.send(json.dumps(chat_msg))
else:
log("WARN", "No lobby_chat_id — skipping chat.send")
# ── 5. Drain incoming (3 s) ──
log("INFO", "Listening 3 s for incoming events...")
deadline = asyncio.get_event_loop().time() + 3.0
while asyncio.get_event_loop().time() < deadline:
try:
raw = await asyncio.wait_for(ws.recv(), timeout=0.4)
incoming = json.loads(raw)
log("RECV", incoming.get("type", "?"), incoming)
except asyncio.TimeoutError:
pass
# ── 6. Unknown event → expect error ──
bad = {"type": "totally.unknown", "foo": "bar"}
log("SEND", "bad event", bad)
await ws.send(json.dumps(bad))
try:
raw = await asyncio.wait_for(ws.recv(), timeout=2.0)
err = json.loads(raw)
log("RECV", err.get("type", "?"), err)
except asyncio.TimeoutError:
log("WARN", "No response to unknown event")
log("WS", "Test complete ✅")
# ── main ─────────────────────────────────────────────────────────────────────
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--url", default=WS_URL)
parser.add_argument("--token", default=None, help="Skip agent creation, use this token")
args = parser.parse_args()
if args.token:
token = args.token
log("SETUP", f"Using provided token: {token[:20]}...")
else:
log("SETUP", f"No token — creating/fetching test agent via {BASE_URL}")
token = ensure_test_agent()
asyncio.run(run_test(args.url, token))
if __name__ == "__main__":
main()