Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
0076131
chore: perf, CI, and structure — v1.8.8
vyakymenko Mar 19, 2026
11f2024
* ci update
vyakymenko Mar 19, 2026
859088c
test: boost API test coverage to 95%
vyakymenko Mar 20, 2026
c00db83
docs: add 95% API test coverage badge
vyakymenko Mar 20, 2026
d2d1dee
refactor(chat): simplify file viewer, add tests, bump version
vyakymenko Mar 21, 2026
9ae046e
* wip
vyakymenko Mar 21, 2026
d6f614d
run bun install
phoenix-playgrounds-dev Mar 21, 2026
163dd6e
Do not watch node_modules, data and playground directories
phoenix-playgrounds-dev Mar 21, 2026
555672d
Display system prompt in settings
phoenix-playgrounds-dev Mar 21, 2026
2b9a7d4
Improve steering inbox
phoenix-playgrounds-dev Mar 21, 2026
e4271f2
gitignore playground
phoenix-playgrounds-dev Mar 21, 2026
5d2524f
[AI] Add backend service and routes for agent files directory
phoenix-playgrounds-dev Mar 21, 2026
4ec4766
[AI] Add gitignore parsing and real-time stats endpoints for file ser…
phoenix-playgrounds-dev Mar 21, 2026
3f79744
[AI] Add file explorer tabs, live stats counters, and tree animations
phoenix-playgrounds-dev Mar 21, 2026
6baa0c4
[AI] Move reasoning filters to global settings and add persistence
phoenix-playgrounds-dev Mar 21, 2026
5fcfbce
[AI] Fix TS and ESLint errors and update broken tests
phoenix-playgrounds-dev Mar 21, 2026
6fcea27
[AI] Fix file deletion animation by temporarily retaining deleted nod…
phoenix-playgrounds-dev Mar 21, 2026
22345e2
[AI] Auto-expand parent directories of animated files to make moves a…
phoenix-playgrounds-dev Mar 21, 2026
687ce9d
[AI] Fix TS2367 type overlap error in FileExplorer caused by an inval…
phoenix-playgrounds-dev Mar 21, 2026
5146bc0
Agent sorting
phoenix-playgrounds-dev Mar 22, 2026
4585596
Single theme
phoenix-playgrounds-dev Mar 22, 2026
8c6707d
fix(chat): restore focus to input after sending message with Enter
vyakymenko Mar 22, 2026
6a0923b
feat(activity-review): follow pill, live response, brain state + test…
vyakymenko Mar 22, 2026
218b57d
feat(activity-review): live walkthrough mode + follow pill redesign
vyakymenko Mar 22, 2026
b0e03a0
Change org name
phoenix-playgrounds-dev Mar 23, 2026
0de70b2
Change domain
phoenix-playgrounds-dev Mar 23, 2026
d9adf5f
Merge branch 'dev' of https://github.com/phoenix-playgrounds/phoenix-…
vyakymenko Mar 23, 2026
2c0338c
* fibe
vyakymenko Mar 23, 2026
c033c85
* ci update
vyakymenko Mar 23, 2026
e7f81eb
feat(chat): fibe brand identity — modern violet theme, SVG wordmark, …
vyakymenko Mar 23, 2026
e642c19
fix(chat): use FibeLogo wordmark in chat header with glowing dot
vyakymenko Mar 23, 2026
4bbeddf
fix(chat): larger fibe logo in chat header, bump to v1.9.0
vyakymenko Mar 23, 2026
b8526e0
* wip
vyakymenko Mar 23, 2026
94bfd8b
refactor(chat): design refinement — Apple + Vogue + futuristic aesthe…
vyakymenko Mar 23, 2026
abd8770
Agent status
phoenix-playgrounds-dev Mar 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,14 @@ jobs:
uses: actions/cache@v4
with:
path: ~/.bun/install/cache
key: bun-${{ runner.os }}-${{ hashFiles('bun.lockb') }}
key: bun-${{ runner.os }}-${{ hashFiles('bun.lock') }}
restore-keys: |
bun-${{ runner.os }}-
- name: Cache Nx
uses: actions/cache@v4
with:
path: .nx/cache
key: nx-${{ runner.os }}-${{ hashFiles('bun.lockb', 'nx.json', 'package.json') }}
key: nx-${{ runner.os }}-${{ hashFiles('bun.lock', 'nx.json', 'package.json') }}
restore-keys: |
nx-${{ runner.os }}-
- name: Install dependencies
Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -52,3 +52,5 @@ test-output
designs/

.env

playground
5 changes: 4 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,8 +33,11 @@ RUN --mount=type=cache,target=/root/.bun/install/cache \

COPY apps/api apps/api
COPY apps/chat apps/chat
COPY shared shared

ENV NX_DAEMON=false
ENV NX_DAEMON=false \
VITE_THEME_SOURCE=frame \
VITE_HIDE_THEME_SWITCH=true
RUN bunx nx run-many --targets=build --projects=api,chat

FROM node:24-slim
Expand Down
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

<p align="center">
<a href="https://github.com/phoenix-playgrounds/phoenix-agent-oauth/actions/workflows/ci.yml"><img src="https://github.com/phoenix-playgrounds/phoenix-agent-oauth/actions/workflows/ci.yml/badge.svg" alt="CI" /></a>
<img src="https://img.shields.io/badge/coverage-100%25-brightgreen.svg" alt="Coverage" />
<a href="https://bun.sh"><img src="https://img.shields.io/badge/bun-1.3.11-000?logo=bun&logoColor=white" alt="Bun" /></a>
<a href="https://nx.dev"><img src="https://img.shields.io/badge/Nx-22-143055?logo=nx&logoColor=white" alt="Nx" /></a>
<img src="https://img.shields.io/badge/license-MIT-blue.svg" alt="License MIT" />
Expand All @@ -27,7 +28,7 @@
| **Real-time chat** | WebSocket at `/ws` with structured client/server actions, single active session semantics, and [documented close codes](docs/API.md#websocket). |
| **REST + integrations** | Messages, activities, uploads, playgrounds, init scripts, and `POST /api/agent/send-message` for async hooks—see [docs/API.md](docs/API.md). |
| **Structured logs** | JSON-per-line logging for containers and aggregators (`LOG_LEVEL`, request IDs, HTTP and WS context)—[details](docs/API.md#container-logging). |
| **E2E & CI** | Playwright suites under `apps/e2e-api` and `apps/e2e-chat`; GitHub Actions runs lint, build, and typecheck on every push/PR. |
| **E2E & CI** | Playwright suites under `apps/e2e-api` and `apps/e2e-chat`; GitHub Actions runs lint, build, typecheck, and unit tests on every push/PR. |
| **Docker** | Multi-arch images published to **GHCR** per provider (`gemini`, `claude-code`, `openai-codex`, `opencode`)—see [CI workflow](.github/workflows/ci.yml). |

## Architecture
Expand Down Expand Up @@ -118,8 +119,8 @@ OpenCode and multi-key setups are documented inline in [`.env.example`](.env.exa
| **test** | `bun run test` | Unit tests |
| **typecheck** | `bun run typecheck` | Type-check all projects |
| **e2e** | `bun run e2e` | Playwright e2e targets |
| **ci** | `bun run ci` | Lint, build, and typecheck (matches the main CI job) |
| **ci:test** | `bun run ci:test` | Same as **ci** plus unit tests |
| **ci** | `bun run ci` | Lint, build, typecheck, and unit tests (matches the main CI job) |
| **ci:notest** | `bun run ci:notest` | Lint, build, and typecheck only (faster local checks) |

## Container images

Expand Down
2 changes: 1 addition & 1 deletion apps/api/package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "@playgrounds.dev/api",
"name": "@fibe.gg/api",
"version": "0.0.1",
"private": true,
"nx": {
Expand Down
54 changes: 54 additions & 0 deletions apps/api/src/app/activity-store/activity-store.service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -176,4 +176,58 @@ describe('ActivityStoreService', () => {
service.createWithEntry({ id: 'e1', type: 'x', message: 'm', timestamp: '' });
expect(service.findByStoryEntryId('other')).toBeUndefined();
});

test('loads activities from disk on construction with valid JSON', async () => {
const config = { getDataDir: () => dataDir, getConversationDataDir: () => dataDir };
// Create a service that writes an activity
const service1 = new ActivityStoreService(config as never);
service1.createWithEntry({ id: 'e1', type: 'step', message: 'Loaded', timestamp: '2026-01-01T00:00:00Z' });
// Wait for json writer to flush
await new Promise((r) => setTimeout(r, 50));
// Create new service that loads from file
const service2 = new ActivityStoreService(config as never);
const activities = service2.all();
expect(activities.length).toBeGreaterThanOrEqual(1);
expect(activities[0].story[0].message).toBe('Loaded');
});

test('loads gracefully when activities file has corrupt JSON', () => {
const config = { getDataDir: () => dataDir, getConversationDataDir: () => dataDir };
// Write corrupt JSON to the activities file
const { writeFileSync } = require('node:fs');
writeFileSync(join(dataDir, 'activity.json'), 'invalid-json-content');
// Service should load gracefully
const service = new ActivityStoreService(config as never);
expect(service.all()).toEqual([]);
});

test('loads gracefully when activities file has non-array JSON', () => {
const config = { getDataDir: () => dataDir, getConversationDataDir: () => dataDir };
const { writeFileSync } = require('node:fs');
writeFileSync(join(dataDir, 'activity.json'), '{"not":"array"}');
const service = new ActivityStoreService(config as never);
expect(service.all()).toEqual([]);
});

test('load deduplicates story entries with same id from disk', async () => {
const config = { getDataDir: () => dataDir, getConversationDataDir: () => dataDir };
const { writeFileSync } = require('node:fs');
// Write activities with duplicate story entries
writeFileSync(join(dataDir, 'activity.json'), JSON.stringify([
{
id: 'act1',
created_at: '2026-01-01',
story: [
{ id: 's1', type: 'step', message: 'First', timestamp: '' },
{ id: 's1', type: 'step', message: 'Duplicate', timestamp: '' },
{ id: 's2', type: 'step', message: 'Second', timestamp: '' },
],
},
]));
const service = new ActivityStoreService(config as never);
const activities = service.all();
expect(activities).toHaveLength(1);
expect(activities[0].story).toHaveLength(2);
expect(activities[0].story.map((e: { id: string }) => e.id)).toEqual(['s1', 's2']);
});
});
22 changes: 9 additions & 13 deletions apps/api/src/app/activity-store/activity-store.service.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import { Injectable } from '@nestjs/common';
import { randomUUID } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync } from 'node:fs';
import { writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { ConfigService } from '../config/config.service';
import { SequentialJsonWriter } from '../persistence/sequential-json-writer';
import type { StoredStoryEntry } from '../message-store/message-store.service';

export interface TokenUsage {
Expand Down Expand Up @@ -31,11 +31,13 @@ function dedupeStoryById(story: StoredStoryEntry[]): StoredStoryEntry[] {
@Injectable()
export class ActivityStoreService {
private readonly activityPath: string;
private readonly jsonWriter: SequentialJsonWriter;
private activities: StoredActivityEntry[] = [];

constructor(private readonly config: ConfigService) {
const dataDir = this.config.getConversationDataDir();
this.activityPath = join(dataDir, 'activity.json');
this.jsonWriter = new SequentialJsonWriter(this.activityPath, () => this.activities);
this.ensureDataDir();
this.activities = this.load();
}
Expand All @@ -61,7 +63,7 @@ export class ActivityStoreService {
story: dedupeStoryById(Array.isArray(story) ? story : []),
};
this.activities.push(entry);
void this.save();
this.jsonWriter.schedule();
return entry;
}

Expand All @@ -72,37 +74,37 @@ export class ActivityStoreService {
story: [firstEntry],
};
this.activities.push(entry);
void this.save();
this.jsonWriter.schedule();
return entry;
}

appendEntry(activityId: string, storyEntry: StoredStoryEntry): void {
const activity = this.activities.find((a) => a.id === activityId);
if (activity && storyEntry?.id && !activity.story.some((e) => e.id === storyEntry.id)) {
activity.story.push(storyEntry);
void this.save();
this.jsonWriter.schedule();
}
}

replaceStory(activityId: string, story: StoredStoryEntry[]): void {
const activity = this.activities.find((a) => a.id === activityId);
if (activity) {
activity.story = dedupeStoryById(Array.isArray(story) ? story : []);
void this.save();
this.jsonWriter.schedule();
}
}

setUsage(activityId: string, usage: TokenUsage): void {
const activity = this.activities.find((a) => a.id === activityId);
if (activity) {
activity.usage = usage;
void this.save();
this.jsonWriter.schedule();
}
}

clear(): void {
this.activities = [];
void this.save();
this.jsonWriter.schedule();
}

private ensureDataDir(): void {
Expand All @@ -126,10 +128,4 @@ export class ActivityStoreService {
}
}

private async save(): Promise<void> {
await writeFile(
this.activityPath,
JSON.stringify(this.activities, null, 2)
);
}
}
44 changes: 44 additions & 0 deletions apps/api/src/app/agent-files/agent-files-watcher.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { watch } from 'node:fs';
import { existsSync } from 'node:fs';
import { Subject } from 'rxjs';
import { AgentFilesService } from './agent-files.service';

const DEBOUNCE_MS = 500;

@Injectable()
export class AgentFilesWatcherService implements OnModuleInit, OnModuleDestroy {
private watcher: ReturnType<typeof watch> | null = null;
private debounceTimer: ReturnType<typeof setTimeout> | null = null;

readonly agentFilesChanged$ = new Subject<void>();

constructor(private readonly agentFiles: AgentFilesService) {}

onModuleInit(): void {
const dir = this.agentFiles.getAgentWorkingDir();
if (!dir || !existsSync(dir)) return;
try {
this.watcher = watch(dir, { recursive: true }, () => {
if (this.debounceTimer) clearTimeout(this.debounceTimer);
this.debounceTimer = setTimeout(() => {
this.debounceTimer = null;
this.agentFilesChanged$.next();
}, DEBOUNCE_MS);
});
} catch {
/* watch may fail on some environments */
}
}

onModuleDestroy(): void {
if (this.debounceTimer) {
clearTimeout(this.debounceTimer);
this.debounceTimer = null;
}
if (this.watcher) {
this.watcher.close();
this.watcher = null;
}
}
}
28 changes: 28 additions & 0 deletions apps/api/src/app/agent-files/agent-files.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { Controller, Get, Query, UseGuards } from '@nestjs/common';
import { AgentAuthGuard } from '../auth/agent-auth.guard';
import { AgentFilesService } from './agent-files.service';

@Controller()
@UseGuards(AgentAuthGuard)
export class AgentFilesController {
constructor(private readonly agentFiles: AgentFilesService) {}

@Get('agent-files')
async getTree() {
return this.agentFiles.getTree();
}

@Get('agent-files/stats')
async getStats() {
return this.agentFiles.getStats();
}

@Get('agent-files/file')
async getFileContent(@Query('path') path: string) {
if (!path || typeof path !== 'string') {
return { content: '' };
}
const content = await this.agentFiles.getFileContent(path);
return { content };
}
}
Loading
Loading